Coverage for custom_components/autoarm/autoarming.py: 86%

617 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-27 09:54 +0000

1import asyncio 

2import datetime as dt 

3import json 

4import logging 

5import re 

6from collections.abc import Callable, Coroutine 

7from dataclasses import dataclass 

8from functools import partial 

9from typing import TYPE_CHECKING, Any, cast 

10 

11import homeassistant.util.dt as dt_util 

12import voluptuous as vol 

13from homeassistant.components.alarm_control_panel.const import ATTR_CHANGED_BY, AlarmControlPanelState 

14from homeassistant.components.calendar.const import DOMAIN as CALENDAR_DOMAIN 

15from homeassistant.components.sun.const import STATE_BELOW_HORIZON 

16from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 

17from homeassistant.const import ( 

18 CONF_CONDITIONS, 

19 CONF_DELAY_TIME, 

20 CONF_ENTITY_ID, 

21 CONF_SERVICE, 

22 EVENT_HOMEASSISTANT_STOP, 

23 SERVICE_RELOAD, 

24 STATE_HOME, 

25) 

26from homeassistant.core import ( 

27 Event, 

28 EventStateChangedData, 

29 HomeAssistant, 

30 ServiceCall, 

31 ServiceResponse, 

32 State, 

33 SupportsResponse, 

34 callback, 

35) 

36from homeassistant.exceptions import ConditionError, ConfigEntryNotReady, HomeAssistantError 

37from homeassistant.helpers import config_validation as cv 

38from homeassistant.helpers import entity_platform 

39from homeassistant.helpers import issue_registry as ir 

40from homeassistant.helpers.event import ( 

41 async_track_point_in_time, 

42 async_track_state_change_event, 

43 async_track_sunrise, 

44 async_track_sunset, 

45 async_track_time_change, 

46) 

47from homeassistant.helpers.reload import ( 

48 async_integration_yaml_config, 

49) 

50from homeassistant.helpers.service import async_register_admin_service 

51from homeassistant.helpers.typing import ConfigType 

52from homeassistant.util.hass_dict import HassKey 

53 

54from custom_components.autoarm.hass_api import HomeAssistantAPI 

55from custom_components.autoarm.notifier import Notifier 

56 

57from .calendar_events import TrackedCalendar, TrackedCalendarEvent 

58from .config_flow import ( 

59 CONF_CALENDAR_ENTITIES, 

60 CONF_CALENDAR_OCCUPANCY_OVERRIDE_STATES, 

61 CONF_NO_EVENT_MODE, 

62 CONF_NOTIFY_ACTION, 

63 CONF_NOTIFY_ENABLED, 

64 CONF_NOTIFY_TARGETS, 

65 CONF_OCCUPANCY_DEFAULT_DAY, 

66 CONF_OCCUPANCY_DEFAULT_NIGHT, 

67 CONF_PERSON_ENTITIES, 

68 CONF_SUNRISE_EARLIEST, 

69 CONF_SUNRISE_LATEST, 

70 CONF_SUNSET_EARLIEST, 

71 CONF_SUNSET_LATEST, 

72 DEFAULT_CALENDAR_OCCUPANCY_OVERRIDE_STATES, 

73 DEFAULT_NOTIFY_ACTION, 

74) 

75from .const import ( 

76 ATTR_RESET, 

77 CONF_ALARM_PANEL, 

78 CONF_BUTTONS, 

79 CONF_CALENDAR_CONTROL, 

80 CONF_CALENDAR_EVENT_STATES, 

81 CONF_CALENDAR_NO_EVENT, 

82 CONF_CALENDAR_POLL_INTERVAL, 

83 CONF_CALENDARS, 

84 CONF_DAY, 

85 CONF_DIURNAL, 

86 CONF_EARLIEST, 

87 CONF_LATEST, 

88 CONF_NIGHT, 

89 CONF_NOTIFY, 

90 CONF_OCCUPANCY, 

91 CONF_OCCUPANCY_DEFAULT, 

92 CONF_RATE_LIMIT, 

93 CONF_RATE_LIMIT_CALLS, 

94 CONF_RATE_LIMIT_PERIOD, 

95 CONF_SUNRISE, 

96 CONF_SUNSET, 

97 CONF_TRANSITIONS, 

98 CONFIG_SCHEMA, 

99 DEFAULT_TRANSITIONS, 

100 DOMAIN, 

101 NO_CAL_EVENT_MODE_AUTO, 

102 NO_CAL_EVENT_MODE_MANUAL, 

103 NOTIFY_COMMON, 

104 YAML_DATA_KEY, 

105 ChangeSource, 

106 ConditionVariables, 

107) 

108from .helpers import ( 

109 AppHealthTracker, 

110 ExtendedExtendedJSONEncoder, 

111 Limiter, 

112 alarm_state_as_enum, 

113 change_source_as_enum, 

114 deobjectify, 

115 safe_state, 

116) 

117 

118if TYPE_CHECKING: 

119 from collections.abc import Mapping 

120 

121 ConditionCheckerType = Callable[[Mapping[str, Any] | None], bool] 

122 

123_LOGGER = logging.getLogger(__name__) 

124 

125OVERRIDE_STATES = (AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMED_CUSTOM_BYPASS) 

126EPHEMERAL_STATES = ( 

127 AlarmControlPanelState.PENDING, 

128 AlarmControlPanelState.ARMING, 

129 AlarmControlPanelState.DISARMING, 

130 AlarmControlPanelState.TRIGGERED, 

131) 

132ZOMBIE_STATES = ("unknown", "unavailable") 

133NS_MOBILE_ACTIONS = "mobile_actions" 

134PLATFORMS = ["autoarm"] 

135 

136HASS_DATA_KEY: HassKey["AutoArmData"] = HassKey(DOMAIN) 

137 

138 

139@dataclass 

140class AutoArmData: 

141 armer: "AlarmArmer" 

142 other_data: dict[str, str | dict[str, str] | list[str] | int | float | bool | None] 

143 

144 

145async def async_setup( 

146 hass: HomeAssistant, 

147 config: ConfigType, 

148) -> bool: 

149 _ = CONFIG_SCHEMA 

150 yaml_config: ConfigType = config.get(DOMAIN, {}) 

151 if yaml_config or YAML_DATA_KEY not in hass.data: 

152 hass.data[YAML_DATA_KEY] = yaml_config 

153 

154 has_alarm_panel = CONF_ALARM_PANEL in yaml_config 

155 existing_entries = hass.config_entries.async_entries(DOMAIN) 

156 

157 if has_alarm_panel and not existing_entries: 

158 _LOGGER.info("AUTOARM Triggering import of YAML configuration to ConfigEntry") 

159 hass.async_create_task(hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=yaml_config)) 

160 elif has_alarm_panel and existing_entries: 

161 _LOGGER.warning("AUTOARM YAML core config present but ConfigEntry already exists; ignoring YAML core settings") 

162 ir.async_create_issue( 

163 hass, 

164 DOMAIN, 

165 "yaml_core_config_deprecated", 

166 is_fixable=False, 

167 severity=ir.IssueSeverity.WARNING, 

168 translation_key="yaml_core_config_deprecated", 

169 ) 

170 

171 async def reload_service_handler(service_call: ServiceCall) -> None: 

172 """Reload yaml entities.""" 

173 _LOGGER.info("AUTOARM Reloading %s.%s component, data %s", service_call.domain, service_call.service, service_call.data) 

