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>
80 lines
3.4 KiB
Python
80 lines
3.4 KiB
Python
"""Heating MPC custom integration, initial release shadow-only."""
|
|
from __future__ import annotations
|
|
|
|
from homeassistant.const import Platform
|
|
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 CONF_HEATER, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
|
from .weather_sources import sources_for
|
|
from .coordinator import HeatingMPCCoordinator
|
|
|
|
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()
|
|
# 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 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}
|
|
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
|
if (entity.platform == DOMAIN and
|
|
entity.unique_id.startswith(f"{entry.entry_id}_sensor.") and
|
|
entity.unique_id.endswith("_predicted_60min") and
|
|
entity.unique_id not in expected):
|
|
registry.async_remove(entity.entity_id)
|
|
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
|
return True
|
|
|
|
|
|
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
await hass.config_entries.async_reload(entry.entry_id)
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
if ok:
|
|
hass.data[DOMAIN].pop(entry.entry_id, None)
|
|
return ok
|
|
|
|
|
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Remove the learned model when this integration instance is deleted."""
|
|
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
|
await store.async_remove()
|
|
|
|
|
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Migrate legacy single weather source without changing user selections."""
|
|
if entry.version > 2:
|
|
return False
|
|
if entry.version == 1:
|
|
def migrate(config):
|
|
result = dict(config)
|
|
if CONF_OUTSIDE in result:
|
|
result[WEATHER_SOURCES["temperature"]] = sources_for(result, "temperature")
|
|
result.pop(CONF_OUTSIDE)
|
|
return result
|
|
hass.config_entries.async_update_entry(
|
|
entry, data=migrate(entry.data), options=migrate(entry.options),
|
|
version=2, minor_version=1,
|
|
)
|
|
return True
|