From dc92e2bac5278eedbac9020695161eb84bd07b4f Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Tue, 27 Oct 2026 03:02:03 +0200 Subject: [PATCH] Keep the entry loaded when the last zone is deleted Deleting the only zone in the UI reloaded the entry with no zones, and min() over an empty set of models raised during setup, so the entry failed to load. Counts and learning progress now default to 0, and the status reads "no_zones" until a zone is added again; adding one resumes normally. HA Core regression test included. Co-Authored-By: Claude Opus 5.5 --- custom_components/heating_mpc/coordinator.py | 17 ++++++++++------- tests/ha/test_lifecycle.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 2f6b6d5..3447922 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -166,8 +166,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._update_counts() def _update_counts(self) -> None: - self._on_samples = min(m.on_count for m in self.learners.values()) - self._off_samples = min(m.off_count for m in self.learners.values()) + # Without zones (all deleted in the UI) there is nothing to count. + self._on_samples = min((m.on_count for m in self.learners.values()), default=0) + self._off_samples = min((m.off_count for m in self.learners.values()), default=0) def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict: state = self.hass.states.get(entity_id) @@ -241,9 +242,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): status["sample_results_since_start"] = dict(self._sample_results) # Least trained room: mean of its three readiness requirements (100 = ready). status["learning_progress"] = round(100 * min( - (min(1.0, m.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES) - + min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3 - for m in self.learners.values()), 1) + ((min(1.0, m.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES) + + min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3 + for m in self.learners.values()), default=0.0), 1) def _extended_diagnostics(self, room: str) -> dict[str, Any]: model = self.extended.get(room) @@ -432,7 +433,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None}, "zone_names": {key: zone.name for key, zone in self.zones.items()}, "on_samples": self._on_samples, "off_samples": self._off_samples, - "samples": min(learner.count for learner in self.learners.values()), + "samples": min((learner.count for learner in self.learners.values()), default=0), "mode": climate.state if climate else "unavailable", "weather": {k: v for k, v in weather.items() if v is not None}, "weather_active_sources": {k: v for k, v in active_sources.items() if v}, @@ -486,6 +487,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._evaluator.reset_window() status["forecast_accuracy"] = self._evaluator.summary(self.rooms) self._count_result(status, "waiting_for_inputs") + if not self.zones: + status["status"] = "no_zones" return status # A zone learns only across a poll pair where it was usable at both ends with @@ -587,7 +590,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "reference_heat": heater_on, "on_samples": self._on_samples, "off_samples": self._off_samples, - "samples": min(learner.count for learner in self.learners.values()), + "samples": min((learner.count for learner in self.learners.values()), default=0), "models": { room: {"name": self.zones[room].name, "loss": round(model.theta[0], 6), diff --git a/tests/ha/test_lifecycle.py b/tests/ha/test_lifecycle.py index 3262ce4..28a05a7 100644 --- a/tests/ha/test_lifecycle.py +++ b/tests/ha/test_lifecycle.py @@ -290,3 +290,18 @@ async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, fr last = hass.states.get(last_id) assert last.state == "waiting_for_inputs" assert last.attributes["counts_since_start"] == {"first_sample": 1, "learned": 1, "waiting_for_inputs": 1} + + +async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs, caplog): + await setup(hass, entry) + assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id) + await hass.async_block_till_done() + assert entry.state.name == "LOADED" + coordinator = hass.data[DOMAIN][entry.entry_id] + assert (coordinator.rooms, coordinator.data["status"]) == ([], "no_zones") + assert coordinator.data["learning_progress"] == 0 + assert "Error setting up entry" not in caplog.text + # Adding a zone back resumes normally. + add_zone(hass, entry, "sensor.room", ["sensor.room"]) + await hass.async_block_till_done() + assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"