174 try: 

175 fresh_config = await async_integration_yaml_config(hass, DOMAIN) 

176 except HomeAssistantError as err: 

177 raise HomeAssistantError(f"Failed to reload YAML configuration: {err}") from err 

178 if fresh_config is not None and DOMAIN in fresh_config: 

179 hass.data[YAML_DATA_KEY] = fresh_config[DOMAIN] 

180 else: 

181 hass.data[YAML_DATA_KEY] = {} 

182 entries = hass.config_entries.async_entries(DOMAIN) 

183 for entry in entries: 

184 await hass.config_entries.async_reload(entry.entry_id) 

185 

186 async_register_admin_service( 

187 hass, 

188 DOMAIN, 

189 SERVICE_RELOAD, 

190 reload_service_handler, 

191 ) 

192 

193 def supplemental_action_enquire_configuration(_call: ServiceCall) -> ConfigType: 

194 entries = hass.config_entries.async_entries(DOMAIN) 

195 if not entries: 

196 raise HomeAssistantError("No config entry found for AutoArm") 

197 entry = entries[0] 

198 stashed_yaml = hass.data.get(YAML_DATA_KEY, {}) 

199 data: ConfigType = { 

200 CONF_ALARM_PANEL: entry.data.get(CONF_ALARM_PANEL), 

201 CONF_DIURNAL: { 

202 CONF_SUNRISE: { 

203 CONF_EARLIEST: entry.options.get(CONF_SUNRISE_EARLIEST), 

204 CONF_LATEST: entry.options.get(CONF_SUNRISE_LATEST), 

205 }, 

206 CONF_SUNSET: { 

207 CONF_EARLIEST: entry.options.get(CONF_SUNSET_EARLIEST), 

208 CONF_LATEST: entry.options.get(CONF_SUNSET_LATEST), 

209 }, 

210 }, 

211 CONF_CALENDAR_CONTROL: stashed_yaml.get(CONF_CALENDAR_CONTROL), 

212 CONF_BUTTONS: stashed_yaml.get(CONF_BUTTONS, {}), 

213 CONF_OCCUPANCY: { 

214 CONF_ENTITY_ID: entry.options.get(CONF_PERSON_ENTITIES, []), 

215 CONF_OCCUPANCY_DEFAULT: { 

216 CONF_DAY: entry.options.get(CONF_OCCUPANCY_DEFAULT_DAY, "armed_home"), 

217 }, 

218 }, 

219 CONF_NOTIFY: { 

220 CONF_SERVICE: entry.options.get(CONF_NOTIFY_ACTION) 

221 or stashed_yaml.get(CONF_NOTIFY, {}).get(NOTIFY_COMMON, {}).get(CONF_SERVICE, DEFAULT_NOTIFY_ACTION), 

222 "targets": entry.options.get(CONF_NOTIFY_TARGETS, []), 

223 "profiles": stashed_yaml.get(CONF_NOTIFY, {}), 

224 "enabled": entry.options.get(CONF_NOTIFY_ENABLED, True), 

225 }, 

226 CONF_RATE_LIMIT: stashed_yaml.get(CONF_RATE_LIMIT, {}), 

227 } 

228 try: 

229 jsonized: str = json.dumps(obj=data, cls=ExtendedExtendedJSONEncoder) 

230 return cast("dict[str,Any]", json.loads(jsonized)) 

231 except Exception as err: 

232 raise HomeAssistantError(f"Failed to serialize configuration: {err}") from err 

233 

234 hass.services.async_register( 

235 DOMAIN, 

236 "enquire_configuration", 

237 supplemental_action_enquire_configuration, 

238 supports_response=SupportsResponse.ONLY, 

239 ) 

240 

241 return True 

242 

243 

244async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 

245 """Set up Auto Arm from a config entry.""" 

246 yaml_config: ConfigType = hass.data.get(YAML_DATA_KEY, {}) 

247 try: 

248 armer = _build_armer_from_entry(hass, entry, yaml_config) 

249 hass.data[HASS_DATA_KEY] = AutoArmData(armer, {}) 

250 await armer.initialize() 

251 except Exception as err: 

252 raise ConfigEntryNotReady(f"Failed to initialize Auto Arm: {err}") from err 

253 entry.async_on_unload(entry.add_update_listener(_async_update_listener)) 

254 return True 

255 

256 

257async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 

258 """Unload Auto Arm config entry.""" 

259 if HASS_DATA_KEY in hass.data: 

260 hass.data[HASS_DATA_KEY].armer.shutdown() 

261 del hass.data[HASS_DATA_KEY] 

262 return True 

263 

264 

265async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 

266 """Handle options update by reloading the entry.""" 

267 await hass.config_entries.async_reload(entry.entry_id) 

268 

269 

270def _build_armer_from_entry(hass: HomeAssistant, entry: ConfigEntry, yaml_config: ConfigType) -> "AlarmArmer": 

271 """Build an AlarmArmer instance from ConfigEntry data/options merged with YAML.""" 

272 migrate(hass) 

273 

274 alarm_panel: str = entry.data[CONF_ALARM_PANEL] 

275 person_entities: list[str] = entry.options.get(CONF_PERSON_ENTITIES, []) 

276 calendar_entities: list[str] = entry.options.get(CONF_CALENDAR_ENTITIES, []) 

277 occupancy_default_day: str = entry.options.get(CONF_OCCUPANCY_DEFAULT_DAY, "disarmed") 

278 occupancy_default_night: str | None = entry.options.get(CONF_OCCUPANCY_DEFAULT_NIGHT, "armed_night") 

279 no_event_mode: str = entry.options.get(CONF_NO_EVENT_MODE, NO_CAL_EVENT_MODE_AUTO) 

280 

281 # Build occupancy config 

282 yaml_occupancy = yaml_config.get(CONF_OCCUPANCY, {}) 

283 occupancy_defaults: dict[str, str] = {CONF_DAY: occupancy_default_day} 

284 if occupancy_default_night: 

285 occupancy_defaults[CONF_NIGHT] = occupancy_default_night 

286 occupancy: ConfigType = { 

287 CONF_ENTITY_ID: person_entities, 

288 CONF_OCCUPANCY_DEFAULT: occupancy_defaults, 

289 } 

290 yaml_delay_time = yaml_occupancy.get(CONF_DELAY_TIME) if isinstance(yaml_occupancy, dict) else None 

291 if yaml_delay_time: 

292 occupancy[CONF_DELAY_TIME] = yaml_delay_time 

293 

294 # Build calendar config 

295 yaml_calendar_control = yaml_config.get(CONF_CALENDAR_CONTROL, {}) 

296 yaml_calendars: list[ConfigType] = yaml_calendar_control.get(CONF_CALENDARS, []) if yaml_calendar_control else [] 

297 yaml_cal_by_entity: dict[str, ConfigType] = {cal[CONF_ENTITY_ID]: cal for cal in yaml_calendars if CONF_ENTITY_ID in cal} 

298 

299 calendar_list: list[ConfigType] = [] 

300 for cal_entity_id in calendar_entities: 

301 yaml_override = yaml_cal_by_entity.get(cal_entity_id, {}) 

302 cal_config: ConfigType = { 

303 CONF_ENTITY_ID: cal_entity_id, 

304 CONF_CALENDAR_POLL_INTERVAL: yaml_override.get(CONF_CALENDAR_POLL_INTERVAL, 15), 

305 CONF_CALENDAR_EVENT_STATES: yaml_override.get(CONF_CALENDAR_EVENT_STATES, _validated_default_calendar_mappings()), 

306 } 

