Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e7ca37765 |
+21
-129
@@ -1,135 +1,27 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.6.0 — 2026-09-23
|
||||
## Unreleased
|
||||
|
||||
### Scope
|
||||
|
||||
Sixth release of NestQuest, promoting the re-imagined architecture
|
||||
(Features 15, 16, 18 and 20) and the foreground development-orchestration
|
||||
tooling from `dev` onto 0.5.0. This is a major architectural change: the
|
||||
Home Assistant integration becomes a thin client of a standalone NestQuest
|
||||
API service that owns the database.
|
||||
|
||||
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
|
||||
migrations, database access, the DAOs, the pure recurrence/presence
|
||||
engines, materialization, completion, the SSE-free event payload
|
||||
builders, the panel snapshot builder, the children/definitions/allowlist
|
||||
business layers, and an explicit settings object) now lives in a
|
||||
Home-Assistant-free package bundled inside the integration at
|
||||
`custom_components/nestquest/core/`. The SQLite schema and migration
|
||||
versions are unchanged, so an existing `nestquest.db` opens without
|
||||
migration. Covered by a no-Home-Assistant end-to-end smoke test.
|
||||
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
|
||||
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
|
||||
(`api/`) that imports `core/` and is the only writer of the database.
|
||||
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
|
||||
token: household snapshot and an idempotent complete route. Admin plane
|
||||
(`/api/v1/admin/*`): children, quest definitions, presence
|
||||
schedule/override, uncomplete, regenerate, history query + CSV export,
|
||||
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
|
||||
JWKS signature and expiry) plus membership in `nestquest-admins`, with
|
||||
the panel service token rejected outright. A server-sent-events stream
|
||||
publishes the quest-completed, quest-uncompleted, quest-missed and
|
||||
child-day-complete transitions; the missed sweep runs in the API with a
|
||||
cross-process-safe watermark. `/health` and the OpenAPI document are
|
||||
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
|
||||
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
|
||||
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
|
||||
- **Feature 18 — HA Integration as API Client:** the integration no longer
|
||||
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
|
||||
existing sensors (entity ids and per-child instance payload shape
|
||||
unchanged), subscribes to the SSE stream and re-fires the four HA bus
|
||||
events, proxies `nestquest.complete_quest` to the panel complete route,
|
||||
and caches the last-good snapshot so a brief API outage does not blank
|
||||
the panel. The HA admin services and the service permission gate are
|
||||
removed; only the panel completion path remains. (Tasks 37d5e806,
|
||||
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
|
||||
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
|
||||
hand-written card YAML and no manual dashboard creation. The cards
|
||||
auto-discover children from a household entity attribute (ordered by the
|
||||
API's `sort_order`), a Lovelace dashboard strategy generates the
|
||||
party-board view and one quest-log view per child at render time with
|
||||
computed navigation paths, and the integration registers the dashboard
|
||||
on first setup. Navigation and the panel transitions match the design
|
||||
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
|
||||
fc69c940, 67775be9.)
|
||||
- **Development orchestration tooling:** durable OpenCode controller and
|
||||
role launchers, the foreground terminal orchestrator, the independent
|
||||
review/approval gates, and the automatic release-preparation policy.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
- A running NestQuest API service, reachable at the base URL and panel
|
||||
service token configured in the integration options. The API service
|
||||
source ships in `api/`; there is no container image, compose file or
|
||||
deployment runbook in this release, and Feature 17 — the deployment
|
||||
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
|
||||
part of this release and must be completed before the integration is
|
||||
functional. See `docs/ARCHITECTURE.md` for the target design.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **The HA integration no longer owns a database.** It reads exclusively
|
||||
from the NestQuest API. The local `nestquest.db` is no longer opened,
|
||||
migrated or written by the integration (the file is left untouched on
|
||||
disk). Without a reachable API the entities become unavailable after the
|
||||
configured staleness threshold; the panel serves the last-good snapshot
|
||||
in the meantime.
|
||||
- **HA admin services are removed** (`uncomplete`, quest-definition
|
||||
create/update/set-active, presence pattern/override, CSV export,
|
||||
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
|
||||
Admin operations move to the API, and — once Feature 19 lands — the
|
||||
admin PWA. Any automation that called an admin service must be
|
||||
repointed at the API.
|
||||
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
|
||||
the migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` (previously
|
||||
`custom_components.nestquest.migrations`), and the children registry and
|
||||
admin allowlist log under `custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist`. The parent
|
||||
`custom_components.nestquest` logger still catches all three; only users
|
||||
who pinned a leaf logger name by its old spelling must update.
|
||||
- **The panel installs with no configuration.** `child_order`,
|
||||
`quest_log_path` and `board_path` are no longer required (an explicit
|
||||
value still wins). The integration registers a `custom:nestquest-party`
|
||||
dashboard on setup; point TouchHub at it.
|
||||
- **Schema is unchanged at version 7** — no migration runs on upgrade.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Feature 17 (API deployment and identity) is not in this release.** The
|
||||
API service source ships in `api/`, but no container image, compose
|
||||
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
|
||||
group, proven network separation of the panel routes, or backup/restore
|
||||
is included. Feature 17's containerization groundwork was completed on
|
||||
its own feature branch, which is **not** merged into `dev` and therefore
|
||||
is not part of this release. Until that deployment is completed the
|
||||
integration has nothing to talk to.
|
||||
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
|
||||
in 0.6.0; admin operations are reachable only through the API directly.
|
||||
- Moving the household database from Home Assistant to the API box is a
|
||||
manual file copy (the schema is unchanged).
|
||||
- Single household; single API service.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema version is unchanged (7), so no migration is involved in either
|
||||
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
|
||||
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
|
||||
resumes using exactly that file.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
|
||||
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
|
||||
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
|
||||
under `custom_components/nestquest/core/`, with the integration's
|
||||
original module names preserved as re-export shims.
|
||||
- **Logger namespace change (breaking for logging configuration):** the
|
||||
migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` instead of
|
||||
`custom_components.nestquest.migrations`. Users who tuned the old
|
||||
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
|
||||
update the entry to keep that level, as the old name no longer
|
||||
receives migration log output.
|
||||
- **Additional logger namespace moves:** the children registry and the
|
||||
admin allowlist business layers now log under
|
||||
`custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist` respectively
|
||||
(moved with the engines/business-layer extraction, task 510c1f78).
|
||||
The parent `custom_components.nestquest` logger still catches both,
|
||||
so users who tune the parent rather than the leaf names are
|
||||
unaffected; only users who pinned a leaf logger name by its old
|
||||
spelling need to update.
|
||||
|
||||
## Version 0.5.0 — 2026-09-18
|
||||
|
||||
|
||||
@@ -116,22 +116,6 @@ owns every rule so the two routes cannot drift:
|
||||
a ``Content-Disposition`` attachment filename and the documented,
|
||||
stable header row (Admin spec §5 event-row fields).
|
||||
|
||||
The snapshot route (task ef3f4877) is the admin-plane twin of the
|
||||
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
|
||||
the PWA is internet-facing and never holds the panel service token
|
||||
(D-012), so it reads the same snapshot here:
|
||||
|
||||
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
|
||||
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
|
||||
child's instances through
|
||||
:func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=True`` — admin payloads KEEP missed instances with
|
||||
their ``missed`` state (D-009), where the panel omits them. The
|
||||
body is the panel's documented shape (``today_iso``, ``cycle_day``,
|
||||
``children`` with the per-child presence, rollup counts and
|
||||
``instances``); the builder, the state derivation and the shaping
|
||||
all live in the core.
|
||||
|
||||
The settings routes (task 2b3de7e5) follow the same pattern over
|
||||
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
|
||||
store. The API now OWNS settings in the database (a validated JSON
|
||||
@@ -270,7 +254,6 @@ from api.nestquest_core import (
|
||||
core_recurrence,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
core_sweep,
|
||||
)
|
||||
|
||||
@@ -700,59 +683,6 @@ class AdminMissedSweepResponse(BaseModel):
|
||||
fired: int
|
||||
|
||||
|
||||
class AdminInstancePayload(BaseModel):
|
||||
"""One instance in the admin snapshot payload.
|
||||
|
||||
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
|
||||
``missed`` (admin payloads keep missed instances, D-009),
|
||||
``on_time`` passes the completion event's flag through verbatim
|
||||
(``None`` unless completed), and ``completed_at`` is the event's
|
||||
UTC timestamp when completed.
|
||||
"""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_time: str | None
|
||||
state: str
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
on_time: bool | None
|
||||
|
||||
|
||||
class AdminChildSnapshotPayload(BaseModel):
|
||||
"""One child's day: presence, rollup counts, and today's instances."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
present: bool
|
||||
#: ISO date of the child's next present day while they are away;
|
||||
#: ``None`` when the child is present today.
|
||||
next_present: str | None
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
instances: list[AdminInstancePayload]
|
||||
|
||||
|
||||
class AdminSnapshotResponse(BaseModel):
|
||||
"""Today's whole household snapshot for the admin Today tab.
|
||||
|
||||
The panel snapshot's shape (``today_iso``, ``cycle_day``,
|
||||
``children`` in the household's sort order), with missed instances
|
||||
kept in each child's ``instances``.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
children: list[AdminChildSnapshotPayload]
|
||||
|
||||
|
||||
#: 404 detail for a child id the core layer reports as non-existent.
|
||||
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
||||
|
||||
@@ -1799,51 +1729,4 @@ async def admin_run_missed_sweep(
|
||||
return AdminMissedSweepResponse(fired=len(events))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
response_model=AdminSnapshotResponse,
|
||||
)
|
||||
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
||||
"""Return today's full household snapshot for the admin Today tab.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: ONE clock read
|
||||
(:func:`_local_now`) goes into
|
||||
:func:`nestquest_core.snapshot.build_snapshot` against the live
|
||||
database, and each child's instances are shaped with
|
||||
``instance_payload(include_missed=True)`` — missed instances are
|
||||
KEPT in the admin payload (D-009) — into
|
||||
:class:`AdminSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return AdminSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
children=[
|
||||
AdminChildSnapshotPayload(
|
||||
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=[
|
||||
AdminInstancePayload.model_validate(instance)
|
||||
for instance in core_snapshot.instance_payload(
|
||||
child.instances, include_missed=True
|
||||
)
|
||||
],
|
||||
)
|
||||
for child in snapshot.children
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -41,38 +41,4 @@ class MetaStateDao:
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
async def claim(self, key: str, value: str) -> bool:
|
||||
"""Atomically set ``key`` to ``value`` unless it already is.
|
||||
|
||||
The single upsert statement carries a ``WHERE`` guard on the
|
||||
conflict path (``t.value IS NOT excluded.value``), so the
|
||||
read-compare-write is ONE SQLite statement — atomic at the
|
||||
DATABASE level, not just within one process: two connections
|
||||
(two processes sharing one SQLite file) cannot both observe
|
||||
"not yet set" and both write, because the second writer's
|
||||
statement re-evaluates the guard against the first writer's
|
||||
committed value and matches zero rows.
|
||||
|
||||
Returns True only when THIS call changed the row: the first
|
||||
claimant wins and every later claimant of the same value
|
||||
returns False. A DIFFERENT value always wins (an advance, e.g.
|
||||
the sweep watermark moving to a new date) and re-arms the
|
||||
same-value loser check for the new value.
|
||||
|
||||
Runs under the shared connection lock and inside one
|
||||
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
|
||||
matched write, 0 for a guarded no-op) is captured by the
|
||||
wrapper on the executor thread, so no sqlite3 object crosses
|
||||
back to the event loop.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
result = await self._database.execute(
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
|
||||
f"WHERE {_TABLE}.value IS NOT excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
)
|
||||
@@ -26,17 +26,12 @@ later completed simply gains its completion event; history keeps both
|
||||
rows (append-only), and the sweep's later runs skip it via the
|
||||
no-completion-event rule.
|
||||
|
||||
The whole watermark read → query → build span runs inside the
|
||||
The whole watermark read → query → build → write span runs inside the
|
||||
per-database sweep lock (one lock per connection wrapper and running
|
||||
loop, the settings store's pattern): an overlapping startup sweep and
|
||||
a rollover sweep of the SAME process queue instead of interleaving.
|
||||
The announce decision itself is the DATABASE-level claim
|
||||
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
|
||||
(the integration's rollover sweep and the API service's scheduler)
|
||||
cannot both announce the same missed batch either: both may read the
|
||||
old watermark and build the events, but the single conditional upsert
|
||||
lets exactly one of them win the claim — the loser returns [] and
|
||||
announces nothing.
|
||||
a rollover sweep queue instead of interleaving, and the loser re-reads
|
||||
the watermark INSIDE the lock — sees the winner's date — and announces
|
||||
nothing twice.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
@@ -65,11 +60,11 @@ LOGGER = logging.getLogger(__name__)
|
||||
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
|
||||
#: the settings store's pattern: the watermark read → query → build
|
||||
#: span is one critical section, so an overlapping startup sweep and
|
||||
#: rollover sweep of the SAME process queue instead of interleaving.
|
||||
#: Same-process callers are fully serialised by it; the cross-process
|
||||
#: case is closed by the database-level claim, not by this lock.
|
||||
#: the settings store's pattern: the whole watermark read → query →
|
||||
#: build → write span is one critical section, so an overlapping
|
||||
#: startup sweep and rollover sweep queue instead of double-announcing.
|
||||
#: The loser re-reads the watermark INSIDE the lock, sees the winner's
|
||||
#: date, and fires nothing.
|
||||
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
@@ -104,24 +99,15 @@ async def run_missed_sweep(
|
||||
the caller decides how to publish them and announces nothing on
|
||||
its own.
|
||||
|
||||
The watermark read → query → build span runs inside the
|
||||
per-database sweep lock, so overlapping callers of ONE process
|
||||
queue instead of interleaving. The announce decision is the
|
||||
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
|
||||
conditional upsert on the watermark, atomic across processes):
|
||||
every caller builds the events optimistically, then only the
|
||||
caller whose claim actually advanced the watermark returns them;
|
||||
a loser (another process already announced for ``today``) returns
|
||||
[] and announces nothing twice. Every event in one run shares one
|
||||
The whole watermark read → query → build → write span runs inside
|
||||
the per-database sweep lock: an overlapping startup sweep and a
|
||||
rollover sweep queue instead of interleaving, and the loser
|
||||
re-reads the watermark inside the lock — sees the winner's date —
|
||||
and announces nothing twice. Every event in one run shares one
|
||||
``occurred_at`` stamp (one run, one announcement moment), and the
|
||||
payload comes from the shared
|
||||
:func:`~.events._instance_payload` builder so the shape can never
|
||||
drift from the documented §3 contract.
|
||||
|
||||
Failure ordering: the watermark is advanced ONLY by the claim,
|
||||
AFTER the events are fully built — a failure before the claim
|
||||
leaves the watermark unadvanced, so a later run re-sweeps the same
|
||||
window and nothing is lost.
|
||||
"""
|
||||
today_iso = today.isoformat()
|
||||
dao = MetaStateDao(database)
|
||||
@@ -148,23 +134,10 @@ async def run_missed_sweep(
|
||||
),
|
||||
)
|
||||
)
|
||||
# The database-level claim gates the announcement: only the
|
||||
# caller that actually advanced the watermark may publish the
|
||||
# built events, so two processes racing one SQLite file cannot
|
||||
# both announce the same missed batch. A same-value loser's
|
||||
# claim matches zero rows and this run announces nothing.
|
||||
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
|
||||
if not claimed:
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s lost the watermark claim; "
|
||||
"announcing nothing",
|
||||
today_iso,
|
||||
)
|
||||
return []
|
||||
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s built %d event(s)",
|
||||
today_iso,
|
||||
len(events),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.6.0"
|
||||
"version": "0.5.0"
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
# NestQuest Architecture
|
||||
|
||||
Status: approved direction (2026-09-21). This document supersedes the
|
||||
single-process design implied by Features 01–14, in which the Home Assistant
|
||||
integration owned the database and the parent admin surface was planned as a
|
||||
Lovelace card inside Home Assistant.
|
||||
|
||||
## 1. Why this changed
|
||||
|
||||
The original design put everything inside the Home Assistant integration: the
|
||||
SQLite database, the business logic, the kid panel cards, and (planned) the
|
||||
parent admin card. Three problems surfaced:
|
||||
|
||||
1. **The panel needed hand configuration.** Standing up the kid panel required
|
||||
hand-writing card YAML (`child_order`, `quest_log_path`, `board_path`) and
|
||||
hand-creating a Lovelace dashboard. A fresh install rendered nothing.
|
||||
2. **The admin surface fought Home Assistant.** The admin design
|
||||
(`design/ADMIN-SPEC.md`) is drawn at 390×844 — it is a phone app. Trapping it
|
||||
inside a Lovelace card meant fighting HA's UI constraints for a mobile-first
|
||||
experience.
|
||||
3. **Data was locked to one process.** A standalone admin web app cannot reach a
|
||||
SQLite file inside Home Assistant's config directory.
|
||||
|
||||
The re-imagined design keeps the panel in Home Assistant (TouchHub loads an HA
|
||||
dashboard), moves data ownership to a standalone API service, and promotes the
|
||||
admin surface to a standalone mobile-first PWA.
|
||||
|
||||
## 2. Target architecture
|
||||
|
||||
```
|
||||
DAKboard TouchHub
|
||||
│ (loads an HA dashboard URL)
|
||||
▼
|
||||
HA box API box
|
||||
┌───────────────────────────────┐ ┌───────────────────────────────────┐
|
||||
│ Lovelace panel dashboard │ │ reverse proxy (TLS) │
|
||||
│ ├ nestquest-party-board-card │ service │ ├ /api/v1/panel/* LAN-only │
|
||||
│ └ nestquest-quest-log-card │ token │ └ /api/v1/admin/* public │
|
||||
│ │ ─────────▶ │ FastAPI (imports core/) │
|
||||
│ NestQuest integration │ │ └ SQLite (owned here) │
|
||||
│ (thin client, no database) │ ◀───────── │ Authentik (OIDC) │
|
||||
│ sensors + HA bus events │ SSE └───────────────────────────────────┘
|
||||
└───────────────────────────────┘ ▲
|
||||
PWA ───────────┘
|
||||
https://nestquest.cubecraftlabs.com
|
||||
```
|
||||
|
||||
One owner of the data (the API). Two clients (the HA integration, the PWA).
|
||||
|
||||
## 3. Data ownership
|
||||
|
||||
**The API service is the only writer.** The HA integration never opens the
|
||||
database; it reads a snapshot and posts completions. This removes the
|
||||
two-writer problem entirely.
|
||||
|
||||
- **Storage:** SQLite on the API box. Single household, trivial concurrency,
|
||||
and the existing schema, migrations, and DAO tests are already SQLite. Postgres
|
||||
buys nothing at this scale.
|
||||
- **Schema:** unchanged. The cutover from the current HA-hosted database is a
|
||||
file copy — same schema, same migration version.
|
||||
- **Backups:** continuous replication (e.g. Litestream) plus a proven restore.
|
||||
A configured-but-untested backup is not a backup.
|
||||
|
||||
## 4. Authentication: two planes
|
||||
|
||||
The kid panel and the admin PWA have opposite trust levels, so they use
|
||||
separate credentials and separate network exposure.
|
||||
|
||||
### Panel plane (machine)
|
||||
|
||||
- **Credential:** a static service token.
|
||||
- **Exposure:** `/api/v1/panel/*` is reachable **only from the LAN**.
|
||||
- **Grants:** read the snapshot, and the single open write — complete a quest as
|
||||
`actor: panel` with the tapped `actor_child_id`.
|
||||
- **Refused:** admin routes reject the service token outright, not merely as a
|
||||
non-admin. The internet cannot reach these routes at all.
|
||||
|
||||
This preserves the original fail-closed boundary: the kiosk can complete a
|
||||
quest and nothing else.
|
||||
|
||||
### Admin plane (human)
|
||||
|
||||
- **Credential:** an Authentik OIDC JWT (authorization code + PKCE).
|
||||
- **Exposure:** `/api/v1/admin/*` is internet-facing behind Authentik.
|
||||
- **Grants:** everything a parent can do — definitions, assignment, presence,
|
||||
children, un-complete, history, export, settings, regenerate.
|
||||
- **Gate:** the API validates the JWT (issuer, audience, JWKS signature, expiry)
|
||||
and requires membership in the `nestquest-admins` group. The Authentik group
|
||||
is the single source of truth for "who is a parent", replacing the
|
||||
integration-side admin allowlist.
|
||||
|
||||
## 5. Components
|
||||
|
||||
### `core/` — domain library
|
||||
|
||||
The extracted domain layer: schema, migrations, database access, DAOs, the pure
|
||||
recurrence and presence engines, materialization, completion, event payload
|
||||
construction, and the children/definitions/allowlist business layers. **No
|
||||
Home Assistant imports.** Consumed by both the API and (during transition) the
|
||||
integration. This is where the ~1500 existing tests live after extraction.
|
||||
|
||||
### `api/` — NestQuest API
|
||||
|
||||
A FastAPI service importing `core/`. Owns the database. Serves the panel and
|
||||
admin planes, validates Authentik tokens, and publishes a transition event
|
||||
stream (SSE) that the integration consumes. No business logic in route handlers.
|
||||
|
||||
### `custom_components/nestquest/` — HA integration (client)
|
||||
|
||||
A thin client. Polls the snapshot and builds the existing sensors; subscribes to
|
||||
the SSE stream and re-fires the four HA bus events; proxies the panel completion.
|
||||
Holds a last-good snapshot cache so a brief API outage does not blank the panel.
|
||||
Owns no database and no business logic.
|
||||
|
||||
### `panel/` — Lovelace cards and dashboard strategy
|
||||
|
||||
The two kid-facing Lit cards plus a dashboard strategy that generates the panel
|
||||
views at render time. Cards auto-discover children, so no `child_order` is
|
||||
required; the strategy computes navigation paths, so `quest_log_path` and
|
||||
`board_path` are not required.
|
||||
|
||||
### `admin/` — Admin PWA
|
||||
|
||||
A React progressive web app implementing `design/ADMIN-SPEC.md`, authenticated
|
||||
via Authentik OIDC, installable on a phone. The only admin surface.
|
||||
|
||||
## 6. Zero-configuration panel
|
||||
|
||||
A fresh install should show the panel with no YAML:
|
||||
|
||||
1. **Auto-discovery.** The party-board card reads an ordered child list from a
|
||||
household entity attribute instead of a configured `child_order`.
|
||||
2. **Dashboard strategy.** A Lovelace strategy generates a party-board view and
|
||||
one quest-log view per child, wiring navigation between them, so paths are
|
||||
computed rather than configured. Explicit configuration still wins if a user
|
||||
supplies it.
|
||||
3. **Auto-registration.** The integration registers the dashboard on first
|
||||
setup, so there is no manual dashboard step.
|
||||
|
||||
## 7. Home Assistant surface preserved
|
||||
|
||||
The re-imagining must not break what already works:
|
||||
|
||||
- **Sensors:** the per-child and household sensor entity_ids and the per-child
|
||||
instance attribute payload shape are unchanged. The panel cards and the four
|
||||
blueprints depend on them.
|
||||
- **Events:** the four HA bus events (`nestquest_quest_completed`,
|
||||
`nestquest_quest_uncompleted`, `nestquest_quest_missed`,
|
||||
`nestquest_child_day_complete`) keep firing with their documented payloads.
|
||||
They are now driven by the API's transition stream rather than by local
|
||||
service handlers.
|
||||
- **Blueprints:** the morning summary, afternoon reminder, end-of-day report, and
|
||||
day-complete celebration continue to work with no change.
|
||||
|
||||
## 8. Events without an HA token on the API box
|
||||
|
||||
The API publishes transitions on an SSE stream; the integration subscribes and
|
||||
re-fires the HA bus events locally. This deliberately avoids storing a Home
|
||||
Assistant long-lived token on the API box, which would grant full HA access.
|
||||
|
||||
## 9. Migration phases
|
||||
|
||||
Mapped to the Maestro features:
|
||||
|
||||
| Phase | Feature | Outcome |
|
||||
|---|---|---|
|
||||
| 1 | 15 — Domain Core Extraction | `core/` runs with no HA imports; one implementation of the rules |
|
||||
| 2 | 16 — NestQuest API Service | Panel and admin planes served with two-plane auth |
|
||||
| 3 | 17 — API Deployment and Identity | TLS, Authentik, backups, network separation |
|
||||
| 4 | 18 — HA Integration as API Client | Integration becomes a client; entity/event surface preserved |
|
||||
| 5 | 19 — Admin PWA | Mobile-first React PWA replaces the admin card |
|
||||
| 6 | 20 — Zero-Config Panel Dashboard | Panel installs itself; navigation and animations match the mockups |
|
||||
|
||||
Cutover: copy the existing SQLite file to the API box (same schema).
|
||||
|
||||
## 10. Locked decisions
|
||||
|
||||
- **API on its own box** — separate from Home Assistant.
|
||||
- **Authentik** (`https://auth.cubecraftlabs.com`) for admin identity; a
|
||||
`nestquest-admins` group is the admin source of truth.
|
||||
- **Single household** — no tenant column, no multi-household auth.
|
||||
- **React** for the admin PWA.
|
||||
- **Drop the HA admin services** — the PWA is the only admin surface. The panel
|
||||
completion path stays.
|
||||
- **SQLite on the API box** initially; Postgres only if multi-household ever
|
||||
becomes real.
|
||||
|
||||
## 11. Preserved vs. changed
|
||||
|
||||
**Preserved:** the tested Python domain layer, the panel card designs and
|
||||
tokens, the sensor and event contract, the completion model, the append-only
|
||||
event log, the panel's fail-closed "complete only" boundary.
|
||||
|
||||
**Changed:** data ownership moves to the API; the integration becomes a client;
|
||||
the admin card becomes a PWA; panel cards become config-free with a
|
||||
strategy-generated dashboard.
|
||||
|
||||
**New:** the API service, Authentik-gated admin auth, the PWA, and a
|
||||
last-good-snapshot cache in the integration.
|
||||
|
||||
**Cost:** two new deployables (the API and the PWA) and one new box. Accepted
|
||||
knowingly in exchange for a self-installing panel, an admin surface that is not
|
||||
fighting HA's UI, and a data layer with real auth in front of it.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
"""Admin-plane snapshot route tests (task ef3f4877).
|
||||
|
||||
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
|
||||
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
|
||||
authenticates with NO network access. The household is seeded with
|
||||
:func:`tests.test_api_panel._seed_household` through the SAME core
|
||||
copy the app uses (``nestquest_core.*``), and both routes' single
|
||||
clock read (``_local_now``) is pinned to noon so nothing straddles
|
||||
midnight.
|
||||
|
||||
Proven per the done-condition:
|
||||
|
||||
- An admin JWT returns 200 with the documented shape: ``today_iso``,
|
||||
``cycle_day`` and, per child, the presence fields, the rollup counts
|
||||
and ``instances`` in the core ``instance_payload`` shape.
|
||||
- A MISSED instance is KEPT in the admin payload (D-009) and still
|
||||
OMITTED from the panel payload. The core builder lists only
|
||||
instances due on ``now.date()``, and ``derive_state`` calls an
|
||||
instance missed only when its due date is BEFORE that day — so a
|
||||
real snapshot never carries a missed view. The test therefore
|
||||
wraps the real builder to add the seeded past-due instance as a
|
||||
view whose state the core's own ``derive_state`` computes
|
||||
(``missed``), and reads it through both routes.
|
||||
- Auth runs through the router's ONE ``require_admin`` dependency: no
|
||||
credential is 401, the panel service token is refused outright
|
||||
(403, the credential-type detail), and a non-admin JWT is 403.
|
||||
- The handler performs exactly ONE clock read per request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from api.nestquest_core import core_snapshot
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import _seed_household
|
||||
|
||||
# The completion layer through the SAME core copy the app uses — the
|
||||
# missed test derives its view's state with the core's own rule.
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: The 403 detail for the panel service token on an admin route.
|
||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
#: The 403 detail for a valid JWT without the admin group.
|
||||
FORBIDDEN_DETAIL = (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _panel_headers() -> dict[str, str]:
|
||||
"""The Authorization header carrying the harness panel token."""
|
||||
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def snapshot_household(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> SimpleNamespace:
|
||||
"""A seeded household served by the admin-plane app, clock pinned.
|
||||
|
||||
``today`` comes from one host clock read; the pinned instant is
|
||||
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
|
||||
the admin and the panel route's ``_local_now`` return it, and the
|
||||
admin one counts its calls.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
|
||||
clock_reads: list[datetime.datetime] = []
|
||||
|
||||
def _admin_now() -> datetime.datetime:
|
||||
clock_reads.append(pinned_now)
|
||||
return pinned_now
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
today=today,
|
||||
pinned_now=pinned_now,
|
||||
pinned_utc_iso=pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
clock_reads=clock_reads,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency ----------------------
|
||||
|
||||
|
||||
async def test_snapshot_without_credential_is_401(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No Authorization header is the uniform 401."""
|
||||
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_snapshot_refuses_panel_service_token_outright(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The panel service token is refused as a credential type (403).
|
||||
|
||||
403 with the credential-type detail — not the uniform 401 and not
|
||||
the non-admin group detail — proves the token was refused outright,
|
||||
never tried as a JWT.
|
||||
"""
|
||||
response = await snapshot_household.client.get(
|
||||
"/api/v1/admin/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_snapshot_refuses_non_admin_jwt(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await snapshot_household.client.get(
|
||||
"/api/v1/admin/snapshot",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
# --- the payload -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_admin_jwt_returns_documented_snapshot(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An admin JWT returns 200 with the documented snapshot shape.
|
||||
|
||||
Every field is asserted as an explicit literal, with ONE clock
|
||||
read for the request. With no missed view in the real snapshot,
|
||||
the admin body equals the panel body for the same household.
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/snapshot", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(snapshot_household.clock_reads) == 1
|
||||
body = response.json()
|
||||
|
||||
assert body == {
|
||||
"today_iso": snapshot_household.today.isoformat(),
|
||||
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
||||
"cycle_day": 2,
|
||||
"children": [
|
||||
{
|
||||
"child_id": seed.ada.id,
|
||||
"child_name": "Ada",
|
||||
"present": False,
|
||||
"next_present": seed.returns_date.isoformat(),
|
||||
"due_today": 1,
|
||||
"completed_today": 0,
|
||||
"remaining_today": 1,
|
||||
"completion_pct": 0,
|
||||
"instances": [
|
||||
{
|
||||
"id": seed.pack_bag_instance.id,
|
||||
"definition_id": seed.pack_bag_instance.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"child_id": seed.bo.id,
|
||||
"child_name": "Bo",
|
||||
"present": True,
|
||||
"next_present": None,
|
||||
"due_today": 1,
|
||||
"completed_today": 1,
|
||||
"remaining_today": 0,
|
||||
"completion_pct": 100,
|
||||
"instances": [
|
||||
{
|
||||
"id": seed.brush_teeth_instance.id,
|
||||
"definition_id": (
|
||||
seed.brush_teeth_instance.definition_id
|
||||
),
|
||||
"child_id": seed.bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": snapshot_household.pinned_utc_iso,
|
||||
"on_time": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
panel = await client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
assert panel.json() == body
|
||||
|
||||
|
||||
async def test_admin_keeps_missed_instance_panel_omits_it(
|
||||
snapshot_household: SimpleNamespace, monkeypatch
|
||||
) -> None:
|
||||
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
|
||||
|
||||
The real builder is wrapped to add the seeded past-due Stale chore
|
||||
instance to Ada's views, its state derived by the core's own
|
||||
``derive_state`` (no events, due yesterday => ``missed``). Both
|
||||
routes read the SAME wrapped snapshot, so the only difference is
|
||||
each route's ``include_missed`` flag.
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
stale = seed.stale_instance
|
||||
missed_state = _completion.derive_state(
|
||||
stale, None, snapshot_household.today
|
||||
)
|
||||
assert missed_state == "missed"
|
||||
missed_view = core_snapshot.QuestInstanceView(
|
||||
instance_id=stale.id,
|
||||
definition_id=stale.definition_id,
|
||||
child_id=stale.child_id,
|
||||
title="Stale chore",
|
||||
icon=None,
|
||||
window=stale.window,
|
||||
due_date=stale.due_date,
|
||||
due_time=stale.due_time,
|
||||
state=missed_state,
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
real_build_snapshot = core_snapshot.build_snapshot
|
||||
|
||||
async def _with_missed_view(database, settings, now):
|
||||
snapshot = await real_build_snapshot(database, settings, now)
|
||||
return dataclasses.replace(
|
||||
snapshot,
|
||||
children=tuple(
|
||||
dataclasses.replace(
|
||||
child, instances=(*child.instances, missed_view)
|
||||
)
|
||||
if child.child_id == seed.ada.id
|
||||
else child
|
||||
for child in snapshot.children
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
|
||||
|
||||
admin = await client.get(
|
||||
"/api/v1/admin/snapshot", headers=_admin_headers()
|
||||
)
|
||||
assert admin.status_code == 200
|
||||
admin_ada = admin.json()["children"][0]
|
||||
assert admin_ada["child_id"] == seed.ada.id
|
||||
assert [instance["id"] for instance in admin_ada["instances"]] == [
|
||||
seed.pack_bag_instance.id,
|
||||
stale.id,
|
||||
]
|
||||
assert admin_ada["instances"][1] == {
|
||||
"id": stale.id,
|
||||
"definition_id": stale.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Stale chore",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "09:00",
|
||||
"state": "missed",
|
||||
"overdue": False,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
|
||||
panel = await client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
panel_ada = panel.json()["children"][0]
|
||||
assert [instance["id"] for instance in panel_ada["instances"]] == [
|
||||
seed.pack_bag_instance.id
|
||||
], "the panel payload must still omit missed instances"
|
||||
@@ -1,295 +0,0 @@
|
||||
"""Cross-process safety of the missed-sweep watermark claim.
|
||||
|
||||
Feature 11's sweep guards its announcement with the watermark; the
|
||||
original guard was a read-then-set inside a per-PROCESS asyncio lock,
|
||||
so two PROCESSES sharing one SQLite file (the HA integration's
|
||||
rollover sweep and the API service's scheduler) could both read the
|
||||
old watermark and both announce the same missed batch. These tests
|
||||
pin the fix:
|
||||
|
||||
- :meth:`MetaStateDao.claim` is the atomic, database-level primitive:
|
||||
one conditional upsert whose ``rowcount`` says whether THIS caller
|
||||
changed the row. The first claimant of a value wins; a second
|
||||
claimant of the same value — even on an INDEPENDENT connection to
|
||||
the same file, the shape two processes produce — loses.
|
||||
- :func:`run_missed_sweep` gates the announcement on that claim: two
|
||||
independent ``NestQuestDatabase`` wrappers (two processes' shape)
|
||||
racing one seeded database yields exactly one announcer.
|
||||
- The documented sweep semantics are unchanged: a same-night rerun
|
||||
announces nothing, and a run after days of downtime announces the
|
||||
accumulated ``[watermark, today)`` window exactly once.
|
||||
|
||||
Everything here runs against the bundled HA-free core copy
|
||||
(``custom_components.nestquest.core``) with a plain
|
||||
``asyncio.to_thread`` executor — no Home Assistant, no API server.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_meta import MetaStateDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.sweep import (
|
||||
SWEEP_WATERMARK_KEY,
|
||||
run_missed_sweep,
|
||||
)
|
||||
|
||||
#: One missed event per unswept past-due instance — the sweep's only
|
||||
#: event type.
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
"""Run a synchronous callable on a worker thread and await it.
|
||||
|
||||
The :class:`NestQuestDatabase` executor contract (the shape of
|
||||
``hass.async_add_executor_job``), so sqlite3 calls never block the
|
||||
event loop.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
async def _open_database(db_path: str) -> NestQuestDatabase:
|
||||
"""Open (without migrating) one independent connection wrapper."""
|
||||
database = NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
return database
|
||||
|
||||
|
||||
async def _seed_missed_household(
|
||||
db_path: str, today: datetime.date
|
||||
) -> SimpleNamespace:
|
||||
"""Seed a household with two past-due open instances on ``db_path``.
|
||||
|
||||
One child, two quests whose ONLY instances are due YESTERDAY
|
||||
(materialized with ``today`` pinned to yesterday, ``end_date``
|
||||
closing the rules so the later today-walk cannot duplicate them).
|
||||
Seeding uses its OWN connection, opened and closed here, so the
|
||||
connections the tests race with start fresh.
|
||||
"""
|
||||
database = await _open_database(db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
rule = ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday_iso,
|
||||
"end_date": yesterday_iso,
|
||||
}
|
||||
)
|
||||
ada = await create_child(database, "Ada", sort_order=0)
|
||||
await create_quest_definition(
|
||||
database, "Stale chore", rule, [ada.id], [("morning", "09:00")]
|
||||
)
|
||||
await create_quest_definition(
|
||||
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
||||
)
|
||||
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
||||
# A quest due TODAY (created after the yesterday walk so it has
|
||||
# no past-due instance): the post-downtime catch-up run's
|
||||
# target — inside [watermark=today, later_today) but not
|
||||
# inside the day-one sweep's window.
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
)
|
||||
await materialize(database, today_iso, today_iso, today=today)
|
||||
instances = QuestInstancesDao(database)
|
||||
rows = await instances.list_by_child_and_date(ada.id, yesterday_iso)
|
||||
assert len(rows) == 2
|
||||
today_rows = await instances.list_by_child_and_date(
|
||||
ada.id, today_iso
|
||||
)
|
||||
assert len(today_rows) == 1
|
||||
return SimpleNamespace(
|
||||
child=ada,
|
||||
instances=rows,
|
||||
recoverable_instance=today_rows[0],
|
||||
yesterday_iso=yesterday_iso,
|
||||
today_iso=today_iso,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the claim primitive: atomic at the database level ------------------
|
||||
|
||||
|
||||
async def test_claim_first_caller_wins_and_second_same_value_loses(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""claim() is a conditional upsert: first True, same-value second False.
|
||||
|
||||
A different value always wins (an advance re-arms the check), and
|
||||
the stored value follows the winner.
|
||||
"""
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
dao = MetaStateDao(database)
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) is None
|
||||
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True
|
||||
# Same value again: the row is already there — nothing changed.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01"
|
||||
# A DIFFERENT value is an advance, not a re-claim: it wins.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02"
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_claim_is_atomic_across_independent_connections(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two connections to one file cannot both claim the same value.
|
||||
|
||||
This is the two-process shape: independent sqlite3 connections (one
|
||||
per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME
|
||||
event loop with no shared lock. The single conditional upsert is
|
||||
serialized by SQLite itself, so exactly one caller's rowcount is 1.
|
||||
"""
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(first)
|
||||
won = await asyncio.gather(
|
||||
MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
)
|
||||
assert sorted(won) == [False, True]
|
||||
# One stored row, holding the winner's value.
|
||||
assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
# --- the sweep: the announcement is gated by the atomic claim -----------
|
||||
|
||||
|
||||
async def test_two_process_shape_race_exactly_one_announces(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two independent connections racing the sweep: ONE announcer.
|
||||
|
||||
Each wrapper carries its own connection AND its own per-process
|
||||
sweep lock, so nothing in-process serialises the two runs — the
|
||||
only guard left is the database-level claim. Both read the unset
|
||||
watermark and both build the events, but exactly one claim wins:
|
||||
one run returns the missed events, the loser returns [].
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
run_missed_sweep(first, today=today),
|
||||
run_missed_sweep(second, today=today),
|
||||
)
|
||||
announcers = [r for r in results if r]
|
||||
assert len(announcers) == 1
|
||||
event_type, payload = announcers[0][0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.yesterday_iso
|
||||
# Both instances of the seeded window, one event each.
|
||||
assert len(announcers[0]) == 2
|
||||
losers = [r for r in results if not r]
|
||||
assert losers == [[]]
|
||||
# The watermark holds the run's date for whoever looks next.
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== seed.today_iso
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None:
|
||||
"""A second sweep for the same date is an empty no-op (the watermark)."""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
await _seed_missed_household(temp_db_path, today)
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
first = await run_missed_sweep(database, today=today)
|
||||
assert len(first) == 2
|
||||
second = await run_missed_sweep(database, today=today)
|
||||
assert second == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_post_downtime_run_announces_accumulated_window_once(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""After days of downtime the catch-up run sweeps the window once.
|
||||
|
||||
The first run sweeps yesterday's instances (watermark := today);
|
||||
``today`` then jumps two days and the catch-up run — on a
|
||||
DIFFERENT connection, the two-process shape — announces the
|
||||
accumulated ``[watermark, later_today)`` window exactly once, and
|
||||
a rerun on the original connection is silent again.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
later_today = today + datetime.timedelta(days=2)
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
# Day one: yesterday's two instances fire once.
|
||||
day_one = await run_missed_sweep(first, today=today)
|
||||
assert len(day_one) == 2
|
||||
assert all(
|
||||
payload["due_date"] == seed.yesterday_iso
|
||||
for _, payload in day_one
|
||||
)
|
||||
|
||||
# The downtime catch-up: the window is [watermark, later_today).
|
||||
catchup = await run_missed_sweep(second, today=later_today)
|
||||
assert len(catchup) == 1
|
||||
event_type, payload = catchup[0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.today_iso
|
||||
assert payload["instance_id"] == seed.recoverable_instance.id
|
||||
assert payload["child_id"] == seed.child.id
|
||||
|
||||
# A further rerun announces nothing.
|
||||
assert await run_missed_sweep(first, today=later_today) == []
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== later_today.isoformat()
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
Reference in New Issue
Block a user