Files
heating-mpc-ha/custom_components/heating_mpc/__init__.py
T
buzeraandClaude Opus 5.5 46587af8a3 Introduce thermal zones as config subentries
Each temperature sensor used to be its own "room", so a room with two
sensors counted twice and comfort importance was tied to sensors.

zones.py adds Zone: a name, one or more sensors with sensor weights, a
comfort weight, an optional target offset and adjacent zones (made
symmetric). Sensor weights only combine readings; comfort weights only
steer the MPC; neither touches the learned thermal coefficients.

Zones are config subentries (HA's native "Add zone" / edit / delete on
the integration page), with validation that a sensor belongs to one
zone and is not an outdoor source, and a sensor-weight step for
multi-sensor zones. Initial setup still asks for room sensors and
weights and creates one zone per sensor. Configure no longer asks for
rooms or weights.

Config entry version 3. Migrating from 2 turns each room sensor into a
one-sensor zone with its comfort weight and the sensor's entity ID as
the zone key, so learned models, forecast entities (now linked to their
zone, so deleting a zone removes them) and accuracy statistics carry
over. Older versions cannot load a version 3 entry; rolling back needs
the HA backup.

A zone learns only across polls that used the same sensors (a sensor
dropping out shifts the mean by its offset); the new poll outcome
"sensors_changed" reports this. Status renames: input_quality.zones
(per-sensor detail nested), excluded_zones, retired_zone_models; adds
zone_names. Invalid stored comfort weights now fall back to 1.0, so the
invalid_weights status is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:32:11 +02:00

111 lines
5.1 KiB
Python

"""Heating MPC custom integration, initial release shadow-only."""
from __future__ import annotations
from types import MappingProxyType
from homeassistant.const import Platform
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
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, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
from .weather_sources import sources_for
from .coordinator import HeatingMPCCoordinator
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
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 zone forecasts belonging to this entry/platform. Zones
# deleted in the UI are also removed by HA through their subentry link.
registry = er.async_get(hass)
expected = {f"{entry.entry_id}_{zone}_predicted_60min" for zone 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}_") 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 older entries without changing the user's selections.
1 -> 2: single outdoor sensor becomes a prioritized source list.
2 -> 3: each room sensor becomes a one-sensor zone subentry with its comfort
weight. Its entity ID stays the zone key, so learned models, forecast entities
and accuracy statistics carry over. Older versions cannot load version 3.
"""
if entry.version > 3:
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,
)
if entry.version == 2:
config = {**entry.data, **entry.options}
rooms = [room for room in config.get(CONF_ROOMS, []) if isinstance(room, str)]
registry = er.async_get(hass)
names = {}
for room in rooms:
state, registered = hass.states.get(room), registry.async_get(room)
names[room] = ((state.name if state else None)
or (registered.name or registered.original_name if registered else None)
or room)
existing = {sub.unique_id for sub in entry.subentries.values()}
for data in legacy_zone_data(rooms, config.get(CONF_WEIGHTS), names):
if data[ZONE_KEY] not in existing:
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
data=MappingProxyType(data), subentry_type=SUBENTRY_ZONE,
title=data[ZONE_NAME], unique_id=data[ZONE_KEY]))
def strip(config):
return {k: v for k, v in config.items() if k not in (CONF_ROOMS, CONF_WEIGHTS)}
hass.config_entries.async_update_entry(
entry, data=strip(entry.data), options=strip(entry.options), version=3, minor_version=1)
return True