307 calendar_list.append(cal_config) 

308 

309 calendar_config: ConfigType = {} 

310 if calendar_list: 

311 calendar_config = { 

312 CONF_CALENDAR_NO_EVENT: no_event_mode, 

313 CONF_CALENDARS: calendar_list, 

314 } 

315 

316 # Build notify config: service from options overrides YAML when explicitly set 

317 notify_profiles = yaml_config.get(CONF_NOTIFY, {}) 

318 

319 # Build diurnal cutoffs: options take priority, YAML is fallback 

320 yaml_diurnal = yaml_config.get(CONF_DIURNAL, {}) or {} 

321 yaml_sunrise = yaml_diurnal.get(CONF_SUNRISE, {}) or {} 

322 yaml_sunset = yaml_diurnal.get(CONF_SUNSET, {}) or {} 

323 

324 def _parse_time(option_key: str, yaml_fallback: dt.time | None) -> dt.time | None: 

325 val = entry.options.get(option_key) 

326 if val is not None: 

327 return cv.time(val) if isinstance(val, str) else val 

328 return yaml_fallback 

329 

330 return AlarmArmer( 

331 hass, 

332 alarm_panel=alarm_panel, 

333 sunrise_earliest=_parse_time(CONF_SUNRISE_EARLIEST, yaml_sunrise.get(CONF_EARLIEST)), 

334 sunrise_latest=_parse_time(CONF_SUNRISE_LATEST, yaml_sunrise.get(CONF_LATEST)), 

335 sunset_earliest=_parse_time(CONF_SUNSET_EARLIEST, yaml_sunset.get(CONF_EARLIEST)), 

336 sunset_latest=_parse_time(CONF_SUNSET_LATEST, yaml_sunset.get(CONF_LATEST)), 

337 buttons=yaml_config.get(CONF_BUTTONS, {}), 

338 occupancy=occupancy, 

339 notify_profiles=notify_profiles, 

340 notify_enabled=entry.options.get(CONF_NOTIFY_ENABLED, False), 

341 notify_action=entry.options.get(CONF_NOTIFY_ACTION), 

342 notify_targets=entry.options.get(CONF_NOTIFY_TARGETS, []), 

343 rate_limit=yaml_config.get(CONF_RATE_LIMIT, {}), 

344 calendar_config=calendar_config, 

345 transitions=yaml_config.get(CONF_TRANSITIONS), 

346 calendar_occupancy_override_states=entry.options.get( 

347 CONF_CALENDAR_OCCUPANCY_OVERRIDE_STATES, DEFAULT_CALENDAR_OCCUPANCY_OVERRIDE_STATES 

348 ), 

349 ) 

350 

351 

352def _validated_default_calendar_mappings() -> dict[str, list[re.Pattern[str]]]: 

353 """Build default calendar event state mappings with compiled regex patterns. 

354 

355 Mirrors the schema validation that CALENDAR_SCHEMA applies (ensure_list + is_regex). 

356 """ 

357 from .const import DEFAULT_CALENDAR_MAPPINGS 

358 

359 result: dict[str, list[re.Pattern[str]]] = {} 

360 for state, patterns in DEFAULT_CALENDAR_MAPPINGS.items(): 

361 state_str = str(state) 

362 if isinstance(patterns, str): 

363 patterns = [patterns] 

364 result[state_str] = [re.compile(p) if isinstance(p, str) else p for p in patterns] 

365 return result 

366 

367 

368def migrate(hass: HomeAssistant) -> None: 

369 for entity_id in ( 

370 "autoarm.configured", 

371 "autoarm.last_calendar_event", 

372 "autoarm.last_intervention", 

373 "autoarm.initialized", 

374 "autoarm.last_calculation", 

375 ): 

376 try: 

377 if hass.states.get(entity_id): 

378 _LOGGER.info("AUTOARM Migration removing legacy entity_id: %s", entity_id) 

379 hass.states.async_remove(entity_id) 

380 except Exception as e: 

381 _LOGGER.warning("AUTOARM Migration fail for %s:%s", entity_id, e) 

382 

383 

384def unlisten(listener: Callable[[], None] | None) -> None: 

385 if listener: 

386 try: 

387 listener() 

388 except Exception as e: 

389 _LOGGER.debug("AUTOARM Failure closing listener %s: %s", listener, e) 

390 

391 

392@dataclass 

393class Intervention: 

394 """Record of a manual intervention, such as a button push, mobile action or alarm panel change""" 

395 

396 created_at: dt.datetime 

397 source: ChangeSource 

398 state: AlarmControlPanelState | None 

399 

400 def as_dict(self) -> dict[str, str | None]: 

401 return { 

402 "created_at": self.created_at.isoformat(), 

403 "source": str(self.source), 

404 "state": str(self.state) if self.state is not None else None, 

405 } 

406 

407 

408@dataclass 

409class AlarmStateWithAttributes: 

410 state: AlarmControlPanelState 

411 source: ChangeSource 

412 attributes: dict[str, str] 

413 

414 

415class AlarmArmer: 

416 def __init__( 

417 self, 

418 hass: HomeAssistant, 

419 alarm_panel: str, 

420 buttons: dict[str, ConfigType] | None = None, 

421 occupancy: ConfigType | None = None, 

422 actions: list[str] | None = None, 

423 notify_enabled: bool = True, 

424 notify_action: str | None = None, 

425 notify_targets: list[str] | None = None, 

426 notify_profiles: ConfigType | None = None, 

427 sunrise_earliest: dt.time | None = None, 

428 sunrise_latest: dt.time | None = None, 

429 sunset_earliest: dt.time | None = None, 

430 sunset_latest: dt.time | None = None, 

431 rate_limit: ConfigType | None = None, 

432 calendar_config: ConfigType | None = None, 

433 transitions: dict[str, dict[str, list[ConfigType]]] | None = None, 

434 calendar_occupancy_override_states: list[str] | None = None, 

435 ) -> None: 

436 occupancy = occupancy or {} 

437 rate_limit = rate_limit or {} 

438 

439 self.hass: HomeAssistant = hass 

440 self.app_health_tracker: AppHealthTracker = AppHealthTracker(hass) 

441 if notify_enabled and not notify_profiles and not notify_action: 

442 _LOGGER.warning("AUTOARM Notification disabled - no config") 

443 notify_enabled = False 

444 if notify_enabled: 

445 self.notifier: Notifier | None = Notifier( 

446 notify_profiles, hass, self.app_health_tracker, notify_action, notify_targets 

447 ) 

448 else: 

449 self.notifier = None 

450 self.local_tz = dt_util.get_time_zone(self.hass.config.time_zone) 

451 calendar_config = calendar_config or {} 

452 self.calendar_configs: list[ConfigType] = calendar_config.get(CONF_CALENDARS, []) or [] 

453 self.calendars: list[TrackedCalendar] = [] 

454 self.calendar_no_event_mode: str | None = calendar_config.get(CONF_CALENDAR_NO_EVENT, NO_CAL_EVENT_MODE_AUTO) 

455 self.calendar_occupancy_override_states: list[str] = ( 

456 calendar_occupancy_override_states 

457 if calendar_occupancy_override_states is not None 

458 else DEFAULT_CALENDAR_OCCUPANCY_OVERRIDE_STATES 

459 ) 

