Coverage for custom_components/autoarm/const.py: 99%

132 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-26 20:57 +0000

1"""The Auto Arm integration""" 

2 

3import datetime as dt 

4import logging 

5from dataclasses import dataclass 

6from enum import StrEnum, auto 

7from typing import Any 

8 

9from homeassistant.components.alarm_control_panel.const import AlarmControlPanelState 

10from homeassistant.components.calendar import CalendarEvent 

11from homeassistant.const import ( 

12 CONF_ACTION, 

13 CONF_ACTIONS, 

14 CONF_ALIAS, 

15 CONF_CONDITIONS, 

16 CONF_DELAY_TIME, 

17 CONF_ENTITY_ID, 

18 CONF_SERVICE, 

19 CONF_TARGET, 

20 STATE_HOME, 

21 STATE_NOT_HOME, 

22) 

23from homeassistant.helpers import config_validation as cv 

24from homeassistant.helpers.typing import ConfigType 

25from homeassistant.util.hass_dict import HassKey 

26 

27from .compat import vol 

28 

29_LOGGER = logging.getLogger(__name__) 

30 

31DOMAIN = "autoarm" 

32YAML_DATA_KEY: HassKey[ConfigType] = HassKey(f"{DOMAIN}_yaml") 

33SIGNAL_STATUS_UPDATED = f"{DOMAIN}_status_updated" 

34# fired when AutoArm acts on its own, so the logbook can show what caused its changes 

35EVENT_TRIGGERED = f"{DOMAIN}_triggered" 

36 

37ATTR_ACTION = "action" 

38ATTR_RESET = "reset" 

39CONF_DATA = "data" 

40CONF_NOTIFY = "notify" 

41CONF_ALARM_PANEL = "alarm_panel" 

42CONF_ALARM_STATES = "alarm_states" 

43 

44ALARM_STATES = [k.lower() for k in AlarmControlPanelState.__members__] 

45PUBLIC_ALARM_STATES = [s for s in ALARM_STATES if s not in ("pending", "triggered", "arming", "disarming")] 

46# the Frigate integration's profile select option for no profile, where cameras run on their base config 

47FRIGATE_NO_PROFILE = "none" 

48 

49# auto is auto_sun if a sunrise or sunset trigger is active today, otherwise auto_occupancy 

50NO_CAL_EVENT_MODE_AUTO = "auto" 

51# recalculates from occupancy and day/night 

52NO_CAL_EVENT_MODE_AUTO_SUN = "auto_sun" 

53NO_CAL_EVENT_MODE_AUTO_OCCUPANCY = "auto_occupancy" 

54NO_CAL_EVENT_MODE_MANUAL = "manual" 

55NO_CAL_EVENT_OPTIONS: list[str] = [ 

56 NO_CAL_EVENT_MODE_AUTO, 

57 NO_CAL_EVENT_MODE_AUTO_SUN, 

58 NO_CAL_EVENT_MODE_AUTO_OCCUPANCY, 

59 NO_CAL_EVENT_MODE_MANUAL, 

60 *ALARM_STATES, 

61] 

62CALENDAR_END_MODE_OPTIONS: list[str] = [ 

63 NO_CAL_EVENT_MODE_AUTO, 

64 NO_CAL_EVENT_MODE_AUTO_SUN, 

65 NO_CAL_EVENT_MODE_AUTO_OCCUPANCY, 

66 NO_CAL_EVENT_MODE_MANUAL, 

67 *PUBLIC_ALARM_STATES, 

68] 

69 

70TRIGGER_ON = "on" 

71TRIGGER_OFF = "off" 

72# on, except on days with a calendar event for a state that stands in for day and night arming 

73TRIGGER_AUTO = "auto" 

74SUN_TRIGGER_OPTIONS: list[str] = [TRIGGER_AUTO, TRIGGER_ON, TRIGGER_OFF] 

75# calendar events for these states replace day and night arming, the others run alongside it 

76DIURNAL_CALENDAR_STATES: list[str] = ["disarmed", "armed_home", "armed_night"] 

77 

78CONF_SUPERNOTIFY = "supernotify" 

79CONF_SCENARIO = "scenario" 

80CONF_SOURCE = "source" 

81CONF_STATE = "state" 

82NOTIFY_COMMON = "common" 

83NOTIFY_QUIET = "quiet" 

84NOTIFY_NORMAL = "normal" 

