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>
This commit is contained in:
1 parent
220ac570d2
commit
46587af8a3
15 files changed
+867
-155
No files matched your search
@@ -1,16 +1,19 @@
|
||||
"""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
|
||||
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, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
||||
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]
|
||||
|
||||
@@ -31,12 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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.
|
||||
# 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}_{room}_predicted_60min" for room in coordinator.rooms}
|
||||
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}_sensor.") 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)
|
||||
@@ -62,8 +66,14 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate legacy single weather source without changing user selections."""
|
||||
if entry.version > 2:
|
||||
"""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):
|
||||
@@ -76,4 +86,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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
|
||||
Reference in new issue
Block a user