460 self.alarm_panel: str = alarm_panel 

461 self.sunrise_earliest: dt.time | None = sunrise_earliest 

462 self.sunrise_latest: dt.time | None = sunrise_latest 

463 self.sunset_earliest: dt.time | None = sunset_earliest 

464 self.sunset_latest: dt.time | None = sunset_latest 

465 self.occupants: list[str] = occupancy.get(CONF_ENTITY_ID, []) 

466 self.occupied_defaults: dict[str, AlarmControlPanelState] = occupancy.get( 

467 CONF_OCCUPANCY_DEFAULT, {CONF_DAY: AlarmControlPanelState.ARMED_HOME} 

468 ) 

469 self.occupied_delay: dict[str, dt.timedelta] = occupancy.get(CONF_DELAY_TIME, {}) 

470 self.buttons: ConfigType = buttons or {} 

471 

472 self.actions: list[str] = actions or [] 

473 self.unsubscribes: list[Callable[[], None]] = [] 

474 self.pre_pending_state: AlarmControlPanelState | None = None 

475 self.button_device: dict[str, str] = {} 

476 self.arming_in_progress: asyncio.Event = asyncio.Event() 

477 

478 self.rate_limiter: Limiter = Limiter( 

479 window=rate_limit.get(CONF_RATE_LIMIT_PERIOD, dt.timedelta(seconds=60)), 

480 max_calls=rate_limit.get(CONF_RATE_LIMIT_CALLS, 5), 

481 ) 

482 

483 self.hass_api: HomeAssistantAPI = HomeAssistantAPI(hass) 

484 self.transitions: dict[AlarmControlPanelState, ConditionCheckerType] = {} 

485 self.transition_config: dict[str, dict[str, list[ConfigType]]] = transitions or {} 

486 

487 self.interventions: list[Intervention] = [] 

488 self.intervention_ttl: int = 60 

489 

490 async def initialize(self) -> None: 

491 """Async initialization""" 

492 _LOGGER.info("AUTOARM occupied=%s, state=%s, calendars=%s", self.is_occupied(), self.armed_state(), len(self.calendars)) 

493 

494 self.initialize_alarm_panel() 

495 await self.initialize_calendar() 

496 await self.initialize_logic() 

497 self.initialize_diurnal() 

498 self.initialize_occupancy() 

499 self.initialize_buttons() 

500 self.initialize_integration() 

501 self.initialize_housekeeping() 

502 self.initialize_home_assistant() 

503 await self.reset_armed_state(source=ChangeSource.STARTUP) 

504 

505 _LOGGER.info("AUTOARM Initialized, state: %s", self.armed_state()) 

506 

507 def initialize_home_assistant(self) -> None: 

508 self.stop_listener: Callable[[], None] | None = self.hass.bus.async_listen_once( 

509 EVENT_HOMEASSISTANT_STOP, self.async_shutdown 

510 ) 

511 self.app_health_tracker.app_initialized() 

512 self.hass.states.async_set(f"sensor.{DOMAIN}_last_calculation", "unavailable", attributes={}) 

513 

514 self.hass.services.async_register( 

515 DOMAIN, 

516 "reset_state", 

517 self.reset_service, 

518 supports_response=SupportsResponse.OPTIONAL, 

519 ) 

520 

521 async def reset_service(self, _call: ServiceCall) -> ServiceResponse: 

522 new_state = await self.reset_armed_state(intervention=self.record_intervention(source=ChangeSource.ACTION, state=None)) 

523 return {"change": new_state or "NO_CHANGE"} 

524 

525 def initialize_integration(self) -> None: 

526 self.hass.states.async_set(f"sensor.{DOMAIN}_last_intervention", "unavailable", attributes={}) 

527 

528 self.unsubscribes.append(self.hass.bus.async_listen("mobile_app_notification_action", self.on_mobile_action)) 

529 

530 def initialize_alarm_panel(self) -> None: 

531 """Set up automation for Home Assistant alarm panel 

532 

533 See https://www.home-assistant.io/integrations/alarm_control_panel/ 

534 

535 Succeeds even if control panel has not yet started, listener will pick up events when it does 

536 """ 

537 self.unsubscribes.append(async_track_state_change_event(self.hass, [self.alarm_panel], self.on_panel_change)) 

538 _LOGGER.debug("AUTOARM Auto-arming %s", self.alarm_panel) 

539 

540 def initialize_housekeeping(self) -> None: 

541 self.unsubscribes.append( 

542 async_track_time_change( 

543 self.hass, 

544 action=self.housekeeping, 

545 minute=0, 

546 ) 

547 ) 

548 

549 def initialize_diurnal(self) -> None: 

550 # events API expects a function, however underlying HassJob is fine with coroutines 

551 self.unsubscribes.append(async_track_sunrise(self.hass, self.on_sunrise, None)) # type: ignore 

552 self.unsubscribes.append(async_track_sunset(self.hass, self.on_sunset, None)) # type: ignore 

553 if self.sunrise_latest: 

554 self.unsubscribes.append( 

555 async_track_time_change( 

556 self.hass, 

557 self.on_sunrise_latest, 

558 hour=self.sunrise_latest.hour, 

559 minute=self.sunrise_latest.minute, 

560 second=self.sunrise_latest.second, 

561 ) 

562 ) 

563 if self.sunset_latest: 

564 self.unsubscribes.append( 

565 async_track_time_change( 

566 self.hass, 

567 self.on_sunset_latest, 

568 hour=self.sunset_latest.hour, 

569 minute=self.sunset_latest.minute, 

570 second=self.sunset_latest.second, 

571 ) 

572 ) 

573 

574 def initialize_occupancy(self) -> None: 

575 """Configure occupants, and listen for changes in their state""" 

576 if self.occupants: 

577 _LOGGER.info("AUTOARM Occupancy determined by %s", ",".join(self.occupants)) 

578 self.unsubscribes.append(async_track_state_change_event(self.hass, self.occupants, self.on_occupancy_change)) 

579 else: 

580 _LOGGER.info("AUTOARM Occupancy not configured") 

581 

582 def initialize_buttons(self) -> None: 

583 """Initialize (optional) physical alarm state control buttons""" 

584 

585 def setup_button(state_name: str, button_entity: str, cb: Callable[..., Coroutine[Any, Any, None]]) -> None: 

586 self.button_device[state_name] = button_entity 

587 if self.button_device[state_name]: 

588 self.unsubscribes.append(async_track_state_change_event(self.hass, [button_entity], cb)) 

589 

590 _LOGGER.debug( 

591 "AUTOARM Configured %s button for %s", 

592 state_name, 

593 self.button_device[state_name], 

594 ) 

595 

596 for button_use, button_config in self.buttons.items(): 

597 delay: dt.timedelta | None = button_config.get(CONF_DELAY_TIME) 

598 for entity_id in button_config[CONF_ENTITY_ID]: 

599 if button_use == ATTR_RESET: 

600 setup_button(ATTR_RESET, entity_id, partial(self.on_reset_button, delay)) 

601 else: 

602 setup_button( 

603 button_use, entity_id, partial(self.on_alarm_state_button, AlarmControlPanelState(button_use), delay) 

604 ) 

605 

606 async def initialize_calendar(self) -> None: 

607 """Configure calendar polling (optional)""" 

608 stage: str = "calendar" 

609 self.hass.states.async_set(f"sensor.{DOMAIN}_last_calendar_event", "unavailable", attributes={}) 

