Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
274 lines
13 KiB
Markdown
274 lines
13 KiB
Markdown
# 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.
|
||
|
||
### Presence model (schema 9)
|
||
|
||
A child has **zero or more** presence patterns, stored one row each in
|
||
`presence_patterns` (`child_id` is not unique):
|
||
|
||
| Column | Meaning |
|
||
|---|---|
|
||
| `name` | Label shown in the admin PWA, not blank |
|
||
| `kind` | `home` or `away` — what the pattern claims on the days it covers |
|
||
| `cycle_length_weeks` | 1–4; the pattern's own repeating cycle |
|
||
| `anchor_date` | ISO date that starts week 1 of the pattern's cycle |
|
||
| `pattern` | Pipe-separated weekday sets, one CSV segment per cycle week, weekdays **Monday=0 .. Sunday=6** (e.g. `0,2,4\|1,3`); an empty segment covers no day that week |
|
||
|
||
Cycle position is anchor-date arithmetic per pattern — **never** ISO week
|
||
numbers or week parity (D-004). A child's presence on a date is decided
|
||
first match wins:
|
||
|
||
1. A presence override covering the date — its flag is the answer.
|
||
2. Any `away` pattern covers the date — **away**.
|
||
3. Any `home` pattern covers the date — **present**.
|
||
4. The child has at least one `home` pattern — **away** (a home pattern lists
|
||
the days the child is home; every other day is away).
|
||
5. Otherwise (only `away` patterns, or no patterns) — **present**.
|
||
|
||
The core presence engine, the snapshot's `present_today`, the materialiser's
|
||
skip-on-away-days rule, and the admin PWA's month grid all apply this order.
|
||
The admin plane manages patterns with
|
||
`GET`/`POST /api/v1/admin/children/{child_id}/presence-patterns` and
|
||
`PATCH`/`DELETE /api/v1/admin/presence-patterns/{pattern_id}`.
|
||
|
||
**Migration 9** replaced the older one-schedule-per-child table: it creates
|
||
`presence_patterns`, copies every old schedule row into a `home` pattern
|
||
named `Home schedule` (cycle length, anchor date and weekday sets
|
||
unchanged, row id kept), and drops the old table, all in one transaction.
|
||
A lone `home` pattern answers exactly as the old schedule did, so no
|
||
child's presence changes. The drop is one-way and 0.7.1 refuses a
|
||
version-9 database; see the rollback step in `deploy/RUNBOOK.md` §11.
|
||
|
||
### Application timezone (manual, admin-set)
|
||
|
||
The application timezone is set **manually by an admin from the admin
|
||
app** — the Settings screen's timezone picker, which reads and writes the
|
||
`timezone` field of `GET`/`PATCH /api/v1/admin/settings`. Nothing sets it
|
||
automatically: there is no Home Assistant zone adoption, no browser-zone
|
||
auto-detect, and no auto-save when a screen loads. Only an admin's `PATCH`
|
||
ever changes the stored value.
|
||
|
||
The stored value is the **single source of local-time truth for the whole
|
||
application.** The API reads its one clock and re-expresses it in that zone
|
||
(`NestQuestSettings.household_now`), so every local date and time comes
|
||
from it:
|
||
|
||
- the panel snapshot's today and overdue state, and both panel cards'
|
||
visible dates and clocks (the snapshot publishes the zone as `timezone`,
|
||
which the integration exposes on the household rollup sensor);
|
||
- completion timestamps (the local completion date and the on-time check);
|
||
- the missed-sweep scheduler's household rollover;
|
||
- every admin read, and the admin app's local-time formatting.
|
||
|
||
**Empty means the API host's local zone.** While `timezone` is empty the
|
||
API reads its clock in the host's local time, and publishes that zone as
|
||
the effective zone (`api/host_zone.py`: the `effective_timezone` field of
|
||
the admin settings response and the snapshot's `timezone`) until an admin
|
||
sets one. No code fills the empty value in.
|
||
|
||
**A save re-plans the sweep.** An admin save that changes `timezone` or
|
||
`day_rollover_time` notifies the running missed-sweep scheduler
|
||
(`MissedSweepScheduler.settings_changed`, under its settings lock), which
|
||
re-plans its next sweep for the new household rollover.
|
||
|
||
## 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.
|