diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 8db4c74..9bf6f0f 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -29,6 +29,8 @@ from .weather_sources import choose_weather_sources, sources_for, normalize _LOGGER = logging.getLogger(__name__) # Relay changes kept between polls; more than this in five minutes is chatter. MAX_RELAY_EVENTS = 256 +# Models of rooms removed in Options, kept in case a room is added back. +MAX_RETIRED_ROOMS = 24 class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -41,6 +43,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.config = {**DEFAULTS, **entry.data, **entry.options} self.rooms: list[str] = self.config[CONF_ROOMS] self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} + self._retired: dict[str, dict[str, Any]] = {} self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") self._previous: dict[str, Any] | None = None # Radiator-lag proxy at the last poll with a valid relay state. Tracked @@ -62,13 +65,23 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]: _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") return - # Models carry room identity, so changing the room list is safe. - saved = stored.get("learners", {}) - if not isinstance(saved, dict): - return - for room, data in saved.items(): - if room in self.learners: - self.learners[room] = Learner.from_dict(data) + # Models carry room identity, so changing the room list is safe. Models of + # rooms no longer configured are retained, most recently removed last. + for key in ("retired_learners", "learners"): # current learners take precedence + saved = stored.get(key) + if not isinstance(saved, dict): + continue + for room, data in saved.items(): + if not isinstance(room, str): + continue + learner = Learner.from_dict(data) + if room in self.learners: + self.learners[room] = learner + elif learner.count: + self._retired.pop(room, None) + self._retired[room] = learner.as_dict() + while len(self._retired) > MAX_RETIRED_ROOMS: + self._retired.pop(next(iter(self._retired))) self._update_counts() def _update_counts(self) -> None: @@ -210,6 +223,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "weather_active_sources": {k: v for k, v in active_sources.items() if v}, "weather_comparison": {k: v for k, v in weather_comparisons.items() if v}, "weather_model_inputs": ["temperature"], + "retired_room_models": sorted(self._retired), } heater_valid = heater is not None and heater.state in ("on", "off") heater_on = heater_valid and heater.state == "on" @@ -256,6 +270,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self._store.async_save({ "schema_version": 2, "learners": {room: learner.as_dict() for room, learner in self.learners.items()}, + # Optional; older 0.2.x readers ignore it and keep working. + "retired_learners": self._retired, "on_samples": self._on_samples, "off_samples": self._off_samples, "heater_entity": self.config[CONF_HEATER], "outside_source": active_sources["temperature"], diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index c0d8805..137149b 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -182,3 +182,26 @@ async def test_relay_listener_integrates_changes_between_polls(hass, entry, inpu hass.states.async_set("switch.boiler", "on") await hass.async_block_till_done() assert not coordinator._relay_events + + +async def test_removed_room_model_survives_options_round_trip(hass, entry, inputs, hass_storage, freezer): + from custom_components.heating_mpc.physics import Learner + key = f"{DOMAIN}.{entry.entry_id}" + hass_storage[key] = {"version": 1, "data": { + "schema_version": 2, "heater_entity": "switch.boiler", + "learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}} + hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"}) + await setup(hass, entry) + hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]}) + await hass.async_block_till_done() + future = dt_util.utcnow() + timedelta(minutes=5, seconds=1) + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + saved = hass_storage[key]["data"] + assert set(saved["learners"]) == {"sensor.new"} + assert saved["retired_learners"]["sensor.room"]["count"] == 250 + hass.config_entries.async_update_entry( + entry, options={"room_entities": ["sensor.room", "sensor.new"]}) + await hass.async_block_till_done() + assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250 diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index b328139..353526f 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -498,3 +498,29 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): for _ in range(coordinator.MAX_RELAY_EVENTS): self.c.record_relay_state(self.now, 'on') self.assertTrue(self.c._relay_events_dropped) + + async def test_removed_room_model_is_kept_and_restored(self): + old = coordinator.Learner(count=500, on_count=250, off_count=250) + self.c._store.async_load.return_value = {'schema_version': 2, 'learners': { + 'sensor.room': coordinator.Learner(count=7).as_dict(), 'sensor.gone': old.as_dict()}} + await self.c.async_restore_model() + self.assertEqual(self.c.learners['sensor.room'].count, 7) + await self.sample() + self.assertEqual((await self.sample())['retired_room_models'], ['sensor.gone']) + payload = self.c._store.async_save.call_args.args[0] + self.assertEqual(set(payload['learners']), {'sensor.room'}) + self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500) + self.entry.options['room_entities'] = ['sensor.room', 'sensor.gone'] + new = coordinator.HeatingMPCCoordinator(self.hass, self.entry) + new._store.async_load.return_value = payload + await new.async_restore_model() + self.assertEqual(new.learners['sensor.gone'].count, 500) + self.assertEqual(new.learners['sensor.room'].count, 8) + self.assertEqual(new._retired, {}) + + async def test_retired_room_models_are_bounded_and_validated(self): + retired = {f'sensor.old{i}': coordinator.Learner(count=i + 1).as_dict() for i in range(30)} + retired.update({'sensor.bad': 'broken', 'sensor.empty': coordinator.Learner().as_dict()}) + self.c._store.async_load.return_value = {'retired_learners': retired} + await self.c.async_restore_model() + self.assertEqual(list(self.c._retired), [f'sensor.old{i}' for i in range(6, 30)])