mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
fix(backend): flush deferred state on HA shutdown (#655)
Issue: #655 User-Visible: yes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- В редакторе плана появились отдельные прямые и винтовые лестницы с физическими
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user