fix(backend): flush deferred state on HA shutdown (#655)

Issue: #655
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-27 15:17:44 +03:00
parent 7a4032ba26
commit 075a2d3fb1
9 changed files with 170 additions and 22 deletions
+40 -6
View File
@@ -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
+1 -1
View File
@@ -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
+17 -12
View File
@@ -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."""
+5
View File
@@ -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
+6
View File
@@ -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
- В редакторе плана появились отдельные прямые и винтовые лестницы с физическими
+26
View File
@@ -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');
+44
View File
@@ -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:
+17
View File
@@ -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
+14 -3
View File
@@ -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"