Skip to content

Commit 4edbb5b

Browse files
authored
Wasp in a Box's Wasp Timeout (#550)
* renamed tests.common to tests.helpers * linting after renaming common to helpers * added timer helper and corresponding test * made the patch async call later block into a fixture * added wasp timeout + tests * added the options and description to translation file, add a check to not enable the timer if the timeout is 0 * fixing some logic errors with the timer implementation on wiab and unnecessary call for delayed wiab logic * increasing voodoo loop * removed requirement box_sensors and wasp_sensors to be present for initialization allowing wiab to be instantiated even with no sensors without errors. this is common during the startup and reloads.
1 parent d607b78 commit 4edbb5b

21 files changed

+458
-84
lines changed

custom_components/magic_areas/binary_sensor/wasp_in_a_box.py

Lines changed: 79 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,24 @@
88
BinarySensorEntity,
99
)
1010
from homeassistant.const import STATE_OFF, STATE_ON
11-
from homeassistant.core import Event, EventStateChangedData, callback
11+
from homeassistant.core import Event, EventStateChangedData, State, callback
1212
from homeassistant.helpers.event import async_track_state_change_event
1313

1414
from custom_components.magic_areas.base.entities import MagicEntity
1515
from custom_components.magic_areas.base.magic import MagicArea
1616
from 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

custom_components/magic_areas/config_flow.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
CONF_TYPE,
117117
CONF_WASP_IN_A_BOX_DELAY,
118118
CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES,
119+
CONF_WASP_IN_A_BOX_WASP_TIMEOUT,
119120
CONFIG_FLOW_ENTITY_FILTER_BOOL,
120121
CONFIG_FLOW_ENTITY_FILTER_EXT,
121122
CONFIGURABLE_AREA_STATE_MAP,
@@ -1273,6 +1274,9 @@ async def async_step_feature_conf_wasp_in_a_box(self, user_input=None):
12731274
CONF_WASP_IN_A_BOX_DELAY: self._build_selector_number(
12741275
min_value=0, unit_of_measurement="seconds"
12751276
),
1277+
CONF_WASP_IN_A_BOX_WASP_TIMEOUT: self._build_selector_number(
1278+
min_value=0, unit_of_measurement="minutes"
1279+
),
12761280
CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES: self._build_selector_select(
12771281
sorted(WASP_IN_A_BOX_WASP_DEVICE_CLASSES), multiple=True
12781282
),

