From 6d5a730cc78bee04c39c56da23869d7111fccf83 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 10:55:09 -0400 Subject: [PATCH] Auto-register the dashboard on setup (#176) --- custom_components/nestquest/__init__.py | 11 +- custom_components/nestquest/core/const.py | 12 ++ custom_components/nestquest/dashboard.py | 215 +++++++++++++++++++ custom_components/nestquest/manifest.json | 3 +- tests/test_lifecycle.py | 246 ++++++++++++++++++++++ 5 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 custom_components/nestquest/dashboard.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 76493ca..479ab03 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -5,7 +5,8 @@ SQLite file, the migrations, and every business rule. Setup builds the entry's ONE shared API client, the coordinator that polls the panel snapshot through it, and the SSE subscription that re-fires the API's transition frames on the HA bus, registers the single -``nestquest.complete_quest`` proxy service, and forwards the sensor and +``nestquest.complete_quest`` proxy service, registers the zero-config +panel dashboard (Feature 20), and forwards the sensor and binary_sensor platforms. """ from __future__ import annotations @@ -29,6 +30,7 @@ from .const import ( PLATFORMS, ) from .coordinator import NestQuestCoordinator, coordinator_client_from_entry +from .dashboard import async_register_dashboard from .frontend import async_register_frontend from .services import async_deregister_services, async_register_services from .sse import NestQuestEventStream @@ -87,6 +89,13 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NestQuest from a config entry.""" await async_register_frontend(hass) + # The zero-config panel dashboard (Feature 20): register the + # storage-mode strategy dashboard if no dashboard under the stable + # url_path exists yet. Unavailability of the Lovelace dashboards + # collection (YAML mode, lovelace not loaded) logs a warning and + # NEVER fails setup; an existing dashboard — ours or the user's — + # is left untouched, so a reload cannot duplicate it. + await async_register_dashboard(hass) hass.data.setdefault(DOMAIN, {}) async def _async_update_listener( diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index 4faadd7..d8aba4b 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -43,6 +43,18 @@ DOMAIN_SERVICES: tuple[str, ...] = ( SQLITE_DB_FILENAME = "nestquest.db" +# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace +# dashboard registered on setup whose config carries the bundle's +# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the +# views are generated at render time from the household roster. The +# url_path IS the domain: the stable key idempotency is keyed on (a +# reload or a second entry never duplicates it, and a dashboard already +# registered under it — ours or the user's — is left untouched). +DASHBOARD_URL_PATH = DOMAIN +DASHBOARD_TITLE = "NestQuest" +DASHBOARD_ICON = "mdi:party-popper" +DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party" + # Configuration keys and default values CONF_HORIZON_DAYS = "horizon_days" DEFAULT_HORIZON_DAYS = 14 diff --git a/custom_components/nestquest/dashboard.py b/custom_components/nestquest/dashboard.py new file mode 100644 index 0000000..17bd6cb --- /dev/null +++ b/custom_components/nestquest/dashboard.py @@ -0,0 +1,215 @@ +"""Zero-config panel dashboard registration for NestQuest (Feature 20). + +On setup the integration registers ONE storage-mode Lovelace dashboard +whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see +``frontend/src/strategy.ts``): the views themselves are GENERATED at +render time from the household roster, so the stored dashboard config +carries nothing but the strategy reference. + +Registration mirrors the REAL two-step sequence the lovelace +integration itself uses when it creates the onboarding map dashboard +(``homeassistant/components/lovelace/__init__.py``, +``_create_map_dashboard``): + +1. create the dashboard METADATA through the ``DashboardsCollection`` + (``hass.data["lovelace"]["dashboards_collection"]``) — url_path, + title, icon, sidebar visibility. The strategy reference is NOT part + of the create payload: the collection's create schema allows only + the metadata fields, so a ``strategy`` key there fails the whole + create (this was the silent no-op the first implementation shipped). +2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the + per-url_path CONTENT manager that the collection's change listener + registers under ``hass.data["lovelace"]["dashboards"][url_path]`` + (a ``LovelaceStorage``), exactly the second step HA performs. + +Registration is IDEMPOTENT — a dashboard already under the stable +``url_path`` (ours or a user's) skips BOTH steps and its stored config +is never overwritten — and the Lovelace surfaces being unavailable +(YAML mode, lovelace not loaded) is a logged warning, never a setup +failure. +""" +from __future__ import annotations + +import asyncio + +from homeassistant.core import HomeAssistant + +from .const import ( + DASHBOARD_ICON, + DASHBOARD_STRATEGY_TYPE, + DASHBOARD_TITLE, + DASHBOARD_URL_PATH, + LOGGER, +) + +_LOGGER = LOGGER + +# Single-flight guard for the check-then-create below: HA can set up +# several config entries CONCURRENTLY, and two overlapping setups would +# both pass the exists-check and race on the url_path create. The lock +# is per hass instance (kept as an attribute on the hass object, since +# hass.data is the integrations' shared registry the unload path +# empties) rather than one module-level asyncio.Lock — a bare +# module-level lock binds to whichever event loop first awaits it and +# raises RuntimeError on any other loop, while the race it must prevent +# only exists between setups running on the SAME hass anyway. +_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock" + + +def _registration_lock(hass: HomeAssistant) -> asyncio.Lock: + """Return the per-hass registration lock, creating it on first use.""" + lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None) + if lock is None: + lock = asyncio.Lock() + setattr(hass, _REGISTRATION_LOCK_ATTR, lock) + return lock + + +def _lovelace_data(hass: HomeAssistant) -> dict | None: + """Return ``hass.data["lovelace"]`` when it is the real dict shape. + + The lovelace integration stores a plain DICT of surfaces (``mode``, + ``dashboards``, ``dashboards_collection``, ``resources``, + ``yaml_dashboards``) under the ``lovelace`` key; anything else + (missing key, unexpected object) means the surfaces below cannot be + reached and registration must warn and continue. + """ + lovelace_data = hass.data.get("lovelace") + if not isinstance(lovelace_data, dict): + return None + return lovelace_data + + +def _dashboards_collection(hass: HomeAssistant): + """Return the Lovelace dashboards METADATA collection, or None. + + The collection lives at ``hass.data["lovelace"] + ["dashboards_collection"]`` — NOT under ``["dashboards"]``, which + maps url_path to the per-dashboard CONTENT managers + (``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace + not loaded) and a missing/None entry (YAML mode never sets the + collection) both mean "cannot create storage dashboards here" — + callers log and continue. The presence of the surface is verified + by capability (``async_create_item`` callable), not by type, so no + lovelace import is needed. + """ + lovelace_data = _lovelace_data(hass) + if lovelace_data is None: + return None + collection = lovelace_data.get("dashboards_collection") + if collection is None or not callable( + getattr(collection, "async_create_item", None) + ): + return None + return collection + + +def _dashboard_store(hass: HomeAssistant, url_path: str): + """Return a url_path's dashboard CONTENT manager, or None. + + After a metadata create, the collection's change listener registers + a ``LovelaceStorage`` for the new dashboard under + ``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard + CONFIG (the strategy reference) is saved through THAT manager — + the second step of HA's own dashboard creation. + """ + lovelace_data = _lovelace_data(hass) + if lovelace_data is None: + return None + dashboards = lovelace_data.get("dashboards") + if not isinstance(dashboards, dict): + return None + store = dashboards.get(url_path) + if store is None or not callable(getattr(store, "async_save", None)): + return None + return store + + +def _item_url_path(item) -> str | None: + """Read a dashboard item's ``url_path`` through either access shape. + + The collection hands back storage items as dicts while builtin + dashboards come back as config objects; the url_path is reachable + through ``item["url_path"]`` or ``item.url_path`` respectively. + """ + url_path = getattr(item, "url_path", None) + if isinstance(url_path, str): + return url_path + get_url_path = getattr(item, "get", None) + if callable(get_url_path): + value = get_url_path("url_path") + if isinstance(value, str): + return value + return None + + +def _existing_dashboard(items) -> bool: + """Whether a dashboard under the stable url_path already exists.""" + return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items) + + +async def async_register_dashboard(hass: HomeAssistant) -> bool: + """Register the NestQuest panel dashboard exactly once. + + Returns True when a dashboard under the stable ``url_path`` exists + after the call (pre-existing or created here), False when the + Lovelace surfaces were unavailable or refused. A dashboard already + registered under the stable ``url_path`` — whether ours from a + previous setup or a user's own — skips BOTH steps and is left + COMPLETELY alone: its stored config is never overwritten, it is + never deleted, never a duplicate. The strategy config is saved + ONLY for the dashboard created in THIS call, whose fresh content + store is empty, so the save cannot clobber anything. + """ + collection = _dashboards_collection(hass) + if collection is None: + _LOGGER.warning( + "Lovelace dashboards collection unavailable; the NestQuest " + "panel dashboard was not registered. Enable the Lovelace " + "integration in storage mode or create a dashboard with " + "strategy %s manually.", + DASHBOARD_STRATEGY_TYPE, + ) + return False + try: + # Single-flight: two concurrent entry setups must not both pass + # the exists-check and race on the url_path create. + async with _registration_lock(hass): + # async_items() is a SYNC @callback on the real collection + # (helpers/collection.py ObservableCollection) — not awaited. + if _existing_dashboard(collection.async_items()): + return True + await collection.async_create_item( + { + "url_path": DASHBOARD_URL_PATH, + "title": DASHBOARD_TITLE, + "icon": DASHBOARD_ICON, + "show_in_sidebar": True, + "allow_single_word": True, + } + ) + # Step two (mirroring HA's _create_map_dashboard): the + # strategy CONFIG goes through the fresh dashboard's + # content manager, not through the metadata collection. + store = _dashboard_store(hass, DASHBOARD_URL_PATH) + if store is None: + _LOGGER.warning( + "The NestQuest panel dashboard was registered but its " + "Lovelace content manager never appeared; add the " + "strategy %s to the dashboard manually.", + DASHBOARD_STRATEGY_TYPE, + ) + return False + await store.async_save( + {"strategy": {"type": DASHBOARD_STRATEGY_TYPE}} + ) + except Exception: + _LOGGER.warning( + "Registering the NestQuest panel dashboard in the Lovelace " + "dashboards collection failed; set it up manually with " + "strategy %s.", + DASHBOARD_STRATEGY_TYPE, + exc_info=True, + ) + return False + return True diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index 157d18a..c4fe61d 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -7,7 +7,8 @@ "config_flow": true, "dependencies": [ "frontend", - "http" + "http", + "lovelace" ], "documentation": "https://github.com/Maniacs-Mansion/NestQuest", "integration_type": "hub", diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 3bfbac9..e5052d0 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -12,12 +12,100 @@ from custom_components.nestquest.const import ( DOMAIN as DOMAIN_CONST, SERVICE_COMPLETE_QUEST, ) +from custom_components.nestquest.dashboard import ( + DASHBOARD_STRATEGY_TYPE, + DASHBOARD_URL_PATH, + async_register_dashboard, +) def _wire(entry, registry): return wire_entry_to_registry(entry, registry) +class FakeDashboardStore: + """Stand-in for a per-dashboard CONTENT manager (LovelaceStorage). + + In the real API the dashboard CONFIG (the strategy reference) is + saved through THIS manager's ``async_save``, NOT through the + metadata collection — so every strategy-save assertion reads here. + """ + + def __init__(self) -> None: + self.saved: list[dict] = [] + self.config: dict | None = None + + async def async_save(self, config: dict) -> None: + self.saved.append(dict(config)) + self.config = dict(config) + + +class FakeDashboardsCollection: + """Stand-in mirroring the REAL Lovelace dashboards API shapes. + + Faithful to ``homeassistant/components/lovelace`` + + ``helpers/collection.py``: ``async_items()`` is a SYNC ``@callback`` + (ObservableCollection.async_items returns list(data.values())), + ``async_create_item`` IS async, and — mirroring lovelace's + ``storage_dashboard_changed`` listener — a successful create + registers a fresh CONTENT manager under + ``hass.data["lovelace"]["dashboards"][url_path]``, which is where + the dashboard config is then saved. Records ``async_create_item`` + calls so tests can assert exactly what the setup path registered — + and that a reload registers nothing more. + """ + + def __init__(self, hass, items=None) -> None: + self.hass = hass + self.items = list(items or []) + self.created: list[dict] = [] + #: url_path -> content manager, mirroring the real listener's + # registrations (pre-existing items included). + self.stores: dict[str, FakeDashboardStore] = {} + + def async_items(self): + """SYNC, like the real ``ObservableCollection.async_items``.""" + return list(self.items) + + async def async_create_item(self, item_config: dict) -> None: + # The real collection awaits validation and change notification + # around the item insertion; yield first so two concurrent + # callers without a single-flight guard would BOTH get here. + await asyncio.sleep(0) + self.created.append(dict(item_config)) + self.items.append(dict(item_config)) + self.stores[item_config["url_path"]] = FakeDashboardStore() + self.hass.data["lovelace"]["dashboards"][item_config["url_path"]] = ( + self.stores[item_config["url_path"]] + ) + + +def _install_dashboard_collection( + hass, items=None +) -> FakeDashboardsCollection: + """Install the REAL-API-shaped lovelace surfaces on the hass stub. + + Mirrors what lovelace's ``async_setup`` leaves behind in storage + mode: ``hass.data["lovelace"]`` is a DICT with the metadata + collection under ``dashboards_collection`` and the per-url_path + CONTENT managers under ``dashboards``. Any pre-existing item gets + a content manager, exactly as lovelace's change listener would + have registered one when the dashboard was created. + """ + collection = FakeDashboardsCollection(hass, items) + hass.data["lovelace"] = { + "mode": "storage", + "dashboards": {}, + "dashboards_collection": collection, + } + for item in collection.items: + url_path = item.get("url_path") + store = FakeDashboardStore() + collection.stores[url_path] = store + hass.data["lovelace"]["dashboards"][url_path] = store + return collection + + async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None: entry = _wire(make_entry(), hass.registry) assert await async_setup_entry(hass, entry) is True @@ -271,3 +359,161 @@ async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> N assert entry.runtime_data is None assert DOMAIN_CONST not in hass.data assert hass.time_change.size == 0 + + +async def test_setup_creates_the_panel_dashboard(hass, make_entry) -> None: + """A fresh install gets the storage-mode strategy dashboard via the + REAL two-step Lovelace sequence: the METADATA (url_path, title, + icon, sidebar, single-word allowance — nothing else: the real + create schema rejects unknown keys like ``strategy``) through the + collection, then the dashboard CONFIG (the strategy reference) + through the fresh dashboard's content manager.""" + collection = _install_dashboard_collection(hass) + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + # Step 1: the metadata create through the collection. + assert len(collection.created) == 1 + created = collection.created[0] + assert created["url_path"] == DASHBOARD_URL_PATH + assert created["title"] + assert created["show_in_sidebar"] is True + assert created["allow_single_word"] is True + assert "icon" in created + # The create payload carries METADATA only: a ``strategy`` key is + # rejected by the real collection's create schema (the silent + # no-op the first implementation shipped). + assert "strategy" not in created + assert "mode" not in created + # Step 2: the strategy CONFIG saved through the content manager + # the create registered, with the custom strategy type. + store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH] + assert store is collection.stores[DASHBOARD_URL_PATH] + assert store.saved == [{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}] + assert store.config == {"strategy": {"type": DASHBOARD_STRATEGY_TYPE}} + + +async def test_setup_reload_does_not_duplicate_the_dashboard( + hass, make_entry +) -> None: + """A reload (or a second entry) must not create a second dashboard: + the one under the stable url_path is reused as-is — BOTH steps are + skipped, so the strategy config is not re-saved either.""" + collection = _install_dashboard_collection(hass) + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert len(collection.created) == 1 + store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH] + assert len(store.saved) == 1 + # A reload: setup again on the same hass (the reload cycle) — + # the existing dashboard must be left alone. + await async_unload_entry(hass, entry) + assert await async_setup_entry(hass, entry) is True + assert len(collection.created) == 1 + assert len(store.saved) == 1 + # And a second entry on the same install. + other = _wire(make_entry(entry_id="other"), hass.registry) + assert await async_setup_entry(hass, other) is True + assert len(collection.created) == 1 + assert len(store.saved) == 1 + + +async def test_setup_leaves_an_existing_user_dashboard_alone( + hass, make_entry +) -> None: + """A dashboard already occupying the stable url_path — the user's + own — is never overwritten, deleted or duplicated: no metadata + create AND no strategy save against its content manager.""" + users_dashboard = {"url_path": DASHBOARD_URL_PATH, "title": "My Board"} + collection = _install_dashboard_collection(hass, items=[users_dashboard]) + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert collection.created == [] + assert collection.items[0] is users_dashboard + users_store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH] + assert users_store.saved == [] + assert users_store.config is None + + +async def test_setup_succeeds_without_the_dashboards_collection( + hass, make_entry, caplog +) -> None: + """YAML mode / lovelace not loaded: log a warning, continue setup.""" + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert entry.entry_id in hass.data[DOMAIN] + assert any( + "dashboard" in record.message.lower() + and record.levelname == "WARNING" + for record in caplog.records + ) + + +async def test_setup_survives_a_failing_dashboards_collection( + hass, make_entry +) -> None: + """A collection that refuses the create (permissions, admin-only) + logs a warning and setup still succeeds — entities come up.""" + class _RefusingCollection(FakeDashboardsCollection): + async def async_create_item(self, item_config): + raise PermissionError("not an admin") + + collection = _RefusingCollection(hass) + hass.data["lovelace"] = { + "mode": "storage", + "dashboards": {}, + "dashboards_collection": collection, + } + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert collection.created == [] + + +async def test_setup_survives_a_failing_content_manager_save( + hass, make_entry +) -> None: + """A content manager that refuses the config save after the + metadata create succeeded (storage write failure) logs a warning + and setup still succeeds — the dashboard exists, the strategy + simply was not persisted through it.""" + class _RefusingStore(FakeDashboardStore): + async def async_save(self, config): + raise OSError("disk full") + + class _StorelessCreateCollection(FakeDashboardsCollection): + async def async_create_item(self, item_config): + await asyncio.sleep(0) + self.created.append(dict(item_config)) + self.items.append(dict(item_config)) + self.stores[item_config["url_path"]] = _RefusingStore() + self.hass.data["lovelace"]["dashboards"][ + item_config["url_path"] + ] = self.stores[item_config["url_path"]] + + collection = _StorelessCreateCollection(hass) + hass.data["lovelace"] = { + "mode": "storage", + "dashboards": {}, + "dashboards_collection": collection, + } + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert len(collection.created) == 1 + store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH] + assert store.saved == [] + + +async def test_concurrent_registrations_create_the_dashboard_once(hass) -> None: + """Two CONCURRENT registrations (the race two entry setups run) + must produce exactly ONE dashboard: the single-flight guard makes + the second caller's exists-check run after the first caller's + create completed, so it skips both steps. The fake's create + yields before recording, so without the guard both callers would + pass the exists-check and the dashboard would be created twice.""" + collection = _install_dashboard_collection(hass) + results = await asyncio.gather( + async_register_dashboard(hass), + async_register_dashboard(hass), + ) + assert results == [True, True] + assert len(collection.created) == 1 + assert len(collection.stores[DASHBOARD_URL_PATH].saved) == 1