Files
buzeraandClaude Opus 5.5 49565d610a Add Reset learned model to Configure
Configure now opens a menu: Settings (the previous form) or Reset learned
model. After an explicit confirmation the entry is unloaded, the learned
parts of the stored model (zone, extended and ventilation coefficients,
models kept for removed zones, Model Error and Forecast Error statistics,
fingerprints) are removed under the storage lock, and the entry is set up
again. Configuration, heat-source baselines and the window event log are
kept, and model_changes records the reset. Unloading first means no update
still running can write the old model back. English and Romanian strings;
the gust hint now refers to Model Error.

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

473 lines
25 KiB
Python

"""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)