From 075a2d3fb1284d58055160663adbfba9a1440202 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 27 Sep 2026 15:17:44 +0300 Subject: [PATCH] fix(backend): flush deferred state on HA shutdown (#655) Issue: #655 User-Visible: yes --- custom_components/houseplan/__init__.py | 46 ++++++++++++++++--- custom_components/houseplan/store.py | 2 +- custom_components/houseplan/virtual_lights.py | 29 +++++++----- docs/CHANGELOG.md | 5 ++ docs/CHANGELOG.ru.md | 6 +++ scripts/mutation-registry.mjs | 26 +++++++++++ tests_backend/test_ha_virtual_lights.py | 44 ++++++++++++++++++ tests_backend/test_trail_recorder.py | 17 +++++++ tests_backend/test_virtual_lights.py | 17 +++++-- 9 files changed, 170 insertions(+), 22 deletions(-) diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py index cd8d69b4..cde27c7f 100755 --- a/custom_components/houseplan/__init__.py +++ b/custom_components/houseplan/__init__.py @@ -6,6 +6,7 @@ import logging from datetime import timedelta from pathlib import Path +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_interval @@ -39,6 +40,30 @@ from .store import ( _LOGGER = logging.getLogger(__name__) +async def _async_flush_runtime( + hass: HomeAssistant, entry: HouseplanConfigEntry, +) -> None: + """Stop deferred writers and persist their latest state once. + + Home Assistant stop and config-entry unload share this exact path. Both + writers are independently guarded so one failed store cannot prevent the + other from reaching disk during shutdown. + """ + recorder = hass.data.get(DOMAIN, {}).get("trail_recorder") + if recorder is not None: + try: + await recorder.async_teardown() + except Exception: # noqa: BLE001 - shutdown must continue with other stores + _LOGGER.exception("House Plan: flushing vacuum trails failed") + + virtual_lights = getattr(entry.runtime_data, "virtual_lights", None) + if virtual_lights is not None: + try: + await virtual_lights.async_flush() + except Exception: # noqa: BLE001 - shutdown must continue with other stores + _LOGGER.exception("House Plan: flushing virtual-light state failed") + + async def async_setup(hass: HomeAssistant, config) -> bool: """Register global handlers (survive config-entry reloads): WS commands, HTTP view.""" hass.data.setdefault(DOMAIN, {}) @@ -252,6 +277,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> # sidebar entry after a migration, repair or initial housekeeping failure. panel_path = Path(__file__).parent / "frontend" / "houseplan-panel.js" await async_setup_panel_registration(hass, entry, panel_path) + + async def _flush_on_stop(_event) -> None: + stop_listener[0] = None + await _async_flush_runtime(hass, entry) + + stop_listener = [hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _flush_on_stop)] + + def _remove_stop_listener() -> None: + if stop_listener[0] is not None: + stop_listener[0]() + stop_listener[0] = None + + entry.async_on_unload(_remove_stop_listener) return True @@ -263,12 +301,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) - entry is loaded. Static paths cannot be unregistered by design. """ remove_panel_registration(hass) - rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None) - if rec: - await rec.async_teardown() - virtual_lights = getattr(entry.runtime_data, "virtual_lights", None) - if virtual_lights is not None: - await virtual_lights.async_flush() + await _async_flush_runtime(hass, entry) + hass.data.get(DOMAIN, {}).pop("trail_recorder", None) if entry.runtime_data.radar_coordinator: entry.runtime_data.radar_coordinator.teardown() entry.runtime_data.radar_coordinator = None diff --git a/custom_components/houseplan/store.py b/custom_components/houseplan/store.py index d85229f2..4b951a0c 100644 --- a/custom_components/houseplan/store.py +++ b/custom_components/houseplan/store.py @@ -133,7 +133,7 @@ def create_data(hass: HomeAssistant) -> HouseplanData: ) from .virtual_lights import VirtualLightController - data.virtual_lights = VirtualLightController(data.virtual_light_store) + data.virtual_lights = VirtualLightController(hass, data.virtual_light_store) return data diff --git a/custom_components/houseplan/virtual_lights.py b/custom_components/houseplan/virtual_lights.py index 41974096..cfefe28a 100644 --- a/custom_components/houseplan/virtual_lights.py +++ b/custom_components/houseplan/virtual_lights.py @@ -7,6 +7,8 @@ import logging from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from .store import HouseplanStore @@ -81,12 +83,14 @@ def _snapshot_payload( class VirtualLightController: """Runtime cache with coalesced durable writes for rapid toggles.""" - def __init__(self, store: HouseplanStore) -> None: + def __init__(self, hass: HomeAssistant, store: HouseplanStore) -> None: + self.hass = hass self.store = store self._state: dict[str, Any] | None = None self._dirty = False self._save_task: asyncio.Task[None] | None = None self._flush_event = asyncio.Event() + self._flush_lock = asyncio.Lock() async def async_snapshot( self, config: dict[str, Any], config_rev: int, @@ -127,7 +131,7 @@ class VirtualLightController: def _schedule_save(self) -> None: if self._save_task is None or self._save_task.done(): - self._save_task = asyncio.create_task(self._delayed_save()) + self._save_task = self.hass.async_create_task(self._delayed_save()) async def _delayed_save(self) -> None: try: @@ -156,16 +160,17 @@ class VirtualLightController: async def async_flush(self) -> None: """Persist the latest state before config transitions or unload.""" - task = self._save_task - if task is not None: - self._flush_event.set() - await task - if self._dirty and self._state is not None: - payload = copy.deepcopy(self._state) - await self.store.async_save(payload) - self._dirty = False - if not self._dirty: - self._flush_event.clear() + async with self._flush_lock: + task = self._save_task + if task is not None: + self._flush_event.set() + await task + if self._dirty and self._state is not None: + payload = copy.deepcopy(self._state) + await self.store.async_save(payload) + self._dirty = False + if not self._dirty: + self._flush_event.clear() def reset(self) -> None: """Forget cache after an external reconciliation wrote the store.""" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dd8684e6..be19536b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Manual virtual-light state and the latest vacuum-trail points are now flushed + when Home Assistant stops, so changes still inside their 0.5 s / 10 s write + debounce windows survive an ordinary restart + ([#655](https://github.com/Matysh/houseplan-card/issues/655)). + ## v1.78.0-beta.5 — 2026-09-27 - The Plan editor can now place straight and one-turn spiral stair objects with diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 0676f136..d1ba64d3 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,12 @@ ## Не выпущено +- Состояние виртуальных источников света и последние точки маршрута пылесоса + теперь принудительно сохраняются при остановке Home Assistant: изменения, + ещё попадающие в окна отложенной записи 0,5 с и 10 с, не теряются после + штатного перезапуска + ([#655](https://github.com/Matysh/houseplan-card/issues/655)). + ## v1.78.0-beta.5 — 2026-09-27 - В редакторе плана появились отдельные прямые и винтовые лестницы с физическими diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 335134df..cc94a72f 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13015,6 +13015,32 @@ const MUTANT_DEFINITIONS = [ replace: " return previous?.key === '__never__'", }], }, + { + id: 'shutdown-skips-deferred-store-flush', + guard: 'node scripts/backend-test-guard.mjs ' + + 'home_assistant_stop_flushes_pending_virtual_light_and_trail ' + + 'tests_backend/test_ha_virtual_lights.py', + because: '#655 AC1/AC2: the HA stop event must synchronously flush both deferred stores; ' + + 'otherwise the latest virtual-light toggle and vacuum-trail points disappear on restart', + patches: [{ + file: 'custom_components/houseplan/__init__.py', + find: ' await _async_flush_runtime(hass, entry)\n\n stop_listener = [', + replace: ' return # mutant: shutdown drops both pending stores\n\n stop_listener = [', + }], + }, + { + id: 'virtual-light-save-bypasses-ha-task-tracking', + guard: 'node scripts/backend-test-guard.mjs ' + + 'runtime_controller_coalesces_rapid_toggles ' + + 'tests_backend/test_virtual_lights.py', + because: '#655 AC4: HA must own the delayed writer so normal shutdown waits for it; a raw ' + + 'asyncio task can be cancelled before its 0.5 second debounce reaches durable storage', + patches: [{ + file: 'custom_components/houseplan/virtual_lights.py', + find: ' self._save_task = self.hass.async_create_task(self._delayed_save())\n', + replace: ' self._save_task = asyncio.create_task(self._delayed_save())\n', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/tests_backend/test_ha_virtual_lights.py b/tests_backend/test_ha_virtual_lights.py index 70da1047..e76a4e34 100644 --- a/tests_backend/test_ha_virtual_lights.py +++ b/tests_backend/test_ha_virtual_lights.py @@ -130,6 +130,50 @@ async def test_unload_flushes_a_toggle_still_inside_the_debounce_window( assert (await _get_config(restarted))["virtual_lights"]["off"] == ["lamp"] +async def test_home_assistant_stop_flushes_pending_virtual_light_and_trail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + monkeypatch, +) -> None: + from custom_components.houseplan import trails as trails_module + from custom_components.houseplan import virtual_lights as virtual_lights_module + + monkeypatch.setattr(virtual_lights_module, "SAVE_DELAY_S", 3600) + monkeypatch.setattr(trails_module, "SAVE_DELAY_S", 3600) + entry = await _setup(hass) + client = await hass_ws_client(hass) + await _set_config(client, _config(_manual()), 0) + assert (await _toggle(client))["result"]["on"] is False + + recorder = hass.data[DOMAIN]["trail_recorder"] + assert recorder.book.on_point("vacuum", "floor", 1.0, 2.0, 10.0) + assert recorder.book.end_run("vacuum", 11.0) + recorder._schedule_save() + expected_trails = { + "vacuum": { + "current": { + "map_id": "floor", + "started": 10.0, + "ended": 11.0, + "points": [[1.0, 2.0]], + }, + }, + } + + assert (await entry.runtime_data.virtual_light_store.async_load())["off"] == [] + assert await recorder.store.async_load() is None + await client.close() + + await hass.async_stop() + + assert await entry.runtime_data.virtual_light_store.async_load() == { + "rev": 1, + "config_rev": 1, + "off": ["lamp"], + } + assert await recorder.store.async_load() == expected_trails + + async def test_lifecycle_preserves_hidden_and_prunes_when_eligibility_ends( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: diff --git a/tests_backend/test_trail_recorder.py b/tests_backend/test_trail_recorder.py index 68de1628..31cdb774 100644 --- a/tests_backend/test_trail_recorder.py +++ b/tests_backend/test_trail_recorder.py @@ -858,6 +858,7 @@ def test_async_teardown_flushes_pending_debounced_state_and_closes_handles(): rec._unsub_save = lambda: cancelled.append(True) rec._unsub_track = lambda: untracked.append(True) + _run_isolated(rec.async_teardown()) _run_isolated(rec.async_teardown()) assert saved == [rec.book.data] @@ -867,6 +868,22 @@ def test_async_teardown_flushes_pending_debounced_state_and_closes_handles(): assert rec._closed is True +def test_async_teardown_is_idempotent_and_never_writes_without_pending_state(): + rec, _hass, _states = _rec() + saved = [] + + class TrailStore: + async def async_save(self, data): + saved.append(json.loads(json.dumps(data))) + + rec.store = TrailStore() + _run_isolated(rec.async_teardown()) + _run_isolated(rec.async_teardown()) + + assert saved == [] + assert rec._closed is True + + def test_refresh_watches_every_route_source_not_only_the_root(monkeypatch): """#162: карты одного робота могут идти через разные камеры.""" import asyncio diff --git a/tests_backend/test_virtual_lights.py b/tests_backend/test_virtual_lights.py index 774ec14d..542c4602 100644 --- a/tests_backend/test_virtual_lights.py +++ b/tests_backend/test_virtual_lights.py @@ -39,6 +39,15 @@ class FakeStore: self.writes.append(data) +class FakeHass: + def __init__(self): + self.created_tasks = 0 + + def async_create_task(self, coroutine): + self.created_tasks += 1 + return asyncio.create_task(coroutine) + + def _config(*markers): return {"spaces": [], "markers": list(markers), "settings": {}} @@ -102,16 +111,18 @@ def test_runtime_controller_coalesces_rapid_toggles_into_one_durable_write(): old_delay = _vl.SAVE_DELAY_S _vl.SAVE_DELAY_S = 0.01 try: + hass = FakeHass() store = FakeStore() - controller = VirtualLightController(store) + controller = VirtualLightController(hass, store) first = await controller.async_toggle(_config(_manual("lamp")), 1, "lamp") second = await controller.async_toggle(_config(_manual("lamp")), 1, "lamp") await controller.async_flush() - return first, second, store + return first, second, store, hass finally: _vl.SAVE_DELAY_S = old_delay - first, second, store = _run(exercise()) + first, second, store, hass = _run(exercise()) assert first == {"marker_id": "lamp", "on": False, "rev": 1} assert second == {"marker_id": "lamp", "on": True, "rev": 2} assert store.writes == [{"rev": 2, "config_rev": 1, "off": []}] + assert hass.created_tasks == 1, "the delayed writer must be tracked by HA"