85NOTIFY_CATEGORIES = [NOTIFY_COMMON, NOTIFY_QUIET, NOTIFY_NORMAL] 

86 

87NOTIFY_DEF_SCHEMA = vol.Schema({ 

88 vol.Optional(CONF_SERVICE): cv.service, 

89 vol.Optional(CONF_SUPERNOTIFY): cv.boolean, 

90 vol.Required(CONF_SUPERNOTIFY, default=True): cv.boolean, 

91 vol.Optional(CONF_TARGET): vol.All(cv.ensure_list, [str]), 

92 vol.Optional(CONF_SOURCE): vol.All(cv.ensure_list, [str]), 

93 vol.Optional(CONF_STATE): vol.All(cv.ensure_list, [vol.In(ALARM_STATES)]), 

94 vol.Optional(CONF_SCENARIO, default=[]): vol.All(cv.ensure_list, [str]), 

95 vol.Optional(CONF_DATA): dict, 

96}) 

97 

98 

99SUPERNOTIFY_ACTION = "supernotify.notify" 

100SUPERNOTIFY_PRIORITIES: list[str] = ["critical", "high", "medium", "low", "minimum"] 

101# titles are translated when sent, from the mobile_action selector in translations json 

102SUPERNOTIFY_MOBILE_ACTIONS: list[dict[str, str]] = [ 

103 {CONF_ACTION: "ALARM_PANEL_DISARM", "icon": "sfsymbols:bell.slash"}, 

104 {CONF_ACTION: "ALARM_PANEL_RESET", "icon": "sfsymbols:bell"}, 

105 {CONF_ACTION: "ALARM_PANEL_AWAY", "icon": "sfsymbols:airplane"}, 

106] 

107# actions that would do nothing with the alarm already in that state 

108MOBILE_ACTION_STATES: dict[str, AlarmControlPanelState] = { 

109 "ALARM_PANEL_DISARM": AlarmControlPanelState.DISARMED, 

110 "ALARM_PANEL_AWAY": AlarmControlPanelState.ARMED_AWAY, 

111} 

112 

113 

114def _apply_notify_defaults(config: dict[str, Any]) -> dict[str, Any]: 

115 """Apply defaults for known notify profiles.""" 

116 if not config: 

117 config = config or {} 

118 # backward compatible with old fixed pair profiles 

119 config.setdefault(NOTIFY_QUIET, {CONF_STATE: PUBLIC_ALARM_STATES}) 

120 config.setdefault(NOTIFY_NORMAL, {CONF_STATE: PUBLIC_ALARM_STATES}) 

121 sources: list[str] = [s for profile in config.values() for s in profile.get(CONF_SOURCE, []) if not profile.get(CONF_STATE)] 

122 

123 if NOTIFY_QUIET in config: 

124 if not config[NOTIFY_QUIET].get(CONF_SOURCE): 

125 config[NOTIFY_QUIET][CONF_SOURCE] = [ 

126 v 

127 for v in [ 

128 ChangeSource.ALARM_PANEL, 

129 ChangeSource.BUTTON, 

130 ChangeSource.CALENDAR, 

131 ChangeSource.TOD, 

132 ChangeSource.SUNRISE, 

133 ChangeSource.SUNSET, 

134 ] 

135 if v not in sources 

136 ] 

137 

138 config.setdefault(NOTIFY_COMMON, {}) 

139 config[NOTIFY_COMMON].setdefault(CONF_DATA, {}) 

140 config[NOTIFY_COMMON].setdefault(CONF_SERVICE, "notify.send_message") 

141 

142 if config[NOTIFY_COMMON].get(CONF_SUPERNOTIFY) is None: 

143 config[NOTIFY_COMMON][CONF_SUPERNOTIFY] = ( 

144 any(config[NOTIFY_COMMON][CONF_SERVICE].endswith(v) for v in ("supernotify", "supernotifier")) 

145 or config[NOTIFY_COMMON][CONF_SERVICE] == SUPERNOTIFY_ACTION 

146 ) 

147 if config[NOTIFY_COMMON].get(CONF_SUPERNOTIFY) and CONF_ACTIONS not in config[NOTIFY_COMMON][CONF_DATA]: 

148 config[NOTIFY_COMMON][CONF_DATA][CONF_ACTIONS] = SUPERNOTIFY_MOBILE_ACTIONS 

149 return config 

150 

151 

152NOTIFY_SCHEMA = vol.All(vol.Schema({cv.string: NOTIFY_DEF_SCHEMA}), _apply_notify_defaults) 