610 if not self.calendar_configs: 

611 return 

612 try: 

613 platforms: list[entity_platform.EntityPlatform] = entity_platform.async_get_platforms(self.hass, CALENDAR_DOMAIN) 

614 if platforms: 

615 platform: entity_platform.EntityPlatform = platforms[0] 

616 else: 

617 self.app_health_tracker.record_initialization_error(stage) 

618 _LOGGER.error("AUTOARM Calendar platform not available from Home Assistant") 

619 return 

620 except Exception as _e: 

621 self.app_health_tracker.record_initialization_error(stage) 

622 _LOGGER.exception("AUTOARM Unable to access calendar platform") 

623 return 

624 for calendar_config in self.calendar_configs: 

625 tracked_calendar = TrackedCalendar( 

626 self.hass, calendar_config, self.calendar_no_event_mode, self, self.app_health_tracker 

627 ) 

628 await tracked_calendar.initialize(platform) 

629 self.calendars.append(tracked_calendar) 

630 

631 async def initialize_logic(self) -> None: 

632 stage: str = "logic" 

633 for state_str, raw_condition in DEFAULT_TRANSITIONS.items(): 

634 if state_str not in self.transition_config: 

635 _LOGGER.info("AUTOARM Defaulting transition condition for %s", state_str) 

636 self.transition_config[state_str] = {CONF_CONDITIONS: cv.CONDITIONS_SCHEMA(raw_condition)} 

637 

638 for state_str, transition_config in self.transition_config.items(): 

639 error: str = "" 

640 condition_config = transition_config.get(CONF_CONDITIONS) 

641 if condition_config is None: 

642 error = "Empty conditions" 

643 _LOGGER.warning(f"AUTOARM Found no conditions for {state_str} transition") 

644 else: 

645 try: 

646 state = AlarmControlPanelState(state_str) 

647 cond: ConditionCheckerType | None = await self.hass_api.build_condition( 

648 condition_config, strict=True, validate=True, name=state_str 

649 ) 

650 

651 if cond: 

652 # re-run without strict wrapper 

653 cond = await self.hass_api.build_condition(condition_config, name=state_str) 

654 if cond: 

655 _LOGGER.debug(f"AUTOARM Validated transition logic for {state_str}") 

656 self.transitions[state] = cond 

657 else: 

658 _LOGGER.warning(f"AUTOARM Failed to validate transition logic for {state_str}") 

659 error = "Condition validation failed" 

660 except ValueError as ve: 

661 self.app_health_tracker.record_initialization_error(stage) 

662 error = f"Invalid state {ve}" 

663 _LOGGER.error(f"AUTOARM Invalid state in {state_str} transition - {ve}") 

664 except vol.Invalid as vi: 

665 self.app_health_tracker.record_initialization_error(stage) 

666 _LOGGER.error(f"AUTOARM Transition {state_str} conditions fails Home Assistant schema check {vi}") 

667 error = f"Schema error {vi}" 

668 except ConditionError as ce: 

669 _LOGGER.error(f"AUTOARM Transition {state_str} conditions fails Home Assistant condition check {ce}") 

670 if hasattr(ce, "message"): 

671 error = ce.message # type: ignore[attr-defined,unused-ignore] 

672 elif hasattr(ce, "error") and hasattr(ce.error, "message"): # type: ignore[attr-defined,unused-ignore] 

673 error = ce.error.message # type: ignore[attr-defined,unused-ignore] 

674 else: 

675 error = str(ce) 

676 except Exception as e: 

677 self.app_health_tracker.record_initialization_error(stage) 

678 _LOGGER.exception("AUTOARM Disabling transition %s with error validating %s", state_str, condition_config) 

679 error = f"Unknown exception {e}" 

680 if error: 

681 _LOGGER.warning(f"AUTOARM raising report issue for {error} on {state_str}") 

682 self.hass_api.raise_issue( 

683 f"transition_condition_{state_str}", 

684 is_fixable=False, 

685 issue_key="transition_condition", 

686 issue_map={"state": state_str, "error": error}, 

687 severity=ir.IssueSeverity.ERROR, 

688 ) 

689 

690 async def async_shutdown(self, _event: Event) -> None: 

691 _LOGGER.info("AUTOARM shut down event received") 

692 self.stop_listener = None 

693 self.shutdown() 

694 

695 def shutdown(self) -> None: 

696 _LOGGER.info("AUTOARM shutting down") 

697 for calendar in self.calendars: 

698 calendar.shutdown() 

699 while self.unsubscribes: 

700 unlisten(self.unsubscribes.pop()) 

701 unlisten(self.stop_listener) 

702 self.stop_listener = None 

703 _LOGGER.info("AUTOARM shut down") 

704 

705 def active_calendar_event(self) -> TrackedCalendarEvent | None: 

706 events: list[TrackedCalendarEvent] = [] 

707 for cal in self.calendars: 

708 events.extend(cal.active_events()) 

709 if events: 

710 # TODO: consider sorting events to LIFO 

711 return events[0] 

712 return None 

713 

714 def has_active_calendar_event(self) -> bool: 

715 return any(cal.has_active_event() for cal in self.calendars) 

716 

717 def is_occupied(self) -> bool | None: 

718 """Ternary - true at least one person entity has state home, false none of them, null if no occupants defined""" 

719 if self.occupants: 

720 return any(safe_state(self.hass.states.get(p)) == STATE_HOME for p in self.occupants) 

721 return None 

722 

723 def at_home(self) -> list[str] | None: 

724 if self.occupants: 

725 return [p for p in self.occupants if safe_state(self.hass.states.get(p)) == STATE_HOME] 

726 return None 

727 

728 def not_home(self) -> list[str] | None: 

729 if self.occupants: 

730 return [p for p in self.occupants if safe_state(self.hass.states.get(p)) != STATE_HOME] 

731 return None 

732 

733 def is_unoccupied(self) -> bool | None: 

734 """Ternary - false at least one person entity has state home, true none of them, null if no occupants defined""" 

735 if self.occupants: 

736 return all(safe_state(self.hass.states.get(p)) != STATE_HOME for p in self.occupants) 

737 return None 

738 

739 def is_night(self) -> bool: 

740 return safe_state(self.hass.states.get("sun.sun")) == STATE_BELOW_HORIZON 

741 

742 def armed_state(self) -> AlarmControlPanelState: 

743 raw_state: str | None = safe_state(self.hass.states.get(self.alarm_panel)) 

744 alarm_state: AlarmControlPanelState | None = alarm_state_as_enum(raw_state) 

745 if alarm_state is None: 

746 _LOGGER.warning("AUTOARM No alarm state available - treating as PENDING") 

747 return AlarmControlPanelState.PENDING 

748 return alarm_state 

749 

750 def current_state(self) -> AlarmStateWithAttributes: 

751 state: State | None = self.hass.states.get(self.alarm_panel) 

752 source: ChangeSource | None = None 

753 alarm_state: AlarmControlPanelState | None = ( 

754 alarm_state_as_enum(state.state) if state and state.state is not None else None 

755 ) 

756 

757 if state and state.attributes and state.attributes.get(ATTR_CHANGED_BY): 

758 source = change_source_as_enum(state.attributes[ATTR_CHANGED_BY].split("_", 1)[-1]) 

