Skip to content

Commit bf71cdb

Browse files
Enhance CalendarEventBinarySensor to manage scheduled updates and cancel pending calls on entity disable; update tests for new functionality
1 parent 5ade5ca commit bf71cdb

File tree

3 files changed

+226
-14
lines changed

3 files changed

+226
-14
lines changed

custom_components/calendar_event/binary_sensor.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(
8787

8888
self._attr_is_on = False
8989
self._attr_extra_state_attributes = {}
90+
self._call_later_handle = None
9091

9192
async def async_added_to_hass(self) -> None:
9293
"""Handle added to Hass."""
@@ -99,6 +100,17 @@ async def async_added_to_hass(self) -> None:
99100

100101
await self._update_state()
101102

103+
def _cancel_call_later(self) -> None:
104+
"""Cancel any pending call_later."""
105+
if self._call_later_handle is not None:
106+
self._call_later_handle.cancel()
107+
self._call_later_handle = None
108+
109+
async def async_will_remove_from_hass(self) -> None:
110+
"""Handle entity removal."""
111+
self._cancel_call_later()
112+
await super().async_will_remove_from_hass()
113+
102114
@callback
103115
async def _state_changed(self, event: Event) -> None:
104116
"""Handle calendar entity state changes."""
@@ -138,10 +150,13 @@ async def _update_state(self) -> None:
138150

139151
self.async_write_ha_state()
140152

141-
if calendar_state.state == "on":
153+
self._cancel_call_later()
154+
155+
# Schedule next update only if calendar is on and entity is enabled
156+
if calendar_state.state == "on" and self.enabled:
142157
now = datetime.now()
143158
seconds_until_next_minute = 60 - now.second
144-
self._hass.loop.call_later(
159+
self._call_later_handle = self._hass.loop.call_later(
145160
seconds_until_next_minute,
146161
lambda: self._hass.async_create_task(self._update_state()),
147162
)

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
colorlog==6.9.0
2-
homeassistant==2025.7.0
2+
homeassistant==2025.8.0
33
pip>=21.0,<25.3
44
ruff==0.11.13

tests/test_binary_sensor.py

Lines changed: 208 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""The test for the calendar_event binary sensor platform."""
22

3-
from unittest.mock import patch
3+
from unittest.mock import MagicMock, patch
44

55
import pytest
66
from homeassistant.core import HomeAssistant
@@ -193,19 +193,24 @@ async def test_binary_sensor_calendar_unavailable(
193193
title="Test Unavailable",
194194
)
195195

196-
await setup_integration(hass, config_entry)
196+
with patch(
197+
"custom_components.calendar_event.binary_sensor.CalendarEventBinarySensor._get_event_matching_summary"
198+
) as mock_get_events:
199+
mock_get_events.return_value = None
197200

198-
binary_sensor_entity_id = "binary_sensor.test_unavailable"
201+
await setup_integration(hass, config_entry)
199202

200-
# Set calendar to unavailable (remove it from the state registry)
201-
hass.states.async_remove(mock_calendar_entity.entity_id)
202-
await hass.async_block_till_done()
203+
binary_sensor_entity_id = "binary_sensor.test_unavailable"
204+
205+
# Set calendar to unavailable (remove it from the state registry)
206+
hass.states.async_remove(mock_calendar_entity.entity_id)
207+
await hass.async_block_till_done()
203208

204-
# Check binary sensor state
205-
binary_sensor_state = hass.states.get(binary_sensor_entity_id)
206-
assert binary_sensor_state is not None
207-
assert binary_sensor_state.state == "off"
208-
assert binary_sensor_state.attributes.get(ATTR_DESCRIPTION) == ""
209+
# Check binary sensor state
210+
binary_sensor_state = hass.states.get(binary_sensor_entity_id)
211+
assert binary_sensor_state is not None
212+
assert binary_sensor_state.state == "off"
213+
assert binary_sensor_state.attributes.get(ATTR_DESCRIPTION) == ""
209214

210215

211216
async def test_binary_sensor_state_change_listener(
@@ -371,3 +376,195 @@ def test_matches_criteria_logic(
371376

372377
result = sensor._matches_criteria(event_summary)
373378
assert result == expected_match
379+
380+
381+
async def test_binary_sensor_disabled_no_call_later(
382+
hass: HomeAssistant,
383+
mock_calendar_entity: er.RegistryEntry,
384+
) -> None:
385+
"""Test that disabled binary sensor does not schedule periodic updates."""
386+
387+
config_entry = MockConfigEntry(
388+
domain=DOMAIN,
389+
data={},
390+
options={
391+
"name": "Test Disabled",
392+
CONF_CALENDAR_ENTITY_ID: mock_calendar_entity.entity_id,
393+
CONF_SUMMARY: "meeting",
394+
CONF_COMPARISON_METHOD: "contains",
395+
},
396+
title="Test Disabled",
397+
)
398+
399+
with patch(
400+
"custom_components.calendar_event.binary_sensor.CalendarEventBinarySensor._get_event_matching_summary"
401+
) as mock_get_events:
402+
mock_get_events.return_value = {
403+
"summary": "Team Meeting",
404+
"description": "Test event description",
405+
}
406+
407+
# Mock the call_later method to track if it's called
408+
with patch.object(hass.loop, "call_later") as mock_call_later:
409+
await setup_integration(hass, config_entry)
410+
411+
binary_sensor_entity_id = "binary_sensor.test_disabled"
412+
413+
# Get the binary sensor entity from the entity registry and disable it
414+
entity_registry = er.async_get(hass)
415+
entity_entry = entity_registry.async_get(binary_sensor_entity_id)
416+
assert entity_entry is not None
417+
418+
# Disable the entity
419+
entity_registry.async_update_entity(
420+
entity_entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER
421+
)
422+
await hass.async_block_till_done()
423+
424+
# Reset the mock to clear any previous calls
425+
mock_call_later.reset_mock()
426+
427+
# Set calendar to active state to trigger potential call_later
428+
hass.states.async_set(
429+
mock_calendar_entity.entity_id,
430+
"on",
431+
{"message": "Team Meeting", "description": "Test description"},
432+
)
433+
await hass.async_block_till_done()
434+
435+
# Verify that call_later was NOT called for the disabled entity
436+
mock_call_later.assert_not_called()
437+
438+
439+
async def test_binary_sensor_enabled_schedules_call_later(
440+
hass: HomeAssistant,
441+
mock_calendar_entity: er.RegistryEntry,
442+
) -> None:
443+
"""Test that enabled binary sensor schedules periodic updates when calendar is on."""
444+
445+
config_entry = MockConfigEntry(
446+
domain=DOMAIN,
447+
data={},
448+
options={
449+
"name": "Test Enabled",
450+
CONF_CALENDAR_ENTITY_ID: mock_calendar_entity.entity_id,
451+
CONF_SUMMARY: "meeting",
452+
CONF_COMPARISON_METHOD: "contains",
453+
},
454+
title="Test Enabled",
455+
)
456+
457+
with patch(
458+
"custom_components.calendar_event.binary_sensor.CalendarEventBinarySensor._get_event_matching_summary"
459+
) as mock_get_events:
460+
mock_get_events.return_value = {
461+
"summary": "Team Meeting",
462+
"description": "Test event description",
463+
}
464+
465+
# Mock the call_later method to track if it's called
466+
with patch.object(hass.loop, "call_later") as mock_call_later:
467+
await setup_integration(hass, config_entry)
468+
469+
binary_sensor_entity_id = "binary_sensor.test_enabled"
470+
471+
# Verify the entity is enabled by default
472+
entity_registry = er.async_get(hass)
473+
entity_entry = entity_registry.async_get(binary_sensor_entity_id)
474+
assert entity_entry is not None
475+
assert not entity_entry.disabled
476+
477+
# Reset the mock to clear any previous calls
478+
mock_call_later.reset_mock()
479+
480+
# Set calendar to active state to trigger call_later
481+
hass.states.async_set(
482+
mock_calendar_entity.entity_id,
483+
"on",
484+
{"message": "Team Meeting", "description": "Test description"},
485+
)
486+
await hass.async_block_till_done()
487+
488+
# Verify that call_later WAS called for the enabled entity
489+
mock_call_later.assert_called_once()
490+
491+
# Verify the call_later was scheduled with correct parameters
492+
call_args = mock_call_later.call_args
493+
assert len(call_args[0]) == 2 # delay and callback
494+
delay = call_args[0][0]
495+
assert isinstance(delay, (int, float))
496+
assert 0 < delay <= 60 # Should be between 0 and 60 seconds
497+
498+
499+
async def test_binary_sensor_cancels_call_later_when_disabled(
500+
hass: HomeAssistant,
501+
mock_calendar_entity: er.RegistryEntry,
502+
) -> None:
503+
"""Test that binary sensor cancels call_later when entity is disabled during runtime."""
504+
505+
config_entry = MockConfigEntry(
506+
domain=DOMAIN,
507+
data={},
508+
options={
509+
"name": "Test Cancel",
510+
CONF_CALENDAR_ENTITY_ID: mock_calendar_entity.entity_id,
511+
CONF_SUMMARY: "meeting",
512+
CONF_COMPARISON_METHOD: "contains",
513+
},
514+
title="Test Cancel",
515+
)
516+
517+
with patch(
518+
"custom_components.calendar_event.binary_sensor.CalendarEventBinarySensor._get_event_matching_summary"
519+
) as mock_get_events:
520+
mock_get_events.return_value = {
521+
"summary": "Team Meeting",
522+
"description": "Test event description",
523+
}
524+
525+
await setup_integration(hass, config_entry)
526+
527+
binary_sensor_entity_id = "binary_sensor.test_cancel"
528+
529+
# Get the entity registry
530+
entity_registry = er.async_get(hass)
531+
entity_entry = entity_registry.async_get(binary_sensor_entity_id)
532+
assert entity_entry is not None
533+
534+
# Create a mock handle to track cancellation
535+
mock_handle = MagicMock()
536+
537+
with patch.object(
538+
hass.loop, "call_later", return_value=mock_handle
539+
) as mock_call_later:
540+
# Set calendar to active state to trigger call_later
541+
hass.states.async_set(
542+
mock_calendar_entity.entity_id,
543+
"on",
544+
{"message": "Team Meeting", "description": "Test description"},
545+
)
546+
await hass.async_block_till_done()
547+
548+
# Verify call_later was called and handle was stored
549+
mock_call_later.assert_called_once()
550+
551+
# Reset mock to clear call
552+
mock_call_later.reset_mock()
553+
554+
# Now disable the entity
555+
entity_registry.async_update_entity(
556+
entity_entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER
557+
)
558+
await hass.async_block_till_done()
559+
560+
# Trigger another calendar state change
561+
hass.states.async_set(
562+
mock_calendar_entity.entity_id,
563+
"on",
564+
{"message": "Another Meeting", "description": "Test description 2"},
565+
)
566+
await hass.async_block_till_done()
567+
568+
# Verify the previous handle was cancelled and no new call_later was scheduled
569+
mock_handle.cancel.assert_called_once()
570+
mock_call_later.assert_not_called()

0 commit comments

Comments
 (0)