153 

154DEFAULT_CALENDAR_MAPPINGS = { 

155 AlarmControlPanelState.ARMED_AWAY: "Away", 

156 AlarmControlPanelState.DISARMED: "Disarmed", 

157 AlarmControlPanelState.ARMED_HOME: "Home", 

158 AlarmControlPanelState.ARMED_VACATION: ["Vacation", "Holiday"], 

159 AlarmControlPanelState.ARMED_NIGHT: "Night", 

160} 

161 

162# ENTRY_NOTIFICATION_ALL = "ALL" 

163# ENTRY_NOTIFICATION_NONE = "NONE" 

164# ENTRY_NOTIFICATION_MATCHED = "MATCHED" 

165# ENTRY_NOTIFICATION_CHOICES = (ENTRY_NOTIFICATION_ALL, ENTRY_NOTIFICATION_MATCHED, ENTRY_NOTIFICATION_NONE) 

166 

167CONF_CALENDAR_CONTROL = "calendar_control" 

168CONF_CALENDARS = "calendars" 

169CONF_CALENDAR_POLL_INTERVAL = "poll_interval" 

170CONF_CALENDAR_EVENT_STATES = "state_patterns" 

171CONF_CALENDAR_NO_EVENT = "no_event_mode" 

172CONF_CALENDAR_ARMED_END = "armed_end_mode" 

173CONF_CALENDAR_DISARMED_END = "disarmed_end_mode" 

174CONF_CALENDAR_ENTRY_NOTIFICATIONS = "entry_notifications" 

175CONF_CALENDAR_REMINDER_NOTIFICATIONS = "reminders" 

176CONF_CALENDAR_NOTIFY_GRACE = "notify_grace_period" 

177DEFAULT_CALENDAR_NOTIFY_GRACE = dt.timedelta(seconds=60) 

178 

179CALENDAR_SCHEMA = vol.Schema({ 

180 vol.Required(CONF_ENTITY_ID): cv.entity_id, 

181 vol.Optional(CONF_ALIAS): cv.string, 

182 vol.Optional(CONF_CALENDAR_POLL_INTERVAL, default=15): cv.positive_int, 

183 # vol.Optional(CONF_CALENDAR_ENTRY_NOTIFICATIONS): vol.In(ENTRY_NOTIFICATION_CHOICES), 

184 # vol.Optional(CONF_CALENDAR_REMINDER_NOTIFICATIONS, default={}): { 

185 # vol.In(ALARM_STATES): vol.All(cv.ensure_list, [cv.time_period])}, 

186 vol.Optional(CONF_CALENDAR_EVENT_STATES, default=DEFAULT_CALENDAR_MAPPINGS): { 

187 vol.In(ALARM_STATES): vol.All(cv.ensure_list, [cv.is_regex]) 

188 }, 

189}) 

190CALENDAR_CONTROL_SCHEMA = vol.Schema({ 

191 vol.Optional(CONF_CALENDAR_NO_EVENT, default=NO_CAL_EVENT_MODE_AUTO): vol.All(vol.Lower, vol.In(NO_CAL_EVENT_OPTIONS)), 

192 # override no_event_mode for events of one kind ending 

193 vol.Optional(CONF_CALENDAR_ARMED_END): vol.All(vol.Lower, vol.In(NO_CAL_EVENT_OPTIONS)), 

194 vol.Optional(CONF_CALENDAR_DISARMED_END): vol.All(vol.Lower, vol.In(NO_CAL_EVENT_OPTIONS)), 

195 vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [CALENDAR_SCHEMA]), 

196 vol.Optional(CONF_CALENDAR_NOTIFY_GRACE, default=DEFAULT_CALENDAR_NOTIFY_GRACE): vol.All( 

197 cv.time_period, cv.positive_timedelta 

198 ), 

199}) 

200 

201CONF_TRANSITIONS = "transitions" 

202TRANSITION_SCHEMA = vol.Schema({vol.Optional(CONF_ALIAS): cv.string, vol.Required(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA}) 

203 

204CONF_BUTTONS = "buttons" 

205BUTTON_OPTIONS = [ATTR_RESET, *ALARM_STATES] 

206BUTTON_SCHEMA = vol.Schema({ 

207 vol.Optional(CONF_ALIAS): cv.string, 

208 vol.Optional(CONF_DELAY_TIME): vol.All(cv.time_period, cv.positive_timedelta), 

209 vol.Required(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.entity_id]), 

210}) 