759 return AlarmStateWithAttributes( 

760 state=alarm_state or AlarmControlPanelState.PENDING, 

761 source=source or ChangeSource.UNKNOWN, 

762 attributes=state.attributes if state else {}, 

763 ) 

764 

765 def _extract_event(self, event: Event[EventStateChangedData]) -> tuple[str | None, str | None, str | None, dict[str, str]]: 

766 entity_id = old = new = None 

767 new_attributes: dict[str, str] = {} 

768 if event and event.data: 

769 entity_id = event.data.get("entity_id") 

770 old_obj = event.data.get("old_state") 

771 if old_obj: 

772 old = old_obj.state 

773 new_obj = event.data.get("new_state") 

774 if new_obj: 

775 new = new_obj.state 

776 new_attributes = new_obj.attributes 

777 return entity_id, old, new, new_attributes 

778 

779 async def pending_state(self, source: ChangeSource | None, change_context: dict[str, Any] | None = None) -> None: 

780 self.pre_pending_state = self.armed_state() 

781 change_context = change_context or {} 

782 change_context.update({ 

783 "source": str(source), 

784 "original_caller": change_context.get("caller"), 

785 "caller": "pending_state", 

786 "pre_pending_state": self.pre_pending_state, 

787 }) 

788 await self.arm( 

789 AlarmControlPanelState.PENDING, 

790 source=source, 

791 change_context=change_context, 

792 ) 

793 

794 @callback 

795 async def delayed_reset_armed_state( 

796 self, triggered_at: dt.datetime, requested_at: dt.datetime | None, **kwargs: Any 

797 ) -> None: 

798 _LOGGER.debug("AUTOARM delayed_arm at %s, requested_at: %s", triggered_at, requested_at) 

799 if self.is_intervention_since_request(requested_at): 

800 return 

801 await self.reset_armed_state(**kwargs) 

802 

803 async def reset_armed_state( 

804 self, intervention: Intervention | None = None, source: ChangeSource | None = None 

805 ) -> str | None: 

806 """Logic to automatically work out appropriate current armed state""" 

807 state: AlarmControlPanelState | None = None 

808 existing_state: AlarmControlPanelState | None = None 

809 must_change_state: bool = False 

810 last_state_intervention: Intervention | None = None 

811 active_calendar_event: TrackedCalendarEvent | None = None 

812 

813 if source is None and intervention is not None: 

814 source = intervention.source 

815 _LOGGER.debug( 

816 "AUTOARM reset_armed_state(intervention=%s,source=%s)", 

817 intervention, 

818 source, 

819 ) 

820 reset_decision: str = "no_change" 

821 try: 

822 existing_state = self.armed_state() 

823 state = existing_state 

824 if self.calendars: 

825 active_calendar_event = self.active_calendar_event() 

826 if active_calendar_event: 

827 cal_state: AlarmControlPanelState = active_calendar_event.arming_state 

828 if ( 

829 source == ChangeSource.OCCUPANCY 

830 and cal_state is not None 

831 and active_calendar_event.is_recurring() 

832 and str(cal_state) in self.calendar_occupancy_override_states 

833 ): 

834 _LOGGER.debug("AUTOARM Allowing occupancy reset for recurring overridable calendar event %s", cal_state) 

835 else: 

836 _LOGGER.debug("AUTOARM Ignoring reset while calendar event active") 

837 reset_decision = "ignore_for_active_calendar_event" 

838 return existing_state 

839 if self.calendar_no_event_mode == NO_CAL_EVENT_MODE_MANUAL: 

840 _LOGGER.debug( 

841 "AUTOARM Ignoring reset while calendar configured, no active event, and default mode is manual" 

842 ) 

843 reset_decision = "ignore_for_calendar_manual_default" 

844 return existing_state 

845 if self.calendar_no_event_mode in AlarmControlPanelState: 

846 # TODO: may be dupe logic with on_cal event 

847 _LOGGER.debug("AUTOARM Applying fixed reset on end of calendar event, %s", self.calendar_no_event_mode) 

848 reset_decision = "reset_on_calendar_event_end" 

849 return await self.arm( 

850 alarm_state_as_enum(self.calendar_no_event_mode), 

851 source=ChangeSource.CALENDAR, 

852 change_context={ 

853 "reset_decision": reset_decision, 

854 "calendar_no_event_mode": self.calendar_no_event_mode, 

855 "caller": "reset_armed_state", 

856 }, 

857 ) 

858 if self.calendar_no_event_mode == NO_CAL_EVENT_MODE_AUTO: 

859 _LOGGER.debug("AUTOARM Applying reset while calendar configured, no active event, and default mode is auto") 

860 else: 

861 _LOGGER.warning("AUTOARM Unexpected state for calendar no event mode: %s", self.calendar_no_event_mode) 

862 

863 # TODO: expose as config ( for manual disarm override ) and condition logic 

864 must_change_state = existing_state is None or existing_state == AlarmControlPanelState.PENDING 

865 if ( 

866 intervention 

867 or source in (ChangeSource.CALENDAR, ChangeSource.OCCUPANCY) 

868 or must_change_state 

869 or (self.is_unoccupied() and state in (AlarmControlPanelState.DISARMED, AlarmControlPanelState.ARMED_HOME)) 

870 ): 

871 _LOGGER.debug("AUTOARM Ignoring previous interventions") 

872 else: 

873 last_state_intervention = self.last_state_intervention() 

874 if last_state_intervention: 

875 _LOGGER.debug( 

876 "AUTOARM Ignoring automated reset for %s set by %s at %s", 

877 last_state_intervention.state, 

878 last_state_intervention.source, 

879 last_state_intervention.created_at, 

880 ) 

881 reset_decision = "ignore_after_manual_intervention" 

882 return existing_state 

883 state = self.determine_state() 

884 if state is not None and state != AlarmControlPanelState.PENDING and state != existing_state: 

885 reset_decision = "change_state" 

886 state = await self.arm( 

887 state, source=source, change_context={"reset_decision": reset_decision, "caller": "reset_armed_state"} 

888 ) 

889 

890 finally: 

891 self.hass.states.async_set( 

892 f"sensor.{DOMAIN}_last_calculation", 

893 str(state is not None and state != existing_state), 

894 attributes={ 

895 "new_state": str(state), 

896 "old_state": str(existing_state), 

897 "source": str(source), 

898 "active_calendar_event": deobjectify(active_calendar_event.event) if active_calendar_event else None, 

899 "occupied": self.is_occupied(), 

900 "night": self.is_night(), 

901 "must_change_state": str(must_change_state), 

902 "last_state_intervention": deobjectify(last_state_intervention), 

903 "intervention": intervention.as_dict() if intervention else None, 

904 "time": dt_util.now().isoformat(), 

905 "reset_decision": reset_decision, 

906 }, 

907 ) 

908 

909 return state 

910 

911 def is_intervention_since_request(self, requested_at: dt.datetime | None) -> bool: 

912 if requested_at is not None and self.has_intervention_since(requested_at): 

913 _LOGGER.debug( 

914 "AUTOARM Cancelling delayed operation since subsequent manual action", 

915 ) 

916 return True 

917 return False 

918 

919 def determine_state(self) -> AlarmControlPanelState | None: 

920 """Compute a new state using occupancy, sun and transition conditions""" 

921 evaluated_state: AlarmControlPanelState | None = None 

922 active_calendar_event: TrackedCalendarEvent | None = self.active_calendar_event() 

