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