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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-26 20:57 +0000
1"""The Auto Arm integration"""
3import datetime as dt
4import logging
5from dataclasses import dataclass
6from enum import StrEnum, auto
7from typing import Any
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
27from .compat import vol
29_LOGGER = logging.getLogger(__name__)
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"
37ATTR_ACTION = "action"
38ATTR_RESET = "reset"
39CONF_DATA = "data"
40CONF_NOTIFY = "notify"
41CONF_ALARM_PANEL = "alarm_panel"
42CONF_ALARM_STATES = "alarm_states"
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"
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]
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"]
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]
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})
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}
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)]
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 ]
138 config.setdefault(NOTIFY_COMMON, {})
139 config[NOTIFY_COMMON].setdefault(CONF_DATA, {})
140 config[NOTIFY_COMMON].setdefault(CONF_SERVICE, "notify.send_message")
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
152NOTIFY_SCHEMA = vol.All(vol.Schema({cv.string: NOTIFY_DEF_SCHEMA}), _apply_notify_defaults)
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}
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)
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)
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})
201CONF_TRANSITIONS = "transitions"
202TRANSITION_SCHEMA = vol.Schema({vol.Optional(CONF_ALIAS): cv.string, vol.Required(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA})
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})
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})
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})
230CONF_DIURNAL = "diurnal"
231CONF_SUNRISE = "sunrise"
232CONF_SUNSET = "sunset"
233CONF_EARLIEST = "earliest"
234CONF_LATEST = "latest"
236DIURNAL_TIME_SCHEMA = vol.Schema({
237 vol.Optional(CONF_EARLIEST): cv.time,
238 vol.Optional(CONF_LATEST): cv.time,
239})
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)
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}
298@dataclass
299class ConditionVariables:
300 """Field with sub-fields added to the template context of Transition Conditions"""
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
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 }
344class ChangeSource(StrEnum):
345 """Enumeration of all the known ways to trigger a state change"""
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()
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"}