923 condition_vars: ConditionVariables = ConditionVariables( 

924 occupied=self.is_occupied(), 

925 unoccupied=self.is_unoccupied(), 

926 night=self.is_night(), 

927 state=self.armed_state(), 

928 calendar_event=active_calendar_event.event if active_calendar_event else None, 

929 occupied_defaults=self.occupied_defaults, 

930 at_home=self.at_home(), 

931 not_home=self.not_home(), 

932 ) 

933 for state, checker in self.transitions.items(): 

934 if self.hass_api.evaluate_condition(checker, condition_vars): 

935 _LOGGER.debug("AUTOARM Computed state as %s from condition", state) 

936 evaluated_state = state 

937 break 

938 if evaluated_state is None: 

939 return None 

940 return AlarmControlPanelState(evaluated_state) 

941 

942 @callback 

943 async def delayed_arm(self, triggered_at: dt.datetime, requested_at: dt.datetime | None, **kwargs: Any) -> None: 

944 _LOGGER.debug("AUTOARM delayed_arm at %s, requested_at: %s", triggered_at, requested_at) 

945 if self.is_intervention_since_request(requested_at): 

946 return 

947 await self.arm(**kwargs) 

948 

949 async def arm( 

950 self, 

951 arming_state: AlarmControlPanelState | None, 

952 source: ChangeSource | None = None, 

953 change_context: dict[str, Any] | None = None, 

954 ) -> AlarmControlPanelState | None: 

955 """Change alarm panel state 

956 

957 Args: 

958 ---- 

959 arming_state (str, optional): _description_. Defaults to None. 

960 source (str,optional): Source of the change, for example 'calendar' or 'button' 

961 change_context (dict,optional): Detailed context for the reason arm triggered 

962 

963 Returns: 

964 ------- 

965 str: New arming state 

966 

967 """ 

968 _LOGGER.debug("AUTOARM arm(arming_state=%s,source=%s,change_context=%s", arming_state, source, change_context) 

969 if arming_state is None: 

970 return None 

971 if self.armed_state() == arming_state: 

972 return None 

973 if self.arming_in_progress.is_set(): 

974 _LOGGER.warning("AUTOARM arming already in progress, skipping for %s", source) 

975 return None 

976 if self.rate_limiter.triggered(): 

977 _LOGGER.debug("AUTOARM Rate limit triggered by %s, skipping arm", source) 

978 return None 

979 try: 

980 self.arming_in_progress.set() 

981 existing_state: AlarmControlPanelState | None = self.armed_state() 

982 if arming_state != existing_state: 

983 attrs: dict[str, str] = {} 

984 panel_state: State | None = self.hass.states.get(self.alarm_panel) 

985 if panel_state: 

986 attrs.update(panel_state.attributes) 

987 attrs[ATTR_CHANGED_BY] = f"{DOMAIN}.{source}" 

988 self.hass.states.async_set(entity_id=self.alarm_panel, new_state=str(arming_state), attributes=attrs) 

989 

990 _LOGGER.info("AUTOARM Setting %s from %s to %s for %s", self.alarm_panel, existing_state, arming_state, source) 

991 if self.notifier and source and arming_state: 

992 await self.notifier.notify(source=source, from_state=existing_state, to_state=arming_state) 

993 

994 self.hass_api.fire_event( 

995 event_name="change", 

996 event_data={ 

997 "panel": self.alarm_panel, 

998 "panel_state": panel_state, 

999 "original_state": existing_state, 

1000 "new_state": arming_state, 

1001 "change_source": source, 

1002 "occupied": self.is_occupied(), 

1003 "night": self.is_night(), 

1004 "context": change_context or {}, 

1005 }, 

1006 ) 

1007 return arming_state 

1008 _LOGGER.debug("AUTOARM Skipping arm for %s, as %s already %s", source, self.alarm_panel, arming_state) 

1009 return existing_state 

1010 except Exception as e: 

1011 _LOGGER.error("AUTOARM Failed to arm: %s", e) 

1012 self.app_health_tracker.record_runtime_error() 

1013 finally: 

1014 self.arming_in_progress.clear() 

1015 return None 

1016 

1017 def schedule_state( 

1018 self, 

1019 trigger_time: dt.datetime, 

1020 state: AlarmControlPanelState | None, 

1021 intervention: Intervention | None, 

1022 source: ChangeSource | None = None, 

1023 ) -> None: 

1024 source = source or intervention.source if intervention else None 

1025 

1026 job: Callable[[dt.datetime], Coroutine[Any, Any, None] | None] 

1027 if state is None: 

1028 _LOGGER.debug("AUTOARM Delayed reset, triggered at: %s, source%s", trigger_time, source) 

1029 job = partial(self.delayed_reset_armed_state, intervention=intervention, source=source, requested_at=dt_util.now()) 

1030 else: 

1031 _LOGGER.debug("AUTOARM Delayed arm %s, triggered at: %s, source%s", state, trigger_time, source) 

1032 

1033 job = partial(self.delayed_arm, arming_state=state, source=source, requested_at=dt_util.now()) 

1034 

1035 self.unsubscribes.append( 

1036 async_track_point_in_time( 

1037 self.hass, 

1038 job, 

1039 trigger_time, 

1040 ) 

1041 ) 

1042 

1043 def record_intervention(self, source: ChangeSource, state: AlarmControlPanelState | None) -> Intervention: 

1044 intervention = Intervention(dt_util.now(), source, state) 

1045 self.interventions.append(intervention) 

1046 self.hass.states.async_set(f"sensor.{DOMAIN}_last_intervention", source, attributes=intervention.as_dict()) 

1047 

1048 return intervention 

1049 

1050 def has_intervention_since(self, cutoff: dt.datetime) -> bool: 

1051 """Has there been a manual intervention since the cutoff time""" 

1052 if not self.interventions: 

1053 return False 

1054 return any(intervention.created_at > cutoff for intervention in self.interventions) 

1055 

1056 def last_state_intervention(self) -> Intervention | None: 

1057 candidates: list[Intervention] = [i for i in self.interventions if i.state is not None] 

1058 if candidates: 

1059 return candidates[-1] 

1060 return None 

1061 

1062 @callback 

1063 async def on_sunrise(self, *args: Any) -> None: 

1064 _LOGGER.debug("AUTOARM Sunrise") 

1065 now = dt_util.now() 

1066 if not self.sunrise_earliest or now.time() >= self.sunrise_earliest: 

1067 await self.reset_armed_state(source=ChangeSource.SUNRISE) 

1068 else: 

1069 _LOGGER.debug("AUTOARM Rescheduling delayed sunrise action to %s", self.sunrise_earliest) 

1070 self.schedule_state( 

1071 dt.datetime.combine(now.date(), self.sunrise_earliest, tzinfo=dt_util.DEFAULT_TIME_ZONE), 

1072 intervention=None, 

1073 state=None, 

1074 source=ChangeSource.SUNRISE, 

1075 ) 

1076 

1077 @callback 

1078 async def on_sunrise_latest(self, *args: Any) -> None: 

1079 _LOGGER.debug("AUTOARM Sunrise latest cutoff reached") 

1080 await self.reset_armed_state(source=ChangeSource.SUNRISE) 

1081 

1082 @callback 

1083 async def on_sunset(self, *args: Any) -> None: 

1084 _LOGGER.debug("AUTOARM Sunset") 

1085 now = dt_util.now() 

1086 if not self.sunset_earliest or now.time() >= self.sunset_earliest: 

