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