Timestamp relay changes with a read-only state listener

Polling only sees the relay's current state and last change, so two or
more changes between polls (e.g. an on/off round trip) made the lag
estimate unknown and paused learning for about half an hour.

Subscribe to the relay's state changes (read-only) and integrate every
recorded change between polls. Because HA delivers these events one
loop iteration late, the current state stays authoritative and fills in
a change not yet recorded. Unknown periods (non on/off states, missed
transitions, event overflow) still restart the estimate with an
explicit error bound. Without the listener, behavior is as before.

HA skips unload callbacks after unexpected setup errors, so setup now
removes the listener itself on any failure and registers it for unload
only once setup succeeds.

Tested on HA Core 2025.12.0 and 2026.2.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 21:49:34 +02:00
1 parent af0a564279
commit 955c679f5e
6 files changed
+185 -22

No files matched your search

+21
View File
@@ -161,3 +161,24 @@ async def test_freshly_written_relay_state_defers_learning(hass, entry, inputs,
await hass.async_block_till_done()
assert coordinator.data["sample_result"] == "lag_uncertain"
assert coordinator.learners["sensor.room"].count == 0
async def test_relay_listener_integrates_changes_between_polls(hass, entry, inputs, freezer):
coordinator = await setup(hass, entry)
start = dt_util.utcnow()
for offset, state in ((60, "on"), (200, "off")): # on/off round trip inside one poll
freezer.move_to(start + timedelta(seconds=offset))
hass.states.async_set("switch.boiler", state)
await hass.async_block_till_done()
for minutes, expected in ((5, "relay_changed"), (10, "learned")):
future = start + timedelta(minutes=minutes, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert coordinator.data["sample_result"] == expected
# Without the listener this round trip would restart the estimate (about 0.46).
assert coordinator.data["heat_lag_uncertainty"] < 0.05
assert await hass.config_entries.async_unload(entry.entry_id)
hass.states.async_set("switch.boiler", "on")
await hass.async_block_till_done()
assert not coordinator._relay_events
+74 -1
View File
@@ -3,6 +3,7 @@ import importlib.util
import sys
import types
import unittest
import unittest.mock
from datetime import datetime, timedelta, timezone
from math import exp, pi, sin
from pathlib import Path
@@ -35,9 +36,13 @@ def load_integration():
'homeassistant', 'homeassistant.const', 'homeassistant.core',
'homeassistant.config_entries', 'homeassistant.helpers',
'homeassistant.helpers.storage', 'homeassistant.helpers.update_coordinator',
'homeassistant.helpers.entity_registry')}
'homeassistant.helpers.entity_registry', 'homeassistant.helpers.event')}
modules['homeassistant.const'].Platform = types.SimpleNamespace(SENSOR='sensor', BINARY_SENSOR='binary_sensor')
modules['homeassistant.core'].HomeAssistant = object
modules['homeassistant.core'].Event = object
modules['homeassistant.core'].callback = lambda func: func
modules['homeassistant.helpers.event'].async_track_state_change_event = (
lambda hass, entity_ids, action: hass.track_state(entity_ids, action))
modules['homeassistant.config_entries'].ConfigEntry = object
modules['homeassistant.helpers.storage'].Store = Store
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
@@ -64,6 +69,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
data={}, states=types.SimpleNamespace(get=self.states.get),
config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')),
services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))),
track_state=Mock(return_value=Mock(name='unsub_relay')),
registry=types.SimpleNamespace(entries=[], async_remove=Mock()),
config_entries=types.SimpleNamespace(
async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True),
@@ -205,6 +211,10 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
kept = types.SimpleNamespace(platform='heating_mpc', unique_id='entry_sensor.room_predicted_60min', entity_id='sensor.forecast')
self.hass.registry.entries = [obsolete, kept]
self.assertTrue(await integration.async_setup_entry(self.hass, self.entry))
self.hass.track_state.assert_called_once_with(['switch.boiler'], unittest.mock.ANY)
unsub = self.hass.track_state.return_value
self.entry.async_on_unload.assert_any_call(unsub)
unsub.assert_not_called()
self.hass.registry.async_remove.assert_called_once_with('sensor.old_forecast')
await integration.async_reload_entry(self.hass, self.entry)
self.hass.config_entries.async_reload.assert_awaited_once_with('entry')
@@ -221,6 +231,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(RuntimeError):
await integration.async_setup_entry(self.hass, self.entry)
self.assertNotIn('entry', self.hass.data['heating_mpc'])
self.hass.track_state.return_value.assert_called_once_with()
async def test_uninstall_removes_storage(self):
store = Store()
@@ -425,3 +436,65 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertGreater(model.on_count, 100)
self.assertAlmostEqual(model.theta[1], b, delta=0.1 * b)
self.assertAlmostEqual(model.theta[0], a, delta=0.25 * a)
async def test_several_relay_changes_between_polls_are_integrated(self):
advance = coordinator.advance_lag
await self.sample()
lag, uncertainty = self.c._lag['lag'], self.c._lag['uncertainty']
start = self.now - timedelta(seconds=300)
# The listener saw on at +60 s and off at +200 s; the state reads off again.
self.c.record_relay_state(start + timedelta(seconds=60), 'on')
self.c.record_relay_state(start + timedelta(seconds=200), 'off')
self.switch('off', 100)
data = await self.sample()
lag = advance(advance(advance(lag, False, 60), True, 140), False, 100)
self.assertEqual(data['sample_result'], 'relay_changed')
self.assertAlmostEqual(self.c._lag['lag'], lag)
self.assertAlmostEqual(self.c._lag['uncertainty'], uncertainty * exp(-300 / 1200))
self.assertEqual(len(self.c._relay_events), 0)
self.assertEqual((await self.sample())['sample_result'], 'learned')
async def test_queued_relay_event_is_reconciled_from_current_state(self):
advance = coordinator.advance_lag
await self.sample()
lag = self.c._lag['lag']
start = self.now - timedelta(seconds=300)
# Only the first change reached the listener before this poll.
self.c.record_relay_state(start + timedelta(seconds=60), 'on')
self.switch('off', 100)
await self.sample()
lag = advance(advance(advance(lag, False, 60), True, 140), False, 100)
self.assertAlmostEqual(self.c._lag['lag'], lag)
# The late event for the change already accounted for is ignored next time.
self.c.record_relay_state(start + timedelta(seconds=200), 'off')
self.assertEqual((await self.sample())['sample_result'], 'learned')
async def test_relay_outage_or_dropped_events_restart_lag_estimate(self):
for events in ((('unavailable', 60), ('off', 120)), ((None, 0),)):
with self.subTest(events=events):
self.c._lag = self.c._previous = None
await self.sample()
start = self.now - timedelta(seconds=300)
if events[0][0] is None:
self.c._relay_events_dropped = True
for state, offset in events:
if state:
self.c.record_relay_state(start + timedelta(seconds=offset), state)
self.switch('off', 180)
data = await self.sample()
self.assertEqual(data['sample_result'], 'relay_changed')
self.assertAlmostEqual(data['heat_lag_uncertainty'], round(exp(-180 / 1200) / 2, 3))
self.assertFalse(self.c._relay_events_dropped)
async def test_relay_listener_records_only_state_changes(self):
def event(old, new, when):
return types.SimpleNamespace(time_fired=when, data={
'old_state': old and types.SimpleNamespace(state=old),
'new_state': new and types.SimpleNamespace(state=new, last_changed=when)})
self.c.async_relay_changed(event('off', 'on', self.now))
self.c.async_relay_changed(event('on', 'on', self.now)) # attribute-only update
self.c.async_relay_changed(event('on', None, self.now)) # entity removed
self.assertEqual(list(self.c._relay_events), [(self.now, 'on'), (self.now, 'removed')])
for _ in range(coordinator.MAX_RELAY_EVENTS):
self.c.record_relay_state(self.now, 'on')
self.assertTrue(self.c._relay_events_dropped)
+11
View File
@@ -148,6 +148,17 @@ class ModelTests(unittest.TestCase):
lag, half_width = m.lag_from_time_in_state(command, 900)
self.assertLessEqual(abs(m.advance_lag(start, command, 900) - lag), half_width + 1e-12)
def test_relay_segments(self):
seg = m.relay_segments
self.assertEqual(seg(False, 0, [], 300), [(False, 300)])
self.assertEqual(seg(False, 0, [(60, 'on'), (200, 'off')], 300),
[(False, 60), (True, 140), (False, 100)])
self.assertEqual(seg(True, 0, [(300, 'off')], 300), [(True, 300), (False, 0)])
for changes in ([(60, 'unavailable')], [(60, 'off')], [(200, 'on'), (60, 'off')],
[(-1, 'on')], [(301, 'on')]):
with self.subTest(changes=changes):
self.assertIsNone(seg(False, 0, changes, 300))
if __name__ == '__main__':
unittest.main()