"""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 from homeassistant.core import callback 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_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST, CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA, ) from .heat_sources import ( BASELINE_AUTO, BASELINE_MANUAL, DEFAULT_RESPONSE_MINUTES, MAX_POWER_W, RESPONSE_MINUTES_RANGE, SOURCE_BASELINE_MODE, SOURCE_BASELINE_W, SOURCE_ENABLED, SOURCE_FRACTION, SOURCE_INCLUDED_IN, SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE, HeatSource, build_sources, validate_source, ) from .coordinator import async_reset_learned_model from .weather_sources import sources_for from .windows import ( DEFAULT_AZIMUTH, OPENING_RANGE, SUBENTRY_WINDOW, WINDOW_AZIMUTH, WINDOW_ENABLED, WINDOW_INVERTED, WINDOW_KEY, WINDOW_NAME, WINDOW_OPENING, WINDOW_SENSOR, WINDOW_ZONE, Window, build_windows, validate_window, ) 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): return selector.EntitySelector( selector.EntitySelectorConfig(domain="sensor", multiple=multiple, **({"reorder": True} if multiple else {})) ) def _required(key: str, data: dict): return vol.Required(key, default=data[key]) if key in data else vol.Required(key) 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")), } 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)) schema[vol.Required(CONF_ROOM_STALE_KEEP, default=bool( data.get(CONF_ROOM_STALE_KEEP, DEFAULTS[CONF_ROOM_STALE_KEEP])))] = selector.BooleanSelector() if detailed: for key, allowed in { CONF_HORIZON: vol.In([60, 90, 120, 150, 180]), CONF_MIN_ON: vol.All(vol.Coerce(int), vol.Range(min=5, max=60)), CONF_MIN_OFF: vol.All(vol.Coerce(int), vol.Range(min=5, max=60)), CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)), CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)), CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)), CONF_INTERNAL_HALF_LIFE: vol.All(vol.Coerce(int), vol.Range(min=0, max=720)), CONF_WINDOW_OPEN_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)), CONF_RECOVERY_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)), }.items(): schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed for key in (CONF_VENT_WIND, CONF_VENT_GUST, CONF_VENT_CROSS): schema[vol.Required(key, default=bool(data.get(key, DEFAULTS[key])))] = selector.BooleanSelector() return vol.Schema(schema) def _weather_schema(config: dict[str, Any], metrics: tuple[str, ...]): schema = {} for metric in metrics: key = WEATHER_SOURCES[metric] selected = sources_for(config, metric) if metric == "temperature": # A temperature source is mandatory: other weather inputs are optional. marker = vol.Required(key, default=selected) if selected else vol.Required(key) else: # A default would re-insert old sources whenever a cleared field is # omitted from the submission; a suggested value only pre-fills the form. marker = vol.Optional(key, description={"suggested_value": selected}) schema[marker] = _entity_picker(multiple=True) return vol.Schema(schema) def _with_cleared_weather(user_input: dict[str, Any], metrics: tuple[str, ...]) -> dict[str, Any]: """Store an omitted optional source list as empty, so it overrides older data.""" result = dict(user_input) for metric in metrics: result.setdefault(WEATHER_SOURCES[metric], []) return result def _weight_schema(rooms: list[str], existing: dict[str, float] | None = None): weights = existing or {} return vol.Schema({ vol.Required(f"weight__{room}", default=weights.get(room, 1.0)): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=10)) for room in rooms }) def _valid_rooms(rooms: list[str]) -> bool: return bool(rooms) and len(rooms) <= 12 and len(rooms) == len(set(rooms)) def _valid_weather(rooms: list[str], data: dict) -> bool: sources = sources_for(data, "temperature") 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 entry_sources(entry: config_entries.ConfigEntry) -> list[HeatSource]: zones = {zone.key for zone in entry_zones(entry)} return build_sources(((sub.data, sub.subentry_id) for sub in entry.subentries.values() if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones) 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.""" # 3: rooms and comfort weights moved into zone subentries. VERSION = 3 def __init__(self): self._draft: dict[str, Any] = {} async def async_step_user(self, user_input: dict[str, Any] | None = None): if user_input is not None: if not _valid_rooms(user_input[CONF_ROOMS]): return self.async_show_form(step_id="user", data_schema=_main_schema(), errors={"base": "invalid_rooms"}) await self.async_set_unique_id("home_heating") self._abort_if_unique_id_configured() self._draft = user_input return await self.async_step_weather() return self.async_show_form(step_id="user", data_schema=_main_schema()) async def async_step_weather(self, user_input: dict[str, Any] | None = None): if user_input is not None: if not _valid_weather(self._draft[CONF_ROOMS], 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)) return await self.async_step_weather_extra() return self.async_show_form(step_id="weather", data_schema=_weather_schema( self._draft, WEATHER_MAIN)) async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None): 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( self._draft, WEATHER_EXTRA)) async def async_step_weights(self, user_input: dict[str, Any] | None = None): if user_input is not None: 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])) @staticmethod @callback 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, SUBENTRY_HEAT_SOURCE: HeatSourceSubentryFlow, SUBENTRY_WINDOW: WindowSubentryFlow} CONFIRM_RESET = "confirm_reset" class HeatingMPCOptionsFlow(config_entries.OptionsFlow): """Edit sensors, ranked alternative weather providers and MPC parameters.""" 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): return self.async_show_menu(step_id="init", menu_options=["settings", "reset_model"]) async def async_step_settings(self, user_input: dict[str, Any] | None = None): config = {**self.config_entry.data, **self.config_entry.options} if user_input is not None: self._draft = user_input return await self.async_step_weather() return self.async_show_form(step_id="settings", data_schema=_main_schema( config, detailed=True)) async def async_step_reset_model(self, user_input: dict[str, Any] | None = None): """Forget the learned model after an explicit confirmation; settings stay.""" if user_input is not None: if user_input.get(CONFIRM_RESET) is not True: return self.async_abort(reason="model_not_reset") await async_reset_learned_model(self.hass, self.config_entry) return self.async_abort(reason="model_reset") return self.async_show_form(step_id="reset_model", data_schema=vol.Schema( {vol.Required(CONFIRM_RESET, default=False): selector.BooleanSelector()})) 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._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)) return await self.async_step_weather_extra() return self.async_show_form(step_id="weather", data_schema=_weather_schema( config, WEATHER_MAIN)) async def async_step_weather_extra(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: self._draft.update(_with_cleared_weather(user_input, WEATHER_EXTRA)) # 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) 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): """One weight per sensor; fields are numbered so they have translated labels.""" sensors = self._draft[ZONE_SENSORS] if user_input is not None: self._draft[ZONE_SENSOR_WEIGHTS] = { s: float(user_input[f"sensor_weight_{i}"]) for i, s in enumerate(sensors, 1)} return self._finish() weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {} names = [] for i, sensor in enumerate(sensors, 1): state = self.hass.states.get(sensor) names.append(f"{i}. {state.name if state else sensor} ({sensor})") return self.async_show_form( step_id="sensor_weights", data_schema=vol.Schema({ vol.Required(f"sensor_weight_{i}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1) for i, s in enumerate(sensors, 1)}), description_placeholders={"sensors": "\n".join(names)}) 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]) class HeatSourceSubentryFlow(config_entries.ConfigSubentryFlow): """Add or edit an electrical heat source (UPS, PC, server) in a zone.""" def _others(self, key: str | None) -> list[HeatSource]: return [s for s in entry_sources(self._get_entry()) if s.key != key] def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema: zones = entry_zones(self._get_entry()) parents = [s for s in self._others(key) if s.included_in is None] schema: dict[Any, Any] = { vol.Required(SOURCE_NAME, default=data.get(SOURCE_NAME, "")): selector.TextSelector(), vol.Required(SOURCE_SENSOR, **({"default": data[SOURCE_SENSOR]} if data.get(SOURCE_SENSOR) else {})): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor", device_class="power")), vol.Required(SOURCE_ZONE, **({"default": data[SOURCE_ZONE]} if data.get(SOURCE_ZONE) else {})): selector.SelectSelector(selector.SelectSelectorConfig( options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])), vol.Required(SOURCE_FRACTION, default=data.get(SOURCE_FRACTION, 1.0)): _number(0.0, 1.0, 0.05), vol.Required(SOURCE_BASELINE_MODE, default=data.get(SOURCE_BASELINE_MODE, BASELINE_AUTO)): selector.SelectSelector(selector.SelectSelectorConfig( options=[BASELINE_AUTO, BASELINE_MANUAL], translation_key=SOURCE_BASELINE_MODE)), vol.Optional(SOURCE_BASELINE_W, description={"suggested_value": data.get(SOURCE_BASELINE_W)}): _number(0.0, MAX_POWER_W, 1.0), vol.Required(SOURCE_RESPONSE, default=data.get(SOURCE_RESPONSE, DEFAULT_RESPONSE_MINUTES)): _number(*RESPONSE_MINUTES_RANGE, 1.0), vol.Required(SOURCE_ENABLED, default=data.get(SOURCE_ENABLED, True)): selector.BooleanSelector(), } if parents: schema[vol.Optional(SOURCE_INCLUDED_IN, description={ "suggested_value": data.get(SOURCE_INCLUDED_IN)})] = selector.SelectSelector( selector.SelectSelectorConfig(options=[ selector.SelectOptionDict(value=s.key, label=s.name) for s in parents])) return vol.Schema(schema) async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]): key = existing[SOURCE_KEY] if not entry_zones(self._get_entry()): return self.async_abort(reason="no_zones") if user_input is not None: data = {**existing, **user_input, SOURCE_KEY: key} data.setdefault(SOURCE_INCLUDED_IN, None) error = validate_source(data, self._others(key)) if error: return self.async_show_form(step_id=step_id, data_schema=self._schema(data, key), errors={"base": error}) data[SOURCE_NAME] = data[SOURCE_NAME].strip() for number in (SOURCE_FRACTION, SOURCE_RESPONSE): data[number] = float(data[number]) if data.get(SOURCE_BASELINE_W) is not None: data[SOURCE_BASELINE_W] = float(data[SOURCE_BASELINE_W]) if self.source == config_entries.SOURCE_RECONFIGURE: return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(), title=data[SOURCE_NAME], data=data) return self.async_create_entry(title=data[SOURCE_NAME], data=data, unique_id=key) 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 hasattr(self, "_key"): self._key = f"source_{uuid4().hex[:10]}" return await self._form("user", user_input, {SOURCE_KEY: self._key}) async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): subentry = self._get_reconfigure_subentry() existing = dict(subentry.data) existing.setdefault(SOURCE_KEY, subentry.subentry_id) return await self._form("reconfigure", user_input, existing) def entry_windows(entry: config_entries.ConfigEntry) -> list[Window]: zones = {zone.key for zone in entry_zones(entry)} return build_windows(((sub.data, sub.subentry_id) for sub in entry.subentries.values() if sub.subentry_type == SUBENTRY_WINDOW), zones) class WindowSubentryFlow(config_entries.ConfigSubentryFlow): """Add or edit a window contact sensor with its zone and orientation.""" def _schema(self, data: dict[str, Any]) -> vol.Schema: zones = entry_zones(self._get_entry()) return vol.Schema({ vol.Required(WINDOW_NAME, default=data.get(WINDOW_NAME, "")): selector.TextSelector(), vol.Required(WINDOW_SENSOR, **({"default": data[WINDOW_SENSOR]} if data.get(WINDOW_SENSOR) else {})): selector.EntitySelector(selector.EntitySelectorConfig(domain="binary_sensor")), vol.Required(WINDOW_ZONE, **({"default": data[WINDOW_ZONE]} if data.get(WINDOW_ZONE) else {})): selector.SelectSelector(selector.SelectSelectorConfig( options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])), vol.Required(WINDOW_INVERTED, default=data.get(WINDOW_INVERTED, False)): selector.BooleanSelector(), vol.Required(WINDOW_AZIMUTH, default=data.get(WINDOW_AZIMUTH, DEFAULT_AZIMUTH)): _number(0.0, 359.0, 1.0), vol.Required(WINDOW_OPENING, default=data.get(WINDOW_OPENING, 1.0)): _number(*OPENING_RANGE, 0.1), vol.Required(WINDOW_ENABLED, default=data.get(WINDOW_ENABLED, True)): selector.BooleanSelector(), }) async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]): key = existing[WINDOW_KEY] if not entry_zones(self._get_entry()): return self.async_abort(reason="no_zones") if user_input is not None: data = {**existing, **user_input, WINDOW_KEY: key} error = validate_window(data, [w for w in entry_windows(self._get_entry()) if w.key != key]) if error: return self.async_show_form(step_id=step_id, data_schema=self._schema(data), errors={"base": error}) data[WINDOW_NAME] = data[WINDOW_NAME].strip() data[WINDOW_AZIMUTH] = float(data[WINDOW_AZIMUTH]) data[WINDOW_OPENING] = float(data[WINDOW_OPENING]) if self.source == config_entries.SOURCE_RECONFIGURE: return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(), title=data[WINDOW_NAME], data=data) return self.async_create_entry(title=data[WINDOW_NAME], data=data, unique_id=key) return self.async_show_form(step_id=step_id, data_schema=self._schema(existing)) async def async_step_user(self, user_input: dict[str, Any] | None = None): if not hasattr(self, "_key"): self._key = f"window_{uuid4().hex[:10]}" return await self._form("user", user_input, {WINDOW_KEY: self._key}) async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): subentry = self._get_reconfigure_subentry() existing = dict(subentry.data) existing.setdefault(WINDOW_KEY, subentry.subentry_id) return await self._form("reconfigure", user_input, existing)