Files
heating-mpc-ha/tests/ha/test_config_flow.py
T
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

313 lines
19 KiB
Python

"""Validate actual HA form schemas, options, zone subentries and entry migration."""
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.heating_mpc.const import DOMAIN
from custom_components.heating_mpc.weather_sources import sources_for
MAIN = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler"}
def zones_of(entry):
return {sub.unique_id: sub for sub in entry.subentries.values() if sub.subentry_type == "zone"}
async def setup_entry(hass, entry):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return hass.data[DOMAIN][entry.entry_id]
async def open_settings(hass, entry):
"""Configure opens a menu; Settings is the first choice."""
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["settings", "reset_model"]
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "settings"})
async def open_reset(hass, entry):
result = await hass.config_entries.options.async_init(entry.entry_id)
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "reset_model"})
async def test_reset_learned_model_keeps_configuration_and_observations(hass, entry, inputs, hass_storage):
coordinator = await setup_entry(hass, entry)
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
payload = coordinator._payload("sensor.outside")
payload["retired_learners"] = {"sensor.gone": {**model.as_dict()}}
payload["ventilation"] = {"events": [{"zone": "sensor.room", "opened": "2026-10-27T10:00:00+00:00"}]}
payload["heat_sources"] = {"samples": {"ups": [[1.0, 200.0]]}}
await coordinator._store.async_save(payload)
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "ready"
before = (dict(entry.data), dict(entry.options), dict(entry.subentries))
# Not confirmed: nothing happens.
result = await open_reset(hass, entry)
assert (result["type"], result["step_id"]) == (FlowResultType.FORM, "reset_model")
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": False})
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_not_reset")
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
# Confirmed: a fresh model, the same configuration.
result = await open_reset(hass, entry)
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": True})
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_reset")
await hass.async_block_till_done()
fresh = hass.data[DOMAIN][entry.entry_id]
assert fresh is not coordinator and fresh.learners["sensor.room"].count == 0
assert fresh.data["status"] == "learning" and not fresh._retired
assert (dict(entry.data), dict(entry.options), dict(entry.subentries)) == before
stored = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"]
assert not {"learners", "retired_learners", "evaluation", "operational_evaluation", "fingerprints"} & set(stored)
assert stored["ventilation"]["events"][0]["opened"] == "2026-10-27T10:00:00+00:00"
assert stored["heat_sources"] == {"samples": {"ups": [[1.0, 200.0]]}}
assert stored["model_changes"][-1]["reasons"] == ["learned model reset by the user"]
assert fresh.data["model_changes"][-1]["recalibrated"] == ["all"]
async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
hass.states.async_set("sensor.bedroom", "19", {"unit_of_measurement": "°C", "friendly_name": "Bedroom"})
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
assert result["type"] is FlowResultType.FORM
validated = result["data_schema"]({**MAIN, "room_entities": ["sensor.room", "sensor.bedroom"]})
assert validated["room_max_age_minutes"] == 30
assert validated["weather_max_age_minutes"] == 120
assert validated["room_stale_use_last_value"] is False
result = await hass.config_entries.flow.async_configure(result["flow_id"], validated)
assert result["step_id"] == "weather"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
assert result["step_id"] == "weather_extra"
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
assert result["step_id"] == "weights"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"weight__sensor.room": 2, "weight__sensor.bedroom": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
entry = result["result"]
assert entry.version == 3
assert "room_entities" not in entry.data and "weights" not in entry.data
zones = zones_of(entry)
assert {key: (sub.title, sub.data["comfort_weight"]) for key, sub in zones.items()} == {
"sensor.room": ("room", 2.0), "sensor.bedroom": ("Bedroom", 1.0)} # HA's name fallback
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.room", "sensor.bedroom"]
async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
await setup_entry(hass, entry)
result = await open_settings(hass, entry)
assert "room_entities" not in {str(key) for key in result["data_schema"].schema}
main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True})
result = await hass.config_entries.options.async_configure(result["flow_id"], main)
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({}))
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["room_max_age_minutes"] == 45
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.config["room_max_age_minutes"] == 45
assert coordinator.config["room_stale_use_last_value"] is True
assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure
result = await open_settings(hass, entry)
assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
hass.config_entries.options.async_abort(result["flow_id"])
async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs):
await setup_entry(hass, entry)
result = await open_settings(hass, entry)
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"](MAIN))
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]}))
assert result["errors"] == {"base": "invalid_weather"}
async def test_v1_entry_migrates_to_zones_on_setup(hass, inputs):
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
**MAIN, "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
await setup_entry(hass, entry)
assert entry.version == 3
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
assert "outside_entity" not in entry.data and "room_entities" not in entry.data
assert set(zones_of(entry)) == {"sensor.room"}
async def test_v2_migration_keeps_weights_models_and_entities(hass, entry, inputs, hass_storage):
from custom_components.heating_mpc.physics import Learner
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C", "friendly_name": "Living room"})
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
entry.add_to_hass(hass)
# A forecast entity created by 0.3 for this room.
registry = er.async_get(hass)
old = registry.async_get_or_create("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min",
config_entry=entry, suggested_object_id="mpc_living_forecast")
hass.config_entries.async_update_entry(entry, options={"weights": {"sensor.room": 4.0}})
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
zone = zones_of(entry)["sensor.room"]
assert (zone.title, zone.data["comfort_weight"], zone.data["sensors"]) == ("Living room", 4.0, ["sensor.room"])
assert "weights" not in entry.options
assert coordinator.learners["sensor.room"].count == 250
# Same entity, now linked to its zone, so deleting the zone removes it.
migrated = registry.async_get(old.entity_id)
assert migrated.config_subentry_id == zone.subentry_id
assert hass.config_entries.async_remove_subentry(entry, zone.subentry_id)
await hass.async_block_till_done()
assert registry.async_get(old.entity_id) is None
async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
await setup_entry(hass, entry)
for entity, value in (("sensor.living_a", "21"), ("sensor.living_b", "22")):
hass.states.async_set(entity, value, {"unit_of_measurement": "°C"})
flows = hass.config_entries.subentries
result = await flows.async_init((entry.entry_id, "zone"), context={"source": SOURCE_USER})
assert result["type"] is FlowResultType.FORM
# A sensor of another zone, or an outdoor source, is refused.
for sensors, error in ((["sensor.room"], "sensor_in_other_zone"), (["sensor.outside"], "sensor_is_weather_source")):
result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": sensors, "comfort_weight": 2, "target_offset": 0})
assert result["errors"] == {"base": error}
result = await flows.async_configure(result["flow_id"], {
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
"target_offset": 0.5, "neighbors": ["sensor.room"]})
assert result["step_id"] == "sensor_weights"
# Numbered, translated fields; the description says which sensor is which.
assert [str(key) for key in result["data_schema"].schema] == ["sensor_weight_1", "sensor_weight_2"]
assert result["description_placeholders"]["sensors"].splitlines() == [
"1. living a (sensor.living_a)", "2. living b (sensor.living_b)"] # HA name fallback
result = await flows.async_configure(result["flow_id"], {"sensor_weight_1": 3, "sensor_weight_2": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
assert living.data["sensor_weights"] == {"sensor.living_a": 3.0, "sensor.living_b": 1.0}
coordinator = hass.data[DOMAIN][entry.entry_id]
key = living.data["key"]
assert coordinator.zones[key].neighbors == ("sensor.room",)
assert coordinator.zones["sensor.room"].neighbors == (key,) # symmetric
assert coordinator.data["temperatures"][key] == 21.25
# Edit: drop a sensor; the key (and so the learned model) is kept.
result = await flows.async_init((entry.entry_id, "zone"), context={
"source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id})
result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
# Editing a multi-sensor zone shows the saved sensor weights.
result = await flows.async_init((entry.entry_id, "zone"), context={
"source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id})
result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 1, "target_offset": 0})
assert result["data_schema"]({})["sensor_weight_1"] == 1.0
flows.async_abort(result["flow_id"])
await hass.async_block_till_done()
assert entry.subentries[living.subentry_id].data["key"] == key
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
async def test_options_flow_clears_optional_weather_sources(hass, inputs):
"""Swapping providers in the UI must allow removing an optional source list."""
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
**MAIN, "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
"weights": {"sensor.room": 1.0}})
await setup_entry(hass, entry)
flow = hass.config_entries.options
result = await open_settings(hass, entry)
result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
markers = {str(key): key for key in result["data_schema"].schema}
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
# The user removes the wind provider; a cleared field may be omitted on submit.
result = await flow.async_configure(result["flow_id"], result["data_schema"](
{"outside_temperature_sources": ["sensor.outside"]}))
result = await flow.async_configure(result["flow_id"], result["data_schema"](
{"outside_pressure_sources": ["sensor.pressure"]}))
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["outside_wind_sources"] == []
assert entry.options["outside_pressure_sources"] == ["sensor.pressure"]
config = hass.data[DOMAIN][entry.entry_id].config
assert sources_for(config, "wind") == []
assert sources_for(config, "pressure") == ["sensor.pressure"]
async def test_heat_source_subentry_flow(hass, entry, inputs):
await setup_entry(hass, entry)
for sensor, value in (("sensor.ups_power", "600"), ("sensor.pc_power", "400")):
hass.states.async_set(sensor, value, {"unit_of_measurement": "W", "device_class": "power"})
flows = hass.config_entries.subentries
base = {"zone": "sensor.room", "heat_fraction": 1, "response_minutes": 15, "enabled": True}
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
result = await flows.async_configure(result["flow_id"], {
**base, "name": "UPS", "sensor": "sensor.ups_power", "baseline_mode": "auto"})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
ups = next(sub for sub in entry.subentries.values() if sub.subentry_type == "heat_source")
# The PC is behind the UPS: its power is already in the UPS reading.
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
pc = {**base, "name": "Gaming PC", "sensor": "sensor.pc_power", "baseline_mode": "manual",
"included_in": ups.unique_id, "response_minutes": 10}
result = await flows.async_configure(result["flow_id"], pc)
assert result["errors"] == {"base": "baseline_required"}
result = await flows.async_configure(result["flow_id"], {**pc, "baseline_w": 0})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert [(s.name, s.included_in) for s in coordinator.sources] == [("UPS", None), ("Gaming PC", ups.unique_id)]
assert coordinator.extended["sensor.room"].features == ["loss", "heat", "bias", "internal"]
registry = er.async_get(hass)
status = hass.states.get(registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
sources = status.attributes["heat_sources"]
assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice
assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400)
async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs):
await setup_entry(hass, entry)
hass.states.async_set("binary_sensor.bedroom_window", "off", {"device_class": "window"})
flows = hass.config_entries.subentries
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
form = result["data_schema"]({"name": "Bedroom window", "sensor": "binary_sensor.bedroom_window",
"zone": "sensor.room"})
assert (form["azimuth"], form["opening_factor"], form["inverted"]) == (345.0, 1.0, False)
result = await flows.async_configure(result["flow_id"], {**form, "azimuth": 340})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert [(w.name, w.azimuth) for w in coordinator.windows] == [("Bedroom window", 340.0)]
# No wind sources yet: only the terms whose inputs exist.
assert coordinator.ventilation["sensor.room"].features == ["vent_natural", "recovery"]
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
result = await flows.async_configure(result["flow_id"], {**form, "name": "Again"})
assert result["errors"] == {"base": "sensor_in_other_window"}
flows.async_abort(result["flow_id"])
# Wind, direction and gust sources plus the gust option add their terms.
for sensor, value, unit in (("sensor.wind", "4", "m/s"), ("sensor.wind_dir", "204", "°"),
("sensor.gust", "9", "m/s")):
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
options = hass.config_entries.options
result = await open_settings(hass, entry)
result = await options.async_configure(result["flow_id"], result["data_schema"](
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({
"outside_temperature_sources": ["sensor.outside"], "outside_wind_sources": ["sensor.wind"],
"outside_wind_direction_sources": ["sensor.wind_dir"], "outside_wind_gust_sources": ["sensor.gust"]}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({}))
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
# Cross-ventilation needs windows in two zones, so it stays off.
assert coordinator.ventilation["sensor.room"].features == [
"vent_natural", "recovery", "vent_wind", "vent_wind_dir", "vent_gust"]
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
window = status.attributes["windows"]["Bedroom window"]
assert (window["state"], window["relative_wind_direction"]) == ("closed", -136.0)
assert {"windows", "ventilation"} <= status.state_info["unrecorded_attributes"]