88 BinarySensorEntity ,
99)
1010from homeassistant .const import STATE_OFF , STATE_ON
11- from homeassistant .core import Event , EventStateChangedData , callback
11+ from homeassistant .core import Event , EventStateChangedData , State , callback
1212from homeassistant .helpers .event import async_track_state_change_event
1313
1414from custom_components .magic_areas .base .entities import MagicEntity
1515from custom_components .magic_areas .base .magic import MagicArea
1616from custom_components .magic_areas .const import (
1717 CONF_WASP_IN_A_BOX_DELAY ,
1818 CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES ,
19+ CONF_WASP_IN_A_BOX_WASP_TIMEOUT ,
1920 DEFAULT_WASP_IN_A_BOX_DELAY ,
2021 DEFAULT_WASP_IN_A_BOX_WASP_DEVICE_CLASSES ,
22+ DEFAULT_WASP_IN_A_BOX_WASP_TIMEOUT ,
23+ ONE_MINUTE ,
2124 WASP_IN_A_BOX_BOX_DEVICE_CLASSES ,
2225 MagicAreasFeatureInfoWaspInABox ,
2326 MagicAreasFeatures ,
2427)
28+ from custom_components .magic_areas .helpers .timer import ReusableTimer
2529
2630_LOGGER = logging .getLogger (__name__ )
2731
@@ -34,35 +38,36 @@ class AreaWaspInABoxBinarySensor(MagicEntity, BinarySensorEntity):
3438 """Wasp In The Box logic tracking sensor for the area."""
3539
3640 feature_info = MagicAreasFeatureInfoWaspInABox ()
37- _wasp_sensors : list [str ]
38- _box_sensors : list [str ]
39- delay : int
40- wasp : bool
4141
4242 def __init__ (self , area : MagicArea ) -> None :
4343 """Initialize the area presence binary sensor."""
4444
4545 MagicEntity .__init__ (self , area , domain = BINARY_SENSOR_DOMAIN )
4646 BinarySensorEntity .__init__ (self )
4747
48- self .delay = self .area .feature_config (MagicAreasFeatures .WASP_IN_A_BOX ).get (
49- CONF_WASP_IN_A_BOX_DELAY , DEFAULT_WASP_IN_A_BOX_DELAY
50- )
48+ self ._delay : int = self .area .feature_config (
49+ MagicAreasFeatures .WASP_IN_A_BOX
50+ ).get (CONF_WASP_IN_A_BOX_DELAY , DEFAULT_WASP_IN_A_BOX_DELAY )
51+
52+ self ._wasp_timeout : int = self .area .feature_config (
53+ MagicAreasFeatures .WASP_IN_A_BOX
54+ ).get (CONF_WASP_IN_A_BOX_WASP_TIMEOUT , DEFAULT_WASP_IN_A_BOX_WASP_TIMEOUT )
5155
5256 self ._attr_device_class = BinarySensorDeviceClass .PRESENCE
5357 self ._attr_extra_state_attributes = {
5458 ATTR_BOX : STATE_OFF ,
5559 ATTR_WASP : STATE_OFF ,
5660 }
5761
58- self .wasp = False
62+ self .wasp : bool = False
63+ self ._wasp_timer : ReusableTimer | None = None
5964 self ._attr_is_on : bool = False
6065
61- self ._wasp_sensors = []
62- self ._box_sensors = []
66+ self ._wasp_sensors : list [ str ] = []
67+ self ._box_sensors : list [ str ] = []
6368
6469 async def async_added_to_hass (self ) -> None :
65- """Call to add the system to hass."""
70+ """Call to add the entity to hass."""
6671 await super ().async_added_to_hass ()
6772
6873 # Check entities exist
@@ -80,74 +85,91 @@ async def async_added_to_hass(self) -> None:
8085 continue
8186 self ._wasp_sensors .append (dc_entity_id )
8287
83- if not self ._wasp_sensors :
84- raise RuntimeError (f"{ self .area .name } : No valid wasp sensors defined." )
85-
8688 for device_class in WASP_IN_A_BOX_BOX_DEVICE_CLASSES :
8789 dc_entity_id = f"{ BINARY_SENSOR_DOMAIN } .magic_areas_aggregates_{ self .area .slug } _aggregate_{ device_class } "
8890 dc_state = self .hass .states .get (dc_entity_id )
8991 if not dc_state :
9092 continue
9193 self ._box_sensors .append (dc_entity_id )
9294
93- if not self . _box_sensors :
94- raise RuntimeError ( f" { self .area . name } : No valid wasp sensors defined." )
95+ # Initialize timer if timeout configured
96+ if self ._wasp_timeout > 0 :
9597
96- # Add listeners
98+ async def forget_wasp (now ):
99+ self .wasp = False
100+ self ._attr_extra_state_attributes [ATTR_WASP ] = STATE_OFF
101+ self ._attr_is_on = self .wasp
102+ self .schedule_update_ha_state ()
97103
98- self .async_on_remove (
99- async_track_state_change_event (
100- self .hass , self ._wasp_sensors , self ._wasp_sensor_state_change
104+ self ._wasp_timer = ReusableTimer (
105+ self .hass , self ._wasp_timeout * ONE_MINUTE , forget_wasp
101106 )
102- )
103- self .async_on_remove (
104- async_track_state_change_event (
105- self .hass , self ._box_sensors , self ._box_sensor_state_change
107+
108+ # Add listeners
109+ if self ._wasp_sensors :
110+ self .async_on_remove (
111+ async_track_state_change_event (
112+ self .hass , self ._wasp_sensors , self ._async_wasp_sensor_state_change
113+ )
106114 )
107- )
115+ if self ._box_sensors :
116+ self .async_on_remove (
117+ async_track_state_change_event (
118+ self .hass , self ._box_sensors , self ._async_box_sensor_state_change
119+ )
120+ )
121+
122+ async def async_will_remove_from_hass (self ) -> None :
123+ """Call to remove the entity to hass."""
124+ if self ._wasp_timer :
125+ await self ._wasp_timer .async_remove ()
126+ await super ().async_will_remove_from_hass ()
108127
109- def _wasp_sensor_state_change (self , event : Event [EventStateChangedData ]) -> None :
128+ @callback
129+ async def _async_wasp_sensor_state_change (
130+ self , event : Event [EventStateChangedData ]
131+ ) -> None :
110132 """Register wasp sensor state change event."""
111133
134+ new_state : State | None = event .data .get ("new_state" )
135+ old_state : State | None = event .data .get ("old_state" )
136+
112137 # Ignore state reports that aren't really a state change
113- if not event . data [ " new_state" ] or not event . data [ " old_state" ] :
138+ if new_state is None or old_state is None :
114139 return
115- if event . data [ " new_state" ] .state == event . data [ " old_state" ] .state :
140+ if new_state .state == old_state .state :
116141 return
117142
118- self .wasp_in_a_box (wasp_state = event . data [ " new_state" ] .state )
143+ self .wasp_in_a_box (wasp_state = new_state .state )
119144
120- def _box_sensor_state_change (self , event : Event [EventStateChangedData ]) -> None :
145+ @callback
146+ async def _async_box_sensor_state_change (
147+ self , event : Event [EventStateChangedData ]
148+ ) -> None :
121149 """Register box sensor state change event."""
122150
151+ new_state : State | None = event .data .get ("new_state" )
152+ old_state : State | None = event .data .get ("old_state" )
153+
123154 # Ignore state reports that aren't really a state change
124- if not event . data [ " new_state" ] or not event . data [ " old_state" ] :
155+ if new_state is None or old_state is None :
125156 return
126- if event . data [ " new_state" ] .state == event . data [ " old_state" ] .state :
157+ if new_state .state == old_state .state :
127158 return
128159
129- if self .delay :
160+ if self ._delay :
130161 self .wasp = False
131162 self ._attr_is_on = self .wasp
132- self ._attr_extra_state_attributes [ATTR_BOX ] = event . data [ " new_state" ] .state
163+ self ._attr_extra_state_attributes [ATTR_BOX ] = new_state .state
133164 self ._attr_extra_state_attributes [ATTR_WASP ] = STATE_OFF
134165 self .schedule_update_ha_state ()
135- self .hass . loop . call_soon_threadsafe (
136- self .wasp_in_a_box_delayed ,
137- None ,
138- event . data [ " new_state" ] .state ,
166+ if self ._wasp_timer :
167+ self ._wasp_timer . cancel ()
168+ self . hass . loop . call_later (
169+ self . _delay , self . wasp_in_a_box , None , new_state .state
139170 )
140171 else :
141- self .wasp_in_a_box (box_state = event .data ["new_state" ].state )
142-
143- @callback
144- def wasp_in_a_box_delayed (
145- self ,
146- wasp_state : str | None = None ,
147- box_state : str | None = None ,
148- ) -> None :
149- """Call Wasp In A Box Logic function after a delay."""
150- self .hass .loop .call_later (self .delay , self .wasp_in_a_box , wasp_state , box_state )
172+ self .wasp_in_a_box (box_state = new_state .state )
151173
152174 def wasp_in_a_box (
153175 self ,
@@ -181,8 +203,16 @@ def wasp_in_a_box(
181203 # Main Logic
182204 if wasp_state == STATE_ON :
183205 self .wasp = True
206+ if self ._wasp_timer :
207+ self ._wasp_timer .cancel ()
184208 elif box_state == STATE_ON :
185209 self .wasp = False
210+ if self ._wasp_timer :
211+ self ._wasp_timer .cancel ()
212+ else :
213+ # Wasp is OFF and Box is OFF → start timer
214+ if self ._wasp_timer and self .wasp :
215+ self ._wasp_timer .start ()
186216
187217 self ._attr_extra_state_attributes [ATTR_BOX ] = box_state
188218 self ._attr_extra_state_attributes [ATTR_WASP ] = wasp_state
0 commit comments