Auto-register the zero-config panel dashboard on setup
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.
This commit is contained in:
@@ -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
|
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
|
snapshot through it, and the SSE subscription that re-fires the API's
|
||||||
transition frames on the HA bus, registers the single
|
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.
|
binary_sensor platforms.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -29,6 +30,7 @@ from .const import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||||
|
from .dashboard import async_register_dashboard
|
||||||
from .frontend import async_register_frontend
|
from .frontend import async_register_frontend
|
||||||
from .services import async_deregister_services, async_register_services
|
from .services import async_deregister_services, async_register_services
|
||||||
from .sse import NestQuestEventStream
|
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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up NestQuest from a config entry."""
|
"""Set up NestQuest from a config entry."""
|
||||||
await async_register_frontend(hass)
|
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, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
async def _async_update_listener(
|
async def _async_update_listener(
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ DOMAIN_SERVICES: tuple[str, ...] = (
|
|||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
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
|
# Configuration keys and default values
|
||||||
CONF_HORIZON_DAYS = "horizon_days"
|
CONF_HORIZON_DAYS = "horizon_days"
|
||||||
DEFAULT_HORIZON_DAYS = 14
|
DEFAULT_HORIZON_DAYS = 14
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -12,12 +13,42 @@ from custom_components.nestquest.const import (
|
|||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
)
|
)
|
||||||
|
from custom_components.nestquest.dashboard import (
|
||||||
|
DASHBOARD_STRATEGY_TYPE,
|
||||||
|
DASHBOARD_URL_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(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:
|
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(), hass.registry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
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 entry.runtime_data is None
|
||||||
assert DOMAIN_CONST not in hass.data
|
assert DOMAIN_CONST not in hass.data
|
||||||
assert hass.time_change.size == 0
|
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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user