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

1"""Config flow for Auto Arm integration.""" 

2 

3import datetime as dt 

4import re 

5from collections.abc import Mapping 

6from typing import Any 

7 

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) 

31 

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 

68 

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" 

95 

96 

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}" 

100 

101 

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" 

135 

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} 

165 

166 

167def button_option(use: str) -> str: 

168 return f"button_{use}" 

169 

170 

171def button_delay_option(use: str) -> str: 

172 return f"button_{use}_delay" 

173 

174 

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" 

177 

178 

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 

182 

183 

184DEFAULT_NOTIFY_ACTION = "notify.send_message" 

185 

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} 

217 

218 

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 } 

241 

242 

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)) 

246 

247 

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 

251 

252 

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 } 

288 

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 } 

496 

497 

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 } 

534 

535 

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)) 

539 

540 

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 

543 

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 

553 

554 

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 

562 

563 

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 {} 

573 

574 

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 

583 

584 

585class AutoArmConfigFlow(ConfigFlow, domain=DOMAIN): 

586 """Handle a config flow for Auto Arm.""" 

587 

588 VERSION = 1 

589 # 2 split no_event_mode into armed and disarmed end modes 

590 MINOR_VERSION = 2 

591 

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 ) 

601 

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 } 

613 

614 return self.async_create_entry( 

615 title="Auto Arm", 

616 data={CONF_ALARM_PANEL: user_input[CONF_ALARM_PANEL]}, 

617 options=options, 

618 ) 

619 

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 ) 

629 

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 

652 

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 ) 

666 

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() 

671 

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 "" 

674 

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, {}) 

678 

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) 

682 

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) 

686 

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 {} 

690 

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 } 

714 

715 return self.async_create_entry( 

716 title="Auto Arm", 

717 data={CONF_ALARM_PANEL: alarm_panel}, 

718 options=options, 

719 ) 

720 

721 @staticmethod 

722 def async_get_options_flow(config_entry: ConfigEntry) -> AutoArmOptionsFlow: 

723 """Get the options flow for this handler.""" 

724 return AutoArmOptionsFlow() 

725 

726 

727class AutoArmOptionsFlow(OptionsFlow): 

728 """Handle options flow for Auto Arm.""" 

729 

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)) 

741 

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 ) 

751 

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)} 

756 

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 ) 

768 

769 

770def _seconds_selector(minimum: int = 0) -> Selector[NumberSelectorConfig]: 

771 return NumberSelector(NumberSelectorConfig(min=minimum, max=3600, mode=NumberSelectorMode.BOX, unit_of_measurement="s")) 

772 

773 

774def _alarm_state_selector() -> Selector[SelectSelectorConfig]: 

775 return SelectSelector(SelectSelectorConfig(options=PUBLIC_ALARM_STATES, mode=SelectSelectorMode.DROPDOWN)) 

776 

777 

778def _time_of_day_selector() -> Selector[EntitySelectorConfig]: 

779 return EntitySelector(EntitySelectorConfig(integration="tod", multiple=True)) 

780 

781 

782def _sun_trigger_selector() -> Selector[SelectSelectorConfig]: 

783 return SelectSelector( 

784 SelectSelectorConfig(options=SUN_TRIGGER_OPTIONS, mode=SelectSelectorMode.DROPDOWN, translation_key="sun_trigger") 

785 ) 

786 

787 

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 )