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,7 +1,8 @@
|
||||
"""UI-only installation and reconfiguration: rooms, source priorities, weights."""
|
||||
"""UI-only installation and reconfiguration: zones, source priorities, weights."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
@@ -11,11 +12,16 @@ from homeassistant.helpers import selector
|
||||
from .const import (
|
||||
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE,
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
||||
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
||||
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
||||
)
|
||||
from .weather_sources import sources_for
|
||||
from .zones import (
|
||||
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE,
|
||||
ZONE_COMFORT_WEIGHT, ZONE_KEY, ZONE_NAME, ZONE_NEIGHBORS, ZONE_SENSOR_WEIGHTS,
|
||||
ZONE_SENSORS, ZONE_TARGET_OFFSET, Zone, build_zones, legacy_zone_data, validate_zone,
|
||||
)
|
||||
|
||||
|
||||
def _entity_picker(multiple: bool = False):
|
||||
@@ -30,14 +36,16 @@ def _required(key: str, data: dict):
|
||||
|
||||
|
||||
def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||
"""Setup asks for room sensors (one zone each); Configure manages zones separately."""
|
||||
data = config or {}
|
||||
schema: dict[Any, Any] = {
|
||||
_required(CONF_CLIMATE, data): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="climate")),
|
||||
_required(CONF_HEATER, data): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="switch")),
|
||||
_required(CONF_ROOMS, data): _entity_picker(multiple=True),
|
||||
}
|
||||
if not detailed:
|
||||
schema[_required(CONF_ROOMS, data)] = _entity_picker(multiple=True)
|
||||
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
|
||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
|
||||
vol.Coerce(int), vol.Range(min=5, max=1440))
|
||||
@@ -98,10 +106,22 @@ def _valid_weather(rooms: list[str], data: dict) -> bool:
|
||||
return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms)
|
||||
|
||||
|
||||
def entry_zones(entry: config_entries.ConfigEntry) -> list[Zone]:
|
||||
"""Zones of a config entry, from its zone subentries."""
|
||||
return build_zones((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_ZONE)
|
||||
|
||||
|
||||
def _number(lo: float, hi: float, step: float) -> selector.NumberSelector:
|
||||
return selector.NumberSelector(selector.NumberSelectorConfig(
|
||||
min=lo, max=hi, step=step, mode=selector.NumberSelectorMode.BOX))
|
||||
|
||||
|
||||
class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Create a native observation-only MPC installation."""
|
||||
|
||||
VERSION = 2
|
||||
# 3: rooms and comfort weights moved into zone subentries.
|
||||
VERSION = 3
|
||||
|
||||
def __init__(self):
|
||||
self._draft: dict[str, Any] = {}
|
||||
@@ -136,11 +156,17 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft[CONF_WEIGHTS] = {
|
||||
room: float(user_input[f"weight__{room}"])
|
||||
for room in self._draft[CONF_ROOMS]
|
||||
}
|
||||
return self.async_create_entry(title="Heating MPC", data=self._draft)
|
||||
rooms = self._draft.pop(CONF_ROOMS)
|
||||
weights = {room: float(user_input[f"weight__{room}"]) for room in rooms}
|
||||
names = {}
|
||||
for room in rooms:
|
||||
state = self.hass.states.get(room)
|
||||
names[room] = state.name if state else room
|
||||
# Each chosen sensor becomes its own zone; zones can be edited later.
|
||||
zones = [{"data": data, "subentry_type": SUBENTRY_ZONE, "title": data[ZONE_NAME],
|
||||
"unique_id": data[ZONE_KEY]}
|
||||
for data in legacy_zone_data(rooms, weights, names)]
|
||||
return self.async_create_entry(title="Heating MPC", data=self._draft, subentries=zones)
|
||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||
self._draft[CONF_ROOMS]))
|
||||
|
||||
@@ -149,6 +175,11 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
||||
return HeatingMPCOptionsFlow()
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry):
|
||||
return {SUBENTRY_ZONE: ZoneSubentryFlow}
|
||||
|
||||
|
||||
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
|
||||
@@ -156,12 +187,12 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
def __init__(self):
|
||||
self._draft: dict[str, Any] = {}
|
||||
|
||||
def _zone_sensors(self) -> list[str]:
|
||||
return [sensor for zone in entry_zones(self.config_entry) for sensor in zone.sensors]
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options}
|
||||
if user_input is not None:
|
||||
if not _valid_rooms(user_input[CONF_ROOMS]):
|
||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||
config, detailed=True), errors={"base": "invalid_rooms"})
|
||||
self._draft = user_input
|
||||
return await self.async_step_weather()
|
||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||
@@ -170,7 +201,7 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||
if user_input is not None:
|
||||
if not _valid_weather(self._draft[CONF_ROOMS], user_input):
|
||||
if not _valid_weather(self._zone_sensors(), user_input):
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"})
|
||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_MAIN))
|
||||
@@ -182,19 +213,87 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||
if user_input is not None:
|
||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_EXTRA))
|
||||
return await self.async_step_weights()
|
||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||
config, WEATHER_EXTRA))
|
||||
|
||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft[CONF_WEIGHTS] = {
|
||||
room: float(user_input[f"weight__{room}"])
|
||||
for room in self._draft[CONF_ROOMS]
|
||||
}
|
||||
# Prevent an old single-temperature sensor from overriding new sources.
|
||||
self._draft.pop(CONF_OUTSIDE, None)
|
||||
return self.async_create_entry(title="", data=self._draft)
|
||||
config = {**self.config_entry.data, **self.config_entry.options}
|
||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||
self._draft[CONF_ROOMS], config.get(CONF_WEIGHTS, {})))
|
||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||
config, WEATHER_EXTRA))
|
||||
|
||||
|
||||
class ZoneSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||
"""Add or edit a thermal zone: its sensors, comfort weight and neighbours."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._draft: dict[str, Any] = {}
|
||||
|
||||
def _other_zones(self, key: str | None) -> list[Zone]:
|
||||
return [zone for zone in entry_zones(self._get_entry()) if zone.key != key]
|
||||
|
||||
def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema:
|
||||
schema: dict[Any, Any] = {
|
||||
vol.Required(ZONE_NAME, default=data.get(ZONE_NAME, "")): selector.TextSelector(),
|
||||
vol.Required(ZONE_SENSORS, default=list(data.get(ZONE_SENSORS, []))):
|
||||
_entity_picker(multiple=True),
|
||||
vol.Required(ZONE_COMFORT_WEIGHT, default=data.get(ZONE_COMFORT_WEIGHT, 1.0)):
|
||||
_number(*COMFORT_WEIGHT_RANGE, 0.1),
|
||||
vol.Required(ZONE_TARGET_OFFSET, default=data.get(ZONE_TARGET_OFFSET, 0.0)):
|
||||
_number(*TARGET_OFFSET_RANGE, 0.1),
|
||||
}
|
||||
others = self._other_zones(key)
|
||||
if others:
|
||||
schema[vol.Optional(ZONE_NEIGHBORS, description={
|
||||
"suggested_value": [n for n in data.get(ZONE_NEIGHBORS, []) if n in {z.key for z in others}]})] = \
|
||||
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in others],
|
||||
multiple=True, mode=selector.SelectSelectorMode.LIST))
|
||||
return vol.Schema(schema)
|
||||
|
||||
async def _zone_form(self, step_id: str, user_input: dict[str, Any] | None,
|
||||
existing: dict[str, Any], key: str | None):
|
||||
if user_input is not None:
|
||||
entry = self._get_entry()
|
||||
config = {**entry.data, **entry.options}
|
||||
weather = {s for metric in ("temperature",) for s in sources_for(config, metric)}
|
||||
error = validate_zone(user_input, self._other_zones(key), weather)
|
||||
if error:
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(user_input, key),
|
||||
errors={"base": error})
|
||||
self._draft = {**existing, **user_input, ZONE_NAME: user_input[ZONE_NAME].strip()}
|
||||
self._draft.setdefault(ZONE_NEIGHBORS, [])
|
||||
if len(user_input[ZONE_SENSORS]) > 1:
|
||||
return await self.async_step_sensor_weights()
|
||||
self._draft[ZONE_SENSOR_WEIGHTS] = {user_input[ZONE_SENSORS][0]: 1.0}
|
||||
return self._finish()
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing, key))
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||
if not self._draft.get(ZONE_KEY):
|
||||
self._draft = {ZONE_KEY: f"zone_{uuid4().hex[:10]}"}
|
||||
return await self._zone_form("user", user_input, self._draft, None)
|
||||
|
||||
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||
subentry = self._get_reconfigure_subentry()
|
||||
existing = dict(subentry.data)
|
||||
existing.setdefault(ZONE_KEY, subentry.subentry_id)
|
||||
return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY])
|
||||
|
||||
async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None):
|
||||
sensors = self._draft[ZONE_SENSORS]
|
||||
if user_input is not None:
|
||||
self._draft[ZONE_SENSOR_WEIGHTS] = {s: float(user_input[f"weight__{s}"]) for s in sensors}
|
||||
return self._finish()
|
||||
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
|
||||
return self.async_show_form(step_id="sensor_weights", data_schema=vol.Schema({
|
||||
vol.Required(f"weight__{s}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
|
||||
for s in sensors}))
|
||||
|
||||
def _finish(self):
|
||||
data = {key: self._draft[key] for key in (
|
||||
ZONE_KEY, ZONE_NAME, ZONE_SENSORS, ZONE_SENSOR_WEIGHTS, ZONE_COMFORT_WEIGHT,
|
||||
ZONE_TARGET_OFFSET, ZONE_NEIGHBORS)}
|
||||
data[ZONE_COMFORT_WEIGHT] = float(data[ZONE_COMFORT_WEIGHT])
|
||||
data[ZONE_TARGET_OFFSET] = float(data[ZONE_TARGET_OFFSET])
|
||||
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||
title=data[ZONE_NAME], data=data)
|
||||
return self.async_create_entry(title=data[ZONE_NAME], data=data, unique_id=data[ZONE_KEY])
|
||||
Reference in new issue
Block a user