From f668cf95b020c22a7d7dd8e31b0921d23604d913 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 10:17:20 -0400 Subject: [PATCH] Auto-register the zero-config panel dashboard on setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh HA install with children configured should show the panel with no YAML and no manual dashboard creation: setup now registers ONE storage-mode Lovelace dashboard whose config is the bundle's custom:nestquest-party strategy (frontend/src/strategy.ts), under the stable url_path 'nestquest' with a title, icon and sidebar visibility. Registration is idempotent — the url_path is the idempotency key, so a reload or a second entry never duplicates the dashboard, and a dashboard already occupying the url_path (ours or the user's) is left completely alone (never overwritten, never deleted). An unavailable Lovelace dashboards collection (YAML mode, lovelace not loaded) or a refused create logs a clear warning and setup continues — never a crash. Tests cover create-on-setup, no-duplicate-on-reload, the untouched user dashboard, the missing collection and a refusing one. --- custom_components/nestquest/__init__.py | 11 ++- custom_components/nestquest/core/const.py | 12 +++ custom_components/nestquest/dashboard.py | 113 ++++++++++++++++++++++ tests/test_lifecycle.py | 110 +++++++++++++++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) 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..227f4b2 --- /dev/null +++ b/custom_components/nestquest/dashboard.py @@ -0,0 +1,113 @@ +"""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 carries +nothing but the strategy reference. Registration is IDEMPOTENT — a +dashboard with the stable ``url_path`` (ours or a user's) is never +touched, and the Lovelace dashboards collection being unavailable +(YAML mode, lovelace not loaded) is a logged warning, never a setup +failure. +""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import ( + DASHBOARD_ICON, + DASHBOARD_STRATEGY_TYPE, + DASHBOARD_TITLE, + DASHBOARD_URL_PATH, + LOGGER, +) + +_LOGGER = LOGGER + + +def _dashboards_collection(hass: HomeAssistant): + """Return the Lovelace dashboards collection, or None when absent. + + The lovelace integration stashes its collections on + ``hass.data["lovelace"]``; a missing key (lovelace not loaded) and a + collection-less LovelaceData 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 = hass.data.get("lovelace") + collection = getattr(lovelace_data, "dashboards", None) + if collection is None or not callable( + getattr(collection, "async_create_item", None) + ): + return None + return collection + + +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 dashboards collection was unavailable or refused. A + dashboard already registered under the stable ``url_path`` — whether + ours from a previous setup or a user's own — is left COMPLETELY + alone: never overwritten, never deleted, never a duplicate. + """ + 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: + items = await collection.async_items() + if _existing_dashboard(items): + return True + await collection.async_create_item( + { + "url_path": DASHBOARD_URL_PATH, + "title": DASHBOARD_TITLE, + "icon": DASHBOARD_ICON, + "show_in_sidebar": True, + "mode": "storage", + "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/tests/test_lifecycle.py b/tests/test_lifecycle.py index 3bfbac9..6ced532 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace import pytest @@ -12,12 +13,42 @@ 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, +) def _wire(entry, registry): return wire_entry_to_registry(entry, registry) +class FakeDashboardsCollection: + """Stand-in for the Lovelace dashboards collection. + + Records ``async_create_item`` calls the way the real collection + would persist them, so tests can assert exactly what the setup path + registered — and that a reload registers nothing more. + """ + + def __init__(self, items=None): + self.items = list(items or []) + self.created: list[dict] = [] + + async def async_items(self): + return list(self.items) + + async def async_create_item(self, item_config: dict) -> None: + self.created.append(dict(item_config)) + self.items.append(dict(item_config)) + + +def _install_dashboard_collection(hass, items=None) -> FakeDashboardsCollection: + collection = FakeDashboardsCollection(items) + hass.data["lovelace"] = SimpleNamespace(dashboards=collection) + 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 +302,82 @@ 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, with the + stable url_path, a title/icon, sidebar visibility and no YAML.""" + 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 + created = collection.created[0] + assert created["url_path"] == DASHBOARD_URL_PATH + assert created["mode"] == "storage" + assert created["strategy"] == {"type": DASHBOARD_STRATEGY_TYPE} + assert created["title"] + assert created["show_in_sidebar"] is True + assert "icon" in created + + +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.""" + 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 + # 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 + # 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 + + +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.""" + 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 + + +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.data["lovelace"] = SimpleNamespace(dashboards=collection) + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert collection.created == []