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

1import datetime as dt 

2import logging 

3from collections.abc import Callable 

4from typing import Any 

5 

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 

11 

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 

14 

15_LOGGER = logging.getLogger(__name__) 

16 

17 

18class TrackedTimeOfDay: 

19 """Generate alarm state changes for a Home Assistant Times of the Day sensor 

20 

21 The alarm is held at the sensor's state for the period it is on, unless a calendar event is live 

22 """ 

23 

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 

40 

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) 

48 

49 def shutdown(self) -> None: 

50 if self.listener: 

51 self.listener() 

52 self.listener = None 

53 

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) 

67 

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 ) 

81 

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)