"""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, 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): 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)), }.items(): schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed 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 _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} 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): 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="init", data_schema=_main_schema( config, detailed=True)) 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): 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])