Coverage for custom_components/autoarm/time_of_day.py: 94%
80 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
1import datetime as dt
2import logging
3from collections.abc import Callable
4from typing import Any
6from homeassistant.components.alarm_control_panel.const import AlarmControlPanelState
7from homeassistant.const import STATE_ON
8from homeassistant.core import Context, Event, EventStateChangedData, HomeAssistant, State
9from homeassistant.helpers.event import async_track_state_change_event
10from homeassistant.util import dt as dt_util
12from .const import NO_CAL_EVENT_MODE_AUTO, NO_CAL_EVENT_MODE_AUTO_OCCUPANCY, NO_CAL_EVENT_MODE_AUTO_SUN, ChangeSource
13from .helpers import alarm_state_as_enum, child_context
15_LOGGER = logging.getLogger(__name__)
18class TrackedTimeOfDay:
19 """Generate alarm state changes for a Home Assistant Times of the Day sensor
21 The alarm is held at the sensor's state for the period it is on, unless a calendar event is live
22 """
24 def __init__(
25 self,
26 entity_id: str,
27 arming_state: AlarmControlPanelState,
28 armer: AlarmArmer, # type: ignore # ruff:ignore[undefined-name]
29 hass: HomeAssistant,
30 ) -> None:
31 self.entity_id: str = entity_id
32 self.name: str = entity_id
33 self.arming_state: AlarmControlPanelState = arming_state
34 self.armer: AlarmArmer = armer # type: ignore # ruff:ignore[undefined-name]
35 self.hass: HomeAssistant = hass
36 self.active: bool = False
37 self.ended_at: dt.datetime | None = None
38 self.previous_state: AlarmControlPanelState | None = None
39 self.listener: Callable[[], None] | None = None
41 async def initialize(self) -> None:
42 self.listener = async_track_state_change_event(self.hass, [self.entity_id], self.on_sensor_change)
43 state: State | None = self.hass.states.get(self.entity_id)
44 if state is not None and state.state == STATE_ON:
45 self.active = True
46 await self.start(state, None)
47 _LOGGER.debug("AUTOARM Now tracking times of the day sensor %s for %s", self.entity_id, self.arming_state)
49 def shutdown(self) -> None:
50 if self.listener:
51 self.listener()
52 self.listener = None
54 async def on_sensor_change(self, event: Event[EventStateChangedData]) -> None:
55 new_state: State | None = event.data["new_state"]
56 # a sensor that is off, unavailable or removed has no period
57 is_on: bool = new_state is not None and new_state.state == STATE_ON
58 if is_on == self.active:
59 return
60 self.active = is_on
61 context: Context = child_context(event.context)
62 if new_state is not None and is_on:
63 await self.start(new_state, context)
64 else:
65 self.ended_at = dt_util.now()
66 await self.end(context)
68 async def start(self, state: State, context: Context | None) -> None:
69 _LOGGER.debug("AUTOARM Times of the day sensor %s turned on", self.entity_id)
70 self.name = state.name
71 self.previous_state = self.armer.armed_state()
72 if self.armer.has_active_calendar_event():
73 _LOGGER.info("AUTOARM Times of the day sensor %s on, leaving state to the live calendar event", self.entity_id)
74 return
75 await self.armer.arm(
76 self.armer.time_of_day_state(self),
77 source=ChangeSource.TOD,
78 change_context={"caller": "time_of_day.start", "entity_id": self.entity_id, "summary": self.name},
79 context=context,
80 )
82 async def end(self, context: Context) -> None:
83 _LOGGER.debug("AUTOARM Times of the day sensor %s no longer on", self.entity_id)
84 if self.armer.has_active_calendar_event():
85 _LOGGER.debug("AUTOARM No action on times of day end since calendar event active")
86 return
87 if await self.armer.resume_time_of_day(context):
88 return
89 if self.arming_state == AlarmControlPanelState.DISARMED:
90 end_mode: str = self.armer.calendar_disarmed_end_mode
91 else:
92 end_mode = self.armer.calendar_armed_end_mode
93 if end_mode == NO_CAL_EVENT_MODE_AUTO:
94 if self.arming_state == AlarmControlPanelState.ARMED_NIGHT:
95 # bedtime is followed by whatever occupancy and the sun have it as, which sunrise then re-evaluates
96 end_mode = NO_CAL_EVENT_MODE_AUTO_SUN
97 else:
98 # the period ends at a set time, so isn't left to be decided by whether the sun is up yet
99 end_mode = NO_CAL_EVENT_MODE_AUTO_OCCUPANCY
100 change_context: dict[str, Any] = {
101 "caller": "time_of_day.end",
102 "entity_id": self.entity_id,
103 "summary": self.name,
104 "no_event_mode": end_mode,
105 }
106 if end_mode == NO_CAL_EVENT_MODE_AUTO_SUN:
107 context = self.armer.cause(ChangeSource.TOD, self.name, context)
108 await self.armer.reset_armed_state(
109 source=ChangeSource.TOD, context=context, change_context=change_context, force=True
110 )
111 elif end_mode == NO_CAL_EVENT_MODE_AUTO_OCCUPANCY:
112 target_state = self.armer.occupancy_end_state(self.arming_state)
113 _LOGGER.info("AUTOARM Times of day %s ended, and arming by occupancy to %s", self.entity_id, target_state)
114 await self.armer.arm(target_state, source=ChangeSource.TOD, change_context=change_context, context=context)
115 elif end_mode in AlarmControlPanelState:
116 _LOGGER.info("AUTOARM Times of day %s ended, and returning to fixed state %s", self.entity_id, end_mode)
117 await self.armer.arm(
118 alarm_state_as_enum(end_mode), source=ChangeSource.TOD, change_context=change_context, context=context
119 )
120 else:
121 _LOGGER.debug("AUTOARM Reinstate previous state on times of day end in manual mode")
122 await self.armer.arm(self.previous_state, source=ChangeSource.TOD, change_context=change_context, context=context)