211 

212CONF_RATE_LIMIT = "rate_limit" 

213CONF_RATE_LIMIT_CALLS = "max_calls" 

214CONF_RATE_LIMIT_PERIOD = "period" 

215RATE_LIMIT_SCHEMA = vol.Schema({ 

216 vol.Optional(CONF_RATE_LIMIT_PERIOD): vol.All(cv.time_period, cv.positive_timedelta), 

217 vol.Optional(CONF_RATE_LIMIT_CALLS): cv.positive_int, 

218}) 

219 

220CONF_OCCUPANCY = "occupancy" 

221CONF_DAY = "day" 

222CONF_NIGHT = "night" 

223CONF_OCCUPANCY_DEFAULT = "default_state" 

224OCCUPANCY_SCHEMA = vol.Schema({ 

225 vol.Optional(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.entity_id]), 

226 vol.Optional(CONF_OCCUPANCY_DEFAULT): {vol.In([CONF_DAY, CONF_NIGHT]): vol.In(ALARM_STATES)}, 

227 vol.Optional(CONF_DELAY_TIME): {vol.In([STATE_HOME, STATE_NOT_HOME]): vol.All(cv.time_period, cv.positive_timedelta)}, 

228}) 

229 

230CONF_DIURNAL = "diurnal" 

231CONF_SUNRISE = "sunrise" 

232CONF_SUNSET = "sunset" 

233CONF_EARLIEST = "earliest" 

234CONF_LATEST = "latest" 

235 

236DIURNAL_TIME_SCHEMA = vol.Schema({ 

237 vol.Optional(CONF_EARLIEST): cv.time, 

238 vol.Optional(CONF_LATEST): cv.time, 

239}) 

240 

241CONFIG_SCHEMA = vol.Schema( 

242 { 

243 DOMAIN: vol.Schema({ 

244 vol.Optional(CONF_ALARM_PANEL): vol.Schema({ 

245 vol.Optional(CONF_ALIAS): cv.string, 

246 vol.Required(CONF_ENTITY_ID): cv.entity_id, 

247 }), 

248 vol.Optional(CONF_DIURNAL): vol.Schema({ 

249 vol.Optional(CONF_SUNRISE): DIURNAL_TIME_SCHEMA, 

250 vol.Optional(CONF_SUNSET): DIURNAL_TIME_SCHEMA, 

251 }), 

252 vol.Optional(CONF_TRANSITIONS): {vol.In(ALARM_STATES): TRANSITION_SCHEMA}, 

253 vol.Optional(CONF_CALENDAR_CONTROL): CALENDAR_CONTROL_SCHEMA, 

254 vol.Optional(CONF_BUTTONS): {vol.In(BUTTON_OPTIONS): BUTTON_SCHEMA}, 

255 vol.Optional(CONF_OCCUPANCY): OCCUPANCY_SCHEMA, 

256 # deprecated, replaced by the Supernotify integration in the options, to be removed with Home Assistant 2027.4 

257 vol.Optional(CONF_NOTIFY): NOTIFY_SCHEMA, 

258 vol.Optional(CONF_RATE_LIMIT): RATE_LIMIT_SCHEMA, 

259 }) 

260 }, 

261 extra=vol.ALLOW_EXTRA, # validation fails without this by trying to include all of HASS config 

262) 

263 

264# The occupied state for the night is for the evening before bedtime, with another for after bedtime, 

265# and if nothing is going to set armed night for a bedtime then the night is armed night 

266NIGHT_STATE_TEMPLATE = ( 

267 "{% set night_state = 'armed_night' if not autoarm.programmed_bedtime else" 

268 " (autoarm.occupied_after_bedtime_state if autoarm.after_bedtime else autoarm.occupied_nighttime_state) %}" 

269) 

