Coverage for custom_components/autoarm/const.py: 99%
103 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-27 16:32 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-27 16:32 +0000
1"""The Auto Arm integration"""
3import logging
4from dataclasses import dataclass
5from enum import StrEnum, auto
6from typing import Any
8import voluptuous as vol
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
27_LOGGER = logging.getLogger(__name__)
29DOMAIN = "autoarm"
30YAML_DATA_KEY: HassKey[ConfigType] = HassKey(f"{DOMAIN}_yaml")
32ATTR_ACTION = "action"
33ATTR_RESET = "reset"
34CONF_DATA = "data"
35CONF_NOTIFY = "notify"
36CONF_ALARM_PANEL = "alarm_panel"
37CONF_ALARM_STATES = "alarm_states"
39ALARM_STATES = [k.lower() for k in AlarmControlPanelState.__members__]
40PUBLIC_ALARM_STATES = [s for s in ALARM_STATES if s not in ("pending", "triggered", "arming", "disarming")]
42NO_CAL_EVENT_MODE_AUTO = "auto"
43NO_CAL_EVENT_MODE_MANUAL = "manual"
44NO_CAL_EVENT_OPTIONS: list[str] = [NO_CAL_EVENT_MODE_AUTO, NO_CAL_EVENT_MODE_MANUAL, *ALARM_STATES]
46CONF_SUPERNOTIFY = "supernotify"
47CONF_SCENARIO = "scenario"
48CONF_SOURCE = "source"
49CONF_STATE = "state"
50NOTIFY_COMMON = "common"
51NOTIFY_QUIET = "quiet"
52NOTIFY_NORMAL = "normal"
53NOTIFY_CATEGORIES = [NOTIFY_COMMON, NOTIFY_QUIET, NOTIFY_NORMAL]
55NOTIFY_DEF_SCHEMA = vol.Schema({
56 vol.Optional(CONF_SERVICE): cv.service,
57 vol.Optional(CONF_SUPERNOTIFY): cv.boolean,
58 vol.Required(CONF_SUPERNOTIFY, default=True): cv.boolean,
59 vol.Optional(CONF_TARGET): vol.All(cv.ensure_list, [str]),
60 vol.Optional(CONF_SOURCE): vol.All(cv.ensure_list, [str]),
61 vol.Optional(CONF_STATE): vol.All(cv.ensure_list, [vol.In(ALARM_STATES)]),
62 vol.Optional(CONF_SCENARIO, default=[]): vol.All(cv.ensure_list, [str]),
63 vol.Optional(CONF_DATA): dict,
64})
67def _apply_notify_defaults(config: dict[str, Any]) -> dict[str, Any]:
68 """Apply defaults for known notify profiles."""
69 if not config:
70 config = config or {}
71 # backward compatible with old fixed pair profiles
72 config.setdefault(NOTIFY_QUIET, {CONF_STATE: PUBLIC_ALARM_STATES})
73 config.setdefault(NOTIFY_NORMAL, {CONF_STATE: PUBLIC_ALARM_STATES})
74 sources: list[str] = [s for profile in config.values() for s in profile.get(CONF_SOURCE, []) if not profile.get(CONF_STATE)]
76 if NOTIFY_QUIET in config:
77 if not config[NOTIFY_QUIET].get(CONF_SOURCE):
78 config[NOTIFY_QUIET][CONF_SOURCE] = [
79 v
80 for v in [
81 ChangeSource.ALARM_PANEL,
82 ChangeSource.BUTTON,
83 ChangeSource.CALENDAR,
84 ChangeSource.SUNRISE,
85 ChangeSource.SUNSET,
86 ]
87 if v not in sources
88 ]
90 config.setdefault(NOTIFY_COMMON, {})
91 config[NOTIFY_COMMON].setdefault(CONF_DATA, {})
92 config[NOTIFY_COMMON].setdefault(CONF_SERVICE, "notify.send_message")
94 if config[NOTIFY_COMMON].get(CONF_SUPERNOTIFY) is None:
95 config[NOTIFY_COMMON][CONF_SUPERNOTIFY] = any(
96 config[NOTIFY_COMMON][CONF_SERVICE].endswith(v) for v in ("supernotify", "supernotifier")
97 )
98 if config[NOTIFY_COMMON].get(CONF_SUPERNOTIFY) and CONF_ACTIONS not in config[NOTIFY_COMMON][CONF_DATA]:
99 config[NOTIFY_COMMON][CONF_DATA][CONF_ACTIONS] = [
100 {CONF_ACTION: "ALARM_PANEL_DISARM", "title": "Disarm Alarm Panel", "icon": "sfsymbols:bell.slash"},
101 {CONF_ACTION: "ALARM_PANEL_RESET", "title": "Reset Alarm Panel", "icon": "sfsymbols:bell"},
102 {CONF_ACTION: "ALARM_PANEL_AWAY", "title": "Arm Alarm Panel Away", "icon": "sfsymbols:airplane"},
103 ]
104 return config
107NOTIFY_SCHEMA = vol.All(vol.Schema({cv.string: NOTIFY_DEF_SCHEMA}), _apply_notify_defaults)
109DEFAULT_CALENDAR_MAPPINGS = {
110 AlarmControlPanelState.ARMED_AWAY: "Away",
111 AlarmControlPanelState.DISARMED: "Disarmed",
112 AlarmControlPanelState.ARMED_HOME: "Home",
113 AlarmControlPanelState.ARMED_VACATION: ["Vacation", "Holiday"],
114 AlarmControlPanelState.ARMED_NIGHT: "Night",
115}
117# ENTRY_NOTIFICATION_ALL = "ALL"
118# ENTRY_NOTIFICATION_NONE = "NONE"
119# ENTRY_NOTIFICATION_MATCHED = "MATCHED"
120# ENTRY_NOTIFICATION_CHOICES = (ENTRY_NOTIFICATION_ALL, ENTRY_NOTIFICATION_MATCHED, ENTRY_NOTIFICATION_NONE)
122CONF_CALENDAR_CONTROL = "calendar_control"
123CONF_CALENDARS = "calendars"
124CONF_CALENDAR_POLL_INTERVAL = "poll_interval"
125CONF_CALENDAR_EVENT_STATES = "state_patterns"
126CONF_CALENDAR_NO_EVENT = "no_event_mode"
127CONF_CALENDAR_ENTRY_NOTIFICATIONS = "entry_notifications"
128CONF_CALENDAR_REMINDER_NOTIFICATIONS = "reminders"
130CALENDAR_SCHEMA = vol.Schema({
131 vol.Required(CONF_ENTITY_ID): cv.entity_id,
132 vol.Optional(CONF_ALIAS): cv.string,
133 vol.Optional(CONF_CALENDAR_POLL_INTERVAL, default=15): cv.positive_int,
134 # vol.Optional(CONF_CALENDAR_ENTRY_NOTIFICATIONS): vol.In(ENTRY_NOTIFICATION_CHOICES),
135 # vol.Optional(CONF_CALENDAR_REMINDER_NOTIFICATIONS, default={}): {
136 # vol.In(ALARM_STATES): vol.All(cv.ensure_list, [cv.time_period])},
137 vol.Optional(CONF_CALENDAR_EVENT_STATES, default=DEFAULT_CALENDAR_MAPPINGS): {
138 vol.In(ALARM_STATES): vol.All(cv.ensure_list, [cv.is_regex])
139 },
140})
141CALENDAR_CONTROL_SCHEMA = vol.Schema({
142 vol.Optional(CONF_CALENDAR_NO_EVENT, default=NO_CAL_EVENT_MODE_AUTO): vol.All(vol.Lower, vol.In(NO_CAL_EVENT_OPTIONS)),
143 vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [CALENDAR_SCHEMA]),
144})
146CONF_TRANSITIONS = "transitions"
147TRANSITION_SCHEMA = vol.Schema({vol.Optional(CONF_ALIAS): cv.string, vol.Required(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA})
149CONF_BUTTONS = "buttons"
150BUTTON_OPTIONS = [ATTR_RESET, *ALARM_STATES]
151BUTTON_SCHEMA = vol.Schema({
152 vol.Optional(CONF_ALIAS): cv.string,
153 vol.Optional(CONF_DELAY_TIME): vol.All(cv.time_period, cv.positive_timedelta),
154 vol.Required(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.entity_id]),
155})
157CONF_RATE_LIMIT = "rate_limit"
158CONF_RATE_LIMIT_CALLS = "max_calls"
159CONF_RATE_LIMIT_PERIOD = "period"
160RATE_LIMIT_SCHEMA = vol.Schema({
161 vol.Optional(CONF_RATE_LIMIT_PERIOD, default=60): vol.All(cv.time_period, cv.positive_timedelta),
162 vol.Optional(CONF_RATE_LIMIT_CALLS, default=6): cv.positive_int,
163})
165CONF_OCCUPANCY = "occupancy"
166CONF_DAY = "day"
167CONF_NIGHT = "night"
168CONF_OCCUPANCY_DEFAULT = "default_state"
169OCCUPANCY_SCHEMA = vol.Schema({
170 vol.Required(CONF_ENTITY_ID, default=[]): vol.All(cv.ensure_list, [cv.entity_id]),
171 vol.Optional(CONF_OCCUPANCY_DEFAULT, default={CONF_DAY: AlarmControlPanelState.ARMED_HOME}): {
172 vol.In([CONF_DAY, CONF_NIGHT]): vol.In(ALARM_STATES)
173 },
174 vol.Optional(CONF_DELAY_TIME): {vol.In([STATE_HOME, STATE_NOT_HOME]): vol.All(cv.time_period, cv.positive_timedelta)},
175})
177CONF_DIURNAL = "diurnal"
178CONF_SUNRISE = "sunrise"
179CONF_SUNSET = "sunset"
180CONF_EARLIEST = "earliest"
181CONF_LATEST = "latest"
183DIURNAL_TIME_SCHEMA = vol.Schema({
184 vol.Optional(CONF_EARLIEST): cv.time,
185 vol.Optional(CONF_LATEST): cv.time,
186})
188CONFIG_SCHEMA = vol.Schema(
189 {
190 DOMAIN: vol.Schema({
191 vol.Optional(CONF_ALARM_PANEL): vol.Schema({
192 vol.Optional(CONF_ALIAS): cv.string,
193 vol.Required(CONF_ENTITY_ID): cv.entity_id,
194 }),
195 vol.Optional(CONF_DIURNAL): vol.Schema({
196 vol.Optional(CONF_SUNRISE): DIURNAL_TIME_SCHEMA,
197 vol.Optional(CONF_SUNSET): DIURNAL_TIME_SCHEMA,
198 }),
199 vol.Optional(CONF_TRANSITIONS): {vol.In(ALARM_STATES): TRANSITION_SCHEMA},
200 vol.Optional(CONF_CALENDAR_CONTROL): CALENDAR_CONTROL_SCHEMA,
201 vol.Optional(CONF_BUTTONS): {vol.In(BUTTON_OPTIONS): BUTTON_SCHEMA},
202 vol.Optional(CONF_OCCUPANCY, default={}): OCCUPANCY_SCHEMA,
203 vol.Optional(CONF_NOTIFY, default={}): NOTIFY_SCHEMA,
204 vol.Optional(CONF_RATE_LIMIT, default={}): RATE_LIMIT_SCHEMA,
205 })
206 },
207 extra=vol.ALLOW_EXTRA, # validation fails without this by trying to include all of HASS config
208)
210DEFAULT_TRANSITIONS: dict[str, str | list[str]] = {
211 "disarmed": [
212 "{{ autoarm.computed and autoarm.occupied}}",
213 "{{ (autoarm.day and autoarm.occupied_daytime_state == 'disarmed') or"
214 " (autoarm.night and autoarm.occupied_nighttime_state == 'disarmed') }}",
215 ],
216 "armed_home": [
217 "{{ autoarm.computed }}",
218 "{{ (autoarm.day and autoarm.occupied and autoarm.occupied_daytime_state == 'armed_home') or"
219 " ( autoarm.night and autoarm.occupied and autoarm.occupied_nighttime_state == 'armed_home' ) or"
220 " ( autoarm.day and autoarm.occupied is none ) }}",
221 ],
222 "armed_night": [
223 "{{ autoarm.computed }}",
224 "{{ ( autoarm.night and autoarm.occupied and autoarm.occupied_nighttime_state == 'armed_night' ) or "
225 " ( autoarm.night and autoarm.occupied is none )}}",
226 ],
227 "armed_away": "{{ autoarm.computed and not autoarm.occupied and autoarm.occupied is not none}}",
228 "armed_vacation": "{{ autoarm.vacation }}",
229}
232@dataclass
233class ConditionVariables:
234 """Field with sub-fields added to the template context of Transition Conditions"""
236 occupied: bool | None
237 unoccupied: bool | None
238 night: bool
239 state: AlarmControlPanelState
240 occupied_defaults: dict[str, AlarmControlPanelState]
241 calendar_event: CalendarEvent | None = None
242 at_home: list[str] | None = None
243 not_home: list[str] | None = None
245 def as_dict(self) -> ConfigType:
246 """Generate the field to be exposed in the context, stringifying alarm states"""
247 return {
248 "daytime": not self.night,
249 "occupied": self.occupied,
250 "at_home": self.at_home or [],
251 "not_home": self.at_home or [],
252 "vacation": self.state == AlarmControlPanelState.ARMED_VACATION,
253 "night": self.night,
254 "day": not self.night,
255 "bypass": self.state == AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
256 "manual": self.state in (AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMED_CUSTOM_BYPASS),
257 "calendar_event": self.calendar_event,
258 "state": str(self.state),
259 "occupied_daytime_state": str(self.occupied_defaults.get(CONF_DAY, AlarmControlPanelState.DISARMED)),
260 "occupied_nighttime_state": str(self.occupied_defaults.get(CONF_NIGHT, AlarmControlPanelState.ARMED_NIGHT)),
261 "disarmed": self.state == AlarmControlPanelState.DISARMED,
262 "computed": not self.calendar_event
263 and self.state not in (AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMED_CUSTOM_BYPASS),
264 }
267class ChangeSource(StrEnum):
268 """Enumeration of all the known ways to trigger a state change"""
270 CALENDAR = auto()
271 MOBILE = auto()
272 OCCUPANCY = auto()
273 ALARM_PANEL = auto()
274 BUTTON = auto()
275 ACTION = auto()
276 SUNRISE = auto()
277 SUNSET = auto()
278 ZOMBIFICATION = auto()
279 STARTUP = auto()
280 UNKNOWN = auto()