1087 await self.reset_armed_state(source=ChangeSource.SUNSET) 

1088 else: 

1089 _LOGGER.debug("AUTOARM Rescheduling delayed sunset action to %s", self.sunset_earliest) 

1090 self.schedule_state( 

1091 dt.datetime.combine(now.date(), self.sunset_earliest, tzinfo=dt_util.DEFAULT_TIME_ZONE), 

1092 intervention=None, 

1093 state=None, 

1094 source=ChangeSource.SUNSET, 

1095 ) 

1096 

1097 @callback 

1098 async def on_sunset_latest(self, *args: Any) -> None: 

1099 _LOGGER.debug("AUTOARM Sunset latest cutoff reached") 

1100 await self.reset_armed_state(source=ChangeSource.SUNSET) 

1101 

1102 @callback 

1103 async def on_mobile_action(self, event: Event) -> None: 

1104 _LOGGER.debug("AUTOARM Mobile Action: %s", event) 

1105 source: ChangeSource = ChangeSource.MOBILE 

1106 

1107 match event.data.get("action"): 

1108 case "ALARM_PANEL_DISARM": 

1109 self.record_intervention(source=source, state=AlarmControlPanelState.DISARMED) 

1110 await self.arm( 

1111 AlarmControlPanelState.DISARMED, 

1112 source=source, 

1113 change_context={"caller": "on_mobile_action", "event_data": event.data, "event_type": event.event_type}, 

1114 ) 

1115 case "ALARM_PANEL_RESET": 

1116 await self.reset_armed_state(intervention=self.record_intervention(source=ChangeSource.BUTTON, state=None)) 

1117 case "ALARM_PANEL_AWAY": 

1118 self.record_intervention(source=source, state=AlarmControlPanelState.ARMED_AWAY) 

1119 await self.arm( 

1120 AlarmControlPanelState.ARMED_AWAY, 

1121 source=source, 

1122 change_context={"caller": "on_mobile_action", "event_data": event.data, "event_type": event.event_type}, 

1123 ) 

1124 case _: 

1125 _LOGGER.debug("AUTOARM Ignoring mobile action: %s", event.data) 

1126 

1127 @callback 

1128 async def on_alarm_state_button(self, state: AlarmControlPanelState, delay: dt.timedelta | None, event: Event) -> None: 

1129 _LOGGER.debug("AUTOARM Alarm %s Button: %s", state, event) 

1130 intervention = self.record_intervention(source=ChangeSource.BUTTON, state=state) 

1131 if delay: 

1132 self.schedule_state(dt_util.now() + delay, state, intervention, source=ChangeSource.BUTTON) 

1133 if self.notifier: 

1134 await self.notifier.notify( 

1135 ChangeSource.BUTTON, 

1136 from_state=self.armed_state(), 

1137 to_state=state, 

1138 message=f"Alarm will be set to {state} in {delay}", 

1139 title=f"Arm set to {state} process starting", 

1140 ) 

1141 else: 

1142 await self.arm( 

1143 state, 

1144 source=ChangeSource.BUTTON, 

1145 change_context={ 

1146 "caller": "on_alarm_state_button", 

1147 "event_data": event.data, 

1148 "delay": str(delay), 

1149 "event_type": event.event_type, 

1150 }, 

1151 ) 

1152 

1153 @callback 

1154 async def on_reset_button(self, delay: dt.timedelta | None, event: Event) -> None: 

1155 _LOGGER.debug("AUTOARM Reset Button: %s", event) 

1156 intervention = self.record_intervention(source=ChangeSource.BUTTON, state=None) 

1157 if delay: 

1158 self.schedule_state(dt_util.now() + delay, None, intervention, ChangeSource.BUTTON) 

1159 if self.notifier: 

1160 await self.notifier.notify( 

1161 ChangeSource.BUTTON, 

1162 message=f"Alarm will be reset in {delay}", 

1163 title="Alarm reset wait initiated", 

1164 ) 

1165 else: 

1166 await self.reset_armed_state(intervention=self.record_intervention(source=ChangeSource.BUTTON, state=None)) 

1167 

1168 @callback 

1169 async def on_occupancy_change(self, event: Event[EventStateChangedData]) -> None: 

1170 """Listen for person state events 

1171 

1172 Args: 

1173 ---- 

1174 event (Event[EventStateChangedData]): state change event 

1175 

1176 """ 

1177 entity_id, old, new, new_attributes = self._extract_event(event) 

1178 if old == new: 

1179 _LOGGER.debug( 

1180 "AUTOARM Occupancy Non-state Change: %s, state:%s->%s, event: %s, attrs:%s", 

1181 entity_id, 

1182 old, 

1183 new, 

1184 event, 

1185 new_attributes, 

1186 ) 

1187 return 

1188 _LOGGER.debug( 

1189 "AUTOARM Occupancy state Change: %s, state:%s->%s, event: %s, attrs:%s", entity_id, old, new, event, new_attributes 

1190 ) 

1191 if new in self.occupied_delay: 

1192 self.schedule_state( 

1193 dt_util.now() + self.occupied_delay[new], state=None, intervention=None, source=ChangeSource.OCCUPANCY 

1194 ) 

1195 else: 

1196 await self.reset_armed_state(source=ChangeSource.OCCUPANCY) 

1197 

1198 @callback 

1199 async def on_panel_change(self, event: Event[EventStateChangedData]) -> None: 

1200 """Alarm Control Panel has been changed outside of AutoArm""" 

1201 entity_id, old, new, new_attributes = self._extract_event(event) 

1202 if new_attributes: 

1203 changed_by = new_attributes.get(ATTR_CHANGED_BY) 

1204 if changed_by and changed_by.startswith(f"{DOMAIN}."): 

1205 _LOGGER.debug( 

1206 "AUTOARM Panel Change Ignored: %s,%s: %s-->%s", 

1207 entity_id, 

1208 event.event_type, 

1209 old, 

1210 new, 

1211 ) 

1212 return 

1213 new_state: AlarmControlPanelState | None = alarm_state_as_enum(new) 

1214 old_state: AlarmControlPanelState | None = alarm_state_as_enum(old) 

1215 

1216 _LOGGER.info( 

1217 "AUTOARM Panel Change: %s,%s: %s-->%s", 

1218 entity_id, 

1219 event.event_type, 

1220 old, 

1221 new, 

1222 ) 

1223 self.record_intervention(ChangeSource.ALARM_PANEL, new_state) 

1224 if new in ZOMBIE_STATES: 

1225 _LOGGER.warning("AUTOARM Dezombifying %s ...", new) 

1226 await self.reset_armed_state(source=ChangeSource.ZOMBIFICATION) 

1227 elif new != old: 

1228 if self.notifier: 

1229 await self.notifier.notify(ChangeSource.ALARM_PANEL, old_state, new_state) 

1230 else: 

1231 _LOGGER.debug("AUTOARM panel change leaves state unchanged at %s", new) 

1232 

1233 @callback 

1234 async def housekeeping(self, triggered_at: dt.datetime) -> None: 

1235 _LOGGER.debug("AUTOARM Housekeeping starting, triggered at %s", triggered_at) 

1236 now = dt_util.now() 

1237 self.interventions = [i for i in self.interventions if now < i.created_at + dt.timedelta(minutes=self.intervention_ttl)] 

1238 for cal in self.calendars: 

1239 await cal.prune_events() 

1240 _LOGGER.debug("AUTOARM Housekeeping finished")