Coverage for custom_components/autoarm/config_flow.py: 100%
213 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"""Config flow for Auto Arm integration."""
3import datetime as dt
4import re
5from collections.abc import Mapping
6from typing import Any
8from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
9from homeassistant.const import CONF_ENABLED, CONF_ENTITY_ID, CONF_SERVICE
10from homeassistant.core import HomeAssistant
11from homeassistant.data_entry_flow import section
12from homeassistant.helpers.selector import (
13 BooleanSelector,
14 EntitySelector,
15 EntitySelectorConfig,
16 NumberSelector,
17 NumberSelectorConfig,
18 NumberSelectorMode,
19 ObjectSelector,
20 ObjectSelectorConfig,
21 Selector,
22 SelectSelector,
23 SelectSelectorConfig,
24 SelectSelectorMode,
25 TargetSelector,
26 TextSelector,
27 TextSelectorConfig,
28 TextSelectorType,
29 TimeSelector,
30)
32from .compat import vol
33from .const import (
34 ATTR_RESET,
35 CALENDAR_END_MODE_OPTIONS,
36 CONF_ALARM_PANEL,
37 CONF_CALENDAR_ARMED_END,
38 CONF_CALENDAR_CONTROL,
39 CONF_CALENDAR_DISARMED_END,
40 CONF_CALENDAR_NO_EVENT,
41 CONF_CALENDARS,
42 CONF_DAY,
43 CONF_DIURNAL,
44 CONF_EARLIEST,
45 CONF_LATEST,
46 CONF_NIGHT,
47 CONF_NOTIFY,
48 CONF_OCCUPANCY,
49 CONF_OCCUPANCY_DEFAULT,
50 CONF_SUNRISE,
51 CONF_SUNSET,
52 DEFAULT_CALENDAR_NOTIFY_GRACE,
53 DOMAIN,
54 FRIGATE_NO_PROFILE,
55 NO_CAL_EVENT_MODE_AUTO,
56 NOTIFY_COMMON,
57 NOTIFY_PRIORITY_DEFAULTS,
58 NOTIFY_PRIORITY_SOURCES,
59 PUBLIC_ALARM_STATES,
60 SUN_TRIGGER_OPTIONS,
61 SUPERNOTIFY_ACTION,
62 SUPERNOTIFY_PRIORITIES,
63 TRIGGER_AUTO,
64)
65from .crypto import async_encrypt
66from .frigate import frigate_profile_selects, frigate_profiles, match_profile
67from .supernotify_api import deliveries, supernotify_available
69CONF_CALENDAR_ENTITIES = "calendar_entities"
70CONF_PERSON_ENTITIES = "person_entities"
71CONF_OCCUPANCY_DEFAULT_DAY = "occupancy_default_day"
72CONF_OCCUPANCY_DEFAULT_NIGHT = "occupancy_default_night"
73# replaced by the armed and disarmed end modes, kept to migrate entries and read YAML
74CONF_NO_EVENT_MODE = "no_event_mode"
75CONF_CALENDAR_ARMED_END_MODE = "calendar_armed_end_mode"
76CONF_CALENDAR_DISARMED_END_MODE = "calendar_disarmed_end_mode"
77# each a calendar, an alarm state and the patterns for the events of that calendar that set the state
78CONF_CALENDAR_MATCH_RULES = "calendar_match_rules"
79CONF_RULE_CALENDAR = "calendar"
80CONF_RULE_STATE = "state"
81CONF_RULE_PATTERNS = "patterns"
82# minutes between looking at each calendar
83CONF_CALENDAR_POLL_MINUTES = "calendar_poll_interval"
84DEFAULT_CALENDAR_POLL_MINUTES = 15
85# seconds to hold a notification of a calendar change, so that changes close together are told as one
86CONF_CALENDAR_NOTIFY_GRACE_SECONDS = "calendar_notify_grace"
87CONF_CALENDAR_OCCUPANCY_OVERRIDE_STATES = "calendar_occupancy_override_states"
88CONF_CALENDAR_OCCUPANCY_ENDS_AWAY = "calendar_occupancy_ends_away"
89CONF_NOTIFY_ACTION = "notify_action"
90CONF_NOTIFY_TARGETS = "notify_targets"
91# as the `target` and `custom_target` of supernotify.notify, and the deliveries to keep to
92CONF_NOTIFY_TARGET = "notify_target"
93CONF_NOTIFY_CUSTOM_TARGET = "notify_custom_target"
94CONF_NOTIFY_DELIVERIES = "notify_deliveries"
97def notify_priority_option(kind: str) -> str:
98 """Option holding the Supernotify priority for a kind of change, as keyed in NOTIFY_PRIORITY_SOURCES"""
99 return f"notify_priority_{kind}"
102CONF_NOTIFY_DATA = "notify_data"
103CONF_NOTIFY_ENABLED = "notify_enabled"
104# for the dark between bedtime ending and sunrise
105CONF_OCCUPANCY_DEFAULT_AFTER_BEDTIME = "occupancy_default_after_bedtime"
106CONF_SUNRISE_TRIGGER = "sunrise_trigger"
107CONF_SUNSET_TRIGGER = "sunset_trigger"
108CONF_OCCUPIED_TRIGGER = "occupied_trigger"
109CONF_UNOCCUPIED_TRIGGER = "unoccupied_trigger"
110CONF_SUNRISE_EARLIEST = "sunrise_earliest"
111CONF_SUNRISE_LATEST = "sunrise_latest"
112CONF_SUNSET_EARLIEST = "sunset_earliest"
113CONF_SUNSET_LATEST = "sunset_latest"
114CONF_USE_ALARM_SERVICE = "use_alarm_service"
115# guard against loops, at most so many changes within a period of seconds
116CONF_RATE_LIMIT_MAX_CALLS = "rate_limit_max_calls"
117CONF_RATE_LIMIT_SECONDS = "rate_limit_period"
118DEFAULT_RATE_LIMIT_MAX_CALLS = 5
119DEFAULT_RATE_LIMIT_SECONDS = 60
120# seconds to wait after someone arrives or leaves before working out the state
121CONF_OCCUPIED_DELAY = "occupied_delay"
122CONF_UNOCCUPIED_DELAY = "unoccupied_delay"
123# time for a tracker to settle, or to come back for something forgotten, before arming for an empty house
124DEFAULT_OCCUPANCY_DELAYS: dict[str, int] = {CONF_OCCUPIED_DELAY: 0, CONF_UNOCCUPIED_DELAY: 120}
125# held encrypted, see crypto
126CONF_PANEL_CODE = "panel_code"
127# only a form field, to remove the stored code, since that isn't shown to be blanked out
128CONF_PANEL_CODE_CLEAR = "panel_code_clear"
129CONF_SENTENCE_ARM = "sentence_arm"
130CONF_SENTENCE_DISARM = "sentence_disarm"
131# switch Frigate profiles with the alarm state, using the Frigate integration's profile select
132CONF_FRIGATE_ENABLED = "frigate_enabled"
133# only needed to choose between more than one Frigate server
134CONF_FRIGATE_PROFILE_ENTITY = "frigate_profile_entity"
136DEFAULT_CALENDAR_OCCUPANCY_OVERRIDE_STATES: list[str] = ["disarmed", "armed_home", "armed_night", "armed_away"]
137RECIPE_URL = "https://autoarm.rhizomatics.org.uk/configuration/examples/recommended_recipe/"
138# My Home Assistant deep links into the built-in helpers that can stand in for a missing entity,
139# a stable redirect rather than a hardcoded frontend path: https://www.home-assistant.io/integrations/my/
140ALARM_PANEL_HELP_URL = "https://my.home-assistant.io/redirect/config_flow_start/?domain=template"
141CALENDAR_HELP_URL = "https://my.home-assistant.io/redirect/config_flow_start/?domain=local_calendar"
142TIME_OF_DAY_HELP_URL = "https://my.home-assistant.io/redirect/config_flow_start/?domain=tod"
143PERSON_HELP_URL = "https://my.home-assistant.io/redirect/people/"
144FRIGATE_PROFILES_URL = "https://docs.frigate.video/configuration/profiles/"
145SETUP_HELP_PLACEHOLDERS: dict[str, str] = {
146 "recipe_url": RECIPE_URL,
147 "alarm_panel_help_url": ALARM_PANEL_HELP_URL,
148 "calendar_help_url": CALENDAR_HELP_URL,
149 "bedtime_help_url": TIME_OF_DAY_HELP_URL,
150 "person_help_url": PERSON_HELP_URL,
151 "frigate_profiles_url": FRIGATE_PROFILES_URL,
152}
153OPTIONS_HELP_PLACEHOLDERS: dict[str, str] = {"recipe_url": RECIPE_URL, "frigate_profiles_url": FRIGATE_PROFILES_URL}
154# option holding the Times of the Day sensors for each alarm state, a daily period doesn't suit vacations
155TIME_OF_DAY_OPTIONS: dict[str, str] = {
156 f"time_of_day_{state}": state for state in PUBLIC_ALARM_STATES if state != "armed_vacation"
157}
158# option holding the Frigate profile for each alarm state, a state without one matched to a profile by name
159FRIGATE_PROFILE_OPTIONS: dict[str, str] = {f"frigate_profile_{state}": state for state in PUBLIC_ALARM_STATES}
160# a reset button, and buttons for each alarm state, each with a delay in seconds before it acts
161BUTTON_USES: list[str] = [ATTR_RESET, *PUBLIC_ALARM_STATES]
162BUTTON_DOMAINS: list[str] = ["binary_sensor", "button", "input_button"]
163# time to get out of the door
164DEFAULT_BUTTON_DELAYS: dict[str, int] = {"armed_away": 120}
167def button_option(use: str) -> str:
168 return f"button_{use}"
171def button_delay_option(use: str) -> str:
172 return f"button_{use}_delay"
175# the bedtime sensor of the recommended recipe, asked for up front rather than in the Times of the Day section
176CONF_BEDTIME_ENTITIES = "time_of_day_armed_night"
179def _time_to_str(t: dt.time | None) -> str | None:
180 """Convert a datetime.time to HH:MM:SS string for ConfigEntry storage."""
181 return t.isoformat() if t else None
184DEFAULT_NOTIFY_ACTION = "notify.send_message"
186# as the recommended recipe, disarmed by day, armed home from sunset and armed night for bedtime
187DEFAULT_OPTIONS: dict[str, Any] = {
188 CONF_CALENDAR_ENTITIES: [],
189 CONF_PERSON_ENTITIES: [],
190 CONF_BEDTIME_ENTITIES: [],
191 CONF_OCCUPANCY_DEFAULT_DAY: "disarmed",
192 CONF_OCCUPANCY_DEFAULT_AFTER_BEDTIME: "armed_home",
193 CONF_OCCUPANCY_DEFAULT_NIGHT: "armed_home",
194 CONF_CALENDAR_ARMED_END_MODE: NO_CAL_EVENT_MODE_AUTO,
195 CONF_CALENDAR_DISARMED_END_MODE: NO_CAL_EVENT_MODE_AUTO,
196 CONF_CALENDAR_OCCUPANCY_OVERRIDE_STATES: DEFAULT_CALENDAR_OCCUPANCY_OVERRIDE_STATES,
197 CONF_CALENDAR_OCCUPANCY_ENDS_AWAY: True,
198 CONF_NOTIFY_ENABLED: True,
199 CONF_NOTIFY_ACTION: DEFAULT_NOTIFY_ACTION,
200 CONF_NOTIFY_TARGETS: [],
201 CONF_NOTIFY_DATA: {},
202 CONF_SUNRISE_TRIGGER: TRIGGER_AUTO,
203 CONF_SUNSET_TRIGGER: TRIGGER_AUTO,
204 CONF_OCCUPIED_TRIGGER: True,
205 CONF_UNOCCUPIED_TRIGGER: True,
206 CONF_SUNRISE_EARLIEST: None,
207 CONF_SUNRISE_LATEST: None,
208 CONF_SUNSET_EARLIEST: None,
209 CONF_SUNSET_LATEST: None,
210 CONF_USE_ALARM_SERVICE: True,
211 **DEFAULT_OCCUPANCY_DELAYS,
212 CONF_RATE_LIMIT_MAX_CALLS: DEFAULT_RATE_LIMIT_MAX_CALLS,
213 CONF_RATE_LIMIT_SECONDS: DEFAULT_RATE_LIMIT_SECONDS,
214 CONF_SENTENCE_ARM: True,
215 CONF_SENTENCE_DISARM: False,
216}
219def _top_level_fields(
220 options: Mapping[str, Any], alarm_panel_default: str | None = None, frigate_available: bool = False
221) -> dict[Any, Any]:
222 """Alarm panel plus the recommended-recipe entities, shared by every setup path and the options flow."""
223 alarm_panel_marker = (
224 vol.Required(CONF_ALARM_PANEL, default=alarm_panel_default) if alarm_panel_default else vol.Required(CONF_ALARM_PANEL)
225 )
226 return {
227 alarm_panel_marker: EntitySelector(EntitySelectorConfig(domain="alarm_control_panel")),
228 vol.Optional(CONF_BEDTIME_ENTITIES, default=options.get(CONF_BEDTIME_ENTITIES, [])): _time_of_day_selector(),
229 vol.Optional(CONF_CALENDAR_ENTITIES, default=options.get(CONF_CALENDAR_ENTITIES, [])): EntitySelector(
230 EntitySelectorConfig(domain="calendar", multiple=True)
231 ),
232 vol.Optional(CONF_PERSON_ENTITIES, default=options.get(CONF_PERSON_ENTITIES, [])): EntitySelector(
233 EntitySelectorConfig(domain="person", multiple=True)
234 ),
235 **(
236 {vol.Optional(CONF_FRIGATE_ENABLED, default=options.get(CONF_FRIGATE_ENABLED, False)): BooleanSelector()}
237 if frigate_available
238 else {}
239 ),
240 }
243def _frigate_available(hass: HomeAssistant, options: Mapping[str, Any]) -> bool:
244 """There's a Frigate with profiles, or profiles are already on, so they can be turned off after Frigate has gone"""
245 return bool(frigate_profile_selects(hass)) or bool(options.get(CONF_FRIGATE_ENABLED))
248def _default_notify_action(hass: HomeAssistant) -> str:
249 """Notification action for a new install, Supernotify if that's already there"""
250 return SUPERNOTIFY_ACTION if supernotify_available(hass) else DEFAULT_NOTIFY_ACTION
253async def _section_fields(hass: HomeAssistant, options: Mapping[str, Any]) -> dict[Any, Any]:
254 """The alarm panel code and the full set of options flow sections, shared by advanced setup and the options flow."""
255 notify_services = sorted(f"notify.{service}" for service in hass.services.async_services().get("notify", {}))
256 notify_target_fields: dict[Any, Any]
257 if supernotify_available(hass):
258 notify_services.insert(0, SUPERNOTIFY_ACTION)
259 # the same pair of target fields as supernotify.notify itself, with targets from before they were split now custom
260 typed_targets: list[str] = [*options.get(CONF_NOTIFY_TARGETS, []), *options.get(CONF_NOTIFY_CUSTOM_TARGET, [])]
261 notify_target_fields = {
262 vol.Optional(CONF_NOTIFY_TARGET, default=options.get(CONF_NOTIFY_TARGET, {})): TargetSelector(),
263 vol.Optional(CONF_NOTIFY_CUSTOM_TARGET, default=list(dict.fromkeys(typed_targets))): TextSelector(
264 TextSelectorConfig(multiple=True)
265 ),
266 # a delivery since removed from Supernotify is still shown, to be taken off
267 vol.Optional(CONF_NOTIFY_DELIVERIES, default=options.get(CONF_NOTIFY_DELIVERIES, [])): SelectSelector(
268 SelectSelectorConfig(options=await deliveries(hass), multiple=True, custom_value=True)
269 ),
270 **{
271 vol.Optional(
272 notify_priority_option(kind),
273 default=options.get(notify_priority_option(kind), NOTIFY_PRIORITY_DEFAULTS[kind]),
274 ): SelectSelector(
275 SelectSelectorConfig(
276 options=SUPERNOTIFY_PRIORITIES, mode=SelectSelectorMode.DROPDOWN, translation_key="notify_priority"
277 )
278 )
279 for kind in NOTIFY_PRIORITY_SOURCES
280 },
281 }
282 else:
283 notify_target_fields = {
284 vol.Optional(CONF_NOTIFY_TARGETS, default=options.get(CONF_NOTIFY_TARGETS, [])): TextSelector(
285 TextSelectorConfig(multiple=True)
286 ),
287 }
289 return {
290 # never filled in with the stored code, so that isn't sent back out to a browser
291 vol.Optional(CONF_PANEL_CODE): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD, autocomplete="off")),
292 **({vol.Optional(CONF_PANEL_CODE_CLEAR, default=False): BooleanSelector()} if options.get(CONF_PANEL_CODE) else {}),
293 vol.Required("assist_options"): section(
294 vol.Schema({
295 vol.Required(
296 CONF_SENTENCE_ARM,
297 default=options.get(CONF_SENTENCE_ARM, True),
298 ): BooleanSelector(),
299 vol.Required(
300 CONF_SENTENCE_DISARM,
301 default=options.get(CONF_SENTENCE_DISARM, False),
302 ): BooleanSelector(),
303 }),
304 {"collapsed": True},
305 ),
306 vol.Required("button_options"): section(
307 vol.Schema({
308 marker: selector
309 for use in BUTTON_USES
310 for marker, selector in (
311 (
312 vol.Optional(button_option(use), default=options.get(button_option(use), [])),
313 EntitySelector(EntitySelectorConfig(domain=BUTTON_DOMAINS, multiple=True)),
314 ),
315 (
316 vol.Optional(
317 button_delay_option(use),
318 default=options.get(button_delay_option(use), DEFAULT_BUTTON_DELAYS.get(use, 0)),
319 ),
320 NumberSelector(
321 NumberSelectorConfig(min=0, max=3600, mode=NumberSelectorMode.BOX, unit_of_measurement="s")
322 ),
323 ),
324 )
325 }),
326 {"collapsed": True},
327 ),
328 vol.Required("notify_options"): section(
329 vol.Schema({
330 vol.Required(
331 CONF_NOTIFY_ENABLED,
332 default=options.get(CONF_NOTIFY_ENABLED, True),
333 ): BooleanSelector(),
334 vol.Optional(
335 CONF_NOTIFY_ACTION,
336 default=options.get(CONF_NOTIFY_ACTION, DEFAULT_NOTIFY_ACTION),
337 ): SelectSelector(
338 SelectSelectorConfig(
339 options=notify_services,
340 multiple=False,
341 mode=SelectSelectorMode.DROPDOWN,
342 )
343 ),
344 **notify_target_fields,
345 vol.Optional(
346 CONF_NOTIFY_DATA,
347 default=options.get(CONF_NOTIFY_DATA, {}),
348 ): ObjectSelector(),
349 }),
350 {"collapsed": True},
351 ),
352 vol.Required("calendar_options"): section(
353 vol.Schema({
354 vol.Required(
355 CONF_CALENDAR_ARMED_END_MODE,
356 default=options.get(CONF_CALENDAR_ARMED_END_MODE, NO_CAL_EVENT_MODE_AUTO),
357 ): _calendar_end_mode_selector(),
358 vol.Required(
359 CONF_CALENDAR_DISARMED_END_MODE,
360 default=options.get(CONF_CALENDAR_DISARMED_END_MODE, NO_CAL_EVENT_MODE_AUTO),
361 ): _calendar_end_mode_selector(),
362 vol.Optional(
363 CONF_CALENDAR_MATCH_RULES,
364 default=options.get(CONF_CALENDAR_MATCH_RULES, []),
365 ): ObjectSelector(
366 ObjectSelectorConfig(
367 multiple=True,
368 label_field=CONF_RULE_STATE,
369 description_field=CONF_RULE_CALENDAR,
370 translation_key="calendar_match_rule",
371 fields={
372 CONF_RULE_CALENDAR: {
373 "selector": EntitySelector(EntitySelectorConfig(domain="calendar")),
374 "required": True,
375 },
376 CONF_RULE_STATE: {"selector": _alarm_state_selector(), "required": True},
377 CONF_RULE_PATTERNS: {
378 "selector": TextSelector(TextSelectorConfig(multiple=True)),
379 "required": True,
380 },
381 },
382 )
383 ),
384 vol.Optional(
385 CONF_CALENDAR_POLL_MINUTES,
386 default=options.get(CONF_CALENDAR_POLL_MINUTES, DEFAULT_CALENDAR_POLL_MINUTES),
387 ): NumberSelector(
388 NumberSelectorConfig(min=1, max=1440, mode=NumberSelectorMode.BOX, unit_of_measurement="min")
389 ),
390 vol.Optional(
391 CONF_CALENDAR_NOTIFY_GRACE_SECONDS,
392 default=options.get(CONF_CALENDAR_NOTIFY_GRACE_SECONDS, int(DEFAULT_CALENDAR_NOTIFY_GRACE.total_seconds())),
393 ): _seconds_selector(),
394 }),
395 {"collapsed": True},
396 ),
397 vol.Required("sun_options"): section(
398 vol.Schema({
399 vol.Optional(
400 CONF_OCCUPANCY_DEFAULT_DAY,
401 default=options.get(CONF_OCCUPANCY_DEFAULT_DAY, "disarmed"),
402 ): _alarm_state_selector(),
403 vol.Optional(
404 CONF_OCCUPANCY_DEFAULT_AFTER_BEDTIME,
405 default=options.get(CONF_OCCUPANCY_DEFAULT_AFTER_BEDTIME, "armed_home"),
406 ): _alarm_state_selector(),
407 vol.Optional(
408 CONF_OCCUPANCY_DEFAULT_NIGHT,
409 description={"suggested_value": options.get(CONF_OCCUPANCY_DEFAULT_NIGHT, "armed_night")},
410 ): _alarm_state_selector(),
411 vol.Required(
412 CONF_SUNRISE_TRIGGER,
413 default=options.get(CONF_SUNRISE_TRIGGER, TRIGGER_AUTO),
414 ): _sun_trigger_selector(),
415 vol.Optional(
416 CONF_SUNRISE_EARLIEST,
417 description={"suggested_value": options.get(CONF_SUNRISE_EARLIEST)},
418 ): TimeSelector(),
419 vol.Optional(
420 CONF_SUNRISE_LATEST,
421 description={"suggested_value": options.get(CONF_SUNRISE_LATEST)},
422 ): TimeSelector(),
423 vol.Required(
424 CONF_SUNSET_TRIGGER,
425 default=options.get(CONF_SUNSET_TRIGGER, TRIGGER_AUTO),
426 ): _sun_trigger_selector(),
427 vol.Optional(
428 CONF_SUNSET_EARLIEST,
429 description={"suggested_value": options.get(CONF_SUNSET_EARLIEST)},
430 ): TimeSelector(),
431 vol.Optional(
432 CONF_SUNSET_LATEST,
433 description={"suggested_value": options.get(CONF_SUNSET_LATEST)},
434 ): TimeSelector(),
435 }),
436 {"collapsed": True},
437 ),
438 vol.Required("presence_options"): section(
439 vol.Schema({
440 vol.Required(
441 CONF_OCCUPIED_TRIGGER,
442 default=options.get(CONF_OCCUPIED_TRIGGER, True),
443 ): BooleanSelector(),
444 vol.Required(
445 CONF_UNOCCUPIED_TRIGGER,
446 default=options.get(CONF_UNOCCUPIED_TRIGGER, True),
447 ): BooleanSelector(),
448 vol.Optional(
449 CONF_CALENDAR_OCCUPANCY_OVERRIDE_STATES,
450 default=options.get(CONF_CALENDAR_OCCUPANCY_OVERRIDE_STATES, DEFAULT_CALENDAR_OCCUPANCY_OVERRIDE_STATES),
451 ): SelectSelector(
452 SelectSelectorConfig(
453 options=PUBLIC_ALARM_STATES,
454 multiple=True,
455 mode=SelectSelectorMode.LIST,
456 )
457 ),
458 vol.Required(
459 CONF_CALENDAR_OCCUPANCY_ENDS_AWAY,
460 default=options.get(CONF_CALENDAR_OCCUPANCY_ENDS_AWAY, True),
461 ): BooleanSelector(),
462 **{
463 vol.Optional(option, default=options.get(option, default)): _seconds_selector()
464 for option, default in DEFAULT_OCCUPANCY_DELAYS.items()
465 },
466 }),
467 {"collapsed": True},
468 ),
469 **_frigate_section(hass, options),
470 vol.Required("advanced_options"): section(
471 vol.Schema({
472 vol.Required(
473 CONF_USE_ALARM_SERVICE,
474 default=options.get(CONF_USE_ALARM_SERVICE, True),
475 ): BooleanSelector(),
476 vol.Optional(
477 CONF_RATE_LIMIT_MAX_CALLS,
478 default=options.get(CONF_RATE_LIMIT_MAX_CALLS, DEFAULT_RATE_LIMIT_MAX_CALLS),
479 ): NumberSelector(NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX)),
480 vol.Optional(
481 CONF_RATE_LIMIT_SECONDS,
482 default=options.get(CONF_RATE_LIMIT_SECONDS, DEFAULT_RATE_LIMIT_SECONDS),
483 ): _seconds_selector(minimum=1),
484 }),
485 {"collapsed": True},
486 ),
487 vol.Required("time_of_day_options"): section(
488 vol.Schema({
489 vol.Optional(option, default=options.get(option, [])): _time_of_day_selector()
490 for option in TIME_OF_DAY_OPTIONS
491 if option != CONF_BEDTIME_ENTITIES
492 }),
493 {"collapsed": True},
494 ),
495 }
498def _frigate_section(hass: HomeAssistant, options: Mapping[str, Any]) -> dict[Any, Any]:
499 """Overrides for Frigate profiles, the profiles matched by name, and the select where there's more than one Frigate"""
500 if not _frigate_available(hass, options):
501 return {}
502 profiles: list[str] = _matchable_profiles(hass, options)
503 # no profile, for a state cleared, then those in Frigate, others can be typed in while Frigate is down
504 profile_choices: list[str] = [FRIGATE_NO_PROFILE, *profiles]
505 return {
506 vol.Required("frigate_options"): section(
507 vol.Schema({
508 **{
509 # filled in by name until saved, so a profile cleared stays cleared
510 vol.Optional(
511 option,
512 description={
513 "suggested_value": options[option] if option in options else match_profile(state, profiles)
514 },
515 ): SelectSelector(
516 SelectSelectorConfig(
517 options=profile_choices,
518 custom_value=True,
519 mode=SelectSelectorMode.DROPDOWN,
520 translation_key="frigate_profile",
521 )
522 )
523 for option, state in FRIGATE_PROFILE_OPTIONS.items()
524 },
525 # last, as only an override for more than one Frigate server
526 vol.Optional(
527 CONF_FRIGATE_PROFILE_ENTITY,
528 description={"suggested_value": options.get(CONF_FRIGATE_PROFILE_ENTITY)},
529 ): EntitySelector(EntitySelectorConfig(domain="select", integration="frigate")),
530 }),
531 {"collapsed": True},
532 ),
533 }
536def _matchable_profiles(hass: HomeAssistant, options: Mapping[str, Any]) -> list[str]:
537 """Profiles of the select that will be used, the Frigate integration's own unless overridden"""
538 return frigate_profiles(hass, options.get(CONF_FRIGATE_PROFILE_ENTITY) or next(iter(frigate_profile_selects(hass)), None))
541def _settle_frigate_profiles(hass: HomeAssistant, data: dict[str, Any]) -> None:
542 """Keep only the profiles that differ from those matched by name, so the rest go on being matched as Frigate changes
544 A state cleared, where there's a match, is kept as no profile so it isn't matched again
545 """
546 if not _frigate_available(hass, data):
547 return
548 profiles: list[str] = _matchable_profiles(hass, data)
549 for option, state in FRIGATE_PROFILE_OPTIONS.items():
550 chosen: str = data.pop(option, None) or FRIGATE_NO_PROFILE
551 if chosen != (match_profile(state, profiles) or FRIGATE_NO_PROFILE):
552 data[option] = chosen
555def _flatten_sections(user_input: dict[str, Any]) -> dict[str, Any]:
556 """Flatten the section sub-dicts from a form submission into a single-level options dict."""
557 data = {k: v for k, v in user_input.items() if not isinstance(v, dict)}
558 for v in user_input.values():
559 if isinstance(v, dict):
560 data.update(v)
561 return data
564def _pattern_errors(data: Mapping[str, Any]) -> dict[str, str]:
565 """Errors for the form if any calendar match rule has a pattern that isn't a regular expression"""
566 for rule in data.get(CONF_CALENDAR_MATCH_RULES, []):
567 for pattern in rule.get(CONF_RULE_PATTERNS, []):
568 try:
569 re.compile(pattern)
570 except re.error:
571 return {"base": "invalid_pattern"}
572 return {}
575async def _protect_panel_code(hass: HomeAssistant, data: dict[str, Any], stored: str | None = None) -> None:
576 """Swap a code typed in for its encrypted form, keeping the one already stored if none was typed"""
577 code: str | None = data.pop(CONF_PANEL_CODE, None)
578 clear: bool = data.pop(CONF_PANEL_CODE_CLEAR, False)
579 if code:
580 data[CONF_PANEL_CODE] = await async_encrypt(hass, code)
581 elif stored and not clear:
582 data[CONF_PANEL_CODE] = stored
585class AutoArmConfigFlow(ConfigFlow, domain=DOMAIN):
586 """Handle a config flow for Auto Arm."""
588 VERSION = 1
589 # 2 split no_event_mode into armed and disarmed end modes
590 MINOR_VERSION = 2
592 async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
593 """Offer a quick recommended-recipe setup, or the full set of options up front."""
594 await self.async_set_unique_id(DOMAIN)
595 self._abort_if_unique_id_configured()
596 return self.async_show_menu(
597 step_id="user",
598 menu_options=["quick_setup", "advanced_setup"],
599 description_placeholders={"recipe_url": RECIPE_URL},
600 )
602 async def async_step_quick_setup(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
603 """Handle set up, of the alarm panel and the entities used by the recommended recipe."""
604 if user_input is not None:
605 options = {
606 **DEFAULT_OPTIONS,
607 CONF_NOTIFY_ACTION: _default_notify_action(self.hass),
608 CONF_BEDTIME_ENTITIES: user_input.get(CONF_BEDTIME_ENTITIES, []),
609 CONF_CALENDAR_ENTITIES: user_input.get(CONF_CALENDAR_ENTITIES, []),
610 CONF_PERSON_ENTITIES: user_input.get(CONF_PERSON_ENTITIES, []),
611 CONF_FRIGATE_ENABLED: user_input.get(CONF_FRIGATE_ENABLED, False),
612 }
614 return self.async_create_entry(
615 title="Auto Arm",
616 data={CONF_ALARM_PANEL: user_input[CONF_ALARM_PANEL]},
617 options=options,
618 )
620 # everyone known to Home Assistant, to be cut down rather than built up
621 default_options = {CONF_PERSON_ENTITIES: sorted(self.hass.states.async_entity_ids("person"))}
622 return self.async_show_form(
623 step_id="quick_setup",
624 data_schema=vol.Schema(
625 _top_level_fields(default_options, frigate_available=_frigate_available(self.hass, default_options))
626 ),
627 description_placeholders=SETUP_HELP_PLACEHOLDERS,
628 )
630 async def async_step_advanced_setup(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
631 """Handle set up with the full set of options shown up front, instead of the quick recipe's defaults."""
632 default_options: dict[str, Any] = {
633 **DEFAULT_OPTIONS,
634 CONF_NOTIFY_ACTION: _default_notify_action(self.hass),
635 CONF_PERSON_ENTITIES: sorted(self.hass.states.async_entity_ids("person")),
636 }
637 errors: dict[str, str] = {}
638 if user_input is not None:
639 data = _flatten_sections(user_input)
640 _settle_frigate_profiles(self.hass, data)
641 errors = _pattern_errors(data)
642 if not errors:
643 await _protect_panel_code(self.hass, data)
644 alarm_panel = data.pop(CONF_ALARM_PANEL)
645 return self.async_create_entry(
646 title="Auto Arm",
647 data={CONF_ALARM_PANEL: alarm_panel},
648 options=data,
649 )
650 # shown again as it was filled in, to put right
651 default_options = data
653 return self.async_show_form(
654 step_id="advanced_setup",
655 data_schema=vol.Schema({
656 **_top_level_fields(
657 default_options,
658 alarm_panel_default=default_options.get(CONF_ALARM_PANEL),
659 frigate_available=_frigate_available(self.hass, default_options),
660 ),
661 **await _section_fields(self.hass, default_options),
662 }),
663 description_placeholders=SETUP_HELP_PLACEHOLDERS,
664 errors=errors,
665 )
667 async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
668 """Handle import from YAML configuration."""
669 await self.async_set_unique_id(DOMAIN)
670 self._abort_if_unique_id_configured()
672 alarm_panel_config = import_data.get(CONF_ALARM_PANEL, {})
673 alarm_panel = alarm_panel_config.get(CONF_ENTITY_ID, "") if isinstance(alarm_panel_config, dict) else ""
675 occupancy_config = import_data.get(CONF_OCCUPANCY, {})
676 person_entities = occupancy_config.get(CONF_ENTITY_ID, [])
677 occupancy_defaults = occupancy_config.get(CONF_OCCUPANCY_DEFAULT, {})
679 calendar_config = import_data.get(CONF_CALENDAR_CONTROL, {})
680 calendar_entities = [cal[CONF_ENTITY_ID] for cal in calendar_config.get(CONF_CALENDARS, []) if CONF_ENTITY_ID in cal]
681 no_event_mode = calendar_config.get(CONF_CALENDAR_NO_EVENT, NO_CAL_EVENT_MODE_AUTO)
683 notify_config = import_data.get(CONF_NOTIFY, {})
684 notify_action = notify_config.get(NOTIFY_COMMON, {}).get(CONF_SERVICE, DEFAULT_NOTIFY_ACTION)
685 notify_enabled: bool = notify_config.get(NOTIFY_COMMON, {}).get(CONF_ENABLED, True)
687 diurnal_config = import_data.get(CONF_DIURNAL, {})
688 sunrise_config = diurnal_config.get(CONF_SUNRISE, {}) if diurnal_config else {}
689 sunset_config = diurnal_config.get(CONF_SUNSET, {}) if diurnal_config else {}
691 options = {
692 CONF_CALENDAR_ENTITIES: calendar_entities,
693 CONF_PERSON_ENTITIES: person_entities,
694 CONF_OCCUPANCY_DEFAULT_DAY: occupancy_defaults.get(CONF_DAY, "armed_home"),
695 CONF_OCCUPANCY_DEFAULT_NIGHT: occupancy_defaults.get(CONF_NIGHT),
696 CONF_CALENDAR_ARMED_END_MODE: calendar_config.get(CONF_CALENDAR_ARMED_END, no_event_mode),
697 CONF_CALENDAR_DISARMED_END_MODE: calendar_config.get(CONF_CALENDAR_DISARMED_END, no_event_mode),
698 CONF_NOTIFY_ENABLED: notify_enabled,
699 CONF_NOTIFY_ACTION: notify_action,
700 CONF_NOTIFY_TARGETS: [],
701 CONF_NOTIFY_DATA: {},
702 CONF_SUNRISE_TRIGGER: TRIGGER_AUTO,
703 CONF_SUNSET_TRIGGER: TRIGGER_AUTO,
704 CONF_OCCUPIED_TRIGGER: True,
705 CONF_UNOCCUPIED_TRIGGER: True,
706 CONF_SUNRISE_EARLIEST: _time_to_str(sunrise_config.get(CONF_EARLIEST)),
707 CONF_SUNRISE_LATEST: _time_to_str(sunrise_config.get(CONF_LATEST)),
708 CONF_SUNSET_EARLIEST: _time_to_str(sunset_config.get(CONF_EARLIEST)),
709 CONF_SUNSET_LATEST: _time_to_str(sunset_config.get(CONF_LATEST)),
710 CONF_USE_ALARM_SERVICE: True,
711 CONF_SENTENCE_ARM: True,
712 CONF_SENTENCE_DISARM: False,
713 }
715 return self.async_create_entry(
716 title="Auto Arm",
717 data={CONF_ALARM_PANEL: alarm_panel},
718 options=options,
719 )
721 @staticmethod
722 def async_get_options_flow(config_entry: ConfigEntry) -> AutoArmOptionsFlow:
723 """Get the options flow for this handler."""
724 return AutoArmOptionsFlow()
727class AutoArmOptionsFlow(OptionsFlow):
728 """Handle options flow for Auto Arm."""
730 async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
731 """Manage the options."""
732 options: Mapping[str, Any] = self.config_entry.options
733 alarm_panel: str = self.config_entry.data.get(CONF_ALARM_PANEL, "")
734 errors: dict[str, str] = {}
735 if user_input is not None:
736 data = _flatten_sections(user_input)
737 _settle_frigate_profiles(self.hass, data)
738 errors = _pattern_errors(data)
739 if not errors:
740 await _protect_panel_code(self.hass, data, self.config_entry.options.get(CONF_PANEL_CODE))
742 # The alarm panel entity lives in config_entry.data, not options.
743 alarm_panel = data.pop(CONF_ALARM_PANEL)
744 if alarm_panel != self.config_entry.data.get(CONF_ALARM_PANEL):
745 # along with the options, as one update, so there's a single reload that has both
746 self.hass.config_entries.async_update_entry(
747 self.config_entry,
748 data={**self.config_entry.data, CONF_ALARM_PANEL: alarm_panel},
749 options=data,
750 )
752 return self.async_create_entry(title="", data=data)
753 # shown again as it was filled in, to put right, other than a panel code which stays as stored
754 alarm_panel = data.pop(CONF_ALARM_PANEL)
755 options = {**data, CONF_PANEL_CODE: self.config_entry.options.get(CONF_PANEL_CODE)}
757 return self.async_show_form(
758 step_id="init",
759 data_schema=vol.Schema({
760 **_top_level_fields(
761 options, alarm_panel_default=alarm_panel, frigate_available=_frigate_available(self.hass, options)
762 ),
763 **await _section_fields(self.hass, options),
764 }),
765 description_placeholders=OPTIONS_HELP_PLACEHOLDERS,
766 errors=errors,
767 )
770def _seconds_selector(minimum: int = 0) -> Selector[NumberSelectorConfig]:
771 return NumberSelector(NumberSelectorConfig(min=minimum, max=3600, mode=NumberSelectorMode.BOX, unit_of_measurement="s"))
774def _alarm_state_selector() -> Selector[SelectSelectorConfig]:
775 return SelectSelector(SelectSelectorConfig(options=PUBLIC_ALARM_STATES, mode=SelectSelectorMode.DROPDOWN))
778def _time_of_day_selector() -> Selector[EntitySelectorConfig]:
779 return EntitySelector(EntitySelectorConfig(integration="tod", multiple=True))
782def _sun_trigger_selector() -> Selector[SelectSelectorConfig]:
783 return SelectSelector(
784 SelectSelectorConfig(options=SUN_TRIGGER_OPTIONS, mode=SelectSelectorMode.DROPDOWN, translation_key="sun_trigger")
785 )
788def _calendar_end_mode_selector() -> Selector[SelectSelectorConfig]:
789 return SelectSelector(
790 SelectSelectorConfig(
791 options=CALENDAR_END_MODE_OPTIONS, mode=SelectSelectorMode.DROPDOWN, translation_key="calendar_end_mode"
792 )
793 )