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

+12 -5
View File
@@ -6,8 +6,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_track_state_change_event
from .const import DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
from .const import CONF_HEATER, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
from .weather_sources import sources_for
from .coordinator import HeatingMPCCoordinator
@@ -17,13 +18,19 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = HeatingMPCCoordinator(hass, entry)
await coordinator.async_restore_model()
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
# Read-only: timestamps relay changes between polls for the radiator-lag estimate.
unsub_relay = async_track_state_change_event(
hass, [coordinator.config[CONF_HEATER]], coordinator.async_relay_changed)
try:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except Exception:
hass.data[DOMAIN].pop(entry.entry_id, None)
except BaseException:
# HA skips unload callbacks for unexpected setup errors, so clean up here.
unsub_relay()
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
raise
entry.async_on_unload(unsub_relay)
# Remove only obsolete room forecasts belonging to this entry/platform.
registry = er.async_get(hass)
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms}