Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403dd736b8 | ||
|
|
684f52ece8 | ||
|
|
b0bbb5f8c5 | ||
|
|
84d179336b | ||
|
|
fafabeabbe | ||
|
|
78fd75ec45 |
@@ -18,7 +18,9 @@ from homeassistant.helpers.event import async_track_time_change
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
|
CONF_SNAPSHOT_STALENESS,
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_SNAPSHOT_STALENESS,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
@@ -26,11 +28,12 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .core.settings import NestQuestSettings
|
from .core.settings import NestQuestSettings
|
||||||
from .frontend import async_register_frontend
|
from .frontend import async_register_frontend
|
||||||
from .coordinator import NestQuestCoordinator
|
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||||
from .db import NestQuestDatabase, make_database
|
from .db import NestQuestDatabase, make_database
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
from .services import async_deregister_services, async_register_services
|
from .services import async_deregister_services, async_register_services
|
||||||
|
from .sse import NestQuestEventStream
|
||||||
from .store import async_get_db_path
|
from .store import async_get_db_path
|
||||||
from .admin_allowlist import seed_setup_admin
|
from .admin_allowlist import seed_setup_admin
|
||||||
from .sweep import run_missed_sweep
|
from .sweep import run_missed_sweep
|
||||||
@@ -242,6 +245,7 @@ class NestQuestRuntimeData:
|
|||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
remove_time_change_listener: Callable[[], Any]
|
remove_time_change_listener: Callable[[], Any]
|
||||||
coordinator: Any = None
|
coordinator: Any = None
|
||||||
|
event_stream: NestQuestEventStream | None = None
|
||||||
|
|
||||||
|
|
||||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||||
@@ -278,9 +282,10 @@ async def _run_horizon_materialization(
|
|||||||
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
||||||
access is executor-only.
|
access is executor-only.
|
||||||
|
|
||||||
This is the ONE generation path: the startup backfill, the daily rollover
|
This is the ONE generation path inside the integration: the
|
||||||
listener and the ``regenerate`` service all funnel through it, so there is
|
startup backfill and the daily rollover listener both funnel
|
||||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
through it, so there is no duplicated materialization logic.
|
||||||
|
``settings`` (REQUIRED — there is no
|
||||||
silent default; every caller already passes the entry's validated
|
silent default; every caller already passes the entry's validated
|
||||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||||
@@ -322,13 +327,10 @@ def _register_day_rollover_listener(
|
|||||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||||
"""Return a live runtime record with a connected database, or None.
|
"""Return a live runtime record with a connected database, or None.
|
||||||
|
|
||||||
The domain-global services (e.g. ``regenerate``) have no database of
|
The domain-global service (the panel completion proxy) has no
|
||||||
their own; they resolve whichever config entry's runtime data is
|
database of its own; it resolves whichever config entry's runtime
|
||||||
currently live at call time, so none of them can hold a stale handle
|
data is currently live at call time, so it cannot hold a stale
|
||||||
to a closed connection after another entry unloads. The record also
|
handle to a closed connection after another entry unloads.
|
||||||
carries that entry's validated ``settings`` (e.g. the configured
|
|
||||||
horizon) so service handlers read horizon days off it rather than
|
|
||||||
re-reading HA config.
|
|
||||||
"""
|
"""
|
||||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
@@ -337,39 +339,17 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _make_regenerate_handler(hass: HomeAssistant):
|
|
||||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
|
||||||
|
|
||||||
async def _regenerate(_call: Any) -> None:
|
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
|
||||||
if runtime_data is None:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"NestQuest regenerate requested but no config entry has a "
|
|
||||||
"live database; ignoring"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await _run_horizon_materialization(
|
|
||||||
hass, runtime_data.database, runtime_data.settings
|
|
||||||
)
|
|
||||||
|
|
||||||
return _regenerate
|
|
||||||
|
|
||||||
|
|
||||||
def _register_services(hass: HomeAssistant) -> None:
|
def _register_services(hass: HomeAssistant) -> None:
|
||||||
"""Register domain-global NestQuest services once.
|
"""Register the domain-global NestQuest service once.
|
||||||
|
|
||||||
Services are DOMAIN-global — registered exactly once (guarded by
|
The service is DOMAIN-global — registered exactly once (guarded by
|
||||||
``has_service`` on regenerate) and NOT bound to any config entry —
|
``has_service``) and NOT bound to any config entry — so the panel
|
||||||
so each handler resolves the currently-live database at call time
|
completion proxy resolves the currently-live runtime at call time
|
||||||
and a name collision or duplicate setup is impossible. Removal is
|
and a name collision or duplicate setup is impossible. Removal is
|
||||||
handled by the unload path only once the LAST entry is removed
|
handled by the unload path only once the LAST entry is removed
|
||||||
(see :func:`_deregister_services`).
|
(see :func:`_deregister_services`).
|
||||||
"""
|
"""
|
||||||
async_register_services(
|
async_register_services(hass, find_runtime=_find_live_runtime_data)
|
||||||
hass,
|
|
||||||
find_runtime=_find_live_runtime_data,
|
|
||||||
regenerate_handler=_make_regenerate_handler(hass),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _deregister_services(hass: HomeAssistant) -> None:
|
def _deregister_services(hass: HomeAssistant) -> None:
|
||||||
@@ -439,6 +419,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
remove_update_listener: Callable[[], Any] | None = None
|
remove_update_listener: Callable[[], Any] | None = None
|
||||||
remove_time_change_listener: Callable[[], Any] | None = None
|
remove_time_change_listener: Callable[[], Any] | None = None
|
||||||
coordinator: Any = None
|
coordinator: Any = None
|
||||||
|
event_stream: NestQuestEventStream | None = None
|
||||||
try:
|
try:
|
||||||
# Seed the admin allowlist on first setup so the owner is never
|
# Seed the admin allowlist on first setup so the owner is never
|
||||||
# locked out: an empty database takes the persisted admin copy
|
# locked out: an empty database takes the persisted admin copy
|
||||||
@@ -478,8 +459,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# Backfill any days the daily listener missed while the integration
|
# Backfill any days the daily listener missed while the integration
|
||||||
# was off (or freshly installed): run the one idempotent walk over
|
# was off (or freshly installed): run the one idempotent walk over
|
||||||
# [today, today + horizon_days] once at setup, using the entry's
|
# [today, today + horizon_days] once at setup, using the entry's
|
||||||
# configured horizon. This runs through the SAME path as the daily
|
# configured horizon. This runs through the SAME path as the
|
||||||
# listener and regenerate service.
|
# daily listener.
|
||||||
await _run_horizon_materialization(hass, database, settings)
|
await _run_horizon_materialization(hass, database, settings)
|
||||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||||
# down at the rollover time, the previous night's still-open
|
# down at the rollover time, the previous night's still-open
|
||||||
@@ -490,18 +471,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_register_services(hass)
|
_register_services(hass)
|
||||||
# The shared Feature 10 coordinator: one refresh cycle every
|
# The shared Feature 10 coordinator: one refresh cycle every
|
||||||
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
||||||
# five minutes). Created AFTER services so a failure below
|
# five minutes). Since Feature 18 it polls the API service's
|
||||||
# unwinds them through the except path's listener handling.
|
# panel snapshot instead of reading the local database; an
|
||||||
|
# entry with no panel token yet gets the typed-error stub
|
||||||
|
# (unavailable entities, never a setup crash). Created AFTER
|
||||||
|
# services so a failure below unwinds them through the except
|
||||||
|
# path's listener handling.
|
||||||
|
# ONE API client for the entry, shared by the coordinator's
|
||||||
|
# snapshot polling and the SSE subscription below (the client
|
||||||
|
# is stateless — one session, one connection pool).
|
||||||
|
api_client = coordinator_client_from_entry(hass, entry)
|
||||||
|
# The shared Feature 10 coordinator: one refresh cycle every
|
||||||
|
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
||||||
|
# five minutes). Since Feature 18 it polls the API service's
|
||||||
|
# panel snapshot instead of reading the local database; an
|
||||||
|
# entry with no panel token yet gets the typed-error stub
|
||||||
|
# (unavailable entities, never a setup crash). Created AFTER
|
||||||
|
# services so a failure below unwinds them through the except
|
||||||
|
# path's listener handling.
|
||||||
coordinator = NestQuestCoordinator(
|
coordinator = NestQuestCoordinator(
|
||||||
hass,
|
hass,
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
database=database,
|
api_client=api_client,
|
||||||
settings=settings,
|
|
||||||
update_interval_seconds=entry.options.get(
|
update_interval_seconds=entry.options.get(
|
||||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||||
),
|
),
|
||||||
|
staleness_seconds=entry.options.get(
|
||||||
|
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
# The panel event subscription (the SSE task): a background
|
||||||
|
# task consumes the API service's transition-event stream and
|
||||||
|
# re-fires the four documented HA bus events. It reuses the
|
||||||
|
# coordinator's client, so it only starts when that client
|
||||||
|
# actually offers a stream — an unconfigured-token stub (or a
|
||||||
|
# test snapshot client without ``stream_events``) leaves it
|
||||||
|
# unstarted, and the typed-error path already covers
|
||||||
|
# availability for those entries.
|
||||||
|
if callable(getattr(api_client, "stream_events", None)):
|
||||||
|
event_stream = NestQuestEventStream(hass, api_client)
|
||||||
|
event_stream.start()
|
||||||
runtime_data = NestQuestRuntimeData(
|
runtime_data = NestQuestRuntimeData(
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
@@ -509,12 +518,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
remove_update_listener=remove_update_listener,
|
remove_update_listener=remove_update_listener,
|
||||||
remove_time_change_listener=remove_time_change_listener,
|
remove_time_change_listener=remove_time_change_listener,
|
||||||
coordinator=coordinator,
|
coordinator=coordinator,
|
||||||
|
event_stream=event_stream,
|
||||||
)
|
)
|
||||||
# runtime_data is assigned BEFORE the platforms forward so each
|
# The runtime record is registered BEFORE the first refresh:
|
||||||
# platform's async_setup_entry reads its coordinator off the
|
# the refresh path (and anything it calls) resolves the entry's
|
||||||
# entry (the HA runtime-data contract); a forward failure below
|
# runtime state through hass.data, and it must find the live
|
||||||
|
# database there — never a half-set-up entry. The platforms
|
||||||
|
# forward below reads the coordinator off the entry
|
||||||
|
# (the HA runtime-data contract); a forward failure below
|
||||||
# unwinds it through the except path.
|
# unwinds it through the except path.
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
entry.runtime_data = runtime_data
|
entry.runtime_data = runtime_data
|
||||||
|
# The first refresh polls the API snapshot; an API failure
|
||||||
|
# (unconfigured token, API service down) does NOT crash setup:
|
||||||
|
# the coordinator reports last_update_success=False, entities
|
||||||
|
# come up unavailable, and HA's standard retry cycle applies
|
||||||
|
# once the API is reachable. HA's real
|
||||||
|
# ``async_config_entry_first_refresh`` wraps ANY failed update
|
||||||
|
# pass in ConfigEntryNotReady — the original error survives
|
||||||
|
# only as ``__cause__``, never as the raised exception — so the
|
||||||
|
# catch keys on ConfigEntryNotReady, NOT on the typed API
|
||||||
|
# error. Everything else (a platform-forward failure,
|
||||||
|
# cancellation) still unwinds through the except block below.
|
||||||
|
try:
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
except ConfigEntryNotReady:
|
||||||
|
pass
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
@@ -522,6 +551,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# unwind EVERY remover we acquired before the failure, keeping the
|
# unwind EVERY remover we acquired before the failure, keeping the
|
||||||
# original error as the one raised. Removal is best-effort — a
|
# original error as the one raised. Removal is best-effort — a
|
||||||
# failing remover must not mask the setup error that triggered it.
|
# failing remover must not mask the setup error that triggered it.
|
||||||
|
# The SSE subscription (started just before the runtime record)
|
||||||
|
# is stopped first: like the coordinator it must never outlive
|
||||||
|
# the database being closed below.
|
||||||
|
if event_stream is not None:
|
||||||
|
try:
|
||||||
|
await event_stream.stop()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
for remove_listener in (
|
for remove_listener in (
|
||||||
remove_time_change_listener,
|
remove_time_change_listener,
|
||||||
remove_update_listener,
|
remove_update_listener,
|
||||||
@@ -548,10 +585,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# closes the already-closed database through the stale record.
|
# closes the already-closed database through the stale record.
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
|
# The runtime record was ALSO registered in hass.data before
|
||||||
|
# the first refresh: remove it here on ANY failure, or a later
|
||||||
|
# unload picks up the stale, already-torn-down record
|
||||||
|
# (hass.data wins over entry.runtime_data there) and
|
||||||
|
# re-invokes the already-consumed update-listener remover.
|
||||||
|
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||||
await database.close()
|
await database.close()
|
||||||
raise
|
raise
|
||||||
assert remove_update_listener is not None
|
assert remove_update_listener is not None
|
||||||
assert remove_time_change_listener is not None
|
assert remove_time_change_listener is not None
|
||||||
|
# The runtime record was already registered before the first
|
||||||
|
# refresh; re-assert the (unchanged) record so the return path
|
||||||
|
# stays readable as one registration site.
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -581,6 +627,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return False
|
return False
|
||||||
unload_error: BaseException | None = None
|
unload_error: BaseException | None = None
|
||||||
if runtime_data is not None:
|
if runtime_data is not None:
|
||||||
|
# The SSE subscription is stopped FIRST: its task must be gone
|
||||||
|
# (no frame can fire) before anything else is torn down.
|
||||||
|
event_stream = getattr(runtime_data, "event_stream", None)
|
||||||
|
if event_stream is not None:
|
||||||
|
stop_stream = getattr(event_stream, "stop", None)
|
||||||
|
if stop_stream is not None:
|
||||||
|
await stop_stream()
|
||||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||||
remove_time_change_listener = getattr(
|
remove_time_change_listener = getattr(
|
||||||
runtime_data, "remove_time_change_listener", None
|
runtime_data, "remove_time_change_listener", None
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""Panel-plane API client for the NestQuest integration (Feature 18).
|
||||||
|
|
||||||
|
The integration talks to the NestQuest API service (Feature 16) over
|
||||||
|
HTTP instead of reading the database: this module is the ONE place that
|
||||||
|
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
|
||||||
|
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
|
||||||
|
(completion as the tapped child profile) — authenticated with the
|
||||||
|
static panel service token as a Bearer credential on every request.
|
||||||
|
|
||||||
|
Transport-injected by construction: the constructor takes the base URL,
|
||||||
|
the panel token, and the HTTP session to use. The session speaks the
|
||||||
|
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
|
||||||
|
json=...)`` returning an async context manager whose response carries
|
||||||
|
``status`` and ``await text()``), so tests stub the transport with NO
|
||||||
|
network and NO aiohttp installed; production builds the client through
|
||||||
|
:func:`client_from_entry`, which hands in Home Assistant's shared
|
||||||
|
aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||||
|
.async_get_clientsession``). The module itself imports NEITHER
|
||||||
|
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
|
||||||
|
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
|
||||||
|
the HA coupling lives in the one production factory.
|
||||||
|
|
||||||
|
Every failure is typed: a transport failure (connection refused, DNS
|
||||||
|
failure, timeout — the exchange never produced a response) and every
|
||||||
|
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
|
||||||
|
carries the HTTP status where there is one (so 401 and 404 are
|
||||||
|
distinguishable at the call site) and ``None`` when there is not; the
|
||||||
|
original transport error is chained as ``__cause__`` — nothing is
|
||||||
|
swallowed. There are NO retries and the client logs nothing: refresh
|
||||||
|
cadence is the coordinator's concern (the next task), and the panel
|
||||||
|
token is a secret that must never reach a log line or an error
|
||||||
|
message — the error texts name the method, path, and status only.
|
||||||
|
|
||||||
|
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
|
||||||
|
exposed by :meth:`NestQuestApiClient.stream_events` — an async
|
||||||
|
generator yielding ``(event_type, payload)`` per Server-Sent Events
|
||||||
|
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
|
||||||
|
line). Opening the stream gets the same one-timeout, typed-error
|
||||||
|
contract as every other call; reading it has NO body-wide timeout (a
|
||||||
|
healthy stream is long-lived — the subscription manager owns
|
||||||
|
reconnection and backoff).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_API_BASE_URL,
|
||||||
|
CONF_PANEL_TOKEN,
|
||||||
|
DEFAULT_API_BASE_URL,
|
||||||
|
DEFAULT_PANEL_TOKEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"COMPLETE_INSTANCE_PATH",
|
||||||
|
"DEFAULT_TIMEOUT_SECONDS",
|
||||||
|
"EVENTS_PATH",
|
||||||
|
"NestQuestApiError",
|
||||||
|
"NestQuestApiClient",
|
||||||
|
"SNAPSHOT_PATH",
|
||||||
|
"client_from_entry",
|
||||||
|
"is_valid_base_url",
|
||||||
|
"resolve_api_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: The panel plane's snapshot route (Feature 16).
|
||||||
|
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
|
||||||
|
|
||||||
|
#: The panel plane's SSE event-stream route (Feature 16): each
|
||||||
|
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
|
||||||
|
EVENTS_PATH = "/api/v1/panel/events"
|
||||||
|
|
||||||
|
#: The panel plane's completion route template (Feature 16); the
|
||||||
|
#: ``{instance_id}`` placeholder is filled per call.
|
||||||
|
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
|
||||||
|
|
||||||
|
#: Request timeout, in seconds. One attempt, one timeout: a hung API
|
||||||
|
#: call surfaces as a typed error for the coordinator to report, never
|
||||||
|
#: a retry loop.
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class NestQuestApiError(Exception):
|
||||||
|
"""A failed NestQuest API exchange, raised for every failure mode.
|
||||||
|
|
||||||
|
``status`` carries the HTTP status when a response arrived (a
|
||||||
|
non-2xx response — 401 for a bad token, 404 for an unknown
|
||||||
|
instance) and is ``None`` when the exchange failed at the transport
|
||||||
|
level (connection refused, DNS failure, timeout) — so callers
|
||||||
|
distinguish the two with ``is_transport_error``. The original
|
||||||
|
transport error, where there is one, is chained as ``__cause__``.
|
||||||
|
Messages carry the method, path, and status — never the request
|
||||||
|
headers, so the panel token can never leak through an error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_transport_error(self) -> bool:
|
||||||
|
"""True when the exchange failed before any response arrived."""
|
||||||
|
return self.status is None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_base_url(value: object) -> bool:
|
||||||
|
"""Return True when ``value`` is a usable API base URL.
|
||||||
|
|
||||||
|
A full ``http://`` or ``https://`` URL with a host — the exact
|
||||||
|
shape the options flow validates (form ergonomics) and the client
|
||||||
|
constructor enforces (fail fast, never build requests against a
|
||||||
|
scheme-less or host-less string).
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return False
|
||||||
|
candidate = value.strip()
|
||||||
|
if not candidate:
|
||||||
|
return False
|
||||||
|
parsed = urlparse(candidate)
|
||||||
|
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_api_config(entry: Any) -> tuple[str, str]:
|
||||||
|
"""Resolve ``(base_url, panel_token)`` off a config entry.
|
||||||
|
|
||||||
|
Precedence mirrors the options flow's stored-values resolution:
|
||||||
|
``entry.options`` first (the live options-flow copy), then
|
||||||
|
``entry.data`` (the config-flow-era copy), then the const defaults.
|
||||||
|
The token is a secret: callers must never log the returned pair.
|
||||||
|
"""
|
||||||
|
options = getattr(entry, "options", None) or {}
|
||||||
|
data = getattr(entry, "data", None) or {}
|
||||||
|
|
||||||
|
def _pick(key: str, default: str) -> str:
|
||||||
|
if key in options:
|
||||||
|
return options[key]
|
||||||
|
if key in data:
|
||||||
|
return data[key]
|
||||||
|
return default
|
||||||
|
|
||||||
|
return (
|
||||||
|
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||||
|
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
|
||||||
|
"""Build the production client for a config entry.
|
||||||
|
|
||||||
|
Reads the configured base URL and panel token off the entry
|
||||||
|
(:func:`resolve_api_config`) and hands the client Home Assistant's
|
||||||
|
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||||
|
.async_get_clientsession``) so panel calls ride HA's one connection
|
||||||
|
pool. The helper import is deferred into this function on purpose:
|
||||||
|
the test harness is mock-only and the module must import (and the
|
||||||
|
client must be stub-transport testable) without homeassistant or
|
||||||
|
aiohttp installed.
|
||||||
|
"""
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
base_url, panel_token = resolve_api_config(entry)
|
||||||
|
return NestQuestApiClient(
|
||||||
|
base_url, panel_token, async_get_clientsession(hass)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NestQuestApiClient:
|
||||||
|
"""Async client for the NestQuest API service's panel plane.
|
||||||
|
|
||||||
|
ONE instance per config entry (the coordinator task wires it into
|
||||||
|
the refresh cycle). The client is deliberately thin: it adds the
|
||||||
|
Bearer credential and the base URL, enforces one timeout per
|
||||||
|
request, and turns every failure into :class:`NestQuestApiError` —
|
||||||
|
no business logic, no retries, no caching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
panel_token: str,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
"""Store the connection settings; the session is injected.
|
||||||
|
|
||||||
|
``session`` is an aiohttp ``ClientSession`` in production and a
|
||||||
|
stub transport in tests. ``base_url`` must be a full http(s)
|
||||||
|
URL and ``panel_token`` must be non-empty — a client built
|
||||||
|
without either can only fail later at request time with a
|
||||||
|
less-explicable error, so construction fails fast instead.
|
||||||
|
"""
|
||||||
|
if not is_valid_base_url(base_url):
|
||||||
|
raise ValueError(
|
||||||
|
f"base_url must be a full http(s) URL, got {base_url!r}"
|
||||||
|
)
|
||||||
|
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||||
|
raise ValueError("panel_token must be a non-empty string")
|
||||||
|
if (
|
||||||
|
not isinstance(timeout_seconds, (int, float))
|
||||||
|
or isinstance(timeout_seconds, bool)
|
||||||
|
or timeout_seconds <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
|
||||||
|
)
|
||||||
|
self._base_url = base_url.strip().rstrip("/")
|
||||||
|
self._panel_token = panel_token
|
||||||
|
self._session = session
|
||||||
|
self._timeout_seconds = float(timeout_seconds)
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> dict[str, Any]:
|
||||||
|
"""Fetch and parse today's household snapshot.
|
||||||
|
|
||||||
|
Returns the JSON the snapshot route returns (``today_iso``,
|
||||||
|
``cycle_day``, and the per-child list — see
|
||||||
|
``api/routes_panel.py`` for the documented shape). Raises
|
||||||
|
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||||
|
non-2xx response.
|
||||||
|
"""
|
||||||
|
return await self._request_json("GET", SNAPSHOT_PATH)
|
||||||
|
|
||||||
|
async def complete_instance(
|
||||||
|
self, instance_id: int, actor_child_id: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Complete one quest instance as the tapped child profile.
|
||||||
|
|
||||||
|
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
|
||||||
|
route and returns the parsed response body (the route answers
|
||||||
|
``{"status": ...}`` both for a first completion and for an
|
||||||
|
idempotent repeat, so a retried panel tap succeeds). Raises
|
||||||
|
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||||
|
non-2xx response — 401 for a wrong token, 404 for an unknown
|
||||||
|
instance.
|
||||||
|
"""
|
||||||
|
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
|
||||||
|
return await self._request_json(
|
||||||
|
"POST", path, json_body={"actor_child_id": actor_child_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream_events(
|
||||||
|
self,
|
||||||
|
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
||||||
|
"""Yield ``(event_type, payload)`` for every panel SSE frame.
|
||||||
|
|
||||||
|
Opens ``GET /api/v1/panel/events`` with the Bearer credential
|
||||||
|
and parses the Server-Sent Events framing the API service
|
||||||
|
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
|
||||||
|
blank line (one frame per transition event; a frame's ``data``
|
||||||
|
may span several ``data:`` lines, joined with newlines — the
|
||||||
|
standard SSE rule). Line splits across chunk boundaries are
|
||||||
|
buffered, so frames are parsed correctly whatever chunking the
|
||||||
|
transport produces.
|
||||||
|
|
||||||
|
Opening the stream carries the SAME one-timeout, typed-error
|
||||||
|
contract as every other call: a non-2xx response raises
|
||||||
|
:class:`NestQuestApiError` with its status and a transport
|
||||||
|
failure (connection refused, DNS, the open timing out) raises
|
||||||
|
one with ``status=None`` and the original error chained. The
|
||||||
|
reading loop itself has NO timeout — a healthy stream is
|
||||||
|
long-lived — but any failure raised from the transport
|
||||||
|
mid-stream raises typed as well, so the consumer can reconnect.
|
||||||
|
A frame whose ``data`` is not valid JSON is a protocol failure
|
||||||
|
and raises typed, carrying the response status it arrived
|
||||||
|
with. Cancellation is never converted: closing the generator
|
||||||
|
(``aclose``, i.e. the consumer's task being cancelled) stops
|
||||||
|
cleanly and closes the underlying response in every path.
|
||||||
|
"""
|
||||||
|
url = f"{self._base_url}{EVENTS_PATH}"
|
||||||
|
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||||
|
# Opening the response gets ONE request timeout, exactly like
|
||||||
|
# ``_request_json``; the read loop below runs WITHOUT one — a
|
||||||
|
# body-wide timeout would kill a healthy long-lived stream.
|
||||||
|
# The response context manager is entered manually (not via
|
||||||
|
# ``async with``) because the block must span the yields below;
|
||||||
|
# the ``finally`` closes it on EVERY exit path.
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(self._timeout_seconds):
|
||||||
|
stream_cm = self._session.request(
|
||||||
|
"GET", url, headers=headers, json=None
|
||||||
|
)
|
||||||
|
response = await stream_cm.__aenter__()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Cancellation is never converted (see ``_request_json``).
|
||||||
|
raise
|
||||||
|
except NestQuestApiError:
|
||||||
|
raise
|
||||||
|
except Exception as err:
|
||||||
|
raise NestQuestApiError(
|
||||||
|
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||||
|
status=None,
|
||||||
|
) from err
|
||||||
|
|
||||||
|
status = response.status
|
||||||
|
if not 200 <= status < 300:
|
||||||
|
await self._close_stream(stream_cm)
|
||||||
|
raise NestQuestApiError(
|
||||||
|
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_type: str | None = None
|
||||||
|
data_lines: list[str] = []
|
||||||
|
buffer = ""
|
||||||
|
async for chunk in response:
|
||||||
|
if isinstance(chunk, (bytes, bytearray)):
|
||||||
|
chunk = chunk.decode("utf-8")
|
||||||
|
buffer += chunk
|
||||||
|
while "\n" in buffer:
|
||||||
|
line, buffer = buffer.split("\n", 1)
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event_type = line[len("event:"):].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
data_lines.append(line[len("data:"):].strip())
|
||||||
|
elif not line and event_type is not None:
|
||||||
|
# A blank line ends the frame. A trailing
|
||||||
|
# incomplete frame (stream ends without its
|
||||||
|
# closing blank line) is discarded, per the
|
||||||
|
# SSE spec's treatment of incomplete events.
|
||||||
|
try:
|
||||||
|
payload = json.loads("\n".join(data_lines))
|
||||||
|
except ValueError as err:
|
||||||
|
raise NestQuestApiError(
|
||||||
|
f"NestQuest API GET {EVENTS_PATH} "
|
||||||
|
f"delivered a malformed SSE frame: {err}",
|
||||||
|
status=status,
|
||||||
|
) from err
|
||||||
|
yield event_type, payload
|
||||||
|
event_type = None
|
||||||
|
data_lines = []
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except NestQuestApiError:
|
||||||
|
raise
|
||||||
|
except Exception as err:
|
||||||
|
raise NestQuestApiError(
|
||||||
|
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||||
|
status=None,
|
||||||
|
) from err
|
||||||
|
finally:
|
||||||
|
await self._close_stream(stream_cm)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _close_stream(stream_cm: Any) -> None:
|
||||||
|
"""Close an opened streaming response context manager.
|
||||||
|
|
||||||
|
Works for aiohttp's response context manager (whose
|
||||||
|
``__aexit__`` is a coroutine) and for test stubs that return
|
||||||
|
either a coroutine or a plain value.
|
||||||
|
"""
|
||||||
|
result = stream_cm.__aexit__(None, None, None)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
async def _request_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
json_body: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Run ONE request and return the parsed JSON body.
|
||||||
|
|
||||||
|
The Bearer token rides the Authorization header only. A
|
||||||
|
non-2xx response raises :class:`NestQuestApiError` with its
|
||||||
|
status; anything the transport raises (connection, timeout) is
|
||||||
|
wrapped as a transport-level :class:`NestQuestApiError` with
|
||||||
|
the original error chained — never swallowed, never retried.
|
||||||
|
A 2xx body that is not valid JSON is a protocol failure and
|
||||||
|
raises typed too, carrying the status it arrived with.
|
||||||
|
"""
|
||||||
|
url = f"{self._base_url}{path}"
|
||||||
|
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(self._timeout_seconds):
|
||||||
|
async with self._session.request(
|
||||||
|
method, url, headers=headers, json=json_body
|
||||||
|
) as response:
|
||||||
|
status = response.status
|
||||||
|
if not 200 <= status < 300:
|
||||||
|
raise NestQuestApiError(
|
||||||
|
f"NestQuest API {method} {path} failed with "
|
||||||
|
f"HTTP {status}",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
body_text = await response.text()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Cancellation is never converted: a shutting-down event
|
||||||
|
# loop must stop, not read a synthesized API error.
|
||||||
|
raise
|
||||||
|
except NestQuestApiError:
|
||||||
|
raise
|
||||||
|
except Exception as err:
|
||||||
|
raise NestQuestApiError(
|
||||||
|
f"NestQuest API {method} {path} transport failure: {err}",
|
||||||
|
status=None,
|
||||||
|
) from err
|
||||||
|
try:
|
||||||
|
return json.loads(body_text)
|
||||||
|
except ValueError as err:
|
||||||
|
raise NestQuestApiError(
|
||||||
|
f"NestQuest API {method} {path} returned malformed "
|
||||||
|
f"JSON: {err}",
|
||||||
|
status=status,
|
||||||
|
) from err
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
|
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
|
||||||
|
|
||||||
ONE refresh pass builds the whole entity snapshot: active children,
|
ONE refresh pass builds the whole entity snapshot: active children,
|
||||||
today's instances with their derived states (open / done / missed via
|
today's instances with their derived states (open / done / missed via
|
||||||
@@ -7,54 +7,81 @@ per-child rollup counts. Every Feature 10 entity reads
|
|||||||
``coordinator.data`` — no entity queries the database itself, so one
|
``coordinator.data`` — no entity queries the database itself, so one
|
||||||
refresh updates the whole board coherently.
|
refresh updates the whole board coherently.
|
||||||
|
|
||||||
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
Since Feature 18 the snapshot no longer comes from the local database:
|
||||||
host clock and never UTC, matching the materialization and completion
|
the coordinator polls the NestQuest API service's panel plane —
|
||||||
layers.
|
``GET /api/v1/panel/snapshot`` through
|
||||||
|
:class:`~.api_client.NestQuestApiClient` on its update interval — and
|
||||||
|
RECONSTRUCTS the same core dataclasses the local builder produced
|
||||||
|
(:class:`~.core.snapshot.NestQuestSnapshot`,
|
||||||
|
:class:`~.core.snapshot.ChildDaySnapshot`,
|
||||||
|
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
|
||||||
|
``binary_sensor.py`` read ``coordinator.data`` and
|
||||||
|
:func:`~.core.snapshot.instance_payload` exactly as before, so every
|
||||||
|
entity_id and attribute payload shape is unchanged: the panel cards and
|
||||||
|
the four blueprints keep working untouched.
|
||||||
|
|
||||||
The snapshot assembly itself lives in the Home-Assistant-free
|
One loss is deliberate and documented (D-009 revisited): the panel
|
||||||
:mod:`custom_components.nestquest.core.snapshot` module
|
payload OMITS missed instances, so a snapshot rebuilt from the API can
|
||||||
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
|
no longer carry them — ``admin_instances`` (``include_missed=True``)
|
||||||
HA wrapper that resolves the local timezone and reads the settings handed
|
now equals ``instances`` (they can hold only open and completed rows).
|
||||||
into its constructor, then delegates to that builder, passing the SAME
|
The admin surface for missed quests is the PWA, which reads the API
|
||||||
HA-local ``now`` it resolved in one clock read. The API service's panel
|
service directly; the integration's entities never needed missed rows
|
||||||
snapshot route shares the same builder, so both consumers render ONE
|
for their states or counts (the counts arrive precomputed).
|
||||||
snapshot shape.
|
|
||||||
|
|
||||||
Presence is resolved through the SAME engine the materializer uses
|
Failure handling: constructing the client fails fast (ValueError) when
|
||||||
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
the entry has no panel token configured, and a fetch failure raises the
|
||||||
with the public DAO methods and the engine's anchor-date arithmetic
|
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
|
||||||
answers ``is this child at this house today`` (D-004 — never ISO week
|
integration setup: the constructor logs a clear message and hands back
|
||||||
parity).
|
a client-shaped stub whose refresh raises the same typed error, and
|
||||||
|
``_async_update_data`` converts every typed error into
|
||||||
|
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
|
||||||
|
exception a HA ``DataUpdateCoordinator`` records as a failed pass
|
||||||
|
(``last_update_success = False``, ``data = None``), so the entities
|
||||||
|
come up unavailable. The typed error is never swallowed silently: it
|
||||||
|
is logged with its message and chained into the UpdateFailed. (The
|
||||||
|
conversion matters at setup: HA's real
|
||||||
|
``async_config_entry_first_refresh`` wraps any failed pass in
|
||||||
|
``ConfigEntryNotReady``, so a setup that keys on the original
|
||||||
|
exception type would never match it.)
|
||||||
|
|
||||||
``cycle_day`` reports where today falls in the custody cycle of the
|
Last-good cache and staleness: every successful pass refreshes a cache
|
||||||
first active child (display order) that HAS a schedule — 1-based day
|
of the rebuilt snapshot stamped with the :func:`time.monotonic`
|
||||||
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
instant of the success. A typed failure is checked against that stamp
|
||||||
anchor date. ``0`` means no active child has a schedule at all, so
|
BEFORE the pass is declared failed: within the configured staleness
|
||||||
the admin header degrades to a harmless zero instead of guessing.
|
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
|
||||||
|
intervals) the pass SUCCEEDS with the cached snapshot — the panel
|
||||||
|
keeps rendering last-good data and the entities stay available — and
|
||||||
|
the outage is logged as a warning. Entities go unavailable only once
|
||||||
|
the cache is older than the threshold (or before the first success);
|
||||||
|
a later successful fetch refreshes the cache and the entities recover
|
||||||
|
on their own, without a config-entry reload.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
from zoneinfo import ZoneInfo
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_SNAPSHOT_STALENESS,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
MIN_UPDATE_INTERVAL,
|
MIN_UPDATE_INTERVAL,
|
||||||
)
|
)
|
||||||
from .core.db import NestQuestDatabase
|
|
||||||
from .core.settings import NestQuestSettings
|
|
||||||
from .core.snapshot import (
|
from .core.snapshot import (
|
||||||
ChildDaySnapshot,
|
ChildDaySnapshot,
|
||||||
NestQuestSnapshot,
|
NestQuestSnapshot,
|
||||||
QuestInstanceView,
|
QuestInstanceView,
|
||||||
build_snapshot,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -65,9 +92,131 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||||
|
"""Reconstruct the core snapshot dataclasses from the API's JSON.
|
||||||
|
|
||||||
|
The panel snapshot route (``api/routes_panel.py``) returns today's
|
||||||
|
household as plain JSON — ``today_iso``, ``cycle_day``, and the
|
||||||
|
per-child list with precomputed counts and the instances in the
|
||||||
|
documented payload shape. This function maps that shape BACK onto
|
||||||
|
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
|
||||||
|
field:
|
||||||
|
|
||||||
|
- the API's ``state`` ``"completed"`` maps back to the core view's
|
||||||
|
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
|
||||||
|
``"completed"`` again — the round-trip is identity for open and
|
||||||
|
completed rows);
|
||||||
|
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
|
||||||
|
``window``, ``due_time``, ``icon`` and the per-child counts pass
|
||||||
|
through verbatim;
|
||||||
|
- ``present`` and ``next_present`` pass through verbatim (the API
|
||||||
|
resolved them through the same presence engine server-side);
|
||||||
|
- the instance list is rebuilt IN ORDER, so the panel payload the
|
||||||
|
sensors emit matches the route's order byte for byte.
|
||||||
|
|
||||||
|
Because the route omits missed instances (D-009), a snapshot built
|
||||||
|
here cannot contain any ``missed`` view — see the module docstring.
|
||||||
|
"""
|
||||||
|
children: list[ChildDaySnapshot] = []
|
||||||
|
for child in payload.get("children", []):
|
||||||
|
views = tuple(
|
||||||
|
QuestInstanceView(
|
||||||
|
instance_id=instance["id"],
|
||||||
|
definition_id=instance["definition_id"],
|
||||||
|
child_id=instance["child_id"],
|
||||||
|
title=instance["title"],
|
||||||
|
icon=instance.get("icon"),
|
||||||
|
window=instance["window"],
|
||||||
|
# The API payload does not carry due_date: every row
|
||||||
|
# is TODAY's instance (the route queries
|
||||||
|
# [today, today]), so the view's due_date is the
|
||||||
|
# snapshot's today_iso — the same value the local
|
||||||
|
# builder stamped on every today row.
|
||||||
|
due_date=payload["today_iso"],
|
||||||
|
due_time=instance.get("due_time"),
|
||||||
|
state=(
|
||||||
|
"done" if instance["state"] == "completed" else
|
||||||
|
instance["state"]
|
||||||
|
),
|
||||||
|
overdue=instance["overdue"],
|
||||||
|
completed_at=instance.get("completed_at"),
|
||||||
|
was_on_time=instance.get("on_time"),
|
||||||
|
)
|
||||||
|
for instance in child.get("instances", [])
|
||||||
|
)
|
||||||
|
children.append(
|
||||||
|
ChildDaySnapshot(
|
||||||
|
child_id=child["child_id"],
|
||||||
|
child_name=child["child_name"],
|
||||||
|
instances=views,
|
||||||
|
present=child["present"],
|
||||||
|
due_today=child["due_today"],
|
||||||
|
completed_today=child["completed_today"],
|
||||||
|
remaining_today=child["remaining_today"],
|
||||||
|
completion_pct=child["completion_pct"],
|
||||||
|
next_present=child.get("next_present"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return NestQuestSnapshot(
|
||||||
|
today_iso=payload["today_iso"],
|
||||||
|
children=tuple(children),
|
||||||
|
cycle_day=payload["cycle_day"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _UnconfiguredApiClient:
|
||||||
|
"""Client-shaped stand-in used when the entry has no panel token.
|
||||||
|
|
||||||
|
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
|
||||||
|
empty token raises ValueError by design (fail fast on a client that
|
||||||
|
could only ever 401). An entry that simply has not been configured
|
||||||
|
yet must not crash integration setup, so the coordinator swaps in
|
||||||
|
this stub: every refresh raises the SAME typed
|
||||||
|
:class:`~.api_client.NestQuestApiError` a transport failure would,
|
||||||
|
carrying a clear "API not configured" message;
|
||||||
|
:meth:`NestQuestCoordinator._async_update_data` converts it to
|
||||||
|
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
|
||||||
|
the coordinator marks the entities unavailable and the HA standard
|
||||||
|
retry cycle applies. The error is logged, never silent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> dict[str, Any]:
|
||||||
|
"""Always raise: there is no configured API to fetch from."""
|
||||||
|
raise NestQuestApiError(
|
||||||
|
"NestQuest API not configured: set the panel token in the "
|
||||||
|
"integration options"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _monotonic() -> float:
|
||||||
|
"""The monotonic clock the staleness math reads.
|
||||||
|
|
||||||
|
A one-line indirection over :func:`time.monotonic` so the staleness
|
||||||
|
tests can drive the clock deterministically (patch THIS function,
|
||||||
|
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
|
||||||
|
(NTP corrections, suspends) must not shorten or extend the window.
|
||||||
|
"""
|
||||||
|
return time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||||
"""Shared refresh cycle for every NestQuest entity.
|
"""Shared refresh cycle for every NestQuest entity.
|
||||||
|
|
||||||
|
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
|
||||||
|
API client, reconstructed into the core snapshot dataclasses every
|
||||||
|
entity reads. A failed pass (unconfigured API, transport failure,
|
||||||
|
non-2xx response) raises the typed error out of the client;
|
||||||
|
``_async_update_data`` converts it to
|
||||||
|
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
|
||||||
|
exception HA's coordinator machinery records as a failed pass: the
|
||||||
|
coordinator's ``data`` keeps its last good value while
|
||||||
|
``last_update_success`` goes False — HA marks the entities
|
||||||
|
unavailable until a later pass succeeds. An unexpected
|
||||||
|
non-typed error still propagates out of ``_async_update_data``
|
||||||
|
and is logged (with its traceback) by HA's machinery, the same way.
|
||||||
|
|
||||||
Forced refreshes (the service paths pushing an immediate update
|
Forced refreshes (the service paths pushing an immediate update
|
||||||
after a completion) are SERIALIZED per entry: overlapping
|
after a completion) are SERIALIZED per entry: overlapping
|
||||||
complete/uncomplete calls must not interleave their snapshot
|
complete/uncomplete calls must not interleave their snapshot
|
||||||
@@ -75,6 +224,15 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
newer one and leave entities wrong until the next poll. Real
|
newer one and leave entities wrong until the next poll. Real
|
||||||
HA's coordinator already serializes internally; the lock keeps
|
HA's coordinator already serializes internally; the lock keeps
|
||||||
the same guarantee on the stand-in and costs nothing upstream.
|
the same guarantee on the stand-in and costs nothing upstream.
|
||||||
|
|
||||||
|
Last-good cache: a successful pass stamps the rebuilt snapshot
|
||||||
|
with the :func:`~.coordinator._monotonic` instant of the success;
|
||||||
|
a typed failure within the configured staleness threshold
|
||||||
|
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
|
||||||
|
that cached snapshot instead of failing the pass, so a short API
|
||||||
|
outage keeps the board up. Past the threshold — or before the
|
||||||
|
first success — the pass fails as above, and a later successful
|
||||||
|
pass refreshes the cache and recovers the entities on its own.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -82,9 +240,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
*,
|
*,
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
database: NestQuestDatabase,
|
api_client: NestQuestApiClient,
|
||||||
settings: NestQuestSettings,
|
|
||||||
update_interval_seconds: int | None = None,
|
update_interval_seconds: int | None = None,
|
||||||
|
staleness_seconds: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
interval = update_interval_seconds
|
interval = update_interval_seconds
|
||||||
if (
|
if (
|
||||||
@@ -93,6 +251,19 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
or interval < MIN_UPDATE_INTERVAL
|
or interval < MIN_UPDATE_INTERVAL
|
||||||
):
|
):
|
||||||
interval = DEFAULT_UPDATE_INTERVAL
|
interval = DEFAULT_UPDATE_INTERVAL
|
||||||
|
staleness = staleness_seconds
|
||||||
|
if (
|
||||||
|
not isinstance(staleness, int)
|
||||||
|
or isinstance(staleness, bool)
|
||||||
|
or staleness < interval
|
||||||
|
):
|
||||||
|
# A missing or nonsensical threshold falls back to the
|
||||||
|
# shipped default; a configured-but-too-short one (below
|
||||||
|
# even one poll interval) falls back to whichever of
|
||||||
|
# default/interval spans at least one failed poll, so the
|
||||||
|
# cache is never dead on arrival. (The options flow
|
||||||
|
# validates the same rule at the form.)
|
||||||
|
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
@@ -100,9 +271,16 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
update_interval=datetime.timedelta(seconds=interval),
|
update_interval=datetime.timedelta(seconds=interval),
|
||||||
)
|
)
|
||||||
self.entry_id = entry_id
|
self.entry_id = entry_id
|
||||||
self.database = database
|
self.api_client = api_client
|
||||||
self.settings = settings
|
|
||||||
self._refresh_lock = asyncio.Lock()
|
self._refresh_lock = asyncio.Lock()
|
||||||
|
#: The configured last-good window, in SECONDS (const.py:
|
||||||
|
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
|
||||||
|
self._staleness_seconds = staleness
|
||||||
|
#: The last-good cache: the snapshot from the most recent
|
||||||
|
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
|
||||||
|
#: landed. Both stay None until the first success.
|
||||||
|
self._last_good_snapshot: NestQuestSnapshot | None = None
|
||||||
|
self._last_good_monotonic: float | None = None
|
||||||
|
|
||||||
async def async_refresh(self) -> None:
|
async def async_refresh(self) -> None:
|
||||||
"""One refresh at a time; a queued refresh runs AFTER the
|
"""One refresh at a time; a queued refresh runs AFTER the
|
||||||
@@ -111,12 +289,98 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
async with self._refresh_lock:
|
async with self._refresh_lock:
|
||||||
await super().async_refresh()
|
await super().async_refresh()
|
||||||
|
|
||||||
def _local_now(self) -> datetime.datetime:
|
|
||||||
time_zone = ZoneInfo(self.hass.config.time_zone)
|
|
||||||
return datetime.datetime.now(time_zone)
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||||
now = self._local_now()
|
"""One refresh pass: fetch the panel snapshot and rebuild it.
|
||||||
return await build_snapshot(
|
|
||||||
self.database, self.settings, now
|
A successful pass rebuilds the snapshot and refreshes the
|
||||||
|
last-good cache (the snapshot plus the
|
||||||
|
:func:`time.monotonic` instant of the success).
|
||||||
|
|
||||||
|
A typed API failure (unconfigured token stub, transport
|
||||||
|
failure, non-2xx response) is first checked against that
|
||||||
|
stamp: WITHIN the configured staleness threshold (elapsed time
|
||||||
|
since the last successful fetch, inclusive of the threshold
|
||||||
|
itself) the pass SUCCEEDS with the cached snapshot — the panel
|
||||||
|
keeps rendering last-good data, the entities stay available —
|
||||||
|
and the outage is logged as a clear warning (never silent).
|
||||||
|
With no cache yet, or past the threshold, the typed error is
|
||||||
|
converted to
|
||||||
|
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
|
||||||
|
— the exception HA's coordinator machinery records as a failed
|
||||||
|
pass (``last_update_success = False``, ``data`` unchanged) so
|
||||||
|
entities go unavailable and the setup-time first refresh
|
||||||
|
arrives as ``ConfigEntryNotReady`` — with the typed error
|
||||||
|
logged and chained. Anything else still propagates.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = await self.api_client.get_snapshot()
|
||||||
|
except NestQuestApiError as err:
|
||||||
|
now = _monotonic()
|
||||||
|
cached = self._last_good_snapshot
|
||||||
|
fetched_at = self._last_good_monotonic
|
||||||
|
if (
|
||||||
|
cached is not None
|
||||||
|
and fetched_at is not None
|
||||||
|
and now - fetched_at <= self._staleness_seconds
|
||||||
|
):
|
||||||
|
LOGGER.warning(
|
||||||
|
"NestQuest API refresh failed (%s); serving the "
|
||||||
|
"last-good snapshot fetched %.0f s ago, within the "
|
||||||
|
"%d s staleness threshold",
|
||||||
|
err,
|
||||||
|
now - fetched_at,
|
||||||
|
self._staleness_seconds,
|
||||||
|
)
|
||||||
|
return cached
|
||||||
|
LOGGER.error(
|
||||||
|
"NestQuest coordinator refresh failed: %s", err
|
||||||
|
)
|
||||||
|
raise UpdateFailed(str(err)) from err
|
||||||
|
snapshot = snapshot_from_api_payload(payload)
|
||||||
|
self._last_good_snapshot = snapshot
|
||||||
|
self._last_good_monotonic = _monotonic()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def coordinator_client_from_entry(
|
||||||
|
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""Build the refresh client for a config entry, tolerating no token.
|
||||||
|
|
||||||
|
Reads ``(base_url, panel_token)`` off the entry and constructs the
|
||||||
|
real client. An entry with no panel token configured (the shipped
|
||||||
|
default is the empty string) makes the constructor raise ValueError
|
||||||
|
by design; integration setup must not crash on an unconfigured
|
||||||
|
entry, so this factory swaps in the typed-error stub instead and
|
||||||
|
logs ONE clear message. The ValueError itself is never re-raised
|
||||||
|
past this point — it is converted, with the reason logged.
|
||||||
|
|
||||||
|
``session_factory`` overrides where the HTTP session comes from
|
||||||
|
(production: HA's shared aiohttp session via
|
||||||
|
:func:`~.api_client.client_from_entry`); it exists so tests can
|
||||||
|
inject a stub transport without importing aiohttp — the mock-only
|
||||||
|
test harness has no ``homeassistant.helpers.aiohttp_client``.
|
||||||
|
"""
|
||||||
|
base_url, panel_token = resolve_api_config(entry)
|
||||||
|
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||||
|
LOGGER.warning(
|
||||||
|
"NestQuest API panel token is not configured for entry %s: "
|
||||||
|
"entities will stay unavailable until a token is set in the "
|
||||||
|
"integration options",
|
||||||
|
entry.entry_id,
|
||||||
)
|
)
|
||||||
|
return _UnconfiguredApiClient()
|
||||||
|
if session_factory is not None:
|
||||||
|
return NestQuestApiClient(
|
||||||
|
base_url, panel_token, session_factory(hass)
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.api_client import client_from_entry
|
||||||
|
|
||||||
|
return client_from_entry(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
|
#: Test seam: entry_id → client, consulted by the conftest's client
|
||||||
|
#: factory BEFORE the production path. Always empty in production
|
||||||
|
#: (nothing in the integration writes to it); tests register scripted
|
||||||
|
#: clients through it and clear it between tests.
|
||||||
|
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
|
||||||
|
|||||||
@@ -20,40 +20,25 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
|||||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||||
|
|
||||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||||
# panel and admin cards listen for these; Feature 10 fires completed,
|
# panel and admin cards listen for these; since Feature 18 the
|
||||||
# uncompleted, and child_day_complete from the service paths, and
|
# completed/uncompleted/day-complete transitions are published by the
|
||||||
# Feature 11's nightly sweep fires quest_missed.
|
# API service on its SSE stream and re-fired on the bus by the
|
||||||
|
# integration's subscription, and Feature 11's nightly sweep fires
|
||||||
|
# quest_missed.
|
||||||
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||||
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||||
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||||
|
|
||||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
|
||||||
#: plus the existing Feature 07 regenerate service).
|
#: Since Feature 18 only the panel completion service remains: the admin
|
||||||
|
#: surface is the PWA and the API service's admin routes, so the former
|
||||||
|
#: HA admin services (uncomplete, quest-definition and presence
|
||||||
|
#: management, CSV export, child management, regenerate) are gone.
|
||||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||||
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
|
||||||
SERVICE_MANAGE_CHILD = "manage_child"
|
|
||||||
SERVICE_REGENERATE = "regenerate"
|
|
||||||
|
|
||||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
@@ -81,6 +66,21 @@ CONF_UPDATE_INTERVAL = "update_interval"
|
|||||||
DEFAULT_UPDATE_INTERVAL = 300
|
DEFAULT_UPDATE_INTERVAL = 300
|
||||||
MIN_UPDATE_INTERVAL = 30
|
MIN_UPDATE_INTERVAL = 30
|
||||||
|
|
||||||
|
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
|
||||||
|
# coordinator keeps SERVING the last good snapshot after the API stops
|
||||||
|
# answering. A failed poll whose elapsed time since the last successful
|
||||||
|
# fetch is within the threshold returns the cached snapshot (the panel
|
||||||
|
# keeps rendering last-good data, entities stay available); past it the
|
||||||
|
# poll fails the HA way and the entities go unavailable. The default is
|
||||||
|
# 3x the default update interval (900 s = 15 minutes): three consecutive
|
||||||
|
# failed five-minute polls before the board is declared stale — enough
|
||||||
|
# to ride out an API service restart, short enough not to render a
|
||||||
|
# days-old board all day. Validation floor: the threshold must be at
|
||||||
|
# least the configured update interval, or the cache could never span
|
||||||
|
# even one failed poll.
|
||||||
|
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
|
||||||
|
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
|
||||||
|
|
||||||
# Notification automations (Feature 11). The notify target is a free-text
|
# Notification automations (Feature 11). The notify target is a free-text
|
||||||
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
||||||
# household fills in — never a hard-coded personal target; the times are
|
# household fills in — never a hard-coded personal target; the times are
|
||||||
@@ -101,6 +101,32 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
|||||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||||
DEFAULT_AUTOMATION_ENABLED = True
|
DEFAULT_AUTOMATION_ENABLED = True
|
||||||
|
|
||||||
|
# Panel-plane API client (Feature 18): the integration reaches the
|
||||||
|
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||||
|
# The base URL points at the API service host serving ``/api/v1/panel/*``
|
||||||
|
# (uvicorn's default host/port is the sensible starting point); the panel
|
||||||
|
# service token is the static Bearer credential the panel plane requires
|
||||||
|
# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the
|
||||||
|
# entry like every other setting — options first, then the entry-data
|
||||||
|
# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never
|
||||||
|
# echoed into error messages, and never committed. The shipped default
|
||||||
|
# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no
|
||||||
|
# configured token is therefore NOT a working client: the options flow
|
||||||
|
# accepts an empty token as "not configured yet" and only rejects
|
||||||
|
# malformed (padded/whitespace-only/non-string) values — the actual
|
||||||
|
# non-emptiness guard is enforced at client construction, where
|
||||||
|
# NestQuestApiClient fails fast on an empty/whitespace token
|
||||||
|
# (api_client.py). So a caller building the client from a bare
|
||||||
|
# entry must either ensure a token is configured first or handle the
|
||||||
|
# construction ValueError and surface an "API not configured" state —
|
||||||
|
# an unconfigured entry never yields a client that can only 401.
|
||||||
|
# This mirrors the API service's fail-closed stance (api/config.py: no
|
||||||
|
# default token exists).
|
||||||
|
CONF_API_BASE_URL = "api_base_url"
|
||||||
|
DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"
|
||||||
|
CONF_PANEL_TOKEN = "panel_token"
|
||||||
|
DEFAULT_PANEL_TOKEN = ""
|
||||||
|
|
||||||
# Quest windows (D-008): a definition may declare several day windows;
|
# Quest windows (D-008): a definition may declare several day windows;
|
||||||
# each produces its own instance per day and groups the panel's Quest
|
# each produces its own instance per day and groups the panel's Quest
|
||||||
# Log into three columns. The clock ranges are stored here so they stay
|
# Log into three columns. The clock ranges are stored here so they stay
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Explicit admin-only versus open service registry (Feature 09).
|
|
||||||
|
|
||||||
One mapping, one file: every registered ``nestquest`` service name is
|
|
||||||
either ``open`` or ``admin_only``. The permission decorator (later
|
|
||||||
Feature 09 tasks) reads this table; it does not take per-handler
|
|
||||||
policy arguments.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
DOMAIN_SERVICES,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
)
|
|
||||||
|
|
||||||
OPEN = "open"
|
|
||||||
ADMIN_ONLY = "admin_only"
|
|
||||||
|
|
||||||
SERVICE_POLICY: dict[str, str] = {
|
|
||||||
SERVICE_COMPLETE_QUEST: OPEN,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
|
||||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
|
||||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
|
||||||
}
|
|
||||||
|
|
||||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
|
||||||
raise RuntimeError(
|
|
||||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
|
||||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
|
||||||
)
|
|
||||||
@@ -4,8 +4,10 @@ The transition-event payload construction lives in
|
|||||||
:mod:`custom_components.nestquest.core.events` (extracted in
|
:mod:`custom_components.nestquest.core.events` (extracted in
|
||||||
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
||||||
shim keeps the integration's old ``fire_quest_completed`` /
|
shim keeps the integration's old ``fire_quest_completed`` /
|
||||||
``fire_quest_uncompleted`` API (the complete/uncomplete service
|
``fire_quest_uncompleted`` API for direct callers — since Feature 18
|
||||||
handlers call them with ``hass``), reads the HA-local timezone from
|
the HA service surface no longer fires transition events itself (the
|
||||||
|
API service publishes them on its SSE stream and the integration's
|
||||||
|
subscription re-fires them) — reads the HA-local timezone from
|
||||||
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
||||||
for the events, and fires each on ``hass.bus`` with the documented
|
for the events, and fires each on ``hass.bus`` with the documented
|
||||||
payloads. The four blueprints keep working with identical payloads.
|
payloads. The four blueprints keep working with identical payloads.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .const import (
|
|||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||||
CONF_AFTERNOON_REMINDER_TIME,
|
CONF_AFTERNOON_REMINDER_TIME,
|
||||||
|
CONF_API_BASE_URL,
|
||||||
CONF_CELEBRATION_ENABLED,
|
CONF_CELEBRATION_ENABLED,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||||
@@ -21,7 +22,10 @@ from .const import (
|
|||||||
CONF_MORNING_SUMMARY_TIME,
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
CONF_NOTIFY_TARGET,
|
CONF_NOTIFY_TARGET,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
|
CONF_PANEL_TOKEN,
|
||||||
|
CONF_SNAPSHOT_STALENESS,
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_API_BASE_URL,
|
||||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||||
DEFAULT_AUTOMATION_ENABLED,
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
@@ -29,12 +33,15 @@ from .const import (
|
|||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_MORNING_SUMMARY_TIME,
|
DEFAULT_MORNING_SUMMARY_TIME,
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||||
|
DEFAULT_PANEL_TOKEN,
|
||||||
|
DEFAULT_SNAPSHOT_STALENESS,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
MIN_UPDATE_INTERVAL,
|
MIN_UPDATE_INTERVAL,
|
||||||
TIME_PATTERN,
|
TIME_PATTERN,
|
||||||
)
|
)
|
||||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||||
|
from .api_client import is_valid_base_url
|
||||||
|
|
||||||
MIN_HORIZON_DAYS = 1
|
MIN_HORIZON_DAYS = 1
|
||||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||||
@@ -45,8 +52,18 @@ def _parse_time(value: str) -> bool:
|
|||||||
return TIME_PATTERN.fullmatch(value) is not None
|
return TIME_PATTERN.fullmatch(value) is not None
|
||||||
|
|
||||||
|
|
||||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
def _async_validate(
|
||||||
"""Validate user input and return field-level errors instead of raising."""
|
user_input: dict[str, Any],
|
||||||
|
current: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Validate user input and return field-level errors instead of raising.
|
||||||
|
|
||||||
|
``current`` carries the stored values (entry options → entry data →
|
||||||
|
defaults) so a raw submit that sets one field but omits a sibling it
|
||||||
|
is validated against (the staleness threshold vs. the update
|
||||||
|
interval) can still check the effective pair; raw submits omitting
|
||||||
|
BOTH keys keep their stored values untouched.
|
||||||
|
"""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
horizon_days = user_input.get(CONF_HORIZON_DAYS)
|
horizon_days = user_input.get(CONF_HORIZON_DAYS)
|
||||||
@@ -81,6 +98,33 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
|||||||
):
|
):
|
||||||
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
||||||
|
|
||||||
|
# The last-good staleness threshold (Feature 18) is OPTIONAL in raw
|
||||||
|
# submits the same way: a missing key keeps the current value. It
|
||||||
|
# must be a positive int, and at least the EFFECTIVE update interval
|
||||||
|
# (the one this submit carries, else the stored or default one) — a
|
||||||
|
# threshold shorter than the poll cadence could never serve even one
|
||||||
|
# cached snapshot.
|
||||||
|
staleness = user_input.get(CONF_SNAPSHOT_STALENESS)
|
||||||
|
if staleness is not None and (
|
||||||
|
isinstance(staleness, bool)
|
||||||
|
or not isinstance(staleness, int)
|
||||||
|
or staleness < 1
|
||||||
|
):
|
||||||
|
errors[CONF_SNAPSHOT_STALENESS] = "invalid"
|
||||||
|
elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL):
|
||||||
|
stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL)
|
||||||
|
effective_interval = update_interval
|
||||||
|
if effective_interval is None and (
|
||||||
|
isinstance(stored_interval, int)
|
||||||
|
and not isinstance(stored_interval, bool)
|
||||||
|
and stored_interval >= MIN_UPDATE_INTERVAL
|
||||||
|
):
|
||||||
|
effective_interval = stored_interval
|
||||||
|
if effective_interval is None:
|
||||||
|
effective_interval = DEFAULT_UPDATE_INTERVAL
|
||||||
|
if staleness < effective_interval:
|
||||||
|
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
|
||||||
|
|
||||||
# The notification section (Feature 11) is OPTIONAL in raw submits
|
# The notification section (Feature 11) is OPTIONAL in raw submits
|
||||||
# the same way: a missing key keeps the current value. The three
|
# the same way: a missing key keeps the current value. The three
|
||||||
# times are strict HH:MM; the notify target is a non-empty HA
|
# times are strict HH:MM; the notify target is a non-empty HA
|
||||||
@@ -116,6 +160,25 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
|||||||
if toggle_value is not None and not isinstance(toggle_value, bool):
|
if toggle_value is not None and not isinstance(toggle_value, bool):
|
||||||
errors[toggle_key] = "invalid"
|
errors[toggle_key] = "invalid"
|
||||||
|
|
||||||
|
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
|
||||||
|
# submits the same way: a missing key keeps the current value. The
|
||||||
|
# base URL must be a full http(s) URL with a host — the same check
|
||||||
|
# the client's constructor enforces, so a malformed URL fails at
|
||||||
|
# the form, not at the first request. The token may be EMPTY (the
|
||||||
|
# API connection is simply not configured yet) but a non-empty one
|
||||||
|
# must be pasted unpadded: a padded or whitespace-only value is a
|
||||||
|
# clipboard mistake that would fail the token check at request
|
||||||
|
# time.
|
||||||
|
api_base_url = user_input.get(CONF_API_BASE_URL)
|
||||||
|
if api_base_url is not None and not is_valid_base_url(api_base_url):
|
||||||
|
errors[CONF_API_BASE_URL] = "invalid_url"
|
||||||
|
panel_token = user_input.get(CONF_PANEL_TOKEN)
|
||||||
|
if panel_token is not None and (
|
||||||
|
not isinstance(panel_token, str)
|
||||||
|
or panel_token != panel_token.strip()
|
||||||
|
):
|
||||||
|
errors[CONF_PANEL_TOKEN] = "invalid"
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +218,14 @@ def _build_schema(
|
|||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||||
): int,
|
): int,
|
||||||
|
# The last-good staleness threshold (Feature 18): how long the
|
||||||
|
# coordinator keeps serving the cached snapshot after the API
|
||||||
|
# stops answering. Schema-validated submits always carry it
|
||||||
|
# (the default pre-fills from the stored value); raw callers
|
||||||
|
# omitting it keep their stored value (see _async_validate).
|
||||||
|
vol.Required(
|
||||||
|
CONF_SNAPSHOT_STALENESS, default=current[CONF_SNAPSHOT_STALENESS]
|
||||||
|
): int,
|
||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
|
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
|
||||||
): str,
|
): str,
|
||||||
@@ -186,6 +257,18 @@ def _build_schema(
|
|||||||
CONF_CELEBRATION_ENABLED,
|
CONF_CELEBRATION_ENABLED,
|
||||||
default=current[CONF_CELEBRATION_ENABLED],
|
default=current[CONF_CELEBRATION_ENABLED],
|
||||||
): _strict_bool,
|
): _strict_bool,
|
||||||
|
# Panel-plane API connection (Feature 18): the base URL the API
|
||||||
|
# client talks to and the panel service token it presents as
|
||||||
|
# the Bearer credential. Schema-validated submits always carry
|
||||||
|
# both (the defaults pre-fill from the stored values); raw
|
||||||
|
# callers omitting them keep their stored values (see
|
||||||
|
# _async_validate).
|
||||||
|
vol.Required(
|
||||||
|
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
|
||||||
|
): str,
|
||||||
|
vol.Required(
|
||||||
|
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
|
||||||
|
): str,
|
||||||
}
|
}
|
||||||
if admin_choices is not None:
|
if admin_choices is not None:
|
||||||
# HA's supported multi-select validator: the frontend can
|
# HA's supported multi-select validator: the frontend can
|
||||||
@@ -255,7 +338,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
errors = _async_validate(user_input)
|
errors = _async_validate(user_input, self._current_values())
|
||||||
# The admin ids are validated only when the key is present:
|
# The admin ids are validated only when the key is present:
|
||||||
# schema-validated submits always carry it (vol.Optional's
|
# schema-validated submits always carry it (vol.Optional's
|
||||||
# default fills from the current allowlist), while raw callers
|
# default fills from the current allowlist), while raw callers
|
||||||
@@ -318,6 +401,10 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
self._current_values()[CONF_UPDATE_INTERVAL],
|
self._current_values()[CONF_UPDATE_INTERVAL],
|
||||||
),
|
),
|
||||||
|
CONF_SNAPSHOT_STALENESS: user_input.get(
|
||||||
|
CONF_SNAPSHOT_STALENESS,
|
||||||
|
self._current_values()[CONF_SNAPSHOT_STALENESS],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
# The notification section (Feature 11) stores the household's
|
# The notification section (Feature 11) stores the household's
|
||||||
# choices the same fall-back-to-current way, so raw submits
|
# choices the same fall-back-to-current way, so raw submits
|
||||||
@@ -336,6 +423,11 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
data[notification_key] = user_input.get(
|
data[notification_key] = user_input.get(
|
||||||
notification_key, current[notification_key]
|
notification_key, current[notification_key]
|
||||||
)
|
)
|
||||||
|
# The API connection section (Feature 18) stores the base URL
|
||||||
|
# and panel token the same fall-back-to-current way, so raw
|
||||||
|
# submits that predate the section keep their stored values.
|
||||||
|
for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
|
||||||
|
data[api_key] = user_input.get(api_key, current[api_key])
|
||||||
if admin_ids_present:
|
if admin_ids_present:
|
||||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||||
elif admin_default:
|
elif admin_default:
|
||||||
@@ -357,6 +449,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||||
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||||
|
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
|
||||||
(CONF_NOTIFY_TARGET, ""),
|
(CONF_NOTIFY_TARGET, ""),
|
||||||
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
|
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
|
||||||
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
|
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
|
||||||
@@ -365,6 +458,8 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||||
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||||
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||||
|
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||||
|
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||||
):
|
):
|
||||||
if key in options:
|
if key in options:
|
||||||
values[key] = options[key]
|
values[key] = options[key]
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Permission gate for NestQuest services (Feature 09).
|
|
||||||
|
|
||||||
A wrapper factory, applied to EVERY registered service at registration
|
|
||||||
time (no handler is exempt), reads ``call.context.user_id``, consults
|
|
||||||
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
|
||||||
:func:`~.admin_allowlist.is_admin`, and raises
|
|
||||||
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
|
||||||
operation called by a non-admin — including a call with NO user
|
|
||||||
context, which fails closed. ``complete_quest`` (policy ``open``)
|
|
||||||
proceeds regardless of the allowlist.
|
|
||||||
|
|
||||||
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
|
||||||
warning and every successful one logs a debug line, both carrying the
|
|
||||||
service, the user id (or ``none``), and a UTC timestamp.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.exceptions import Unauthorized
|
|
||||||
|
|
||||||
from .admin_allowlist import is_admin
|
|
||||||
from .service_policy import ADMIN_ONLY
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
|
||||||
|
|
||||||
|
|
||||||
def _context_user_id(call: Any) -> str | None:
|
|
||||||
context = getattr(call, "context", None)
|
|
||||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
|
||||||
if isinstance(user_id, str) and user_id:
|
|
||||||
return user_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp() -> str:
|
|
||||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
|
||||||
timespec="seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def permission_gate(
|
|
||||||
service: str,
|
|
||||||
policy: str,
|
|
||||||
database_of: Callable[[Any], Any],
|
|
||||||
) -> Callable[[ServiceHandler], ServiceHandler]:
|
|
||||||
"""Wrap ``handler`` with the Feature 09 permission check.
|
|
||||||
|
|
||||||
``database_of`` resolves the live database from the call-time
|
|
||||||
runtime (the same ``find_runtime`` the handler uses), so the gate
|
|
||||||
never opens its own connection. The verdict is fail-closed:
|
|
||||||
absent allowlist row, unknown id, no user context — all not admin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
|
||||||
if policy != ADMIN_ONLY:
|
|
||||||
return handler
|
|
||||||
|
|
||||||
async def _gated(call: Any) -> None:
|
|
||||||
user_id = _context_user_id(call)
|
|
||||||
allowed = user_id is not None and await is_admin(
|
|
||||||
database_of(call), user_id
|
|
||||||
)
|
|
||||||
if not allowed:
|
|
||||||
LOGGER.warning(
|
|
||||||
"NestQuest denied %s: caller user_id=%s at %s",
|
|
||||||
service,
|
|
||||||
user_id if user_id is not None else "none",
|
|
||||||
_stamp(),
|
|
||||||
)
|
|
||||||
raise Unauthorized(
|
|
||||||
f"User '{user_id if user_id is not None else 'none'}' "
|
|
||||||
f"is not allowed to call nestquest.{service}"
|
|
||||||
)
|
|
||||||
LOGGER.debug(
|
|
||||||
"NestQuest allowed %s: caller user_id=%s at %s",
|
|
||||||
service,
|
|
||||||
user_id,
|
|
||||||
_stamp(),
|
|
||||||
)
|
|
||||||
await handler(call)
|
|
||||||
|
|
||||||
return _gated
|
|
||||||
|
|
||||||
return _wrap
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
|
|
||||||
|
|
||||||
The service policy registry lives in
|
|
||||||
:mod:`custom_components.nestquest.core.service_policy` (extracted in
|
|
||||||
task 510c1f78); this module re-exports it so existing
|
|
||||||
``from .service_policy import ...`` references inside the integration
|
|
||||||
keep working.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .core.service_policy import * # noqa: F401,F403
|
|
||||||
@@ -1,61 +1,36 @@
|
|||||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
|
||||||
|
|
||||||
Registration is domain-scoped and happens once (see
|
Registration is domain-scoped and happens once (see
|
||||||
:func:`async_register_services`); handlers resolve a live database at
|
:func:`async_register_services`). Since Feature 18 the integration is
|
||||||
call time through the injected ``find_runtime`` callable. Permission
|
a client of the NestQuest API service, so ``complete_quest`` proxies
|
||||||
checks are Feature 09 later tasks — this module only validates payload
|
to the API's panel complete route through the entry's shared API
|
||||||
shape and forwards to the Feature 03/06/08 business layers (the
|
client instead of writing any database. The former HA admin services
|
||||||
presence writes included, through :mod:`.presence_management` — the
|
(uncomplete, quest-definition and presence management, CSV export,
|
||||||
ONE implementation the API admin plane shares).
|
child management, regenerate) are removed: the admin surface is the
|
||||||
``export_history_csv`` is registered with a schema but raises until
|
PWA and the API service's admin routes, so the only HA service left is
|
||||||
Feature 13. ``regenerate`` is supplied by the caller so
|
the panel completion path.
|
||||||
the Feature 07 handler stays the one in ``__init__``.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from . import children as children_layer
|
from .api_client import NestQuestApiError
|
||||||
from . import completion as completion_layer
|
|
||||||
from . import presence_management as presence_management_layer
|
|
||||||
from . import quest_definitions as quest_definitions_layer
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DOMAIN_SERVICES,
|
DOMAIN_SERVICES,
|
||||||
QUEST_WINDOWS,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
)
|
)
|
||||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
|
||||||
from .permissions import permission_gate
|
|
||||||
from .recurrence import ScheduleRule
|
|
||||||
from .service_policy import SERVICE_POLICY
|
|
||||||
|
|
||||||
FindRuntime = Callable[[HomeAssistant], Any]
|
FindRuntime = Callable[[HomeAssistant], Any]
|
||||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||||
|
|
||||||
ACTOR_PANEL = "panel"
|
ACTOR_PANEL = "panel"
|
||||||
ACTOR_USER = "user"
|
ACTOR_USER = "user"
|
||||||
MANAGE_CHILD_CREATE = "create"
|
|
||||||
MANAGE_CHILD_EDIT = "edit"
|
|
||||||
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
|
||||||
MANAGE_CHILD_REORDER = "reorder"
|
|
||||||
|
|
||||||
|
|
||||||
def _strict_int(value: object) -> int:
|
def _strict_int(value: object) -> int:
|
||||||
@@ -65,82 +40,6 @@ def _strict_int(value: object) -> int:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _strict_bool(value: object) -> bool:
|
|
||||||
"""Reject ints and other truthy values; require a real bool."""
|
|
||||||
if type(value) is not bool:
|
|
||||||
raise vol.Invalid(f"expected bool, got {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _weekday_list(value: object) -> list[int]:
|
|
||||||
if type(value) is not list:
|
|
||||||
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
|
||||||
return [_strict_int(item) for item in value]
|
|
||||||
|
|
||||||
|
|
||||||
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
|
||||||
pattern: dict[int, list[int]] = {}
|
|
||||||
for week, days in value.items():
|
|
||||||
if isinstance(week, str):
|
|
||||||
try:
|
|
||||||
week_index = int(week)
|
|
||||||
except ValueError as err:
|
|
||||||
raise vol.Invalid(
|
|
||||||
f"pattern week index must be an integer, got {week!r}"
|
|
||||||
) from err
|
|
||||||
if str(week_index) != week:
|
|
||||||
raise vol.Invalid(
|
|
||||||
f"pattern week index must be an integer, got {week!r}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
week_index = _strict_int(week)
|
|
||||||
pattern[week_index] = _weekday_list(days)
|
|
||||||
return pattern
|
|
||||||
|
|
||||||
|
|
||||||
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
|
||||||
if isinstance(value, str):
|
|
||||||
if value not in QUEST_WINDOWS:
|
|
||||||
raise vol.Invalid(f"unknown window {value!r}")
|
|
||||||
return value
|
|
||||||
if isinstance(value, dict):
|
|
||||||
parsed = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
|
||||||
vol.Optional("due_time"): str,
|
|
||||||
}
|
|
||||||
)(value)
|
|
||||||
due_time = parsed.get("due_time")
|
|
||||||
if due_time is None:
|
|
||||||
return parsed["window"]
|
|
||||||
return (parsed["window"], due_time)
|
|
||||||
raise vol.Invalid(
|
|
||||||
f"windows entry must be a window name or object, got {value!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
|
||||||
if type(value) is not list:
|
|
||||||
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
|
||||||
if not value:
|
|
||||||
raise vol.Invalid("windows must not be empty")
|
|
||||||
return [_window_entry(entry) for entry in value]
|
|
||||||
|
|
||||||
|
|
||||||
def _int_list(value: object) -> list[int]:
|
|
||||||
if type(value) is not list:
|
|
||||||
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
|
||||||
return [_strict_int(item) for item in value]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_dict(value: object) -> dict:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("instance_id"): _strict_int,
|
vol.Required("instance_id"): _strict_int,
|
||||||
@@ -150,86 +49,6 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
|
||||||
|
|
||||||
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("title"): str,
|
|
||||||
vol.Required("rule"): _rule_dict,
|
|
||||||
vol.Required("assignee_child_ids"): vol.All(
|
|
||||||
_int_list, vol.Length(min=1)
|
|
||||||
),
|
|
||||||
vol.Required("windows"): _windows,
|
|
||||||
vol.Optional("description"): str,
|
|
||||||
vol.Optional("icon"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("definition_id"): _strict_int,
|
|
||||||
vol.Optional("title"): str,
|
|
||||||
vol.Optional("description"): vol.Any(str, None),
|
|
||||||
vol.Optional("icon"): vol.Any(str, None),
|
|
||||||
vol.Optional("rule"): _rule_dict,
|
|
||||||
vol.Optional("windows"): _windows,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("definition_id"): _strict_int,
|
|
||||||
vol.Required("is_active"): _strict_bool,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("child_id"): _strict_int,
|
|
||||||
vol.Required("cycle_length_weeks"): _strict_int,
|
|
||||||
vol.Required("anchor_date"): str,
|
|
||||||
vol.Required("pattern"): _presence_pattern,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("child_id"): _strict_int,
|
|
||||||
vol.Required("start_date"): str,
|
|
||||||
vol.Required("end_date"): str,
|
|
||||||
vol.Required("is_present"): _strict_bool,
|
|
||||||
vol.Optional("note"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("override_id"): _strict_int,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
|
||||||
|
|
||||||
SCHEMA_MANAGE_CHILD = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("action"): vol.In(
|
|
||||||
[
|
|
||||||
MANAGE_CHILD_CREATE,
|
|
||||||
MANAGE_CHILD_EDIT,
|
|
||||||
MANAGE_CHILD_SET_ACTIVE,
|
|
||||||
MANAGE_CHILD_REORDER,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
vol.Optional("child_id"): _strict_int,
|
|
||||||
vol.Optional("display_name"): str,
|
|
||||||
vol.Optional("colour"): str,
|
|
||||||
vol.Optional("avatar_ref"): str,
|
|
||||||
vol.Optional("sort_order"): _strict_int,
|
|
||||||
vol.Optional("is_active"): _strict_bool,
|
|
||||||
vol.Optional("ordered_ids"): _int_list,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||||
runtime = find_runtime(hass)
|
runtime = find_runtime(hass)
|
||||||
@@ -241,11 +60,11 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
|||||||
|
|
||||||
|
|
||||||
async def _refresh_entities(runtime: Any) -> None:
|
async def _refresh_entities(runtime: Any) -> None:
|
||||||
"""Force an immediate coordinator refresh after a service mutation.
|
"""Force an immediate coordinator refresh after the service call.
|
||||||
|
|
||||||
Only the SERVICE paths call this — the scheduled materialize and
|
Only the SERVICE path calls this — the scheduled materialize and
|
||||||
rollover internals are direct function calls and never widen this
|
rollover internals are direct function calls and never widen this
|
||||||
gate — so entities reflect a completion the moment the service
|
gate — so the entities reflect a completion the moment the service
|
||||||
call returns instead of waiting for the poll interval.
|
call returns instead of waiting for the poll interval.
|
||||||
"""
|
"""
|
||||||
coordinator = getattr(runtime, "coordinator", None)
|
coordinator = getattr(runtime, "coordinator", None)
|
||||||
@@ -253,12 +72,6 @@ async def _refresh_entities(runtime: Any) -> None:
|
|||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
|
|
||||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
|
||||||
today = datetime.datetime.now(time_zone).date()
|
|
||||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||||
actor = call.data["actor"]
|
actor = call.data["actor"]
|
||||||
if actor == ACTOR_PANEL:
|
if actor == ACTOR_PANEL:
|
||||||
@@ -301,223 +114,50 @@ def _complete_quest(
|
|||||||
async def _handler(call: Any) -> None:
|
async def _handler(call: Any) -> None:
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
instance_id = call.data["instance_id"]
|
instance_id = call.data["instance_id"]
|
||||||
# ``appended`` is decided under the same lock as the write, so
|
# The panel contract (decision 9): a tap completes as the
|
||||||
# a concurrent duplicate call (a double tap) can not produce a
|
# tapped child profile, the only actor shape the API's panel
|
||||||
# second transition event: the loser appends nothing.
|
# route accepts. The payload schema is unchanged — the panel
|
||||||
result = await completion_layer.complete_instance(
|
# cards call it with instance_id, actor=panel, actor_child_id.
|
||||||
runtime.database,
|
actor = _actor_kwargs(call)
|
||||||
instance_id,
|
if actor["actor_source"] != ACTOR_PANEL:
|
||||||
**_actor_kwargs(call),
|
|
||||||
)
|
|
||||||
if result.appended:
|
|
||||||
await fire_quest_completed(
|
|
||||||
hass,
|
|
||||||
runtime.database,
|
|
||||||
instance_id,
|
|
||||||
was_on_time=result.was_on_time,
|
|
||||||
)
|
|
||||||
await _refresh_entities(runtime)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _uncomplete_quest(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
instance_id = call.data["instance_id"]
|
|
||||||
result = await completion_layer.uncomplete_instance(
|
|
||||||
runtime.database,
|
|
||||||
instance_id,
|
|
||||||
**_actor_kwargs(call),
|
|
||||||
)
|
|
||||||
if result.appended:
|
|
||||||
await fire_quest_uncompleted(
|
|
||||||
hass, runtime.database, instance_id
|
|
||||||
)
|
|
||||||
await _refresh_entities(runtime)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_quest_definition(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
database = _require_runtime(hass, find_runtime).database
|
|
||||||
data = call.data
|
|
||||||
await quest_definitions_layer.create_quest_definition(
|
|
||||||
database,
|
|
||||||
data["title"],
|
|
||||||
ScheduleRule.from_dict(data["rule"]),
|
|
||||||
data["assignee_child_ids"],
|
|
||||||
data["windows"],
|
|
||||||
description=data.get("description"),
|
|
||||||
icon=data.get("icon"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_quest_definition(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
data = call.data
|
|
||||||
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
|
|
||||||
if "title" in data:
|
|
||||||
kwargs["title"] = data["title"]
|
|
||||||
if "description" in data:
|
|
||||||
kwargs["description"] = data["description"]
|
|
||||||
if "icon" in data:
|
|
||||||
kwargs["icon"] = data["icon"]
|
|
||||||
if "rule" in data:
|
|
||||||
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
|
||||||
if "windows" in data:
|
|
||||||
kwargs["windows"] = data["windows"]
|
|
||||||
await quest_definitions_layer.edit_quest_definition(
|
|
||||||
runtime.database, data["definition_id"], **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_quest_definition_active(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
await quest_definitions_layer.set_quest_definition_active(
|
|
||||||
runtime.database,
|
|
||||||
call.data["definition_id"],
|
|
||||||
call.data["is_active"],
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_presence_pattern(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
data = call.data
|
|
||||||
await presence_management_layer.set_presence_schedule(
|
|
||||||
runtime.database,
|
|
||||||
data["child_id"],
|
|
||||||
data["cycle_length_weeks"],
|
|
||||||
data["anchor_date"],
|
|
||||||
data["pattern"],
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_presence_override(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
data = call.data
|
|
||||||
await presence_management_layer.create_presence_override(
|
|
||||||
runtime.database,
|
|
||||||
data["child_id"],
|
|
||||||
data["start_date"],
|
|
||||||
data["end_date"],
|
|
||||||
data["is_present"],
|
|
||||||
note=data.get("note"),
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_presence_override(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
await presence_management_layer.delete_presence_override(
|
|
||||||
runtime.database,
|
|
||||||
call.data["override_id"],
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
async def _export_history_csv(_call: Any) -> None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"CSV history export is not implemented until Feature 13"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _manage_child(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
database = _require_runtime(hass, find_runtime).database
|
|
||||||
data = call.data
|
|
||||||
action = data["action"]
|
|
||||||
if action == MANAGE_CHILD_CREATE:
|
|
||||||
display_name = data.get("display_name")
|
|
||||||
if display_name is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"display_name is required when action is create"
|
|
||||||
)
|
|
||||||
kwargs: dict[str, Any] = {}
|
|
||||||
if "colour" in data:
|
|
||||||
kwargs["colour"] = data["colour"]
|
|
||||||
if "avatar_ref" in data:
|
|
||||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
|
||||||
if "sort_order" in data:
|
|
||||||
kwargs["sort_order"] = data["sort_order"]
|
|
||||||
await children_layer.create_child(
|
|
||||||
database, display_name, **kwargs
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if action == MANAGE_CHILD_EDIT:
|
|
||||||
child_id = data.get("child_id")
|
|
||||||
if child_id is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"child_id is required when action is edit"
|
|
||||||
)
|
|
||||||
kwargs = {}
|
|
||||||
if "display_name" in data:
|
|
||||||
kwargs["display_name"] = data["display_name"]
|
|
||||||
if "colour" in data:
|
|
||||||
kwargs["colour"] = data["colour"]
|
|
||||||
if "avatar_ref" in data:
|
|
||||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
|
||||||
if "sort_order" in data:
|
|
||||||
kwargs["sort_order"] = data["sort_order"]
|
|
||||||
await children_layer.edit_child(database, child_id, **kwargs)
|
|
||||||
return
|
|
||||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
|
||||||
child_id = data.get("child_id")
|
|
||||||
is_active = data.get("is_active")
|
|
||||||
if child_id is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"child_id is required when action is set_active"
|
|
||||||
)
|
|
||||||
if is_active is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"is_active is required when action is set_active"
|
|
||||||
)
|
|
||||||
await children_layer.set_child_active(
|
|
||||||
database, child_id, is_active
|
|
||||||
)
|
|
||||||
return
|
|
||||||
ordered_ids = data.get("ordered_ids")
|
|
||||||
if ordered_ids is None:
|
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"ordered_ids is required when action is reorder"
|
"nestquest.complete_quest completes through the API's "
|
||||||
|
"panel route as the tapped child profile; actor must "
|
||||||
|
"be 'panel' with actor_child_id"
|
||||||
)
|
)
|
||||||
await children_layer.reorder_children(database, ordered_ids)
|
# The entry's ONE shared API client (built at setup): the
|
||||||
|
# coordinator's snapshot poll, the SSE subscription, and this
|
||||||
|
# proxy all ride the same instance. An entry with no panel
|
||||||
|
# token runs the unconfigured stub, which has no completion
|
||||||
|
# surface — fail fast with a clear, token-free message.
|
||||||
|
complete_instance = getattr(
|
||||||
|
getattr(runtime.coordinator, "api_client", None),
|
||||||
|
"complete_instance",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if complete_instance is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"NestQuest API is not configured; cannot complete a "
|
||||||
|
"quest"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await complete_instance(instance_id, actor["actor_child_id"])
|
||||||
|
except NestQuestApiError as err:
|
||||||
|
# The typed API failure surfaces as a service error the
|
||||||
|
# caller sees — never swallowed, never retried here. The
|
||||||
|
# typed message names method, path, and status only; the
|
||||||
|
# panel token never appears in it.
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"NestQuest quest completion failed: {err}"
|
||||||
|
) from err
|
||||||
|
# NO local completion write and NO local event: the API service
|
||||||
|
# emits the transition on its SSE stream and the integration's
|
||||||
|
# subscription re-fires it on the bus — firing it here too
|
||||||
|
# would double-fire. The immediate refresh keeps the old
|
||||||
|
# behaviour where the sensors reflect the completion the
|
||||||
|
# moment the service call returns instead of waiting for the
|
||||||
|
# poll interval.
|
||||||
|
await _refresh_entities(runtime)
|
||||||
|
|
||||||
return _with_errors(_handler)
|
return _with_errors(_handler)
|
||||||
|
|
||||||
@@ -526,84 +166,25 @@ def async_register_services(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
*,
|
*,
|
||||||
find_runtime: FindRuntime,
|
find_runtime: FindRuntime,
|
||||||
regenerate_handler: ServiceHandler,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register every canonical service once at domain scope.
|
"""Register the panel completion service once at domain scope.
|
||||||
|
|
||||||
Every service — regenerate included — passes through the Feature
|
The one remaining HA service is the panel completion path; every
|
||||||
09 permission gate before its handler runs; the gate reads
|
admin operation moved to the API service's admin routes, so there
|
||||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
is no permission gate to apply here.
|
||||||
registered exempt from the policy.
|
|
||||||
"""
|
"""
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
|
||||||
return
|
return
|
||||||
gate = permission_gate
|
hass.services.async_register(
|
||||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
DOMAIN,
|
||||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
SERVICE_COMPLETE_QUEST,
|
||||||
(
|
_complete_quest(hass, find_runtime),
|
||||||
SERVICE_COMPLETE_QUEST,
|
schema=SCHEMA_COMPLETE_QUEST,
|
||||||
_complete_quest(hass, find_runtime),
|
)
|
||||||
SCHEMA_COMPLETE_QUEST,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
_uncomplete_quest(hass, find_runtime),
|
|
||||||
SCHEMA_UNCOMPLETE_QUEST,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
_create_quest_definition(hass, find_runtime),
|
|
||||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
_update_quest_definition(hass, find_runtime),
|
|
||||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
_set_quest_definition_active(hass, find_runtime),
|
|
||||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
_set_presence_pattern(hass, find_runtime),
|
|
||||||
SCHEMA_SET_PRESENCE_PATTERN,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
_create_presence_override(hass, find_runtime),
|
|
||||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
_delete_presence_override(hass, find_runtime),
|
|
||||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
_export_history_csv,
|
|
||||||
SCHEMA_EXPORT_HISTORY_CSV,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
_manage_child(hass, find_runtime),
|
|
||||||
SCHEMA_MANAGE_CHILD,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def _database_of(_call: Any) -> Any:
|
|
||||||
return _require_runtime(hass, find_runtime).database
|
|
||||||
|
|
||||||
for service, handler, schema in specs:
|
|
||||||
gated = gate(
|
|
||||||
service, SERVICE_POLICY[service], _database_of
|
|
||||||
)(handler)
|
|
||||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
|
||||||
|
|
||||||
|
|
||||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||||
"""Remove every canonical service when the last entry unloads."""
|
"""Remove every canonical service when the last entry unloads."""
|
||||||
for service in DOMAIN_SERVICES:
|
for service in DOMAIN_SERVICES:
|
||||||
if hass.services.has_service(DOMAIN, service):
|
if hass.services.has_service(DOMAIN, service):
|
||||||
hass.services.async_remove(DOMAIN, service)
|
hass.services.async_remove(DOMAIN, service)
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
# Service declarations for the NestQuest integration (Home Assistant
|
# Service declarations for the NestQuest integration (Home Assistant
|
||||||
# services.yaml metadata). Services are domain-scoped: nested under the
|
# services.yaml metadata). Services are domain-scoped: nested under the
|
||||||
# "nestquest" domain, not repeated per config entry.
|
# "nestquest" domain, not repeated per config entry. Since Feature 18
|
||||||
|
# only the panel completion service remains — every admin operation
|
||||||
regenerate:
|
# moved to the PWA and the API service's admin routes.
|
||||||
name: Regenerate quest instances
|
|
||||||
description: >-
|
|
||||||
Regenerate every future quest instance for the household over the rolling
|
|
||||||
horizon, using the same idempotent materialization as the daily rollover
|
|
||||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
|
||||||
same rows instead of duplicating them, and completed instances are never
|
|
||||||
touched.
|
|
||||||
|
|
||||||
complete_quest:
|
complete_quest:
|
||||||
name: Complete quest
|
name: Complete quest
|
||||||
@@ -43,284 +36,4 @@ complete_quest:
|
|||||||
description: Home Assistant user id when actor is user and no call context is present.
|
description: Home Assistant user id when actor is user and no call context is present.
|
||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
|
||||||
uncomplete_quest:
|
|
||||||
name: Uncomplete quest
|
|
||||||
description: Undo a quest instance completion.
|
|
||||||
fields:
|
|
||||||
instance_id:
|
|
||||||
name: Instance ID
|
|
||||||
description: The quest instance to uncomplete.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
actor:
|
|
||||||
name: Actor
|
|
||||||
description: Who is undoing the completion (panel or user).
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
options:
|
|
||||||
- panel
|
|
||||||
- user
|
|
||||||
actor_child_id:
|
|
||||||
name: Actor child ID
|
|
||||||
description: The tapped child profile. Required when actor is panel.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
actor_user_id:
|
|
||||||
name: Actor user ID
|
|
||||||
description: Home Assistant user id when actor is user and no call context is present.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
|
|
||||||
create_quest_definition:
|
|
||||||
name: Create quest definition
|
|
||||||
description: Create a quest definition with its schedule rule, assignees and windows.
|
|
||||||
fields:
|
|
||||||
title:
|
|
||||||
name: Title
|
|
||||||
description: Display title for the quest.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
rule:
|
|
||||||
name: Rule
|
|
||||||
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
assignee_child_ids:
|
|
||||||
name: Assignee child IDs
|
|
||||||
description: Child ids this definition is assigned to.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
windows:
|
|
||||||
name: Windows
|
|
||||||
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
description:
|
|
||||||
name: Description
|
|
||||||
description: Optional longer description.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
icon:
|
|
||||||
name: Icon
|
|
||||||
description: Optional icon name.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
|
|
||||||
update_quest_definition:
|
|
||||||
name: Update quest definition
|
|
||||||
description: Edit a quest definition's title, description, icon, rule or windows.
|
|
||||||
fields:
|
|
||||||
definition_id:
|
|
||||||
name: Definition ID
|
|
||||||
description: The quest definition to update.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
title:
|
|
||||||
name: Title
|
|
||||||
description: Replacement display title.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
description:
|
|
||||||
name: Description
|
|
||||||
description: Replacement description, or null to clear.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
icon:
|
|
||||||
name: Icon
|
|
||||||
description: Replacement icon, or null to clear.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
rule:
|
|
||||||
name: Rule
|
|
||||||
description: Replacement recurrence rule as a mapping.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
windows:
|
|
||||||
name: Windows
|
|
||||||
description: Replacement day windows.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
|
|
||||||
set_quest_definition_active:
|
|
||||||
name: Set quest definition active
|
|
||||||
description: Deactivate or reactivate a quest definition.
|
|
||||||
fields:
|
|
||||||
definition_id:
|
|
||||||
name: Definition ID
|
|
||||||
description: The quest definition to update.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
is_active:
|
|
||||||
name: Active
|
|
||||||
description: Whether the definition should generate future instances.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
|
|
||||||
set_presence_pattern:
|
|
||||||
name: Set presence pattern
|
|
||||||
description: Upsert a child's repeating presence schedule.
|
|
||||||
fields:
|
|
||||||
child_id:
|
|
||||||
name: Child ID
|
|
||||||
description: The child whose schedule is being set.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
cycle_length_weeks:
|
|
||||||
name: Cycle length weeks
|
|
||||||
description: Number of weeks in the repeating cycle (1 to 4).
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
min: 1
|
|
||||||
max: 4
|
|
||||||
anchor_date:
|
|
||||||
name: Anchor date
|
|
||||||
description: Cycle anchor as a YYYY-MM-DD date.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
date:
|
|
||||||
pattern:
|
|
||||||
name: Pattern
|
|
||||||
description: Mapping of week index to present weekday ints (Monday is 0).
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
|
|
||||||
create_presence_override:
|
|
||||||
name: Create presence override
|
|
||||||
description: Create a date-range presence override for a child.
|
|
||||||
fields:
|
|
||||||
child_id:
|
|
||||||
name: Child ID
|
|
||||||
description: The child the override applies to.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
start_date:
|
|
||||||
name: Start date
|
|
||||||
description: Inclusive start date (YYYY-MM-DD).
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
date:
|
|
||||||
end_date:
|
|
||||||
name: End date
|
|
||||||
description: Inclusive end date (YYYY-MM-DD).
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
date:
|
|
||||||
is_present:
|
|
||||||
name: Present
|
|
||||||
description: Whether the child is present for this range.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
note:
|
|
||||||
name: Note
|
|
||||||
description: Optional note stored with the override.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
|
|
||||||
delete_presence_override:
|
|
||||||
name: Delete presence override
|
|
||||||
description: Delete a presence override by id.
|
|
||||||
fields:
|
|
||||||
override_id:
|
|
||||||
name: Override ID
|
|
||||||
description: The override to delete.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
|
|
||||||
export_history_csv:
|
|
||||||
name: Export history CSV
|
|
||||||
description: Export completion history as CSV. Not implemented until Feature 13.
|
|
||||||
|
|
||||||
manage_child:
|
|
||||||
name: Manage child
|
|
||||||
description: Create, edit, activate or reorder child profiles.
|
|
||||||
fields:
|
|
||||||
action:
|
|
||||||
name: Action
|
|
||||||
description: create, edit, set_active, or reorder.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
options:
|
|
||||||
- create
|
|
||||||
- edit
|
|
||||||
- set_active
|
|
||||||
- reorder
|
|
||||||
child_id:
|
|
||||||
name: Child ID
|
|
||||||
description: Required for edit and set_active.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
display_name:
|
|
||||||
name: Display name
|
|
||||||
description: Required for create; optional for edit.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
colour:
|
|
||||||
name: Colour
|
|
||||||
description: Optional colour string.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
avatar_ref:
|
|
||||||
name: Avatar
|
|
||||||
description: Optional avatar reference.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
sort_order:
|
|
||||||
name: Sort order
|
|
||||||
description: Optional panel sort order.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
mode: box
|
|
||||||
is_active:
|
|
||||||
name: Active
|
|
||||||
description: Required for set_active.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
ordered_ids:
|
|
||||||
name: Ordered IDs
|
|
||||||
description: Complete permutation of child ids. Required for reorder.
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""HA-bus subscription over the API service's panel SSE stream.
|
||||||
|
|
||||||
|
The NestQuest API service (Feature 16) publishes every quest
|
||||||
|
transition on ``GET /api/v1/panel/events`` as Server-Sent Events
|
||||||
|
frames. This module is the integration-side subscriber: ONE
|
||||||
|
background task consumes :meth:`~.api_client.NestQuestApiClient
|
||||||
|
.stream_events` and re-fires each frame on ``hass.bus`` with the SAME
|
||||||
|
event type and payload fields the local transition path
|
||||||
|
(:mod:`~.events`) emits — ``nestquest_quest_completed``,
|
||||||
|
``nestquest_quest_uncompleted``, ``nestquest_quest_missed`` and
|
||||||
|
``nestquest_child_day_complete`` — so the four blueprints fire
|
||||||
|
identically whether a completion happens through the integration's own
|
||||||
|
service or through the API service's panel route.
|
||||||
|
|
||||||
|
Failure handling: a failed stream (API down, network blip, non-2xx, a
|
||||||
|
dropped connection) is retried FOREVER with a bounded exponential
|
||||||
|
backoff — :data:`RECONNECT_MIN_SECONDS` after the first failure,
|
||||||
|
doubling up to :data:`RECONNECT_MAX_SECONDS` — so a long API outage
|
||||||
|
neither pins the event loop nor spams the log, and a frame received on
|
||||||
|
a healthy stream resets the backoff. Nothing escapes the task: every
|
||||||
|
failure is logged with its reason and the loop reconnects; a
|
||||||
|
:class:`asyncio.CancelledError` (from :meth:`stop` or integration
|
||||||
|
unload) is the only thing that ends it, and it stops cleanly — no
|
||||||
|
frame fires after :meth:`stop` returns and the response is closed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .const import LOGGER
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NestQuestEventStream",
|
||||||
|
"RECONNECT_MAX_SECONDS",
|
||||||
|
"RECONNECT_MIN_SECONDS",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Delay before the FIRST reconnect attempt after a stream failure.
|
||||||
|
#: One failed frame must not turn into a hot reconnect loop.
|
||||||
|
RECONNECT_MIN_SECONDS = 5.0
|
||||||
|
|
||||||
|
#: Ceiling for the exponential reconnect backoff: a long API outage
|
||||||
|
#: retries at a calm fixed cadence instead of growing without bound.
|
||||||
|
RECONNECT_MAX_SECONDS = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class NestQuestEventStream:
|
||||||
|
"""Consume the panel SSE stream in the background, fire the HA bus.
|
||||||
|
|
||||||
|
ONE instance per config entry, built in integration setup with the
|
||||||
|
SAME API client the coordinator polls (the client is stateless —
|
||||||
|
one shared session, one connection pool). ``start()`` schedules
|
||||||
|
the background task; ``stop()`` cancels and awaits it, so unload
|
||||||
|
never leaks the task and no frame fires after teardown. The task
|
||||||
|
itself is failure-proof: any exception from the stream (typed
|
||||||
|
:class:`~.api_client.NestQuestApiError` or anything else) is
|
||||||
|
logged and retried after the current backoff, never allowed to
|
||||||
|
escape the task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: Any,
|
||||||
|
client: Any,
|
||||||
|
*,
|
||||||
|
reconnect_min_seconds: float = RECONNECT_MIN_SECONDS,
|
||||||
|
reconnect_max_seconds: float = RECONNECT_MAX_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
"""Store the hass bus target, the API client, and the backoff.
|
||||||
|
|
||||||
|
The client is anything exposing ``stream_events()`` (the real
|
||||||
|
:class:`~.api_client.NestQuestApiClient` in production, a stub
|
||||||
|
in tests). The backoff bounds must be positive and the minimum
|
||||||
|
must not exceed the maximum — a misconfiguration should fail
|
||||||
|
loudly at construction, not quietly reconnect at delay 0.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
not isinstance(reconnect_min_seconds, (int, float))
|
||||||
|
or isinstance(reconnect_min_seconds, bool)
|
||||||
|
or reconnect_min_seconds <= 0
|
||||||
|
or reconnect_min_seconds > reconnect_max_seconds
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"reconnect_min_seconds must be a positive number no "
|
||||||
|
f"greater than reconnect_max_seconds, got "
|
||||||
|
f"{reconnect_min_seconds!r}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(reconnect_max_seconds, (int, float))
|
||||||
|
or isinstance(reconnect_max_seconds, bool)
|
||||||
|
or reconnect_max_seconds <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"reconnect_max_seconds must be a positive number, got "
|
||||||
|
f"{reconnect_max_seconds!r}"
|
||||||
|
)
|
||||||
|
self._hass = hass
|
||||||
|
self._client = client
|
||||||
|
self._min_backoff = float(reconnect_min_seconds)
|
||||||
|
self._max_backoff = float(reconnect_max_seconds)
|
||||||
|
self._backoff = float(reconnect_min_seconds)
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def task(self) -> asyncio.Task | None:
|
||||||
|
"""The background task, or ``None`` when never started/stopped."""
|
||||||
|
return self._task
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Schedule the subscription task (idempotent while it runs)."""
|
||||||
|
if self._task is not None and not self._task.done():
|
||||||
|
return
|
||||||
|
self._backoff = self._min_backoff
|
||||||
|
self._task = asyncio.create_task(
|
||||||
|
self._run(), name="nestquest-sse-subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Cancel and await the subscription task.
|
||||||
|
|
||||||
|
Returns only after the task has actually finished, so unload
|
||||||
|
can rely on: no further frame is fired after ``stop()``, and
|
||||||
|
the underlying stream response has been closed. Safe to call
|
||||||
|
when the task was never started or already stopped.
|
||||||
|
"""
|
||||||
|
task, self._task = self._task, None
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# The task's own cancellation surfaces here — expected and
|
||||||
|
# swallowed; a cancelling the CALLER would re-raise out of
|
||||||
|
# the surrounding await on its own.
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
"""Consume stream frames forever, reconnecting on failure."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async for event_type, payload in self._client.stream_events():
|
||||||
|
# A frame arrived on a healthy stream: the next
|
||||||
|
# failure reconnects quickly again.
|
||||||
|
self._backoff = self._min_backoff
|
||||||
|
try:
|
||||||
|
self._hass.bus.async_fire(event_type, dict(payload))
|
||||||
|
except Exception as err:
|
||||||
|
# Firing one frame must never tear down a
|
||||||
|
# healthy connection: log and keep consuming.
|
||||||
|
LOGGER.error(
|
||||||
|
"NestQuest panel event %s could not be fired "
|
||||||
|
"on the Home Assistant event bus: %s",
|
||||||
|
event_type,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Unload / shutdown: stop cleanly, never converted.
|
||||||
|
raise
|
||||||
|
except Exception as err:
|
||||||
|
delay = self._backoff
|
||||||
|
self._backoff = min(
|
||||||
|
self._backoff * 2, self._max_backoff
|
||||||
|
)
|
||||||
|
LOGGER.warning(
|
||||||
|
"NestQuest panel event stream failed (%s); "
|
||||||
|
"reconnecting in %.1f s",
|
||||||
|
err,
|
||||||
|
delay,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
@@ -18,11 +18,14 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NestQuest Settings",
|
"title": "NestQuest Settings",
|
||||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
|
||||||
"data": {
|
"data": {
|
||||||
"horizon_days": "Generation horizon (days)",
|
"horizon_days": "Generation horizon (days)",
|
||||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||||
|
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||||
|
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||||
|
"panel_token": "Panel service token",
|
||||||
"admin_user_ids": "Admin users (parents)"
|
"admin_user_ids": "Admin users (parents)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,8 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"invalid": "Invalid value.",
|
"invalid": "Invalid value.",
|
||||||
"invalid_time": "Time must use 24-hour HH:MM format.",
|
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||||
|
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||||
|
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval.",
|
||||||
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||||
"invalid_admin": "Invalid admin selection."
|
"invalid_admin": "Invalid admin selection."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,14 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NestQuest Settings",
|
"title": "NestQuest Settings",
|
||||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
|
||||||
"data": {
|
"data": {
|
||||||
"horizon_days": "Generation horizon (days)",
|
"horizon_days": "Generation horizon (days)",
|
||||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||||
|
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||||
|
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||||
|
"panel_token": "Panel service token",
|
||||||
"admin_user_ids": "Admin users (parents)"
|
"admin_user_ids": "Admin users (parents)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,8 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"invalid": "Invalid value.",
|
"invalid": "Invalid value.",
|
||||||
"invalid_time": "Time must use 24-hour HH:MM format.",
|
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||||
|
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||||
|
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval.",
|
||||||
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||||
"invalid_admin": "Invalid admin selection."
|
"invalid_admin": "Invalid admin selection."
|
||||||
}
|
}
|
||||||
|
|||||||
+288
-6
@@ -52,6 +52,7 @@ _HA_MODULES = (
|
|||||||
"homeassistant.helpers.entity",
|
"homeassistant.helpers.entity",
|
||||||
"homeassistant.helpers.event",
|
"homeassistant.helpers.event",
|
||||||
"homeassistant.helpers.update_coordinator",
|
"homeassistant.helpers.update_coordinator",
|
||||||
|
"homeassistant.helpers.aiohttp_client",
|
||||||
"homeassistant.components",
|
"homeassistant.components",
|
||||||
"homeassistant.components.sensor",
|
"homeassistant.components.sensor",
|
||||||
"homeassistant.components.binary_sensor",
|
"homeassistant.components.binary_sensor",
|
||||||
@@ -99,6 +100,7 @@ for _parent, _child in (
|
|||||||
("homeassistant.helpers", "entity"),
|
("homeassistant.helpers", "entity"),
|
||||||
("homeassistant.helpers", "event"),
|
("homeassistant.helpers", "event"),
|
||||||
("homeassistant.helpers", "update_coordinator"),
|
("homeassistant.helpers", "update_coordinator"),
|
||||||
|
("homeassistant.helpers", "aiohttp_client"),
|
||||||
("homeassistant", "components"),
|
("homeassistant", "components"),
|
||||||
("homeassistant.components", "sensor"),
|
("homeassistant.components", "sensor"),
|
||||||
("homeassistant.components", "binary_sensor"),
|
("homeassistant.components", "binary_sensor"),
|
||||||
@@ -365,6 +367,15 @@ class BinarySensorEntity(Entity):
|
|||||||
return self._attr_device_class
|
return self._attr_device_class
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFailed(HomeAssistantError):
|
||||||
|
"""Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed.
|
||||||
|
|
||||||
|
The exception an update pass raises to report a failed fetch: the
|
||||||
|
coordinator records ``last_update_success = False`` instead of
|
||||||
|
propagating it out of the refresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class DataUpdateCoordinator:
|
class DataUpdateCoordinator:
|
||||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||||
|
|
||||||
@@ -373,8 +384,20 @@ class DataUpdateCoordinator:
|
|||||||
``async_add_listener``/``async_update_listeners`` for entity
|
``async_add_listener``/``async_update_listeners`` for entity
|
||||||
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||||
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||||
and ``data``. Interval scheduling is NOT simulated (no real clock
|
``last_exception``, and ``data``. Interval scheduling is NOT
|
||||||
ticks in tests) — tests drive refreshes directly.
|
simulated (no real clock ticks in tests) — tests drive refreshes
|
||||||
|
directly.
|
||||||
|
|
||||||
|
The failure contract mirrors real HA exactly (it is the trap this
|
||||||
|
harness once hid): an update failure NEVER propagates out of
|
||||||
|
``async_refresh`` — the coordinator records
|
||||||
|
``last_update_success = False``, keeps its last good ``data``, and
|
||||||
|
still notifies listeners so entities re-render unavailable — and
|
||||||
|
``async_config_entry_first_refresh`` wraps ANY failed pass in
|
||||||
|
:class:`ConfigEntryNotReady`, with the original error only
|
||||||
|
surviving as ``__cause__``. A stand-in that re-raised the raw
|
||||||
|
update error would let setup code key on the original exception
|
||||||
|
type — a clause real HA can never reach.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||||
@@ -384,6 +407,7 @@ class DataUpdateCoordinator:
|
|||||||
self.update_interval = update_interval
|
self.update_interval = update_interval
|
||||||
self.data = None
|
self.data = None
|
||||||
self.last_update_success = False
|
self.last_update_success = False
|
||||||
|
self.last_exception = None
|
||||||
self._listeners = []
|
self._listeners = []
|
||||||
|
|
||||||
def async_add_listener(self, update_callback, context=None):
|
def async_add_listener(self, update_callback, context=None):
|
||||||
@@ -403,18 +427,35 @@ class DataUpdateCoordinator:
|
|||||||
callback()
|
callback()
|
||||||
|
|
||||||
async def async_refresh(self):
|
async def async_refresh(self):
|
||||||
"""Run one update pass and notify listeners."""
|
"""Run one update pass and notify listeners.
|
||||||
|
|
||||||
|
Mirrors HA: a failed update pass is RECORDED, never raised —
|
||||||
|
``last_update_success`` goes False, the exception lands on
|
||||||
|
``last_exception``, and listeners still fire; only cancellation
|
||||||
|
propagates.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.data = await self._async_update_data()
|
self.data = await self._async_update_data()
|
||||||
self.last_update_success = True
|
self.last_update_success = True
|
||||||
except BaseException:
|
except Exception as err:
|
||||||
self.last_update_success = False
|
self.last_update_success = False
|
||||||
raise
|
self.last_exception = err
|
||||||
self.async_update_listeners()
|
self.async_update_listeners()
|
||||||
|
|
||||||
async def async_config_entry_first_refresh(self):
|
async def async_config_entry_first_refresh(self):
|
||||||
"""First refresh at config-entry setup (alias of refresh)."""
|
"""First refresh at config-entry setup, per HA's real contract.
|
||||||
|
|
||||||
|
HA's real ``async_config_entry_first_refresh`` wraps ANY failed
|
||||||
|
update pass in ``ConfigEntryNotReady`` — the original error
|
||||||
|
survives only as ``__cause__`` — so setup code that must
|
||||||
|
tolerate a failed first refresh keys on ConfigEntryNotReady,
|
||||||
|
never on the original exception type.
|
||||||
|
"""
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
if not self.last_update_success:
|
||||||
|
raise ConfigEntryNotReady(
|
||||||
|
f"error fetching {self.name} data: {self.last_exception}"
|
||||||
|
) from self.last_exception
|
||||||
|
|
||||||
async def async_shutdown(self):
|
async def async_shutdown(self):
|
||||||
"""Release listeners on teardown."""
|
"""Release listeners on teardown."""
|
||||||
@@ -424,6 +465,7 @@ class DataUpdateCoordinator:
|
|||||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||||
|
_update_coordinator_mock.UpdateFailed = UpdateFailed
|
||||||
|
|
||||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||||
_entity_mock.DeviceInfo = DeviceInfo
|
_entity_mock.DeviceInfo = DeviceInfo
|
||||||
@@ -700,6 +742,246 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
class LazyLocalSnapshotClient:
|
||||||
|
"""A test client that serves the snapshot built LOCALLY.
|
||||||
|
|
||||||
|
Feature 18 moved the coordinator's refresh onto the API service's
|
||||||
|
panel snapshot, but most integration tests still seed the local
|
||||||
|
database directly (the DB removal is a later task). This stub
|
||||||
|
bridges the two: ``get_snapshot`` resolves the entry's runtime
|
||||||
|
record (its database and settings) and runs the SAME pure core
|
||||||
|
builder the API route runs
|
||||||
|
(:func:`~custom_components.nestquest.core.snapshot.build_snapshot`),
|
||||||
|
shaping the payload exactly like ``api/routes_panel.py`` —
|
||||||
|
instances via ``instance_payload(include_missed=False)`` — so the
|
||||||
|
coordinator reconstructs from a byte-identical payload without any
|
||||||
|
network or FastAPI machinery.
|
||||||
|
|
||||||
|
``complete_instance`` bridges the panel complete route the same
|
||||||
|
way (the ``nestquest.complete_quest`` proxy calls it through the
|
||||||
|
API client): it resolves the entry's database and delegates to the
|
||||||
|
SAME core completion the API route delegates to, with the panel
|
||||||
|
actor shape, and answers the route's ``{"status": ...}`` body.
|
||||||
|
Like the real API service it publishes its transition events on
|
||||||
|
the SSE stream — NOT on the Home Assistant bus and NOT from the
|
||||||
|
service handler; tests that assert bus events drive them through
|
||||||
|
the SSE subscription the way production delivers them.
|
||||||
|
|
||||||
|
The resolution is LAZY on purpose: the coordinator's factory runs
|
||||||
|
mid-setup, before the runtime record exists, so the first refresh
|
||||||
|
(and any refresh while the entry is torn down) serves an EMPTY but
|
||||||
|
valid snapshot; every later refresh reads the seeded database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass, entry_id: str) -> None:
|
||||||
|
self._hass = hass
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self.calls = 0
|
||||||
|
#: Every ``(instance_id, CompletionResult)`` this stand-in
|
||||||
|
# produced, in call order — tests read ``appended`` /
|
||||||
|
# ``was_on_time`` off these to check the completion verdicts.
|
||||||
|
self.completions = []
|
||||||
|
#: The SSE frames the stand-in published for its completions
|
||||||
|
# (the API service's stream content), drained by
|
||||||
|
#: :func:`refire_api_transitions`.
|
||||||
|
self.published_frames = []
|
||||||
|
|
||||||
|
def _live_runtime(self):
|
||||||
|
"""The entry's runtime record, or ``None`` before/after setup."""
|
||||||
|
from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN
|
||||||
|
|
||||||
|
runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id)
|
||||||
|
database = getattr(runtime, "database", None)
|
||||||
|
if database is None or not getattr(database, "connected", True):
|
||||||
|
return None
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
def _live_database(self):
|
||||||
|
"""The entry's open database, or ``None`` before/after setup."""
|
||||||
|
runtime = self._live_runtime()
|
||||||
|
return None if runtime is None else runtime.database
|
||||||
|
|
||||||
|
async def get_snapshot(self):
|
||||||
|
import datetime
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
|
from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN
|
||||||
|
from custom_components.nestquest.core import snapshot as core_snapshot
|
||||||
|
|
||||||
|
self.calls += 1
|
||||||
|
runtime = self._live_runtime()
|
||||||
|
if runtime is None:
|
||||||
|
return {
|
||||||
|
"today_iso": datetime.date.today().isoformat(),
|
||||||
|
"cycle_day": 0,
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
database = runtime.database
|
||||||
|
settings = getattr(runtime, "settings", None)
|
||||||
|
# The HA-LOCAL now, the same single clock read the production
|
||||||
|
# coordinator used to hand the builder: the hass stub's
|
||||||
|
# configured time zone, never the host clock.
|
||||||
|
now = datetime.datetime.now(
|
||||||
|
zoneinfo.ZoneInfo(self._hass.config.time_zone)
|
||||||
|
)
|
||||||
|
snapshot = await core_snapshot.build_snapshot(database, settings, now)
|
||||||
|
return {
|
||||||
|
"today_iso": snapshot.today_iso,
|
||||||
|
"cycle_day": snapshot.cycle_day,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"child_id": child.child_id,
|
||||||
|
"child_name": child.child_name,
|
||||||
|
"present": child.present,
|
||||||
|
"next_present": child.next_present,
|
||||||
|
"due_today": child.due_today,
|
||||||
|
"completed_today": child.completed_today,
|
||||||
|
"remaining_today": child.remaining_today,
|
||||||
|
"completion_pct": child.completion_pct,
|
||||||
|
"instances": core_snapshot.instance_payload(
|
||||||
|
child.instances, include_missed=False
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for child in snapshot.children
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def complete_instance(self, instance_id: int, actor_child_id: int):
|
||||||
|
"""The panel complete route's stand-in: complete through the
|
||||||
|
SAME core completion the route delegates to, panel actor
|
||||||
|
shape, publish the route's SSE transition frames (built by the
|
||||||
|
same core builders, appended completions only — see
|
||||||
|
``api/routes_panel.py``), and answer the route's
|
||||||
|
``{"status": ...}`` body."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.completion import complete_instance
|
||||||
|
from custom_components.nestquest.core import events as core_events
|
||||||
|
|
||||||
|
result = await complete_instance(
|
||||||
|
self._live_database(),
|
||||||
|
instance_id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
)
|
||||||
|
self.completions.append((instance_id, result))
|
||||||
|
if result.appended:
|
||||||
|
# The route publishes the transition the moment the core
|
||||||
|
# write lands (appended completions only), evaluating the
|
||||||
|
# day-complete rule against the API host's local date —
|
||||||
|
# the frames queue here for the SSE stream to carry.
|
||||||
|
event_type, payload = await core_events.build_quest_completed_event(
|
||||||
|
self._live_database(),
|
||||||
|
instance_id,
|
||||||
|
was_on_time=result.was_on_time,
|
||||||
|
)
|
||||||
|
self.published_frames.append((event_type, payload))
|
||||||
|
now = datetime.datetime.now().astimezone()
|
||||||
|
day_complete = await core_events.build_child_day_complete_event(
|
||||||
|
self._live_database(), payload, today=now.date()
|
||||||
|
)
|
||||||
|
if day_complete is not None:
|
||||||
|
self.published_frames.append(day_complete)
|
||||||
|
return {"status": str(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def coordinator_serves_local_snapshot():
|
||||||
|
"""Default every test entry's coordinator to the local-snapshot client.
|
||||||
|
|
||||||
|
The integration's setup builds its API client through
|
||||||
|
``coordinator_client_from_entry``; the fixture swaps that factory
|
||||||
|
for a test one handing out :class:`LazyLocalSnapshotClient` (so
|
||||||
|
DB-seeded tests exercise the coordinator's API path against local
|
||||||
|
data). Tests that script their own client register it with
|
||||||
|
:func:`set_coordinator_client`, which the factory honours first.
|
||||||
|
"""
|
||||||
|
import custom_components.nestquest as nq_mod
|
||||||
|
import custom_components.nestquest.coordinator as coordinator_module
|
||||||
|
|
||||||
|
_COORDINATOR_CLIENT_OVERRIDES = (
|
||||||
|
coordinator_module._COORDINATOR_CLIENT_OVERRIDES
|
||||||
|
)
|
||||||
|
|
||||||
|
def _factory(hass, entry, **kwargs):
|
||||||
|
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
|
||||||
|
if override is not None:
|
||||||
|
return override
|
||||||
|
return LazyLocalSnapshotClient(hass, entry.entry_id)
|
||||||
|
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||||
|
patcher = mock.patch.object(
|
||||||
|
nq_mod, "coordinator_client_from_entry", _factory
|
||||||
|
)
|
||||||
|
patcher.start()
|
||||||
|
yield
|
||||||
|
patcher.stop()
|
||||||
|
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def set_coordinator_client(entry, client):
|
||||||
|
"""Register a scripted client for ``entry``'s coordinator factory."""
|
||||||
|
import custom_components.nestquest.coordinator as coordinator_module
|
||||||
|
|
||||||
|
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def refire_api_transitions(hass, entry, client):
|
||||||
|
"""Deliver the SSE frames the stand-in client has published so far
|
||||||
|
through the integration's SSE subscription — the production event
|
||||||
|
path.
|
||||||
|
|
||||||
|
Since Feature 18 the ``complete_quest`` service fires no local
|
||||||
|
bus event: the API service builds and publishes the transition
|
||||||
|
frames on its SSE stream, and the integration's subscription
|
||||||
|
(:mod:`custom_components.nestquest.sse`) re-fires each frame on
|
||||||
|
the bus. This helper streams the frames the stand-in client
|
||||||
|
published (``client.published_frames``) through a real
|
||||||
|
:class:`~custom_components.nestquest.sse.NestQuestEventStream`
|
||||||
|
and waits (bounded) until the bus has re-fired every one of them.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from custom_components.nestquest.sse import NestQuestEventStream
|
||||||
|
|
||||||
|
frames, client.published_frames = client.published_frames, []
|
||||||
|
|
||||||
|
class _ScriptedStream:
|
||||||
|
"""A healthy stream: the frames, then it stays open."""
|
||||||
|
|
||||||
|
def __init__(self, frames) -> None:
|
||||||
|
self._frames = frames
|
||||||
|
|
||||||
|
def stream_events(self):
|
||||||
|
async def _gen():
|
||||||
|
for frame in self._frames:
|
||||||
|
yield frame
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
before = {etype: len(hass.bus.fired(etype)) for etype, _ in frames}
|
||||||
|
expected = len(frames)
|
||||||
|
manager = NestQuestEventStream(hass, _ScriptedStream(frames))
|
||||||
|
manager.start()
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(2):
|
||||||
|
while True:
|
||||||
|
delivered = sum(
|
||||||
|
len(hass.bus.fired(etype)) - before[etype]
|
||||||
|
for etype, _ in frames
|
||||||
|
)
|
||||||
|
if delivered >= expected:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
finally:
|
||||||
|
await manager.stop()
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
class _HassNamespace(SimpleNamespace):
|
class _HassNamespace(SimpleNamespace):
|
||||||
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
"""Tests for the panel-plane API client (Feature 18).
|
||||||
|
|
||||||
|
The client is exercised against a STUB transport only — no network, no
|
||||||
|
aiohttp, no homeassistant: the injected session is a fake that speaks
|
||||||
|
the same ``request(...)`` surface aiohttp's ``ClientSession`` exposes
|
||||||
|
and records every call, so the tests assert exactly what the client
|
||||||
|
puts on the wire (method, URL, Bearer header, JSON body) and how every
|
||||||
|
failure mode (non-2xx, connection failure, timeout, malformed JSON)
|
||||||
|
surfaces as the typed :class:`NestQuestApiError`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import make_config_entry
|
||||||
|
|
||||||
|
from custom_components.nestquest import api_client
|
||||||
|
from custom_components.nestquest.api_client import (
|
||||||
|
NestQuestApiClient,
|
||||||
|
NestQuestApiError,
|
||||||
|
is_valid_base_url,
|
||||||
|
resolve_api_config,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_API_BASE_URL,
|
||||||
|
CONF_PANEL_TOKEN,
|
||||||
|
DEFAULT_API_BASE_URL,
|
||||||
|
DEFAULT_PANEL_TOKEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_URL = "http://api.test:8000"
|
||||||
|
TOKEN = "test-panel-token"
|
||||||
|
|
||||||
|
#: A realistic panel snapshot payload (the documented shape of the
|
||||||
|
#: Feature 16 snapshot route).
|
||||||
|
SNAPSHOT_PAYLOAD = {
|
||||||
|
"today_iso": "2026-09-23",
|
||||||
|
"cycle_day": 2,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"child_id": 1,
|
||||||
|
"child_name": "Ada",
|
||||||
|
"present": False,
|
||||||
|
"next_present": "2026-09-24",
|
||||||
|
"due_today": 1,
|
||||||
|
"completed_today": 0,
|
||||||
|
"remaining_today": 1,
|
||||||
|
"completion_pct": 0,
|
||||||
|
"instances": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"definition_id": 3,
|
||||||
|
"child_id": 1,
|
||||||
|
"title": "Pack bag",
|
||||||
|
"icon": None,
|
||||||
|
"window": "morning",
|
||||||
|
"due_time": "10:00",
|
||||||
|
"state": "open",
|
||||||
|
"overdue": True,
|
||||||
|
"completed_at": None,
|
||||||
|
"on_time": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StubResponse:
|
||||||
|
"""A fake aiohttp response: ``status`` plus ``await text()``."""
|
||||||
|
|
||||||
|
def __init__(self, status: int, body: str) -> None:
|
||||||
|
self.status = status
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
async def text(self) -> str:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _OutcomeContext:
|
||||||
|
"""Async context manager yielding one scripted outcome.
|
||||||
|
|
||||||
|
Mirrors aiohttp's ``session.request(...)`` return value: entering
|
||||||
|
the context performs the request, so a scripted transport error
|
||||||
|
raises THERE — before any response object exists, exactly where
|
||||||
|
aiohttp's connector raises on a refused connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, response, error) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class StubTransport:
|
||||||
|
"""A no-network stand-in for aiohttp's ``ClientSession``.
|
||||||
|
|
||||||
|
Records every ``request(...)`` call as a dict (method, url, headers,
|
||||||
|
json body) and answers from a scripted queue of outcomes — a
|
||||||
|
:class:`StubResponse` or an exception per call, consumed in order —
|
||||||
|
so each test drives exactly one exchange.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *outcomes) -> None:
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
self._outcomes = list(outcomes)
|
||||||
|
|
||||||
|
def request(self, method: str, url: str, **kwargs):
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"headers": kwargs.get("headers"),
|
||||||
|
"json": kwargs.get("json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not self._outcomes:
|
||||||
|
raise AssertionError("StubTransport ran out of scripted outcomes")
|
||||||
|
outcome = self._outcomes.pop(0)
|
||||||
|
if isinstance(outcome, BaseException):
|
||||||
|
return _OutcomeContext(None, outcome)
|
||||||
|
return _OutcomeContext(outcome, None)
|
||||||
|
|
||||||
|
|
||||||
|
class HangingTransport:
|
||||||
|
"""A transport whose request never answers (for the timeout test)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
def request(self, method: str, url: str, **kwargs):
|
||||||
|
self.calls.append({"method": method, "url": url})
|
||||||
|
return _HangingContext()
|
||||||
|
|
||||||
|
|
||||||
|
class _HangingContext:
|
||||||
|
async def __aenter__(self):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
raise AssertionError("hanging transport was never cancelled")
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(transport, *, timeout_seconds: float = 5.0) -> NestQuestApiClient:
|
||||||
|
"""Build a client over the stub transport with the test credentials."""
|
||||||
|
return NestQuestApiClient(
|
||||||
|
BASE_URL, TOKEN, transport, timeout_seconds=timeout_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Success paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_snapshot_parses_payload_and_sends_bearer() -> None:
|
||||||
|
transport = StubTransport(
|
||||||
|
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD))
|
||||||
|
)
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
snapshot = await client.get_snapshot()
|
||||||
|
|
||||||
|
assert snapshot == SNAPSHOT_PAYLOAD
|
||||||
|
call = transport.calls[0]
|
||||||
|
assert call["method"] == "GET"
|
||||||
|
assert call["url"] == f"{BASE_URL}/api/v1/panel/snapshot"
|
||||||
|
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
|
||||||
|
assert call["json"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_instance_posts_body_and_headers() -> None:
|
||||||
|
transport = StubTransport(StubResponse(200, '{"status": "done"}'))
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
result = await client.complete_instance(42, 3)
|
||||||
|
|
||||||
|
assert result == {"status": "done"}
|
||||||
|
call = transport.calls[0]
|
||||||
|
assert call["method"] == "POST"
|
||||||
|
assert call["url"] == f"{BASE_URL}/api/v1/panel/instances/42/complete"
|
||||||
|
assert call["json"] == {"actor_child_id": 3}
|
||||||
|
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_base_url_trailing_slash_is_normalized() -> None:
|
||||||
|
"""A base URL with a trailing slash must not double the path slash."""
|
||||||
|
transport = StubTransport(StubResponse(200, "{}"))
|
||||||
|
client = NestQuestApiClient(
|
||||||
|
f"{BASE_URL}/", TOKEN, transport, timeout_seconds=5.0
|
||||||
|
)
|
||||||
|
await client.get_snapshot()
|
||||||
|
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP failures: the typed error carries the status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_401_raises_typed_error() -> None:
|
||||||
|
"""A wrong/missing token answers 401 and surfaces with that status."""
|
||||||
|
transport = StubTransport(
|
||||||
|
StubResponse(401, '{"detail": "Missing or invalid panel service token"}')
|
||||||
|
)
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
await client.get_snapshot()
|
||||||
|
|
||||||
|
error = excinfo.value
|
||||||
|
assert error.status == 401
|
||||||
|
assert not error.is_transport_error
|
||||||
|
assert error.__cause__ is None
|
||||||
|
# The token is a secret: it never reaches the raised message.
|
||||||
|
assert TOKEN not in str(error)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_unknown_instance_404_raises_typed_error() -> None:
|
||||||
|
transport = StubTransport(
|
||||||
|
StubResponse(404, '{"detail": "Quest instance not found"}')
|
||||||
|
)
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
await client.complete_instance(999999, 1)
|
||||||
|
|
||||||
|
assert excinfo.value.status == 404
|
||||||
|
assert not excinfo.value.is_transport_error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_500_raises_typed_error() -> None:
|
||||||
|
"""Any non-2xx response is typed, whatever the status."""
|
||||||
|
transport = StubTransport(StubResponse(500, "{}"))
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
await client.get_snapshot()
|
||||||
|
|
||||||
|
assert excinfo.value.status == 500
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_2xx_body_raises_typed_with_status() -> None:
|
||||||
|
"""A 2xx body that is not JSON is a protocol failure, carrying its status."""
|
||||||
|
transport = StubTransport(StubResponse(200, "<html>not json</html>"))
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
await client.get_snapshot()
|
||||||
|
|
||||||
|
assert excinfo.value.status == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transport failures: the typed error carries no status and chains the cause
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connection_failure_raises_transport_error() -> None:
|
||||||
|
transport = StubTransport(OSError("connection refused"))
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
await client.get_snapshot()
|
||||||
|
|
||||||
|
error = excinfo.value
|
||||||
|
assert error.status is None
|
||||||
|
assert error.is_transport_error
|
||||||
|
assert isinstance(error.__cause__, OSError)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connection_failure_on_complete_is_typed() -> None:
|
||||||
|
transport = StubTransport(OSError("connection refused"))
|
||||||
|
client = make_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
await client.complete_instance(7, 1)
|
||||||
|
|
||||||
|
assert excinfo.value.status is None
|
||||||
|
assert excinfo.value.is_transport_error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_timeout_raises_transport_error() -> None:
|
||||||
|
"""A hung API call surfaces as a typed transport error, once, with
|
||||||
|
no retry: the one request is cancelled by the client's timeout."""
|
||||||
|
transport = HangingTransport()
|
||||||
|
client = make_client(transport, timeout_seconds=0.05)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
await client.get_snapshot()
|
||||||
|
|
||||||
|
assert excinfo.value.status is None
|
||||||
|
assert excinfo.value.is_transport_error
|
||||||
|
assert len(transport.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Construction and config resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_requires_full_base_url() -> None:
|
||||||
|
for bad_url in (None, "", " ", "api.lan:8000", "http://", "ftp://x"):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NestQuestApiClient(bad_url, TOKEN, StubTransport())
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_requires_non_empty_token() -> None:
|
||||||
|
for bad_token in (None, "", " "):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NestQuestApiClient(BASE_URL, bad_token, StubTransport())
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_requires_positive_timeout() -> None:
|
||||||
|
for bad_timeout in (0, -1, "5", True, None):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NestQuestApiClient(
|
||||||
|
BASE_URL, TOKEN, StubTransport(), timeout_seconds=bad_timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_base_url_accepts_full_http_urls() -> None:
|
||||||
|
assert is_valid_base_url("http://127.0.0.1:8000")
|
||||||
|
assert is_valid_base_url("https://nestquest.lan")
|
||||||
|
assert is_valid_base_url(" http://127.0.0.1:8000 ")
|
||||||
|
assert not is_valid_base_url("api.lan:8000")
|
||||||
|
assert not is_valid_base_url("http://")
|
||||||
|
assert not is_valid_base_url("")
|
||||||
|
assert not is_valid_base_url(None)
|
||||||
|
assert not is_valid_base_url(42)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_api_config_prefers_options_then_data_then_defaults() -> None:
|
||||||
|
options_only = make_config_entry(
|
||||||
|
options={CONF_API_BASE_URL: "http://from-options:1"}
|
||||||
|
)
|
||||||
|
assert resolve_api_config(options_only) == (
|
||||||
|
"http://from-options:1",
|
||||||
|
DEFAULT_PANEL_TOKEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
data_only = make_config_entry(
|
||||||
|
data={
|
||||||
|
CONF_API_BASE_URL: "http://from-data:2",
|
||||||
|
CONF_PANEL_TOKEN: "from-data",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert resolve_api_config(data_only) == ("http://from-data:2", "from-data")
|
||||||
|
|
||||||
|
both = make_config_entry(
|
||||||
|
options={CONF_API_BASE_URL: "http://from-options:3"},
|
||||||
|
data={
|
||||||
|
CONF_API_BASE_URL: "http://from-data:4",
|
||||||
|
CONF_PANEL_TOKEN: "from-data",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resolve_api_config(both) == (
|
||||||
|
"http://from-options:3",
|
||||||
|
"from-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
bare = make_config_entry()
|
||||||
|
assert resolve_api_config(bare) == (DEFAULT_API_BASE_URL, DEFAULT_PANEL_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
|
||||||
|
"""The production factory resolves the entry config and hands in HA's
|
||||||
|
shared aiohttp session (stubbed module — no real HA, no network)."""
|
||||||
|
fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client")
|
||||||
|
shared_session = object()
|
||||||
|
fake_module.async_get_clientsession = lambda hass: shared_session
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules, "homeassistant.helpers.aiohttp_client", fake_module
|
||||||
|
)
|
||||||
|
entry = make_config_entry(
|
||||||
|
options={
|
||||||
|
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||||
|
CONF_PANEL_TOKEN: "entry-token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
client = api_client.client_from_entry(object(), entry)
|
||||||
|
|
||||||
|
assert client._session is shared_session
|
||||||
|
assert client._base_url == "http://panel.lan:8443"
|
||||||
|
assert client._panel_token == "entry-token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_from_entry_unconfigured_token_raises_at_construction(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""An entry with NO configured token (the shipped default) raises
|
||||||
|
ValueError AT CONSTRUCTION — the client's fail-fast guard — rather
|
||||||
|
than silently building a client that can only 401 (the documented
|
||||||
|
contract: the options flow's non-empty validation is the user-facing
|
||||||
|
guard; the coordinator must ensure a token is configured first or
|
||||||
|
handle the construction error as "API not configured")."""
|
||||||
|
fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client")
|
||||||
|
fake_module.async_get_clientsession = lambda hass: object()
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules, "homeassistant.helpers.aiohttp_client", fake_module
|
||||||
|
)
|
||||||
|
entry = make_config_entry(
|
||||||
|
options={CONF_API_BASE_URL: DEFAULT_API_BASE_URL},
|
||||||
|
)
|
||||||
|
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||||
|
api_client.client_from_entry(object(), entry)
|
||||||
|
|
||||||
|
# Same for a whitespace-only stored token: whitespace is not a token.
|
||||||
|
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
|
||||||
|
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||||
|
api_client.client_from_entry(object(), entry_ws)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_logs_nothing() -> None:
|
||||||
|
"""The client emits no log records at all (the token must never
|
||||||
|
reach a log line), success or failure."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
records: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class _Capture(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
handler = _Capture()
|
||||||
|
logging.getLogger().addHandler(handler)
|
||||||
|
try:
|
||||||
|
transport = StubTransport(StubResponse(401, "{}"))
|
||||||
|
client = make_client(transport)
|
||||||
|
with pytest.raises(NestQuestApiError):
|
||||||
|
await client.get_snapshot()
|
||||||
|
finally:
|
||||||
|
logging.getLogger().removeHandler(handler)
|
||||||
|
assert records == []
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""The ``nestquest.complete_quest`` proxy to the API client (Feature 18).
|
||||||
|
|
||||||
|
The service is a thin proxy: a panel tap must reach the API's panel
|
||||||
|
complete route through the entry's ONE shared API client (the same
|
||||||
|
instance the coordinator polls and the SSE subscription reads), with
|
||||||
|
exactly the payload contract the panel cards send (``instance_id``,
|
||||||
|
``actor=panel``, ``actor_child_id``). The service performs NO local
|
||||||
|
database completion and fires NO local bus event — the API service
|
||||||
|
emits the transition on its SSE stream and the integration's
|
||||||
|
subscription re-fires it (firing it locally too would double-fire) —
|
||||||
|
and it refreshes the coordinator after a successful call so the
|
||||||
|
sensors reflect the completion immediately. Every failure (a typed
|
||||||
|
:class:`NestQuestApiError`, an unconfigured client, a non-panel
|
||||||
|
actor) surfaces as a :class:`HomeAssistantError`, token-free.
|
||||||
|
|
||||||
|
The scripted clients here stand in for the API service: recording and
|
||||||
|
failing ``complete_instance`` implementations, no network, no aiohttp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
from conftest import (
|
||||||
|
LazyLocalSnapshotClient,
|
||||||
|
set_coordinator_client,
|
||||||
|
wire_entry_to_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry
|
||||||
|
from custom_components.nestquest.api_client import NestQuestApiError
|
||||||
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_ADMIN_USER_IDS,
|
||||||
|
CONF_PANEL_TOKEN,
|
||||||
|
DOMAIN,
|
||||||
|
EVENT_CHILD_DAY_COMPLETE,
|
||||||
|
EVENT_QUEST_COMPLETED,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
from custom_components.nestquest.materialize import materialize
|
||||||
|
from custom_components.nestquest.quest_definitions import (
|
||||||
|
create_quest_definition,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.recurrence import ScheduleRule
|
||||||
|
|
||||||
|
ADMIN_CTX = {"user_id": "admin-1"}
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingApiClient(LazyLocalSnapshotClient):
|
||||||
|
"""Snapshot-only stand-in that RECORDS completion calls.
|
||||||
|
|
||||||
|
``complete_instance`` deliberately performs NO database write: the
|
||||||
|
API service is remote, and the assertions below must be able to
|
||||||
|
prove the service handler itself completed nothing locally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass, entry_id: str) -> None:
|
||||||
|
super().__init__(hass, entry_id)
|
||||||
|
self.complete_calls: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
async def complete_instance(
|
||||||
|
self, instance_id: int, actor_child_id: int
|
||||||
|
) -> dict:
|
||||||
|
self.complete_calls.append((instance_id, actor_child_id))
|
||||||
|
return {"status": "done"}
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingApiClient(_RecordingApiClient):
|
||||||
|
"""A stand-in whose completion fails the way the client types it."""
|
||||||
|
|
||||||
|
def __init__(self, hass, entry_id: str, error: NestQuestApiError) -> None:
|
||||||
|
super().__init__(hass, entry_id)
|
||||||
|
self._error = error
|
||||||
|
|
||||||
|
async def complete_instance(
|
||||||
|
self, instance_id: int, actor_child_id: int
|
||||||
|
) -> dict:
|
||||||
|
self.complete_calls.append((instance_id, actor_child_id))
|
||||||
|
raise self._error
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_seeded_entry(hass, make_entry, client):
|
||||||
|
"""Wire, script, and set up an entry with one child's daily quest."""
|
||||||
|
entry = wire_entry_to_registry(
|
||||||
|
make_entry(
|
||||||
|
data={
|
||||||
|
CONF_ADMIN_USER_IDS: [ADMIN_CTX["user_id"]],
|
||||||
|
CONF_PANEL_TOKEN: "secret-panel-token",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
hass.registry,
|
||||||
|
)
|
||||||
|
set_coordinator_client(entry, client)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
from custom_components.nestquest.children import create_child
|
||||||
|
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
today = datetime.date.today()
|
||||||
|
await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Brush teeth",
|
||||||
|
ScheduleRule.from_dict(
|
||||||
|
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||||
|
),
|
||||||
|
[child.id],
|
||||||
|
["morning"],
|
||||||
|
)
|
||||||
|
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||||
|
await materialize(database, today.isoformat(), end, today=today)
|
||||||
|
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child.id, today.isoformat(), today.isoformat()
|
||||||
|
)
|
||||||
|
assert instances
|
||||||
|
return entry, client, child, instances[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _record_refreshes(entry) -> list[bool]:
|
||||||
|
"""Replace the coordinator's refresh with a recording stand-in."""
|
||||||
|
refreshes: list[bool] = []
|
||||||
|
|
||||||
|
async def _fake_refresh():
|
||||||
|
refreshes.append(True)
|
||||||
|
|
||||||
|
entry.runtime_data.coordinator.async_refresh = _fake_refresh
|
||||||
|
return refreshes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_quest_reaches_the_shared_api_client(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""A panel tap calls ``complete_instance`` on the entry's shared
|
||||||
|
client with the tapped child, completes NOTHING locally, fires NO
|
||||||
|
local event, and refreshes the coordinator immediately."""
|
||||||
|
entry, client, child, instance = await _setup_seeded_entry(
|
||||||
|
hass, make_entry, _RecordingApiClient(hass, "test_entry")
|
||||||
|
)
|
||||||
|
assert entry.runtime_data.coordinator.api_client is client, (
|
||||||
|
"the service must proxy through the entry's ONE shared client"
|
||||||
|
)
|
||||||
|
refreshes = _record_refreshes(entry)
|
||||||
|
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance.id,
|
||||||
|
"actor": "panel",
|
||||||
|
"actor_child_id": child.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.complete_calls == [(instance.id, child.id)]
|
||||||
|
# No local completion: the instance is still open in the local
|
||||||
|
# database — only the API service writes (through its own client).
|
||||||
|
from custom_components.nestquest.completion import instance_state
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await instance_state(entry.runtime_data.database, instance.id)
|
||||||
|
== "open"
|
||||||
|
)
|
||||||
|
# No local firing: the completed (and day-complete) events arrive
|
||||||
|
# over the SSE stream, re-fired by the subscription — never from
|
||||||
|
# this handler.
|
||||||
|
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||||
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||||
|
# The old immediate-update behaviour: the sensors' coordinator
|
||||||
|
# refresh runs before the service call returns.
|
||||||
|
assert refreshes == [True]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("error", "fragment"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
NestQuestApiError(
|
||||||
|
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||||
|
"failed with HTTP 401",
|
||||||
|
status=401,
|
||||||
|
),
|
||||||
|
"failed with HTTP 401",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
NestQuestApiError(
|
||||||
|
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||||
|
"transport failure: connection refused",
|
||||||
|
status=None,
|
||||||
|
),
|
||||||
|
"transport failure",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_api_failure_surfaces_as_a_service_error(
|
||||||
|
hass, make_entry, error, fragment
|
||||||
|
) -> None:
|
||||||
|
"""A typed API failure (non-2xx or transport) raises
|
||||||
|
``HomeAssistantError`` carrying the typed message — which never
|
||||||
|
includes the panel token — with nothing completed or fired
|
||||||
|
locally, and no refresh for a mutation that never happened."""
|
||||||
|
entry, client, child, instance = await _setup_seeded_entry(
|
||||||
|
hass,
|
||||||
|
make_entry,
|
||||||
|
_FailingApiClient(hass, "test_entry", error),
|
||||||
|
)
|
||||||
|
refreshes = _record_refreshes(entry)
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance.id,
|
||||||
|
"actor": "panel",
|
||||||
|
"actor_child_id": child.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# The typed error is chained, and the service error carries the
|
||||||
|
# typed message only — never the panel token.
|
||||||
|
assert excinfo.value.__cause__ is error
|
||||||
|
assert "secret-panel-token" not in str(excinfo.value)
|
||||||
|
assert client.complete_calls == [(instance.id, child.id)]
|
||||||
|
from custom_components.nestquest.completion import instance_state
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await instance_state(entry.runtime_data.database, instance.id)
|
||||||
|
== "open"
|
||||||
|
)
|
||||||
|
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||||
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||||
|
assert refreshes == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""An entry with no completion surface (the unconfigured-token
|
||||||
|
stub the coordinator swaps in) fails the service clearly instead
|
||||||
|
of reaching an attribute error."""
|
||||||
|
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
||||||
|
|
||||||
|
entry, _client, child, instance = await _setup_seeded_entry(
|
||||||
|
hass, make_entry, _UnconfiguredApiClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
HomeAssistantError, match="NestQuest API is not configured"
|
||||||
|
):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance.id,
|
||||||
|
"actor": "panel",
|
||||||
|
"actor_child_id": child.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_panel_actor_is_rejected_before_the_client(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""The API's panel route completes as the tapped child only: a
|
||||||
|
user-actor call fails fast with a clear error and never reaches
|
||||||
|
the client."""
|
||||||
|
entry, client, child, instance = await _setup_seeded_entry(
|
||||||
|
hass, make_entry, _RecordingApiClient(hass, "test_entry")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance.id,
|
||||||
|
"actor": "user",
|
||||||
|
"actor_user_id": ADMIN_CTX["user_id"],
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
assert client.complete_calls == []
|
||||||
+684
-232
File diff suppressed because it is too large
Load Diff
+37
-10
@@ -123,9 +123,11 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
|
|||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
settings = entry.runtime_data.settings
|
settings = entry.runtime_data.settings
|
||||||
|
|
||||||
# Single clock read for the seed/snapshot anchor (no midnight skew).
|
# Single clock read for the seed/snapshot anchor (no midnight skew);
|
||||||
today = coordinator._local_now().date()
|
# the coordinator no longer owns a clock read (the API serves the
|
||||||
|
# snapshot), so the test resolves the HA-local zone directly.
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
|
today = datetime.datetime.now(time_zone).date()
|
||||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||||
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
|
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
|
||||||
pinned_now = datetime.datetime(
|
pinned_now = datetime.datetime(
|
||||||
@@ -294,13 +296,38 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
|
|||||||
== "missed"
|
== "missed"
|
||||||
)
|
)
|
||||||
|
|
||||||
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
|
# REQUIRED wrapper tie-in: since Feature 18 the coordinator's
|
||||||
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
|
# refresh polls the snapshot through its API client, so pinning the
|
||||||
# pinned_now the builder was called with must reproduce the builder's
|
# client to serve the payload of the SAME snapshot built with
|
||||||
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
|
# pinned_now must reproduce the builder's snapshot byte-for-byte.
|
||||||
# deep compare — a wrapper that mis-passed today vs now, or stopped
|
# The dataclasses are frozen, so == is a full deep compare — a
|
||||||
# calling the builder, would diverge here even though the literal
|
# reconstruction that dropped or reshaped a field would diverge
|
||||||
# assertions above still pass.
|
# here even though the literal assertions above still pass.
|
||||||
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
|
from conftest import set_coordinator_client
|
||||||
|
|
||||||
|
class _PinnedPayloadClient:
|
||||||
|
async def get_snapshot(self):
|
||||||
|
return {
|
||||||
|
"today_iso": built.today_iso,
|
||||||
|
"cycle_day": built.cycle_day,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"child_id": child.child_id,
|
||||||
|
"child_name": child.child_name,
|
||||||
|
"present": child.present,
|
||||||
|
"next_present": child.next_present,
|
||||||
|
"due_today": child.due_today,
|
||||||
|
"completed_today": child.completed_today,
|
||||||
|
"remaining_today": child.remaining_today,
|
||||||
|
"completion_pct": child.completion_pct,
|
||||||
|
"instances": instance_payload(
|
||||||
|
child.instances, include_missed=False
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for child in built.children
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
set_coordinator_client(entry, _PinnedPayloadClient())
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
assert coordinator.data == built
|
assert coordinator.data == built
|
||||||
|
|||||||
+44
-15
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import refire_api_transitions, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
from custom_components.nestquest.children import (
|
from custom_components.nestquest.children import (
|
||||||
@@ -22,7 +22,7 @@ from custom_components.nestquest.children import (
|
|||||||
list_children,
|
list_children,
|
||||||
set_child_active,
|
set_child_active,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.completion import derive_state
|
from custom_components.nestquest.completion import derive_state, uncomplete_instance
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -30,7 +30,9 @@ from custom_components.nestquest.const import (
|
|||||||
EVENT_QUEST_COMPLETED,
|
EVENT_QUEST_COMPLETED,
|
||||||
EVENT_QUEST_UNCOMPLETED,
|
EVENT_QUEST_UNCOMPLETED,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
)
|
||||||
|
from custom_components.nestquest.core.events import (
|
||||||
|
build_quest_uncompleted_events,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
from custom_components.nestquest.materialize import materialize
|
from custom_components.nestquest.materialize import materialize
|
||||||
@@ -154,6 +156,11 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
"actor_child_id": ada.id,
|
"actor_child_id": ada.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The transition events arrive over the API's SSE stream (the
|
||||||
|
# subscription re-fires them), never from the service handler.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
||||||
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
||||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||||
@@ -169,20 +176,38 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Reversing one quest fires the uncompleted event exactly once
|
# --- Reversing one quest fires the uncompleted event exactly once
|
||||||
# and restores the owed counts.
|
# and restores the owed counts. The HA ``uncomplete_quest``
|
||||||
await hass.services.call(
|
# service is gone (the API service's admin route is the reversal
|
||||||
DOMAIN,
|
# path): drive the core completion layer it calls, publish the
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
# uncompleted transition the way the API does, and deliver it
|
||||||
{"instance_id": instances[0].id, "actor": "user"},
|
# through the SSE subscription.
|
||||||
context={"user_id": "admin-1"},
|
reversal = await uncomplete_instance(
|
||||||
|
entry.runtime_data.database,
|
||||||
|
instances[0].id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="admin-1",
|
||||||
)
|
)
|
||||||
|
assert reversal.appended is True
|
||||||
|
client = coordinator.api_client
|
||||||
|
client.published_frames.extend(
|
||||||
|
await build_quest_uncompleted_events(
|
||||||
|
entry.runtime_data.database, instances[0].id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||||
|
# The reversal no longer carries an immediate local refresh: the
|
||||||
|
# coordinator's next poll picks the restored counts up.
|
||||||
|
await coordinator.async_refresh()
|
||||||
assert _sensor(hass, ada.id, "all_done").is_on is False
|
assert _sensor(hass, ada.id, "all_done").is_on is False
|
||||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
||||||
|
|
||||||
# --- The instances payload omits missed instances while
|
# --- The instances payload omits missed instances (D-009) — and
|
||||||
# admin_instances includes them (D-009): pin the derive clock
|
# since Feature 18 the snapshot comes from the API panel route,
|
||||||
# forward in the TEST ONLY so today's open instances read missed.
|
# which omits missed rows ENTIRELY, so admin_instances cannot
|
||||||
|
# carry them either (the admin surface for missed quests is the
|
||||||
|
# PWA, reading the API directly): pin the derive clock forward in
|
||||||
|
# the TEST ONLY so today's open instances read missed.
|
||||||
original_refresh = coordinator._async_update_data
|
original_refresh = coordinator._async_update_data
|
||||||
|
|
||||||
async def _future_dated_refresh():
|
async def _future_dated_refresh():
|
||||||
@@ -203,9 +228,9 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
|
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
|
||||||
assert attributes["instances"] == [], "panel payload omits missed"
|
assert attributes["instances"] == [], "panel payload omits missed"
|
||||||
assert len(attributes["admin_instances"]) == 2
|
assert attributes["admin_instances"] == [], (
|
||||||
assert all(
|
"the API payload omits missed rows, so the admin payload "
|
||||||
entry["state"] == "missed" for entry in attributes["admin_instances"]
|
"carries none either (the PWA is the admin surface now)"
|
||||||
)
|
)
|
||||||
coordinator._async_update_data = original_refresh
|
coordinator._async_update_data = original_refresh
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
@@ -231,6 +256,10 @@ async def test_payload_schema_matches_the_documented_contract(
|
|||||||
"actor_child_id": ada.id,
|
"actor_child_id": ada.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The completed event arrives over the API's SSE stream.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
||||||
for key, check in (
|
for key, check in (
|
||||||
("child_id", lambda v: isinstance(v, int)),
|
("child_id", lambda v: isinstance(v, int)),
|
||||||
|
|||||||
+148
-85
@@ -1,12 +1,29 @@
|
|||||||
"""Tests for the NestQuest bus events (Feature 10, task 6)."""
|
"""Tests for the NestQuest bus events (Feature 10, task 6).
|
||||||
|
|
||||||
|
Since Feature 18 the integration is a client of the NestQuest API
|
||||||
|
service: ``nestquest.complete_quest`` proxies to the API's panel
|
||||||
|
complete route and fires NO local bus event — the API service builds
|
||||||
|
and publishes the transition frames on its SSE stream, and the
|
||||||
|
integration's subscription (:mod:`custom_components.nestquest.sse`)
|
||||||
|
re-fires them. The service-driven tests below therefore deliver the
|
||||||
|
frames through :func:`conftest.refire_api_transitions` (the
|
||||||
|
production event path, driven synchronously) before asserting the
|
||||||
|
bus; the shim-level tests drive :mod:`custom_components.nestquest
|
||||||
|
.events` directly, the way its remaining callers do. The HA
|
||||||
|
``uncomplete_quest`` service is also gone (the API service's admin
|
||||||
|
uncomplete route is the reversal path), so the reversal tests drive
|
||||||
|
the core completion layer the route calls and deliver the published
|
||||||
|
frame through the same SSE subscription helper.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import refire_api_transitions, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
from custom_components.nestquest.children import create_child
|
from custom_components.nestquest.children import create_child
|
||||||
|
from custom_components.nestquest.completion import uncomplete_instance
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -15,10 +32,9 @@ from custom_components.nestquest.const import (
|
|||||||
EVENT_QUEST_MISSED,
|
EVENT_QUEST_MISSED,
|
||||||
EVENT_QUEST_UNCOMPLETED,
|
EVENT_QUEST_UNCOMPLETED,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
)
|
||||||
SERVICE_MANAGE_CHILD,
|
from custom_components.nestquest.core.events import (
|
||||||
SERVICE_REGENERATE,
|
build_quest_uncompleted_events,
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
from custom_components.nestquest.materialize import materialize
|
from custom_components.nestquest.materialize import materialize
|
||||||
@@ -27,7 +43,6 @@ from custom_components.nestquest.quest_definitions import (
|
|||||||
)
|
)
|
||||||
from custom_components.nestquest.recurrence import ScheduleRule
|
from custom_components.nestquest.recurrence import ScheduleRule
|
||||||
|
|
||||||
ADMIN_CTX = {"user_id": "admin-1"}
|
|
||||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||||
|
|
||||||
|
|
||||||
@@ -49,15 +64,7 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=N
|
|||||||
)
|
)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
await hass.services.call(
|
child = await create_child(database, "Ada")
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
|
|
||||||
child = (await list_children(database))[0]
|
|
||||||
await create_quest_definition(
|
await create_quest_definition(
|
||||||
database,
|
database,
|
||||||
"Brush teeth",
|
"Brush teeth",
|
||||||
@@ -86,6 +93,11 @@ async def test_quest_completed_fires_with_documented_payload(
|
|||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||||
)
|
)
|
||||||
|
# The completed event arrives over the API's SSE stream (the
|
||||||
|
# subscription re-fires it), never from the service handler.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
|
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
|
||||||
assert len(fired) == 1
|
assert len(fired) == 1
|
||||||
payload = fired[0]
|
payload = fired[0]
|
||||||
@@ -111,6 +123,15 @@ async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None:
|
|||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||||
)
|
)
|
||||||
|
# The no-op verdict is decided in the API's completion layer: the
|
||||||
|
# second call appended nothing, so the API published no second
|
||||||
|
# frame and the bus stays at exactly one event.
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
|
assert [result.appended for _id, result in client.completions] == [
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
]
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +149,9 @@ async def test_child_day_complete_fires_when_day_clears(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
||||||
assert len(day_complete) == 1, "exactly one day-complete per cleared day"
|
assert len(day_complete) == 1, "exactly one day-complete per cleared day"
|
||||||
payload = day_complete[0]
|
payload = day_complete[0]
|
||||||
@@ -153,6 +177,12 @@ async def test_child_day_complete_does_not_fire_partway(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The API publishes the completion but not a day-complete (the
|
||||||
|
# child's day is not clear yet); the subscription re-fires only
|
||||||
|
# what was published.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||||
await hass.services.call(
|
await hass.services.call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -163,36 +193,51 @@ async def test_child_day_complete_does_not_fire_partway(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None:
|
async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None:
|
||||||
|
"""An actual reversal fires the uncompleted event — but only once,
|
||||||
|
and only through the API service's transition stream: the HA
|
||||||
|
``uncomplete_quest`` service is gone, so the reversal is driven
|
||||||
|
through the core completion layer the API's admin uncomplete route
|
||||||
|
calls, with the published frame delivered by the SSE subscription."""
|
||||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||||
instance_id = instances[0].id
|
instance_id = instances[0].id
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
await hass.services.call(
|
await hass.services.call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||||
)
|
)
|
||||||
# Un-completing an open instance is a no-op and fires nothing.
|
# The reversal appends through the core completion layer; the API
|
||||||
await hass.services.call(
|
# route publishes the transition on its SSE stream and the
|
||||||
DOMAIN,
|
# subscription re-fires it on the bus.
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
result = await uncomplete_instance(
|
||||||
{"instance_id": instance_id, "actor": "user"},
|
database, instance_id, actor_source="user", actor_user_id="admin-1"
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
)
|
||||||
|
assert result.appended is True
|
||||||
|
client.published_frames.extend(
|
||||||
|
await build_quest_uncompleted_events(database, instance_id)
|
||||||
|
)
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED)
|
fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED)
|
||||||
assert len(fired) == 1
|
assert len(fired) == 1
|
||||||
payload = fired[0]
|
payload = fired[0]
|
||||||
assert payload["instance_id"] == instance_id
|
assert payload["instance_id"] == instance_id
|
||||||
assert payload["quest_title"] == "Brush teeth"
|
assert payload["quest_title"] == "Brush teeth"
|
||||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||||
await hass.services.call(
|
# Un-completing an already-open instance is a no-op: nothing is
|
||||||
DOMAIN,
|
# appended, nothing is published, and nothing further fires.
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
no_op = await uncomplete_instance(
|
||||||
{"instance_id": instance_id, "actor": "user"},
|
database, instance_id, actor_source="user", actor_user_id="admin-1"
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
)
|
||||||
|
assert no_op.appended is False
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -223,6 +268,15 @@ async def test_appended_verdict_is_atomic_under_duplicate_calls(
|
|||||||
for _ in range(2)
|
for _ in range(2)
|
||||||
]
|
]
|
||||||
await asyncio.gather(*calls)
|
await asyncio.gather(*calls)
|
||||||
|
# The API's completion layer (the same core lock) decides the
|
||||||
|
# verdict: one call appended, the duplicate did not — so the API
|
||||||
|
# published exactly one frame and the bus carries one event.
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
|
assert len(client.completions) == 2
|
||||||
|
assert [result.appended for _id, result in client.completions].count(
|
||||||
|
True
|
||||||
|
) == 1
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -263,6 +317,9 @@ async def test_day_complete_ignored_for_backdated_completion(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||||
|
|
||||||
await _complete(
|
await _complete(
|
||||||
@@ -280,12 +337,19 @@ async def test_day_complete_ignored_for_backdated_completion(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The service call on the already-done future instance is a no-op
|
||||||
|
# inside the API's completion layer: nothing appended, nothing
|
||||||
|
# published, nothing re-fired — today's single announcement stands.
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
|
assert client.completions[-1][1].appended is False
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, (
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, (
|
||||||
"a back-dated/future completion must not re-announce today"
|
"a back-dated/future completion must not re-announce today"
|
||||||
)
|
)
|
||||||
# The direct business-layer call fired nothing (only the service
|
# The direct business-layer call published nothing (only the API's
|
||||||
# path fires), and the follow-up service call on the now-done
|
# completion path publishes), and the follow-up service call on
|
||||||
# future instance was a no-op: today's single completion stands.
|
# the now-done future instance was a no-op: today's single
|
||||||
|
# completion stands.
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances)
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances)
|
||||||
|
|
||||||
|
|
||||||
@@ -324,45 +388,15 @@ async def test_completion_updates_entities_immediately(
|
|||||||
assert remaining_after == len(instances) - 1, (
|
assert remaining_after == len(instances) - 1, (
|
||||||
"sensor must reflect the completion without a manual refresh"
|
"sensor must reflect the completion without a manual refresh"
|
||||||
)
|
)
|
||||||
|
# The transition events arrive over the API's SSE stream.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
|
||||||
1 if len(instances) == 1 else 0
|
1 if len(instances) == 1 else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_uncompletion_updates_entities_immediately(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
|
||||||
coordinator = entry.runtime_data.coordinator
|
|
||||||
await coordinator.async_refresh()
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
{
|
|
||||||
"instance_id": instances[0].id,
|
|
||||||
"actor": "panel",
|
|
||||||
"actor_child_id": child.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
completed_after_complete = hass.entities[
|
|
||||||
f"nestquest_child_{child.id}_quests_completed_today"
|
|
||||||
].native_value
|
|
||||||
assert completed_after_complete == 1
|
|
||||||
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
{"instance_id": instances[0].id, "actor": "user"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
completed_after_uncomplete = hass.entities[
|
|
||||||
f"nestquest_child_{child.id}_quests_completed_today"
|
|
||||||
].native_value
|
|
||||||
assert completed_after_uncomplete == 0, (
|
|
||||||
"sensor must reflect the reversal without a manual refresh"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_forced_refreshes_serialize_no_stale_publish(
|
async def test_forced_refreshes_serialize_no_stale_publish(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -538,17 +572,32 @@ async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> Non
|
|||||||
"""The shim fires ``nestquest_quest_completed`` BEFORE
|
"""The shim fires ``nestquest_quest_completed`` BEFORE
|
||||||
``nestquest_child_day_complete`` (the pre-extraction ordering), so
|
``nestquest_child_day_complete`` (the pre-extraction ordering), so
|
||||||
a failure in the day-complete DB reads cannot suppress the
|
a failure in the day-complete DB reads cannot suppress the
|
||||||
completed event. Asserted by bus event ORDER, not just counts."""
|
completed event. Asserted by bus event ORDER, not just counts.
|
||||||
|
|
||||||
|
Driven directly: since Feature 18 the completion service proxies
|
||||||
|
to the API (whose event ordering is tested in test_api_sse.py);
|
||||||
|
this targets the shim module itself, the way its remaining
|
||||||
|
callers invoke it.
|
||||||
|
"""
|
||||||
|
from custom_components.nestquest.completion import (
|
||||||
|
complete_instance as _complete,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.events import fire_quest_completed
|
||||||
|
|
||||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||||
|
database = entry.runtime_data.database
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
await hass.services.call(
|
result = await _complete(
|
||||||
DOMAIN,
|
database,
|
||||||
SERVICE_COMPLETE_QUEST,
|
instance.id,
|
||||||
{
|
actor_source="panel",
|
||||||
"instance_id": instance.id,
|
actor_child_id=child.id,
|
||||||
"actor": "panel",
|
)
|
||||||
"actor_child_id": child.id,
|
await fire_quest_completed(
|
||||||
},
|
hass,
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
was_on_time=result.was_on_time,
|
||||||
)
|
)
|
||||||
ordered = [
|
ordered = [
|
||||||
etype for etype, _payload in hass.bus.events
|
etype for etype, _payload in hass.bus.events
|
||||||
@@ -732,21 +781,31 @@ async def test_day_complete_fully_cleared_today_yields_event(
|
|||||||
async def test_shim_day_complete_uses_ha_local_timezone(
|
async def test_shim_day_complete_uses_ha_local_timezone(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The day-complete rule reads ``hass.config.time_zone`` and
|
"""The shim's day-complete rule reads ``hass.config.time_zone``
|
||||||
derives ``today`` in HA-local time, not UTC.
|
and derives ``today`` in HA-local time, not UTC.
|
||||||
|
|
||||||
Picks a zone whose current local date differs from UTC, seeds an
|
Picks a zone whose current local date differs from UTC, seeds an
|
||||||
instance for THAT zone's today (not UTC's today), completes it
|
instance for THAT zone's today (not UTC's today), fires the shim
|
||||||
through the service path, and asserts
|
directly for a completion of that instance, and asserts
|
||||||
``nestquest_child_day_complete`` fires — proving the local date
|
``nestquest_child_day_complete`` fires — proving the local date
|
||||||
reached the day-complete rule. If no candidate zone has a
|
reached the day-complete rule. If no candidate zone has a
|
||||||
different date right now (only possible inside the ~1h window
|
different date right now (only possible inside the ~1h window
|
||||||
around 12:00 UTC when all zones share one date), the test skips
|
around 12:00 UTC when all zones share one date), the test skips
|
||||||
rather than run vacuously.
|
rather than run vacuously.
|
||||||
|
|
||||||
|
Driven directly: since Feature 18 the completion service proxies
|
||||||
|
to the API (whose day-complete ``today`` is the API host's local
|
||||||
|
date, tested in test_api_sse.py); this targets the shim module
|
||||||
|
itself, the way its remaining callers invoke it.
|
||||||
"""
|
"""
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from custom_components.nestquest.completion import (
|
||||||
|
complete_instance as _complete,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.events import fire_quest_completed
|
||||||
|
|
||||||
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
|
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||||
far_zone = None
|
far_zone = None
|
||||||
far_today = None
|
far_today = None
|
||||||
@@ -775,15 +834,19 @@ async def test_shim_day_complete_uses_ha_local_timezone(
|
|||||||
entry, child, instances = await _setup_and_seed(
|
entry, child, instances = await _setup_and_seed(
|
||||||
hass, make_entry, seed_date=far_today
|
hass, make_entry, seed_date=far_today
|
||||||
)
|
)
|
||||||
|
database = entry.runtime_data.database
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
await hass.services.call(
|
result = await _complete(
|
||||||
DOMAIN,
|
database,
|
||||||
SERVICE_COMPLETE_QUEST,
|
instance.id,
|
||||||
{
|
actor_source="panel",
|
||||||
"instance_id": instance.id,
|
actor_child_id=child.id,
|
||||||
"actor": "panel",
|
)
|
||||||
"actor_child_id": child.id,
|
await fire_quest_completed(
|
||||||
},
|
hass,
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
was_on_time=result.was_on_time,
|
||||||
)
|
)
|
||||||
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
||||||
assert len(day_complete) == 1, (
|
assert len(day_complete) == 1, (
|
||||||
|
|||||||
+2
-131
@@ -9,22 +9,14 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
|||||||
|
|
||||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_MORNING_SUMMARY_TIME,
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
SERVICE_REGENERATE,
|
SERVICE_COMPLETE_QUEST,
|
||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
)
|
)
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_data():
|
|
||||||
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
|
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(entry, registry)
|
return wire_entry_to_registry(entry, registry)
|
||||||
@@ -601,127 +593,6 @@ async def test_setup_keeps_horizon_when_sibling_option_is_bad(
|
|||||||
assert entry.runtime_data.settings.horizon_days == 5
|
assert entry.runtime_data.settings.horizon_days == 5
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_service_registered_and_materializes(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""``nestquest.regenerate`` is registered and calling it regenerates."""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
# A definition created AFTER setup has no instances yet (create does not
|
|
||||||
# materialize); the service must generate them on demand.
|
|
||||||
database = entry.runtime_data.database
|
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
|
|
||||||
today = _local_today(hass)
|
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
|
||||||
records = await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child_id, today.isoformat(), end
|
|
||||||
)
|
|
||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_service_uses_configured_horizon_days(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""``nestquest.regenerate`` regenerates only the configured horizon."""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry = _wire(
|
|
||||||
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
|
||||||
hass.registry,
|
|
||||||
)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
|
|
||||||
today = _local_today(hass)
|
|
||||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
|
||||||
records = await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child_id, today.isoformat(), end
|
|
||||||
)
|
|
||||||
assert len(records) == 6
|
|
||||||
|
|
||||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
|
||||||
beyond_end = (
|
|
||||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
assert await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child_id, beyond, beyond_end
|
|
||||||
) == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
|
||||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
|
||||||
entry = _wire(make_entry(), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
assert await async_unload_entry(hass, entry) is True
|
|
||||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_service_is_domain_scoped_across_entries(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""The ``regenerate`` service is domain-global, not per-entry.
|
|
||||||
|
|
||||||
With two loaded entries the service must be registered and functional;
|
|
||||||
unloading one entry must keep it registered and functional for the
|
|
||||||
remaining entry; unloading the final entry must remove it.
|
|
||||||
"""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry_a = _wire(
|
|
||||||
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
|
|
||||||
)
|
|
||||||
entry_b = _wire(
|
|
||||||
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await async_setup_entry(hass, entry_a) is True
|
|
||||||
assert await async_setup_entry(hass, entry_b) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
# The domain-global handler resolves a live database from any entry.
|
|
||||||
database_b = entry_b.runtime_data.database
|
|
||||||
child_id = await _seed_daily_child_and_definition(
|
|
||||||
database_b, _local_today(hass)
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
today = _local_today(hass)
|
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
|
||||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
|
||||||
child_id, today.isoformat(), end
|
|
||||||
)
|
|
||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
|
||||||
|
|
||||||
# Unloading one entry must not tear down the shared service.
|
|
||||||
assert await async_unload_entry(hass, entry_a) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
# It still works for the remaining entry.
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
|
|
||||||
# Unloading the final entry removes the domain-global service.
|
|
||||||
assert await async_unload_entry(hass, entry_b) is True
|
|
||||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_cleans_listeners_when_materialization_raises(
|
async def test_setup_cleans_listeners_when_materialization_raises(
|
||||||
hass, make_entry, monkeypatch
|
hass, make_entry, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -745,5 +616,5 @@ async def test_setup_cleans_listeners_when_materialization_raises(
|
|||||||
|
|
||||||
assert hass.time_change.size == 0
|
assert hass.time_change.size == 0
|
||||||
assert hass.registry.size == 0
|
assert hass.registry.size == 0
|
||||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
||||||
assert not hasattr(entry, "runtime_data")
|
assert not hasattr(entry, "runtime_data")
|
||||||
+224
-8
@@ -15,22 +15,28 @@ from custom_components.nestquest import config_flow
|
|||||||
from custom_components.nestquest import options_flow
|
from custom_components.nestquest import options_flow
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
|
||||||
CONF_HORIZON_DAYS,
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
|
||||||
CONF_UPDATE_INTERVAL,
|
|
||||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||||
CONF_AFTERNOON_REMINDER_TIME,
|
CONF_AFTERNOON_REMINDER_TIME,
|
||||||
|
CONF_API_BASE_URL,
|
||||||
CONF_CELEBRATION_ENABLED,
|
CONF_CELEBRATION_ENABLED,
|
||||||
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||||
CONF_END_OF_DAY_REPORT_TIME,
|
CONF_END_OF_DAY_REPORT_TIME,
|
||||||
|
CONF_HORIZON_DAYS,
|
||||||
CONF_MORNING_SUMMARY_ENABLED,
|
CONF_MORNING_SUMMARY_ENABLED,
|
||||||
CONF_MORNING_SUMMARY_TIME,
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
CONF_NOTIFY_TARGET,
|
CONF_NOTIFY_TARGET,
|
||||||
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
|
CONF_PANEL_TOKEN,
|
||||||
|
CONF_SNAPSHOT_STALENESS,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_API_BASE_URL,
|
||||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||||
DEFAULT_AUTOMATION_ENABLED,
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||||
DEFAULT_MORNING_SUMMARY_TIME,
|
DEFAULT_MORNING_SUMMARY_TIME,
|
||||||
|
DEFAULT_PANEL_TOKEN,
|
||||||
|
DEFAULT_SNAPSHOT_STALENESS,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
@@ -49,11 +55,13 @@ VALID_INPUT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
||||||
#: fresh entry: the Feature 10 refresh interval and the Feature 11
|
#: fresh entry: the Feature 10 refresh interval, the Feature 11
|
||||||
#: notification section fill from their defaults.
|
#: notification section, and the Feature 18 API connection fill from
|
||||||
|
#: their defaults.
|
||||||
FULL_INPUT = {
|
FULL_INPUT = {
|
||||||
**VALID_INPUT,
|
**VALID_INPUT,
|
||||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||||
|
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
|
||||||
CONF_NOTIFY_TARGET: "",
|
CONF_NOTIFY_TARGET: "",
|
||||||
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
|
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
|
||||||
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
|
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||||
@@ -62,6 +70,8 @@ FULL_INPUT = {
|
|||||||
CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||||
CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||||
CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||||
|
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
|
||||||
|
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -239,6 +249,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
|||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
CONF_UPDATE_INTERVAL: 90,
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 450,
|
||||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||||
@@ -251,6 +262,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
|||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
CONF_UPDATE_INTERVAL: 90,
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 450,
|
||||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||||
@@ -259,6 +271,8 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
|||||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||||
CONF_END_OF_DAY_REPORT_ENABLED: True,
|
CONF_END_OF_DAY_REPORT_ENABLED: True,
|
||||||
CONF_CELEBRATION_ENABLED: True,
|
CONF_CELEBRATION_ENABLED: True,
|
||||||
|
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
|
||||||
|
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
||||||
CONF_ADMIN_USER_IDS: [],
|
CONF_ADMIN_USER_IDS: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +286,9 @@ def test_options_flow_schema_coerces_field_types() -> None:
|
|||||||
assert validators[CONF_HORIZON_DAYS] is int
|
assert validators[CONF_HORIZON_DAYS] is int
|
||||||
assert validators[CONF_DAY_ROLLOVER_TIME] is str
|
assert validators[CONF_DAY_ROLLOVER_TIME] is str
|
||||||
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
|
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
|
||||||
|
# The Feature 18 API connection fields are free-text strings.
|
||||||
|
assert validators[CONF_API_BASE_URL] is str
|
||||||
|
assert validators[CONF_PANEL_TOKEN] is str
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_horizon", [0, -5])
|
@pytest.mark.parametrize("bad_horizon", [0, -5])
|
||||||
@@ -326,8 +343,10 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
|||||||
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_creates_entry_with_four_keys() -> None:
|
def test_options_flow_creates_entry_with_validated_keys() -> None:
|
||||||
"""A valid submit creates an entry with exactly the four validated keys."""
|
"""A valid submit creates an entry with exactly the validated keys
|
||||||
|
(the legacy settings plus every later section's keys, filled from
|
||||||
|
defaults where the raw submit omitted them)."""
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
@@ -799,3 +818,200 @@ def test_options_flow_no_hard_coded_personal_target_in_source() -> None:
|
|||||||
assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == ""
|
assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == ""
|
||||||
form = _run(_make_flow(_make_entry()).async_step_init(None))
|
form = _run(_make_flow(_make_entry()).async_step_init(None))
|
||||||
assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == ""
|
assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Panel-plane API connection section (Feature 18)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_api_section_defaults_saved() -> None:
|
||||||
|
"""A bare submit saves the API connection defaults: the const base
|
||||||
|
URL and an EMPTY panel token (no token is ever invented)."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_API_BASE_URL] == DEFAULT_API_BASE_URL
|
||||||
|
assert result["data"][CONF_PANEL_TOKEN] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_api_base_url_must_be_full_http_url() -> None:
|
||||||
|
"""Scheme-less, host-less, empty, and non-string base URLs reject
|
||||||
|
with a field error — the same rule the client's constructor
|
||||||
|
enforces, so a malformed URL fails at the form, not at the first
|
||||||
|
request."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
for bad_url in ("api.lan:8000", "http://", "", " ", 42):
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init(
|
||||||
|
{**VALID_INPUT, CONF_API_BASE_URL: bad_url}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_API_BASE_URL: "invalid_url"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_panel_token_validation() -> None:
|
||||||
|
"""The EMPTY token is valid (the API connection is simply not
|
||||||
|
configured yet); padded and non-string values reject — a padded
|
||||||
|
paste would fail the token check at request time instead."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init({**VALID_INPUT, CONF_PANEL_TOKEN: ""})
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_PANEL_TOKEN] == ""
|
||||||
|
|
||||||
|
for bad_token in (" secret ", " ", 42):
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init(
|
||||||
|
{**VALID_INPUT, CONF_PANEL_TOKEN: bad_token}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_PANEL_TOKEN: "invalid"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_api_section_stored_values_round_trip() -> None:
|
||||||
|
"""Stored API settings are the form's defaults and a raw legacy
|
||||||
|
submit without them keeps the stored values."""
|
||||||
|
entry = _make_entry(
|
||||||
|
options={
|
||||||
|
**VALID_INPUT,
|
||||||
|
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||||
|
CONF_PANEL_TOKEN: "stored-token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
flow = _make_flow(entry)
|
||||||
|
form = _run(flow.async_step_init(None))
|
||||||
|
schema_defaults = form["data_schema"]({})
|
||||||
|
assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443"
|
||||||
|
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
|
||||||
|
|
||||||
|
# A raw submit carrying only the legacy keys keeps the stored
|
||||||
|
# API connection settings.
|
||||||
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_API_BASE_URL] == "http://panel.lan:8443"
|
||||||
|
assert result["data"][CONF_PANEL_TOKEN] == "stored-token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_strings_cover_api_section(
|
||||||
|
strings: dict, translations_en: dict
|
||||||
|
) -> None:
|
||||||
|
"""Both string files name the API fields and the URL error."""
|
||||||
|
data = strings["options"]["step"]["init"]["data"]
|
||||||
|
for key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
|
||||||
|
assert data.get(key), f"Missing field name for '{key}'"
|
||||||
|
assert strings["options"]["errors"]["invalid_url"]
|
||||||
|
assert translations_en["options"] == strings["options"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Last-good snapshot staleness section (Feature 18)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_staleness_below_update_interval_refused() -> None:
|
||||||
|
"""A staleness threshold shorter than the submit's update interval
|
||||||
|
is refused with the dedicated error: such a threshold could never
|
||||||
|
serve even one cached snapshot."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init(
|
||||||
|
{
|
||||||
|
**VALID_INPUT,
|
||||||
|
CONF_UPDATE_INTERVAL: 300,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 60,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_staleness_below_stored_interval_refused() -> None:
|
||||||
|
"""A raw submit setting the staleness but omitting the update
|
||||||
|
interval is checked against the STORED interval, not the default."""
|
||||||
|
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300})
|
||||||
|
flow = _make_flow(entry)
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60})
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_staleness_must_be_positive_int() -> None:
|
||||||
|
"""Non-int, boolean, and non-positive staleness values reject with
|
||||||
|
the plain invalid error."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
for bad_staleness in ("900", True, 0, -10):
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init(
|
||||||
|
{**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_staleness_equal_to_interval_saves() -> None:
|
||||||
|
"""A threshold exactly as long as the update interval is valid (the
|
||||||
|
boundary is inclusive)."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init(
|
||||||
|
{
|
||||||
|
**VALID_INPUT,
|
||||||
|
CONF_UPDATE_INTERVAL: 300,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 300,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_SNAPSHOT_STALENESS] == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_staleness_defaults_saved() -> None:
|
||||||
|
"""A bare submit saves the staleness default (three update
|
||||||
|
intervals) alongside the other timing defaults."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_staleness_stored_values_round_trip() -> None:
|
||||||
|
"""A stored staleness is the form's default and a raw legacy submit
|
||||||
|
without it keeps the stored value."""
|
||||||
|
entry = _make_entry(
|
||||||
|
options={
|
||||||
|
**VALID_INPUT,
|
||||||
|
CONF_UPDATE_INTERVAL: 300,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 1200,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
flow = _make_flow(entry)
|
||||||
|
form = _run(flow.async_step_init(None))
|
||||||
|
schema_defaults = form["data_schema"]({})
|
||||||
|
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
|
||||||
|
|
||||||
|
# A raw submit carrying only the legacy keys keeps the stored
|
||||||
|
# staleness (and never trips the interval cross-check).
|
||||||
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_strings_cover_staleness_section(
|
||||||
|
strings: dict, translations_en: dict
|
||||||
|
) -> None:
|
||||||
|
"""Both string files name the staleness field and its error."""
|
||||||
|
data = strings["options"]["step"]["init"]["data"]
|
||||||
|
assert data.get(CONF_SNAPSHOT_STALENESS), (
|
||||||
|
f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'"
|
||||||
|
)
|
||||||
|
assert strings["options"]["errors"]["invalid_staleness"]
|
||||||
|
assert translations_en["options"] == strings["options"]
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
"""Feature 09 permission test matrix across every registered service."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from homeassistant.exceptions import Unauthorized
|
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
|
||||||
from custom_components.nestquest.const import (
|
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
|
||||||
DOMAIN,
|
|
||||||
DOMAIN_SERVICES,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
)
|
|
||||||
from custom_components.nestquest.service_policy import (
|
|
||||||
ADMIN_ONLY,
|
|
||||||
OPEN,
|
|
||||||
SERVICE_POLICY,
|
|
||||||
)
|
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
NON_ADMIN_ID = "non-admin"
|
|
||||||
NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID}
|
|
||||||
KIOSK_ID = "kiosk-panel"
|
|
||||||
KIOSK_CTX = {"user_id": KIOSK_ID}
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_registered_service_is_in_the_operation_registry() -> None:
|
|
||||||
"""The registry is total: a service outside it could never be gated."""
|
|
||||||
missing = [
|
|
||||||
service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY
|
|
||||||
]
|
|
||||||
extra = [
|
|
||||||
service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES
|
|
||||||
]
|
|
||||||
assert not missing, f"registered but ungated: {missing}"
|
|
||||||
assert not extra, f"gated but never registered: {extra}"
|
|
||||||
assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY}
|
|
||||||
|
|
||||||
|
|
||||||
async def _setup(hass, make_entry, *, admin=True):
|
|
||||||
data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {}
|
|
||||||
entry = wire_entry_to_registry(make_entry(data=data), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
async def _seed(hass, entry) -> dict:
|
|
||||||
"""Seed one child, one definition, and materialized instances."""
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
{
|
|
||||||
"title": "Brush teeth",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [(await list_children(database))[0].id],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
child = (await list_children(database))[0]
|
|
||||||
today = datetime.date.today().isoformat()
|
|
||||||
end = (
|
|
||||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child.id, today, end
|
|
||||||
)
|
|
||||||
assert instances
|
|
||||||
from custom_components.nestquest.dao_presence import (
|
|
||||||
PresenceOverridesDao,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"child_id": child.id,
|
|
||||||
"instance_id": instances[0].id,
|
|
||||||
"definition_id": instances[0].definition_id,
|
|
||||||
"override": await PresenceOverridesDao(database).create(
|
|
||||||
child.id, "2027-01-01", "2027-01-02", False
|
|
||||||
),
|
|
||||||
"today": today,
|
|
||||||
"database": database,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_success_payload(service: str, ctx: dict) -> dict | None:
|
|
||||||
"""A schema-valid, end-to-end-successful payload per admin service."""
|
|
||||||
if service == SERVICE_UNCOMPLETE_QUEST:
|
|
||||||
return {"instance_id": ctx["instance_id"], "actor": "user"}
|
|
||||||
if service == SERVICE_CREATE_QUEST_DEFINITION:
|
|
||||||
return {
|
|
||||||
"title": "Second quest",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [ctx["child_id"]],
|
|
||||||
"windows": ["afternoon"],
|
|
||||||
}
|
|
||||||
if service == SERVICE_UPDATE_QUEST_DEFINITION:
|
|
||||||
return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"}
|
|
||||||
if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE:
|
|
||||||
return {"definition_id": ctx["definition_id"], "is_active": False}
|
|
||||||
if service == SERVICE_SET_PRESENCE_PATTERN:
|
|
||||||
return {
|
|
||||||
"child_id": ctx["child_id"],
|
|
||||||
"cycle_length_weeks": 1,
|
|
||||||
"anchor_date": ctx["today"],
|
|
||||||
"pattern": {0: [0, 1, 2, 3, 4, 5, 6]},
|
|
||||||
}
|
|
||||||
if service == SERVICE_CREATE_PRESENCE_OVERRIDE:
|
|
||||||
return {
|
|
||||||
"child_id": ctx["child_id"],
|
|
||||||
"start_date": "2027-03-01",
|
|
||||||
"end_date": "2027-03-02",
|
|
||||||
"is_present": False,
|
|
||||||
}
|
|
||||||
if service == SERVICE_DELETE_PRESENCE_OVERRIDE:
|
|
||||||
return {"override_id": ctx["override"].id}
|
|
||||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
|
||||||
return None # raises HomeAssistantError (Feature 13) — not Unauthorized
|
|
||||||
if service == SERVICE_MANAGE_CHILD:
|
|
||||||
return {"action": "create", "display_name": "Bo"}
|
|
||||||
if service == SERVICE_REGENERATE:
|
|
||||||
return {}
|
|
||||||
raise AssertionError(f"unexpected service {service}")
|
|
||||||
|
|
||||||
|
|
||||||
_ADMIN_ONLY = [
|
|
||||||
service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
|
||||||
async def test_admin_succeeds_on_every_admin_only_service(
|
|
||||||
hass, make_entry, service
|
|
||||||
) -> None:
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
|
||||||
# The gate passes; the Feature 13 stub raises its "not
|
|
||||||
# implemented" HomeAssistantError — NOT Unauthorized.
|
|
||||||
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, service, None, context=ADMIN_CTX
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
|
||||||
async def test_non_admin_refused_on_every_admin_only_service(
|
|
||||||
hass, make_entry, service
|
|
||||||
) -> None:
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
if service != SERVICE_EXPORT_HISTORY_CSV:
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
service,
|
|
||||||
_admin_success_payload(service, ctx),
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
service,
|
|
||||||
_admin_success_payload(service, ctx),
|
|
||||||
context=NON_ADMIN_CTX,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None:
|
|
||||||
from custom_components.nestquest.completion import instance_state
|
|
||||||
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
{
|
|
||||||
"instance_id": ctx["instance_id"],
|
|
||||||
"actor": "panel",
|
|
||||||
"actor_child_id": ctx["child_id"],
|
|
||||||
},
|
|
||||||
context=NON_ADMIN_CTX,
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await instance_state(ctx["database"], ctx["instance_id"]) == "done"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_kiosk_refused_on_uncomplete_and_regenerate(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
from custom_components.nestquest.admin_allowlist import list_admin_ids
|
|
||||||
|
|
||||||
assert KIOSK_ID not in await list_admin_ids(ctx["database"])
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
{"instance_id": ctx["instance_id"], "actor": "user"},
|
|
||||||
context=KIOSK_CTX,
|
|
||||||
)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX
|
|
||||||
)
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""Tests for the Feature 09 permission gate's audit logging."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from homeassistant.exceptions import Unauthorized
|
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
|
||||||
from custom_components.nestquest.const import (
|
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
)
|
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _set_up(hass, make_entry):
|
|
||||||
entry = wire_entry_to_registry(
|
|
||||||
make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry
|
|
||||||
)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
async def test_denied_call_logs_warning_with_fields(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context={"user_id": "kiosk-panel"},
|
|
||||||
)
|
|
||||||
denials = [
|
|
||||||
record
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.WARNING
|
|
||||||
and "denied" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert len(denials) == 1
|
|
||||||
message = denials[0].getMessage()
|
|
||||||
assert SERVICE_MANAGE_CHILD in message
|
|
||||||
assert "kiosk-panel" in message
|
|
||||||
assert _TIMESTAMP.search(message), message
|
|
||||||
|
|
||||||
|
|
||||||
async def test_denied_no_context_logs_user_none(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
)
|
|
||||||
denials = [
|
|
||||||
record
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.WARNING
|
|
||||||
and "denied" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert len(denials) == 1
|
|
||||||
message = denials[0].getMessage()
|
|
||||||
assert "none" in message
|
|
||||||
assert _TIMESTAMP.search(message), message
|
|
||||||
|
|
||||||
|
|
||||||
async def test_successful_admin_call_logs_debug_with_fields(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
caplog.set_level(logging.DEBUG, logger="custom_components.nestquest")
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
allowed = [
|
|
||||||
record
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.DEBUG
|
|
||||||
and "allowed" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert len(allowed) == 1
|
|
||||||
message = allowed[0].getMessage()
|
|
||||||
assert SERVICE_MANAGE_CHILD in message
|
|
||||||
assert ADMIN_ID in message
|
|
||||||
assert _TIMESTAMP.search(message), message
|
|
||||||
|
|
||||||
|
|
||||||
async def test_denied_log_carries_no_personal_data_beyond_user_id(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
"""The log line names the caller's user id only: the payload's
|
|
||||||
display name must not leak into the audit trail."""
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context={"user_id": "kiosk-panel"},
|
|
||||||
)
|
|
||||||
denials = [
|
|
||||||
record.getMessage()
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.WARNING and "denied" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert denials, "expected a denial log line"
|
|
||||||
assert "Ada" not in denials[0]
|
|
||||||
assert "display_name" not in denials[0]
|
|
||||||
+21
-14
@@ -221,19 +221,23 @@ async def test_due_today_attributes_carry_the_panel_payload(
|
|||||||
assert bo_instance["on_time"] is None
|
assert bo_instance["on_time"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
|
async def test_due_today_payload_omits_missed_admin_equals_panel(
|
||||||
hass, make_entry, monkeypatch
|
hass, make_entry, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""D-009: the panel payload omits missed instances, the admin one keeps them.
|
"""D-009: the panel payload omits missed instances.
|
||||||
|
|
||||||
A missed instance is derived (``due_date`` before the snapshot's
|
Since Feature 18 the snapshot comes from the API panel route,
|
||||||
"today"), never stored. The coordinator's day listing is keyed on
|
which omits missed rows entirely (the admin surface for missed
|
||||||
``_local_now``'s date, so pinning the clock forward would move the
|
quests is the PWA, reading the API directly). The entity payload
|
||||||
listing window off today's rows entirely; the pinned FUTURE clock
|
therefore cannot carry missed rows any more: ``admin_instances``
|
||||||
is therefore applied at the ``derive_state`` boundary instead —
|
equals ``instances`` — open and completed rows only. A missed
|
||||||
today's open instances then read as missed through the same
|
instance is still derived locally (``due_date`` before the
|
||||||
derivation rule, without back-dating instance rows past the DAO's
|
snapshot's "today"); the pinned-FUTURE clock at the
|
||||||
no-past guard or touching append-only completion events.
|
``derive_state`` boundary makes today's open instances read
|
||||||
|
missed through the same derivation rule, and this test pins the
|
||||||
|
change: neither payload carries them, and the count rollups
|
||||||
|
(precomputed server-side from the full day) are unaffected here
|
||||||
|
because the API counts arrive per the route's own build.
|
||||||
"""
|
"""
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
@@ -256,10 +260,13 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
|
|||||||
assert attributes["instances"] == [], (
|
assert attributes["instances"] == [], (
|
||||||
"missed instances are omitted from the panel payload"
|
"missed instances are omitted from the panel payload"
|
||||||
)
|
)
|
||||||
[missed] = attributes["admin_instances"]
|
assert attributes["admin_instances"] == [], (
|
||||||
assert missed["state"] == "missed"
|
"the API payload omits missed rows, so the admin payload "
|
||||||
assert missed["completed_at"] is None
|
"carries none either (the PWA is the admin surface now)"
|
||||||
# Counts still roll the missed instance up as not-completed.
|
)
|
||||||
|
# Counts still roll the missed instance up as not-completed: the
|
||||||
|
# counts arrive precomputed from the route's own build, which rolls
|
||||||
|
# the FULL day (the missed row included) into the numbers.
|
||||||
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
|
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
|
||||||
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
|
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Tests for the Feature 09 admin-only versus open service registry."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from custom_components.nestquest.const import (
|
|
||||||
DOMAIN_SERVICES,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
)
|
|
||||||
from custom_components.nestquest.service_policy import (
|
|
||||||
ADMIN_ONLY,
|
|
||||||
OPEN,
|
|
||||||
SERVICE_POLICY,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_covers_exactly_the_registered_services() -> None:
|
|
||||||
assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_complete_quest_is_open() -> None:
|
|
||||||
open_services = {
|
|
||||||
name for name, access in SERVICE_POLICY.items() if access == OPEN
|
|
||||||
}
|
|
||||||
assert open_services == {SERVICE_COMPLETE_QUEST}
|
|
||||||
assert all(
|
|
||||||
access == ADMIN_ONLY
|
|
||||||
for name, access in SERVICE_POLICY.items()
|
|
||||||
if name != SERVICE_COMPLETE_QUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_values_are_only_open_or_admin_only() -> None:
|
|
||||||
assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY}
|
|
||||||
+40
-472
@@ -1,4 +1,13 @@
|
|||||||
"""Tests for NestQuest service declarations and domain-global handlers."""
|
"""Tests for NestQuest service declarations and domain-global handlers.
|
||||||
|
|
||||||
|
Since Feature 18 the integration is a client of the NestQuest API
|
||||||
|
service: the only HA service left is the panel completion path
|
||||||
|
(``nestquest.complete_quest``, a proxy to the API's panel complete
|
||||||
|
route). Every admin service was removed — the admin surface is the
|
||||||
|
PWA and the API service's admin routes — so these tests pin the
|
||||||
|
shrunk surface: services.yaml declares nothing else, setup registers
|
||||||
|
nothing else, and no removed admin service name may come back.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -6,27 +15,16 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DOMAIN_SERVICES,
|
DOMAIN_SERVICES,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY
|
|
||||||
|
|
||||||
SERVICES_PATH = (
|
SERVICES_PATH = (
|
||||||
Path(__file__).parent.parent
|
Path(__file__).parent.parent
|
||||||
@@ -35,21 +33,27 @@ SERVICES_PATH = (
|
|||||||
/ "services.yaml"
|
/ "services.yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: The admin services removed in Feature 18. None of these names may
|
||||||
|
#: be registered as an HA service again — the admin surface is the PWA
|
||||||
|
#: and the API service's admin routes.
|
||||||
|
REMOVED_ADMIN_SERVICES = (
|
||||||
|
"uncomplete_quest",
|
||||||
|
"create_quest_definition",
|
||||||
|
"update_quest_definition",
|
||||||
|
"set_quest_definition_active",
|
||||||
|
"set_presence_pattern",
|
||||||
|
"create_presence_override",
|
||||||
|
"delete_presence_override",
|
||||||
|
"export_history_csv",
|
||||||
|
"manage_child",
|
||||||
|
"regenerate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(entry, registry)
|
return wire_entry_to_registry(entry, registry)
|
||||||
|
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_admin_entry(make_entry):
|
|
||||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
|
||||||
|
|
||||||
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
|
|
||||||
|
|
||||||
|
|
||||||
def _service_section(text: str, name: str) -> str:
|
def _service_section(text: str, name: str) -> str:
|
||||||
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
||||||
assert match is not None, f"services.yaml must declare {name!r}"
|
assert match is not None, f"services.yaml must declare {name!r}"
|
||||||
@@ -77,42 +81,33 @@ def test_services_yaml_declares_every_canonical_service() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_declares_regenerate() -> None:
|
def test_services_yaml_no_longer_declares_removed_admin_services() -> None:
|
||||||
|
"""The admin services are gone from the HA service surface."""
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
for name in REMOVED_ADMIN_SERVICES:
|
||||||
"services.yaml must declare the 'regenerate' service"
|
assert not re.search(rf"^{name}:\s*$", text, re.MULTILINE), (
|
||||||
)
|
f"removed admin service {name!r} must not be declared"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
def test_services_yaml_canonical_services_declare_fields() -> None:
|
||||||
"""The regenerate service is documented with a clear description and no
|
|
||||||
required fields (there is no ``fields`` block under it)."""
|
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
|
||||||
section = _service_section(text, "regenerate")
|
|
||||||
assert re.search(r"description:", section), (
|
|
||||||
"regenerate must carry a description"
|
|
||||||
)
|
|
||||||
assert "fields:" not in section, (
|
|
||||||
"regenerate declares no required fields"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_other_services_declare_fields() -> None:
|
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
for name in DOMAIN_SERVICES:
|
for name in DOMAIN_SERVICES:
|
||||||
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
|
|
||||||
continue
|
|
||||||
section = _service_section(text, name)
|
section = _service_section(text, name)
|
||||||
assert "fields:" in section, f"{name} must declare fields"
|
assert "fields:" in section, f"{name} must declare fields"
|
||||||
|
|
||||||
|
|
||||||
async def test_all_canonical_services_registered_after_setup(
|
async def test_only_complete_quest_registered_after_setup(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Setup registers exactly the panel completion service — and no
|
||||||
|
removed admin service under any name."""
|
||||||
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
|
||||||
for service in DOMAIN_SERVICES:
|
for service in DOMAIN_SERVICES:
|
||||||
assert hass.services.has_service(DOMAIN, service), service
|
assert hass.services.has_service(DOMAIN, service), service
|
||||||
|
for service in REMOVED_ADMIN_SERVICES:
|
||||||
|
assert not hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
|
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
|
||||||
@@ -138,271 +133,6 @@ async def test_unload_one_of_two_entries_keeps_services(
|
|||||||
assert not hass.services.has_service(DOMAIN, service), service
|
assert not hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
async def test_manage_child_create_persists_child(hass, make_entry) -> None:
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
children = await list_children(entry.runtime_data.database)
|
|
||||||
assert [child.display_name for child in children] == ["Ada"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_and_set_quest_definition_active(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
from custom_components.nestquest.quest_definitions import (
|
|
||||||
list_active_definitions,
|
|
||||||
)
|
|
||||||
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
|
|
||||||
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
child = (await list_children(entry.runtime_data.database))[0]
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
{
|
|
||||||
"title": "Brush teeth",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [child.id],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
active = await list_active_definitions(entry.runtime_data.database)
|
|
||||||
assert len(active) == 1
|
|
||||||
definition_id = active[0].definition.id
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
{"definition_id": definition_id, "is_active": False},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
assert await list_active_definitions(entry.runtime_data.database) == []
|
|
||||||
stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
|
|
||||||
definition_id
|
|
||||||
)
|
|
||||||
assert stored is not None
|
|
||||||
assert stored.is_active is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_complete_and_uncomplete_quest_call_business_layer(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
from custom_components.nestquest.completion import instance_state
|
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
child = (await list_children(entry.runtime_data.database))[0]
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
{
|
|
||||||
"title": "Brush teeth",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [child.id],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
today = datetime.date.today().isoformat()
|
|
||||||
end = (
|
|
||||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
records = await QuestInstancesDao(
|
|
||||||
entry.runtime_data.database
|
|
||||||
).list_by_date_range(child.id, today, end)
|
|
||||||
assert records
|
|
||||||
instance_id = records[0].id
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
{
|
|
||||||
"instance_id": instance_id,
|
|
||||||
"actor": "panel",
|
|
||||||
"actor_child_id": child.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await instance_state(entry.runtime_data.database, instance_id)
|
|
||||||
== "done"
|
|
||||||
)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
{
|
|
||||||
"instance_id": instance_id,
|
|
||||||
"actor": "user",
|
|
||||||
"actor_user_id": ADMIN_ID,
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await instance_state(entry.runtime_data.database, instance_id)
|
|
||||||
== "open"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_set_presence_pattern_regenerates_child_instances(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
child = (await list_children(entry.runtime_data.database))[0]
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
{
|
|
||||||
"title": "Brush teeth",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [child.id],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
today = datetime.date.today().isoformat()
|
|
||||||
end = (
|
|
||||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
dao = QuestInstancesDao(entry.runtime_data.database)
|
|
||||||
assert await dao.list_by_date_range(child.id, today, end)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
{
|
|
||||||
"child_id": child.id,
|
|
||||||
"cycle_length_weeks": 1,
|
|
||||||
"anchor_date": today,
|
|
||||||
"pattern": {0: []},
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
assert await dao.list_by_date_range(child.id, today, end) == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_presence_override_create_and_delete_regenerate(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
from custom_components.nestquest.dao_presence import PresenceOverridesDao
|
|
||||||
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
child = (await list_children(entry.runtime_data.database))[0]
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
{
|
|
||||||
"title": "Brush teeth",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [child.id],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
today = datetime.date.today()
|
|
||||||
end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
dao = QuestInstancesDao(entry.runtime_data.database)
|
|
||||||
before = await dao.list_by_date_range(
|
|
||||||
child.id, today.isoformat(), end.isoformat()
|
|
||||||
)
|
|
||||||
assert before
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
{
|
|
||||||
"child_id": child.id,
|
|
||||||
"start_date": today.isoformat(),
|
|
||||||
"end_date": end.isoformat(),
|
|
||||||
"is_present": False,
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await dao.list_by_date_range(
|
|
||||||
child.id, today.isoformat(), end.isoformat()
|
|
||||||
)
|
|
||||||
== []
|
|
||||||
)
|
|
||||||
override = (
|
|
||||||
await PresenceOverridesDao(
|
|
||||||
entry.runtime_data.database
|
|
||||||
).list_by_child_and_range(
|
|
||||||
child.id, today.isoformat(), end.isoformat()
|
|
||||||
)
|
|
||||||
)[0]
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
{"override_id": override.id},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
after = await dao.list_by_date_range(
|
|
||||||
child.id, today.isoformat(), end.isoformat()
|
|
||||||
)
|
|
||||||
assert len(after) == len(before)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_export_history_csv_raises_home_assistant_error(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
|
async def test_invalid_schema_data_surfaces_as_error(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
|
||||||
@@ -414,168 +144,6 @@ async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
|
|||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
|
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
|
||||||
)
|
)
|
||||||
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None:
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context={"user_id": "kiosk-panel"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_only_service_denies_missing_user_context(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_only_service_denies_admin_on_empty_allowlist(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""Even the allowlisted id is denied when the allowlist is empty."""
|
|
||||||
entry = _wire(make_entry(), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_unauthorized_is_raised_before_handler_runs(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context={"user_id": "kiosk-panel"},
|
|
||||||
)
|
|
||||||
assert await list_children(entry.runtime_data.database) == []
|
|
||||||
|
|
||||||
|
|
||||||
_MINIMAL_PAYLOADS = {
|
|
||||||
"uncomplete_quest": {"instance_id": 1, "actor": "user"},
|
|
||||||
"create_quest_definition": {
|
|
||||||
"title": "T",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [1],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
"update_quest_definition": {"definition_id": 1},
|
|
||||||
"set_quest_definition_active": {"definition_id": 1, "is_active": True},
|
|
||||||
"set_presence_pattern": {
|
|
||||||
"child_id": 1,
|
|
||||||
"cycle_length_weeks": 1,
|
|
||||||
"anchor_date": "2026-09-19",
|
|
||||||
"pattern": {0: [0]},
|
|
||||||
},
|
|
||||||
"create_presence_override": {
|
|
||||||
"child_id": 1,
|
|
||||||
"start_date": "2026-09-19",
|
|
||||||
"end_date": "2026-09-19",
|
|
||||||
"is_present": False,
|
|
||||||
},
|
|
||||||
"delete_presence_override": {"override_id": 1},
|
|
||||||
"export_history_csv": {},
|
|
||||||
"manage_child": {"action": "create", "display_name": "Ada"},
|
|
||||||
"regenerate": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ADMIN_ONLY_SERVICES = [
|
|
||||||
name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES)
|
|
||||||
async def test_admin_only_service_fails_closed_without_user_context(
|
|
||||||
hass, make_entry, service
|
|
||||||
) -> None:
|
|
||||||
"""A call with no user_id is denied on every admin-only service."""
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, service, dict(_MINIMAL_PAYLOADS[service])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_complete_quest_proceeds_without_user_context(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""The open service needs no user context: the kiosk panel taps a
|
|
||||||
child profile and the call goes through with no HA user at all."""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
from custom_components.nestquest.completion import instance_state
|
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
child = (await list_children(entry.runtime_data.database))[0]
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
{
|
|
||||||
"title": "Brush teeth",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [child.id],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
today = datetime.date.today().isoformat()
|
|
||||||
end = (
|
|
||||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
instance_id = (
|
|
||||||
await QuestInstancesDao(
|
|
||||||
entry.runtime_data.database
|
|
||||||
).list_by_date_range(child.id, today, end)
|
|
||||||
)[0].id
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await instance_state(entry.runtime_data.database, instance_id)
|
|
||||||
== "done"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_scheduled_internals_never_call_the_service_layer(
|
async def test_scheduled_internals_never_call_the_service_layer(
|
||||||
@@ -590,4 +158,4 @@ async def test_scheduled_internals_never_call_the_service_layer(
|
|||||||
assert "hass.services.call" not in source, (
|
assert "hass.services.call" not in source, (
|
||||||
"scheduled internals must call the materialization directly, "
|
"scheduled internals must call the materialization directly, "
|
||||||
"never through hass.services.call"
|
"never through hass.services.call"
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"""Tests for the panel SSE subscription (HA-bus re-firing task).
|
||||||
|
|
||||||
|
Three layers are exercised, all STUB-driven — no network, no aiohttp,
|
||||||
|
no real Home Assistant:
|
||||||
|
|
||||||
|
- The API client's ``stream_events`` (transport stub): what goes on
|
||||||
|
the wire (GET, the events URL, the Bearer header), SSE frame
|
||||||
|
parsing (``event:``/``data:`` lines, blank-line frame ends, chunk
|
||||||
|
boundaries, multi-line data), and every failure mode (non-2xx,
|
||||||
|
connection failure, mid-stream drop, malformed frame) surfacing as
|
||||||
|
the typed :class:`NestQuestApiError`; cancellation closes the
|
||||||
|
stream cleanly.
|
||||||
|
- The :class:`~custom_components.nestquest.sse.NestQuestEventStream`
|
||||||
|
subscription manager (duck-typed stream client): the four documented
|
||||||
|
transition events (design/ENTITIES-AND-SERVICES.md §3, exactly as
|
||||||
|
``tests/test_api_sse.py`` observes them on the API side) fire on
|
||||||
|
``hass.bus`` with the documented payload fields; ``stop()`` cancels
|
||||||
|
the task so no frame fires afterwards; a stream failure reconnects
|
||||||
|
with a bounded, reset-on-success backoff and never crashes the
|
||||||
|
task.
|
||||||
|
- The integration wiring: setup starts the stream alongside the
|
||||||
|
runtime record, unload tears it down (no firing afterwards, task
|
||||||
|
cancelled), and an entry whose client has no stream surface (the
|
||||||
|
unconfigured-token stub, the test snapshot client) starts none.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import set_coordinator_client, wire_entry_to_registry
|
||||||
|
|
||||||
|
import custom_components.nestquest as nq_integration
|
||||||
|
from custom_components.nestquest import (
|
||||||
|
async_setup_entry,
|
||||||
|
async_unload_entry,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest import sse as sse_module
|
||||||
|
from custom_components.nestquest.api_client import (
|
||||||
|
NestQuestApiClient,
|
||||||
|
NestQuestApiError,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.sse import NestQuestEventStream
|
||||||
|
|
||||||
|
BASE_URL = "http://api.test:8000"
|
||||||
|
TOKEN = "test-panel-token"
|
||||||
|
|
||||||
|
# Captured BEFORE any test patches asyncio.sleep (the reconnect test
|
||||||
|
# swaps in a recording fake; this reference keeps helpers sleeping for
|
||||||
|
# real regardless).
|
||||||
|
_REAL_SLEEP = asyncio.sleep
|
||||||
|
|
||||||
|
#: The four documented transition events with their documented payload
|
||||||
|
#: fields — the exact shapes the API side streams (test_api_sse.py).
|
||||||
|
DOCUMENTED_FRAMES = (
|
||||||
|
(
|
||||||
|
"nestquest_quest_completed",
|
||||||
|
{
|
||||||
|
"child_id": 1,
|
||||||
|
"child_name": "Ada",
|
||||||
|
"instance_id": 7,
|
||||||
|
"quest_title": "Pack bag",
|
||||||
|
"window": "morning",
|
||||||
|
"due_date": "2026-09-23",
|
||||||
|
"due_time": "10:00",
|
||||||
|
"occurred_at": "2026-09-23T12:00:00+00:00",
|
||||||
|
"was_on_time": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"nestquest_quest_uncompleted",
|
||||||
|
{
|
||||||
|
"child_id": 1,
|
||||||
|
"child_name": "Ada",
|
||||||
|
"instance_id": 7,
|
||||||
|
"quest_title": "Pack bag",
|
||||||
|
"window": "morning",
|
||||||
|
"due_date": "2026-09-23",
|
||||||
|
"due_time": "10:00",
|
||||||
|
"occurred_at": "2026-09-23T12:00:00+00:00",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"nestquest_quest_missed",
|
||||||
|
{
|
||||||
|
"child_id": 1,
|
||||||
|
"child_name": "Ada",
|
||||||
|
"instance_id": 9,
|
||||||
|
"quest_title": "Stale chore",
|
||||||
|
"window": "morning",
|
||||||
|
"due_date": "2026-09-23",
|
||||||
|
"due_time": "09:00",
|
||||||
|
"occurred_at": "2026-09-23T23:30:00+00:00",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"nestquest_child_day_complete",
|
||||||
|
{
|
||||||
|
"child_id": 1,
|
||||||
|
"child_name": "Ada",
|
||||||
|
"quests_due": 1,
|
||||||
|
"quests_completed": 1,
|
||||||
|
"occurred_at": "2026-09-23T12:00:00+00:00",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The documented payload field sets per event type.
|
||||||
|
DOCUMENTED_FIELDS = {
|
||||||
|
"nestquest_quest_completed": {
|
||||||
|
"child_id",
|
||||||
|
"child_name",
|
||||||
|
"instance_id",
|
||||||
|
"quest_title",
|
||||||
|
"window",
|
||||||
|
"due_date",
|
||||||
|
"due_time",
|
||||||
|
"occurred_at",
|
||||||
|
"was_on_time",
|
||||||
|
},
|
||||||
|
"nestquest_quest_uncompleted": {
|
||||||
|
"child_id",
|
||||||
|
"child_name",
|
||||||
|
"instance_id",
|
||||||
|
"quest_title",
|
||||||
|
"window",
|
||||||
|
"due_date",
|
||||||
|
"due_time",
|
||||||
|
"occurred_at",
|
||||||
|
},
|
||||||
|
"nestquest_quest_missed": {
|
||||||
|
"child_id",
|
||||||
|
"child_name",
|
||||||
|
"instance_id",
|
||||||
|
"quest_title",
|
||||||
|
"window",
|
||||||
|
"due_date",
|
||||||
|
"due_time",
|
||||||
|
"occurred_at",
|
||||||
|
},
|
||||||
|
"nestquest_child_day_complete": {
|
||||||
|
"child_id",
|
||||||
|
"child_name",
|
||||||
|
"quests_due",
|
||||||
|
"quests_completed",
|
||||||
|
"occurred_at",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#: A minimal panel snapshot payload for the full-setup wiring tests
|
||||||
|
#: (the shape ``snapshot_from_api_payload`` consumes; no children).
|
||||||
|
MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stub transports and stream clients (no network anywhere)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamingResponse:
|
||||||
|
"""A fake aiohttp streaming response: ``status`` + chunk iteration.
|
||||||
|
|
||||||
|
``chunks`` are the body pieces the stream yields in order; a chunk
|
||||||
|
may be an exception, raised when the iteration reaches it (a
|
||||||
|
mid-stream transport drop). ``hold_open`` keeps the stream open
|
||||||
|
forever after the chunks run out, for cancellation tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
|
||||||
|
self.status = status
|
||||||
|
self._chunks = list(chunks)
|
||||||
|
self._hold_open = hold_open
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self._iterate()
|
||||||
|
|
||||||
|
async def _iterate(self):
|
||||||
|
for chunk in self._chunks:
|
||||||
|
if isinstance(chunk, BaseException):
|
||||||
|
raise chunk
|
||||||
|
yield chunk
|
||||||
|
if self._hold_open:
|
||||||
|
await asyncio.Event().wait() # cancelled, never ends
|
||||||
|
|
||||||
|
|
||||||
|
class _RequestContext:
|
||||||
|
"""Async context manager mirroring ``session.request(...)``'s return.
|
||||||
|
|
||||||
|
Entering performs the request (a scripted transport error raises
|
||||||
|
THERE, where aiohttp's connector raises on a refused connection);
|
||||||
|
exiting marks the response closed, so tests can assert the stream
|
||||||
|
was released.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, response, error) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||||
|
if self._response is not None:
|
||||||
|
self._response.closed = True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamTransport:
|
||||||
|
"""A no-network stand-in for aiohttp's ``ClientSession``.
|
||||||
|
|
||||||
|
Records every ``request(...)`` call and answers from a scripted
|
||||||
|
queue of outcomes (a :class:`_StreamingResponse` or an exception
|
||||||
|
per attempt, consumed in order).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *outcomes) -> None:
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
self._outcomes = list(outcomes)
|
||||||
|
|
||||||
|
def request(self, method: str, url: str, **kwargs):
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"headers": kwargs.get("headers"),
|
||||||
|
"json": kwargs.get("json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
outcome = self._outcomes.pop(0)
|
||||||
|
if isinstance(outcome, BaseException):
|
||||||
|
return _RequestContext(None, outcome)
|
||||||
|
return _RequestContext(outcome, None)
|
||||||
|
|
||||||
|
|
||||||
|
def make_stream_client(transport) -> NestQuestApiClient:
|
||||||
|
"""Build a client over the streaming stub with the test credentials."""
|
||||||
|
return NestQuestApiClient(BASE_URL, TOKEN, transport)
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_text(event_type: str, payload: dict) -> str:
|
||||||
|
"""One documented SSE frame: event line, data line, blank line."""
|
||||||
|
return f"event: {event_type}\ndata: {json.dumps(payload)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
class _ScriptedStreamClient:
|
||||||
|
"""Duck-typed API client: one scripted stream per connection.
|
||||||
|
|
||||||
|
``script`` holds generator FACTORIES — ``stream_events()`` pops the
|
||||||
|
next one and returns its async generator, so a test scripts exactly
|
||||||
|
what each connection attempt yields (or how it fails).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, script) -> None:
|
||||||
|
self._script = list(script)
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def stream_events(self):
|
||||||
|
self.calls += 1
|
||||||
|
return self._script.pop(0)()
|
||||||
|
|
||||||
|
|
||||||
|
def _frames(*frames):
|
||||||
|
"""A live stream factory: yields ``frames``, then stays open."""
|
||||||
|
|
||||||
|
def _factory():
|
||||||
|
async def _gen():
|
||||||
|
for frame in frames:
|
||||||
|
yield frame
|
||||||
|
await asyncio.Event().wait() # cancelled, never ends
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(error: BaseException):
|
||||||
|
"""A stream factory that fails before yielding anything."""
|
||||||
|
|
||||||
|
def _factory():
|
||||||
|
async def _gen():
|
||||||
|
raise error
|
||||||
|
yield # noqa: unreachable — makes _gen an async generator
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_after(error: BaseException, *frames):
|
||||||
|
"""A stream factory that yields ``frames`` then drops mid-stream."""
|
||||||
|
|
||||||
|
def _factory():
|
||||||
|
async def _gen():
|
||||||
|
for frame in frames:
|
||||||
|
yield frame
|
||||||
|
raise error
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_until(predicate, *, timeout: float = 2.0) -> None:
|
||||||
|
"""Poll ``predicate`` until true or the deadline passes."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + timeout
|
||||||
|
while not predicate():
|
||||||
|
if loop.time() > deadline:
|
||||||
|
raise AssertionError("condition not met within the deadline")
|
||||||
|
await _REAL_SLEEP(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager(
|
||||||
|
hass, client, *, min_backoff: float = 0.01, max_backoff: float = 0.05
|
||||||
|
) -> NestQuestEventStream:
|
||||||
|
"""A manager with fast (test-sized) backoff bounds."""
|
||||||
|
return NestQuestEventStream(
|
||||||
|
hass,
|
||||||
|
client,
|
||||||
|
reconnect_min_seconds=min_backoff,
|
||||||
|
reconnect_max_seconds=max_backoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The client's stream_events: parsing and typed failures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None:
|
||||||
|
"""All four documented frames parse, with the Bearer GET on the wire."""
|
||||||
|
body = "".join(
|
||||||
|
_frame_text(event_type, payload)
|
||||||
|
for event_type, payload in DOCUMENTED_FRAMES
|
||||||
|
)
|
||||||
|
transport = _StreamTransport(_StreamingResponse(200, [body]))
|
||||||
|
client = make_stream_client(transport)
|
||||||
|
|
||||||
|
frames = [frame async for frame in client.stream_events()]
|
||||||
|
|
||||||
|
assert frames == list(DOCUMENTED_FRAMES)
|
||||||
|
call = transport.calls[0]
|
||||||
|
assert call["method"] == "GET"
|
||||||
|
assert call["url"] == f"{BASE_URL}/api/v1/panel/events"
|
||||||
|
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
|
||||||
|
assert call["json"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None:
|
||||||
|
"""Chunk-split lines parse, and multi-line data joins per SSE rules."""
|
||||||
|
first_type, first_payload = DOCUMENTED_FRAMES[0]
|
||||||
|
# The first frame is cut MID-LINE across two chunks; the second
|
||||||
|
# frame spreads its JSON over two ``data:`` lines.
|
||||||
|
chunks = [
|
||||||
|
f"event: {first_type}\nda",
|
||||||
|
f"ta: {json.dumps(first_payload)}\n\nevent: "
|
||||||
|
f"{DOCUMENTED_FRAMES[1][0]}\n",
|
||||||
|
f'data: {{"extra":\ndata: true}}\n\n',
|
||||||
|
]
|
||||||
|
transport = _StreamTransport(_StreamingResponse(200, chunks))
|
||||||
|
client = make_stream_client(transport)
|
||||||
|
|
||||||
|
frames = [frame async for frame in client.stream_events()]
|
||||||
|
|
||||||
|
assert len(frames) == 2
|
||||||
|
assert frames[0] == DOCUMENTED_FRAMES[0]
|
||||||
|
# The split-out frame's data spread over two ``data:`` lines joins
|
||||||
|
# with a newline (the SSE standard) and parses as one JSON body.
|
||||||
|
assert frames[1] == (DOCUMENTED_FRAMES[1][0], {"extra": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_events_non_2xx_raises_typed_and_closes() -> None:
|
||||||
|
"""A wrong token (401) is typed with its status; no stream is read."""
|
||||||
|
response = _StreamingResponse(401, ["event: x\ndata: {}\n\n"])
|
||||||
|
transport = _StreamTransport(response)
|
||||||
|
client = make_stream_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
async for _ in client.stream_events():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert excinfo.value.status == 401
|
||||||
|
assert not excinfo.value.is_transport_error
|
||||||
|
assert TOKEN not in str(excinfo.value)
|
||||||
|
assert response.closed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_events_connection_failure_raises_typed_transport_error() -> None:
|
||||||
|
"""A failure before any response is a typed transport error."""
|
||||||
|
transport = _StreamTransport(OSError("connection refused"))
|
||||||
|
client = make_stream_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
async for _ in client.stream_events():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert excinfo.value.status is None
|
||||||
|
assert excinfo.value.is_transport_error
|
||||||
|
assert isinstance(excinfo.value.__cause__, OSError)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_events_midstream_drop_raises_typed() -> None:
|
||||||
|
"""A transport error mid-stream is typed, so the consumer reconnects."""
|
||||||
|
response = _StreamingResponse(
|
||||||
|
200,
|
||||||
|
[_frame_text(DOCUMENTED_FRAMES[0][0], DOCUMENTED_FRAMES[0][1]), OSError("reset")],
|
||||||
|
)
|
||||||
|
transport = _StreamTransport(response)
|
||||||
|
client = make_stream_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
async for _ in client.stream_events():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert excinfo.value.status is None
|
||||||
|
assert excinfo.value.is_transport_error
|
||||||
|
assert isinstance(excinfo.value.__cause__, OSError)
|
||||||
|
assert response.closed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_events_stops_cleanly_on_cancellation() -> None:
|
||||||
|
"""Closing the generator (the consumer task's cancellation) stops
|
||||||
|
cleanly: no error, the underlying response is closed, and nothing
|
||||||
|
further is yielded."""
|
||||||
|
response = _StreamingResponse(
|
||||||
|
200,
|
||||||
|
[_frame_text(DOCUMENTED_FRAMES[0][0], DOCUMENTED_FRAMES[0][1])],
|
||||||
|
hold_open=True,
|
||||||
|
)
|
||||||
|
transport = _StreamTransport(response)
|
||||||
|
client = make_stream_client(transport)
|
||||||
|
|
||||||
|
generator = client.stream_events()
|
||||||
|
first = await asyncio.wait_for(generator.__anext__(), timeout=2.0)
|
||||||
|
assert first == DOCUMENTED_FRAMES[0]
|
||||||
|
|
||||||
|
await asyncio.wait_for(generator.aclose(), timeout=2.0)
|
||||||
|
assert response.closed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_events_malformed_frame_raises_typed() -> None:
|
||||||
|
"""A frame whose data is not JSON is a typed protocol failure."""
|
||||||
|
response = _StreamingResponse(
|
||||||
|
200, ["event: nestquest_quest_completed\ndata: not-json\n\n"]
|
||||||
|
)
|
||||||
|
transport = _StreamTransport(response)
|
||||||
|
client = make_stream_client(transport)
|
||||||
|
|
||||||
|
with pytest.raises(NestQuestApiError) as excinfo:
|
||||||
|
async for _ in client.stream_events():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert excinfo.value.status == 200
|
||||||
|
assert response.closed
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The subscription manager: firing, teardown, reconnect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_fires_documented_frames_on_ha_bus(hass) -> None:
|
||||||
|
"""Every documented transition fires on hass.bus with its payload."""
|
||||||
|
client = _ScriptedStreamClient([_frames(*DOCUMENTED_FRAMES)])
|
||||||
|
manager = make_manager(hass, client)
|
||||||
|
manager.start()
|
||||||
|
try:
|
||||||
|
await _wait_until(lambda: len(hass.bus.events) >= 4)
|
||||||
|
assert hass.bus.events == [tuple(f) for f in DOCUMENTED_FRAMES]
|
||||||
|
for event_type, payload in DOCUMENTED_FRAMES:
|
||||||
|
fired = hass.bus.fired(event_type)
|
||||||
|
assert fired, f"{event_type} never fired"
|
||||||
|
assert set(fired[-1]) == DOCUMENTED_FIELDS[event_type]
|
||||||
|
finally:
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_stop_cancels_task_and_blocks_later_frames(hass) -> None:
|
||||||
|
"""stop() cancels and awaits the task: no frame fires afterwards,
|
||||||
|
even when the (dead) stream is later offered more data."""
|
||||||
|
stream_release = asyncio.Event()
|
||||||
|
first_type, first_payload = DOCUMENTED_FRAMES[0]
|
||||||
|
later_type, later_payload = DOCUMENTED_FRAMES[1]
|
||||||
|
|
||||||
|
def _factory():
|
||||||
|
async def _gen():
|
||||||
|
yield (first_type, dict(first_payload))
|
||||||
|
await stream_release.wait()
|
||||||
|
yield (later_type, dict(later_payload))
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
client = _ScriptedStreamClient([_factory])
|
||||||
|
manager = make_manager(hass, client)
|
||||||
|
manager.start()
|
||||||
|
await _wait_until(lambda: len(hass.bus.events) == 1)
|
||||||
|
|
||||||
|
task = manager.task
|
||||||
|
await manager.stop()
|
||||||
|
assert task.cancelled()
|
||||||
|
assert manager.task is None
|
||||||
|
|
||||||
|
# Offer the stream more data after teardown: nothing fires.
|
||||||
|
stream_release.set()
|
||||||
|
await _REAL_SLEEP(0.05)
|
||||||
|
assert hass.bus.events == [(first_type, dict(first_payload))]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_reconnects_with_bounded_backoff(hass, monkeypatch) -> None:
|
||||||
|
"""A failed stream reconnects with a bounded, reset-on-success
|
||||||
|
backoff and never crashes the task."""
|
||||||
|
delays: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(delay, *args, **kwargs):
|
||||||
|
delays.append(delay)
|
||||||
|
await _REAL_SLEEP(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sse_module.asyncio, "sleep", fake_sleep)
|
||||||
|
|
||||||
|
stream_error = ConnectionError("stream reset")
|
||||||
|
client = _ScriptedStreamClient(
|
||||||
|
[
|
||||||
|
_fail(RuntimeError("api down")),
|
||||||
|
_fail(RuntimeError("api down")),
|
||||||
|
_fail(RuntimeError("api down")),
|
||||||
|
_fail(RuntimeError("api down")),
|
||||||
|
_drop_after(stream_error, DOCUMENTED_FRAMES[0]),
|
||||||
|
_frames(*DOCUMENTED_FRAMES),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
manager = make_manager(hass, client, min_backoff=0.05, max_backoff=0.2)
|
||||||
|
manager.start()
|
||||||
|
try:
|
||||||
|
await _wait_until(lambda: len(hass.bus.events) >= 5)
|
||||||
|
# 5s → 10s → 20s (cap) → 20s (cap), then a frame on a healthy
|
||||||
|
# stream reset it to the 5s minimum for the next failure.
|
||||||
|
assert delays == [0.05, 0.1, 0.2, 0.2, 0.05]
|
||||||
|
assert hass.bus.events == [
|
||||||
|
tuple(DOCUMENTED_FRAMES[0]),
|
||||||
|
*[tuple(f) for f in DOCUMENTED_FRAMES],
|
||||||
|
]
|
||||||
|
# The task is still alive (subscribed and idle), not crashed.
|
||||||
|
assert manager.task is not None and not manager.task.done()
|
||||||
|
finally:
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_rejects_invalid_backoff_bounds(hass) -> None:
|
||||||
|
"""Non-positive or inverted backoff bounds fail at construction."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NestQuestEventStream(hass, object(), reconnect_min_seconds=0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NestQuestEventStream(hass, object(), reconnect_min_seconds=-1)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NestQuestEventStream(hass, object(), reconnect_max_seconds=0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NestQuestEventStream(hass, object(), reconnect_min_seconds=10.0, reconnect_max_seconds=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration wiring: setup starts the stream, unload stops it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SetupClient:
|
||||||
|
"""Client for full-setup wiring tests: scripted snapshot + stream."""
|
||||||
|
|
||||||
|
def __init__(self, script) -> None:
|
||||||
|
self._script = list(script)
|
||||||
|
self.stream_calls = 0
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> dict:
|
||||||
|
return dict(MINIMAL_SNAPSHOT)
|
||||||
|
|
||||||
|
def stream_events(self):
|
||||||
|
self.stream_calls += 1
|
||||||
|
return self._script.pop(0)()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_starts_stream_and_unload_stops_it(hass, make_entry) -> None:
|
||||||
|
"""Setup starts the subscription alongside the runtime record; unload
|
||||||
|
tears the task down so no frame fires afterwards."""
|
||||||
|
client = _SetupClient([_frames(*DOCUMENTED_FRAMES)])
|
||||||
|
set_coordinator_client(make_entry(), client)
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await nq_integration.async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
assert runtime.event_stream is not None
|
||||||
|
await _wait_until(lambda: len(hass.bus.events) >= 4)
|
||||||
|
assert [event for event, _ in hass.bus.events] == [
|
||||||
|
frame[0] for frame in DOCUMENTED_FRAMES
|
||||||
|
]
|
||||||
|
|
||||||
|
task = runtime.event_stream.task
|
||||||
|
assert await nq_integration.async_unload_entry(hass, entry) is True
|
||||||
|
assert task.cancelled()
|
||||||
|
assert runtime.event_stream.task is None
|
||||||
|
|
||||||
|
events_after_unload = len(hass.bus.events)
|
||||||
|
await _REAL_SLEEP(0.05)
|
||||||
|
assert len(hass.bus.events) == events_after_unload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_without_stream_surface_starts_no_subscription(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""An entry whose API client has no stream surface (the local-snapshot
|
||||||
|
stub the harness installs, like the unconfigured-token stub in
|
||||||
|
production) starts NO subscription — and setup still succeeds."""
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await nq_integration.async_setup_entry(hass, entry) is True
|
||||||
|
assert entry.runtime_data.event_stream is None
|
||||||
|
assert await nq_integration.async_unload_entry(hass, entry) is True
|
||||||
Reference in New Issue
Block a user