custom_components/magic_areas/const.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,10 @@ class MetaAreaType(StrEnum):
640640
"delay",
641641
90,
642642
) # cv.positive_int
643+
CONF_WASP_IN_A_BOX_WASP_TIMEOUT, DEFAULT_WASP_IN_A_BOX_WASP_TIMEOUT = (
644+
"wasp_timeout",
645+
0, # 0 = disabled
646+
) # cv.positive_int
643647
CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES, DEFAULT_WASP_IN_A_BOX_WASP_DEVICE_CLASSES = (
644648
"wasp_device_classes",
645649
[BinarySensorDeviceClass.MOTION, BinarySensorDeviceClass.OCCUPANCY],
@@ -796,6 +800,9 @@ class MetaAreaType(StrEnum):
796800
vol.Optional(
797801
CONF_WASP_IN_A_BOX_DELAY, default=DEFAULT_WASP_IN_A_BOX_DELAY
798802
): cv.positive_int,
803+
vol.Optional(
804+
CONF_WASP_IN_A_BOX_WASP_TIMEOUT, default=DEFAULT_WASP_IN_A_BOX_WASP_TIMEOUT
805+
): cv.positive_int,
799806
vol.Optional(
800807
CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES,
801808
default=DEFAULT_WASP_IN_A_BOX_WASP_DEVICE_CLASSES,
@@ -1204,6 +1211,11 @@ class MetaAreaType(StrEnum):
12041211

12051212
OPTIONS_WASP_IN_A_BOX = [
12061213
(CONF_WASP_IN_A_BOX_DELAY, DEFAULT_WASP_IN_A_BOX_DELAY, cv.positive_int),
1214+
(
1215+
CONF_WASP_IN_A_BOX_WASP_TIMEOUT,
1216+
DEFAULT_WASP_IN_A_BOX_WASP_TIMEOUT,
1217+
cv.positive_int,
1218+
),
12071219
(
12081220
CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES,
12091221
DEFAULT_WASP_IN_A_BOX_WASP_DEVICE_CLASSES,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Reusable Timer helper for Magic Areas."""
2+
3+
from collections.abc import Awaitable, Callable
4+
from datetime import datetime
5+
import logging
6+
7+
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
8+
from homeassistant.helpers.event import async_call_later
9+
10+
LOGGER: logging.Logger = logging.getLogger(__name__)
11+
12+
13+
class ReusableTimer:
14+
"""Single active reusable timer with fixed delay and callback."""
15+
16+
def __init__(
17+
self,
18+
hass: HomeAssistant,
19+
delay: float,
20+
callback: Callable[[datetime], Awaitable[None]],
21+
) -> None:
22+
"""Initialize the timer with a fixed delay and async callback."""
23+
self.hass = hass
24+
self._delay = delay
25+
self._callback = callback
26+
self._handle: CALLBACK_TYPE | None = None
27+
self._token: int = 0 # protects against race conditions
28+
29+
LOGGER.debug(
30+
"Initialized logger with delay=%d and callback=%s",
31+
self._delay,
32+
str(self._callback),
33+
)
34+
35+
def start(self) -> None:
36+
"""(Re)start the timer using the configured delay + callback."""
37+
self.cancel()
38+
self._token += 1
39+
token = self._token
40+
41+
async def _scheduled(now: datetime) -> None:
42+
# Ignore if a newer start() happened after scheduling
43+
if token != self._token:
44+
LOGGER.debug("Token mismatch. Skipping (%d/%d)", self._token, token)
45+
return
46+
self._handle = None
47+
await self._callback(now)
48+
LOGGER.debug("Timer fired.")
49+
50+
self._handle = async_call_later(self.hass, self._delay, _scheduled)
51+
LOGGER.debug("Timer started.")
52+
53+
def cancel(self) -> None:
54+
"""Cancel the timer if running."""
55+
if self._handle:
56+
self._handle() # async_call_later returns a cancel function
57+
self._handle = None
58+
LOGGER.debug("Timer cancelled.")
59+
60+
async def async_remove(self) -> None:
61+
"""Cleanup when entity/integration is removed."""
62+
self.cancel()

custom_components/magic_areas/translations/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,12 @@
178178
"description": "If we see a wasp, then there is a wasp in the box. If we close the box while there is a wasp in the box, then the wasp remains in the box. If the box remains closed when there is a wasp in the box, and the wasp stops moving, we assume it's still in the box. If the box is opened, and the wasp is not moving, we assume that the wasp has escaped (there is no wasp in the box). This feature monitors your `door` aggregates as the box and your `motion` (default) aggregates as your wasp. A new `binary_sensor` will be created and automatically tracked for presence.",
179179
"data": {
180180
"delay": "Delay",
181+
"wasp_timeout": "Wasp Timeout",
181182
"wasp_device_classes": "Wasp sensor device classes"
182183
},
183184
"data_description": {
184185
"delay": "Time to wait before checking for wasps after a box changing state. Set to 0 to disable.",
186+
"wasp_timeout": "If greater than 0, wasp will be 'forgotten' after being inactive for this long.",
185187
"wasp_device_classes": "If you wish other aggregates to be considered as wasps, please select from the list."
186188
}
187189
},

tests/conftest.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import AsyncGenerator
44
import logging
55
from typing import Any
6+
from unittest.mock import patch
67

78
import pytest
89
from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -22,13 +23,14 @@
2223
AreaType,
2324
)
2425

25-
from tests.common import (
26+
from tests.const import DEFAULT_MOCK_AREA, MOCK_AREAS, MockAreaIds
27+
from tests.helpers import (
2628
get_basic_config_entry_data,
29+
immediate_call_factory,
2730
init_integration,
2831
setup_mock_entities,
2932
shutdown_integration,
3033
)
31-
from tests.const import DEFAULT_MOCK_AREA, MOCK_AREAS, MockAreaIds
3234
from tests.mocks import MockBinarySensor
3335

3436
_LOGGER = logging.getLogger(__name__)
@@ -45,6 +47,19 @@ async def auto_enable_custom_integrations(
4547
yield
4648

4749

50+
# Timer-related
51+
52+
53+
@pytest.fixture
54+
def patch_async_call_later(hass):
55+
"""Automatically patch async_call_later for ReusableTimer tests."""
56+
with patch(
57+
"custom_components.magic_areas.helpers.timer.async_call_later",
58+
side_effect=immediate_call_factory(hass),
59+
):
60+
yield
61+
62+
4863
# Config entries
4964

5065

0 commit comments

Comments
 (0)