270DEFAULT_TRANSITIONS: dict[str, str | list[str]] = { 

271 "disarmed": [ 

272 "{{ autoarm.computed and autoarm.occupied}}", 

273 ( 

274 NIGHT_STATE_TEMPLATE + "{{ (autoarm.day and autoarm.occupied_daytime_state == 'disarmed') or" 

275 " (autoarm.night and night_state == 'disarmed') }}" 

276 ), 

277 ], 

278 "armed_home": [ 

279 "{{ autoarm.computed }}", 

280 ( 

281 NIGHT_STATE_TEMPLATE + "{{ (autoarm.day and autoarm.occupied and autoarm.occupied_daytime_state == 'armed_home') or" 

282 " ( autoarm.night and autoarm.occupied and night_state == 'armed_home' ) or" 

283 " ( autoarm.day and autoarm.occupied is none ) }}" 

284 ), 

285 ], 

286 "armed_night": [ 

287 "{{ autoarm.computed }}", 

288 ( 

289 NIGHT_STATE_TEMPLATE + "{{ ( autoarm.night and autoarm.occupied and night_state == 'armed_night' ) or " 

290 " ( autoarm.night and autoarm.occupied is none )}}" 

291 ), 

292 ], 

293 "armed_away": "{{ autoarm.computed and not autoarm.occupied and autoarm.occupied is not none}}", 

294 "armed_vacation": "{{ autoarm.vacation }}", 

295} 

296 

297 

298@dataclass 

299class ConditionVariables: 

300 """Field with sub-fields added to the template context of Transition Conditions""" 

301 

302 occupied: bool | None 

303 unoccupied: bool | None 

304 night: bool 

305 state: AlarmControlPanelState 

306 occupied_defaults: dict[str, AlarmControlPanelState] 

307 calendar_event: CalendarEvent | None = None 

308 at_home: list[str] | None = None 

309 not_home: list[str] | None = None 

310 # a bedtime sensor is on, or an armed night calendar event is live 

311 bedtime: bool = False 

312 # bedtime has ended since the sun last set 

313 after_bedtime: bool = False 

314 # there is a bedtime sensor, or a calendar event today, to set armed night 

315 programmed_bedtime: bool = True 

316 occupied_after_bedtime: AlarmControlPanelState = AlarmControlPanelState.ARMED_HOME 

317 

318 def as_dict(self) -> ConfigType: 

319 """Generate the field to be exposed in the context, stringifying alarm states""" 

320 return { 

321 "daytime": not self.night, 

322 "occupied": self.occupied, 

323 "at_home": self.at_home or [], 

324 "not_home": self.not_home or [], 

325 "vacation": self.state == AlarmControlPanelState.ARMED_VACATION, 

326 "night": self.night, 

327 "day": not self.night, 

328 "bedtime": self.bedtime, 

329 "after_bedtime": self.after_bedtime, 

330 "programmed_bedtime": self.programmed_bedtime, 

331 "bypass": self.state == AlarmControlPanelState.ARMED_CUSTOM_BYPASS, 

332 "manual": self.state in (AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMED_CUSTOM_BYPASS), 

333 "calendar_event": self.calendar_event, 

334 "state": str(self.state), 

335 "occupied_daytime_state": str(self.occupied_defaults.get(CONF_DAY, AlarmControlPanelState.DISARMED)), 

336 "occupied_nighttime_state": str(self.occupied_defaults.get(CONF_NIGHT, AlarmControlPanelState.ARMED_NIGHT)), 

337 "occupied_after_bedtime_state": str(self.occupied_after_bedtime), 

338 "disarmed": self.state == AlarmControlPanelState.DISARMED, 

339 "computed": not self.calendar_event 

340 and self.state not in (AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMED_CUSTOM_BYPASS), 

341 } 

342 

343 

344class ChangeSource(StrEnum): 

345 """Enumeration of all the known ways to trigger a state change""" 

346 

347 CALENDAR = auto() 

348 TOD = auto() 

349 MOBILE = auto() 

350 OCCUPANCY = auto() 

351 ALARM_PANEL = auto() 

352 BUTTON = auto() 

353 ACTION = auto() 

354 SUNRISE = auto() 

355 SUNSET = auto() 

356 ZOMBIFICATION = auto() 

357 VOICE = auto() 

358 STARTUP = auto() 

359 UNKNOWN = auto() 

360 

361 

362# the kinds of change that can each be given their own Supernotify priority in the options 

363NOTIFY_PRIORITY_SOURCES: dict[str, list[ChangeSource]] = { 

364 "scheduled": [ChangeSource.TOD, ChangeSource.CALENDAR, ChangeSource.SUNRISE, ChangeSource.SUNSET], 

365 "occupancy": [ChangeSource.OCCUPANCY], 

366 "manual": [ChangeSource.ALARM_PANEL, ChangeSource.BUTTON, ChangeSource.MOBILE, ChangeSource.VOICE], 

367} 

368NOTIFY_PRIORITY_DEFAULTS: dict[str, str] = {"scheduled": "low", "occupancy": "medium", "manual": "medium"}