Each temperature sensor used to be its own "room", so a room with two sensors counted twice and comfort importance was tied to sensors. zones.py adds Zone: a name, one or more sensors with sensor weights, a comfort weight, an optional target offset and adjacent zones (made symmetric). Sensor weights only combine readings; comfort weights only steer the MPC; neither touches the learned thermal coefficients. Zones are config subentries (HA's native "Add zone" / edit / delete on the integration page), with validation that a sensor belongs to one zone and is not an outdoor source, and a sensor-weight step for multi-sensor zones. Initial setup still asks for room sensors and weights and creates one zone per sensor. Configure no longer asks for rooms or weights. Config entry version 3. Migrating from 2 turns each room sensor into a one-sensor zone with its comfort weight and the sensor's entity ID as the zone key, so learned models, forecast entities (now linked to their zone, so deleting a zone removes them) and accuracy statistics carry over. Older versions cannot load a version 3 entry; rolling back needs the HA backup. A zone learns only across polls that used the same sensors (a sensor dropping out shifts the mean by its offset); the new poll outcome "sensors_changed" reports this. Status renames: input_quality.zones (per-sensor detail nested), excluded_zones, retired_zone_models; adds zone_names. Invalid stored comfort weights now fall back to 1.0, so the invalid_weights status is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
11 KiB
Python
181 lines
11 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 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 hass.config_entries.options.async_init(entry.entry_id)
|
|
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 hass.config_entries.options.async_init(entry.entry_id)
|
|
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 hass.config_entries.options.async_init(entry.entry_id)
|
|
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"
|
|
result = await flows.async_configure(result["flow_id"], {
|
|
"weight__sensor.living_a": 3, "weight__sensor.living_b": 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"
|
|
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 flow.async_init(entry.entry_id)
|
|
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"]
|