Compare commits

..
Author SHA1 Message Date
overseer 608fb4d926 test: completion event lifecycle coverage 2026-09-18 07:11:51 -04:00
120 changed files with 4965 additions and 23342 deletions
-1
View File
@@ -4,4 +4,3 @@ __pycache__/
.venv/
.pytest_cache/
*.metadata.json
frontend/node_modules/
+2 -220
View File
@@ -13,14 +13,14 @@ NestQuest helps households coordinate daily life, chore tracking, custody schedu
1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed.
2. In Home Assistant, open **HACS** > **Integrations**.
3. Select the three-dots menu in the top right and click **Custom repositories**.
4. Enter the repository URL: `https://github.com/Maniacs-Mansion/NestQuest`
4. Enter the repository URL: `https://github.com/talon2king/NestQuest`
5. Select category **Integration** and click **Add**.
6. Search for **NestQuest** and click **Download**.
7. Restart Home Assistant.
> **Note:** Development happens in the private Gitea repository
> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub
> repository is a private mirror maintained for HACS distribution.
> repository is a public mirror maintained for HACS distribution.
### Manual Installation
@@ -38,202 +38,6 @@ After installation and restart:
2. Click **Add Integration** and search for **NestQuest**.
3. Follow the on-screen configuration flow.
## Events
NestQuest fires Home Assistant bus events so automations can react to a
child's progress. Each payload carries `child_id`, `child_name`,
`instance_id`, `quest_title`, the instance's `window`, `due_date`,
`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp).
| Event | Fires when | Extra payload fields |
|---|---|---|
| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) |
| `nestquest_quest_uncompleted` | an admin reverses a completion | — |
| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` |
| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — |
Example automation trigger:
```yaml
triggers:
- trigger: event
event_type: nestquest_child_day_complete
conditions:
- condition: template
value_template: "{{ trigger.event.data.child_name == 'Declan' }}"
actions:
- action: notify.mobile_app_declans_phone
data:
message: "Every quest cleared — legendary!"
```
## Automation Blueprints
NestQuest ships four automation blueprints in
`custom_components/nestquest/blueprints/automation/`. Import them in
Home Assistant under **Settings → Automations & Scenes → Blueprints**
(or place the files in your `config/blueprints/automation/` folder),
then create one automation per blueprint, filling the inputs with your
household's choices.
### Wiring to the integration's notification settings
The integration's options flow records your notification choices —
notify target, morning/afternoon/end-of-day times, and a toggle per
automation. The blueprints take their own inputs at import time;
fill them from the same values you stored in the options flow (the
integration does not push its settings into imported automations):
| Blueprint input | Options-flow counterpart |
|---|---|
| `morning_time` (default 08:00) | `morning_summary_time` |
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
| `report_time` (default 20:00) | `end_of_day_report_time` |
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
The end-of-day report deliberately runs BEFORE the midnight missed
sweep and reads the same day's data — do not schedule it at the
day-rollover time.
### The four blueprints
1. **Morning summary** (`morning_summary.yaml`) — at the configured
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
is on and what they owe from `sensor.nestquest_<child>_quests_remaining_today`.
Children not present today are omitted entirely.
2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the
configured time, notifies only about present children who still
have open quests. Sends nothing at all when every present child is
clear.
3. **End-of-day report** (`end_of_day_report.yaml`) — one message per
evening: each present child's completion count and the quests they
still owe (the ones the midnight sweep will mark
`nestquest_quest_missed`). Absent children are omitted. Sends
nothing on an all-clear day unless `send_even_when_clear` is on.
4. **Day-complete celebration** (`day_complete_celebration.yaml`) —
triggers on the `nestquest_child_day_complete` event (which only
fires when quests were owed and all are complete, so a zero-quest
day never celebrates). Create one `input_text` helper
(`nestquest_last_celebrated`) for the dedupe: the automation
records each celebrated child as `<child_id>:<today>` and
celebrates each child at most once per day — a re-cleared day does
not re-fire, but a second child clearing the same day still
celebrates.
### Example package
```yaml
# One automation per blueprint — Settings → Automations & Scenes →
# Create → Blueprint, then pick each NestQuest blueprint.
#
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
# afternoon_reminder: afternoon_time 15:00, same notify target
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
# day_complete_celebration: input_text.nestquest_last_celebrated,
# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
#
# Every blueprint has its own send/celebrate toggle input; turn an
# automation off entirely from HA's automation menu.
```
### Events the automations rely on
| Event | Payload |
|---|---|
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
| `nestquest_quest_uncompleted` | 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 |
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
(attributes: `child_id`, `child_name`, `present`, `instances`,
`admin_instances`), `..._quests_remaining_today`,
`..._quests_completed_today`, and
`binary_sensor.nestquest_<child>_present_today`.
## TouchHub setup
Run these steps in order to display the NestQuest panel on a TouchHub
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
is simply a Home Assistant client.
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
a new user for the wall device. Make it **non-admin**, and ensure it is
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
`update_quest_definition`, `set_quest_definition_active`,
`set_presence_pattern`, `create_presence_override`,
`delete_presence_override`, `export_history_csv`, and `manage_child`
(plus `regenerate` flows) — it does not make the account view-only. The
kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest
service, to complete quests from the panel, and as a non-admin Home
Assistant user it may also reach other permitted Home Assistant entities
and services.
2. **Build a dashboard with only the NestQuest panel cards.** Create a
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
log reads the selected child's slug from the view's URL path, so the
board → log hop is a plain Lovelace navigation.
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
view → View type: Panel). Home Assistant's default Masonry layout constrains
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
layout is what renders the card full-screen at 1920×1080.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
Assistant Lovelace** app to the dock and paste the dashboard URL from
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
loads that URL directly as the kiosk landing page). Set TouchHub
**Auto-Return** to a value GREATER than the quest log's
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
Auto-Return is at or below 40s, TouchHub returns to its launcher before
the quest log's idle return can navigate back to the party board.
4. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 1, and leave that session logged in.
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
Lovelace cards, not an iframe, so no Home Assistant framing setting is
needed.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
@@ -251,28 +55,6 @@ To set up the development environment and install dependencies:
uv sync
```
### Frontend cards
The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a
single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The
integration registers that bundle as a Lovelace resource automatically on
setup — do not add it by hand. Card YAML is documented in
`design/ENTITIES-AND-SERVICES.md` §4.
```bash
cd frontend
npm install
npm run build
```
### Kids panel layout
The Lovelace panel cards are designed for the wall display's confirmed
resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom
dock overlays the NestQuest weather dock strip until it auto-hides, so
no tappable NestQuest control sits in that strip (the dock carries
`pointer-events: none`). Keep it that way when editing the cards.
### Running Tests
Run the test suite with `pytest`:
+1 -97
View File
@@ -1,101 +1,5 @@
# NestQuest Release Notes
## Unreleased
- **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.
## Version 0.5.0 — 2026-09-18
### Scope
Fifth release of NestQuest, promoting Features 06, 07, and 08 from `dev` onto 0.4.0 (quest model, children registry, presence engine):
- **Feature 06 — quest definitions and assignment:** business layer `quest_definitions.py` over the multi-assignee / multi-window model (D-008). Create/edit/deactivate definitions; assign/unassign children; query helpers for materialization. `ScheduleRule` ⇄ `schedule_rules` storage mapping in the DAO layer.
- **Feature 07 — instance materialization:** `materialize.py` fans out one instance per (definition, child, window, firing date) when the recurrence rule fires and the child is present. Idempotent on `(definition_id, child_id, due_date, window)`; skips completed instances; never generates past dates. HA-local `today` and configured `horizon_days` threaded end-to-end. Regeneration on definition/assignee/presence changes; daily rollover listener; startup backfill; `nestquest.regenerate` service.
- **Feature 08 — completion and event log:** business layer `completion.py` wrapping the existing append-only `CompletionEventsDao`. `complete_instance` / `uncomplete_instance` (reversal event, original row untouched); state derived from the latest event; HA-local `was_on_time`; derived `missed` (never stored). Actor shapes `'user'` / `'panel'` with `actor_child_id` (D-008). Permission gating of un-complete is Feature 09.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
### Breaking changes
- None at the schema layer. Schema remains **version 6** (advanced in 0.4.0). This release adds application code only.
### Known Limitations
- No permission decorator yet (Feature 09): `complete_instance` / `uncomplete_instance` are Python APIs, not gated HA services. The only HA service in this release is `nestquest.regenerate`.
- No entity or event surface (Feature 10): no sensors, no `nestquest_quest_completed` bus events.
- No panel or admin Lovelace cards (Features 12/13).
- Un-complete is implemented but not admin-gated until Feature 09.
- Single-instance only.
### Rollback
Schema version is unchanged from 0.4.0 (still 6). A code downgrade to 0.4.0 does not require a database restore for schema compatibility, but **back up `nestquest.db` (plus `-wal`/`-shm`) before upgrading** — generated instances and completion events written by 0.5.0 remain in the file after downgrade; 0.4.0 will not generate or complete them.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.4.0).
2. Restart Home Assistant.
**Manual installs:**
1. Replace `custom_components/nestquest/` with the 0.4.0 tree.
2. Restart Home Assistant.
**Repository operators:** git-revert the 0.5.0 release merge on `main`.
## Version 0.4.0 — 2026-09-15
### Scope
Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine:
- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**.
- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users.
- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
### Breaking changes
- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback.
- Dev installs from before 2026-09-15 migrate automatically on first start.
### Known Limitations
- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface).
- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07).
- Single-instance only.
### Rollback
**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated.
**Rollback procedure (after a migrated database):**
1. Stop Home Assistant.
2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading.
3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually.
4. Restart Home Assistant.
Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`).
**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database.
## Version 0.3.0 — 2026-09-14
### Scope
@@ -222,4 +126,4 @@ v0.1.0 is the first release — there is no previous NestQuest version to downgr
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
+41 -122
View File
@@ -20,23 +20,17 @@ from .const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
PLATFORMS,
SERVICE_REGENERATE,
)
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase, make_database
from .db import NestQuestDatabase
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
from .sweep import run_missed_sweep
_LOGGER = LOGGER
@@ -174,7 +168,7 @@ async def _async_open_database(
"""
try:
await _preflight_existing_file(hass, db_path)
database = await make_database(hass).open(db_path)
database = await NestQuestDatabase(hass).open(db_path)
except asyncio.CancelledError:
raise
except sqlite3.DatabaseError as err:
@@ -244,7 +238,6 @@ class NestQuestRuntimeData:
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
@@ -329,23 +322,18 @@ def _register_day_rollover_listener(
database: NestQuestDatabase,
options: dict[str, Any],
) -> Callable[[], Any]:
"""Register the daily rollover listener and return its remover.
"""Register the daily materialization listener and return its remover.
Fires once per day at the configured local ``day_rollover_time``:
first re-materializes the rolling horizon
``[today, today + horizon_days]`` through the shared
:func:`_run_horizon_materialization` path (the entry's configured,
validated ``horizon_days``), then runs the Feature 11 missed sweep
— announcing yesterday's still-open quests as missed AFTER today's
instances exist, watermark-guarded so a re-fire of the listener the
same night announces nothing twice.
Fires once per day at the configured local ``day_rollover_time`` and
materializes the rolling horizon ``[today, today + horizon_days]``
through the shared :func:`_run_horizon_materialization` path, using the
entry's configured (validated) ``horizon_days``.
"""
hour, minute = _day_rollover_hour_minute(options)
horizon_days = _configured_horizon_days(options)
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database, horizon_days)
await run_missed_sweep(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
@@ -368,8 +356,21 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
return None
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
def _register_regenerate_service(hass: HomeAssistant) -> None:
"""Register ``nestquest.regenerate`` once at domain scope.
The service triggers a full on-demand regeneration over the rolling
horizon, running the SAME idempotent materialize path
(:func:`_run_horizon_materialization`) as the startup backfill and the
daily rollover listener. It is DOMAIN-global — registered exactly once
(guarded by ``has_service``) and NOT bound to any config entry — so the
handler resolves the currently-live database at call time and a name
collision or duplicate setup is impossible. Removal is handled by the
unload path only once the LAST entry is removed (see
:func:`_deregister_regenerate_service`).
"""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
async def _regenerate(_call: Any) -> None:
runtime_data = _find_live_runtime_data(hass)
@@ -384,40 +385,22 @@ def _make_regenerate_handler(hass: HomeAssistant):
hass, runtime_data.database, horizon_days
)
return _regenerate
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
Services are DOMAIN-global — registered exactly once (guarded by
``has_service`` on regenerate) and NOT bound to any config entry —
so each handler resolves the currently-live database at call time
and a name collision or duplicate setup is impossible. Removal is
handled by the unload path only once the LAST entry is removed
(see :func:`_deregister_services`).
"""
async_register_services(
hass,
find_runtime=_find_live_runtime_data,
regenerate_handler=_make_regenerate_handler(hass),
)
def _deregister_services(hass: HomeAssistant) -> None:
"""Remove domain-global NestQuest services when the last entry unloads."""
async_deregister_services(hass)
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
await async_register_frontend(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
@@ -470,7 +453,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
coordinator: Any = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
@@ -507,40 +489,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _run_horizon_materialization(
hass, database, _configured_horizon_days(dict(entry.options))
)
# The missed sweep runs at startup too (Feature 11): if HA was
# down at the rollover time, the previous night's still-open
# quests are announced as missed now instead of waiting for
# the next midnight. Watermark-guarded, so this is a no-op
# when the night's sweep already ran.
await run_missed_sweep(hass, database)
_register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below
# unwinds them through the except path's listener handling.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
database=database,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
)
await coordinator.async_config_entry_first_refresh()
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
options=dict(entry.options),
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
)
# runtime_data is assigned BEFORE the platforms forward so each
# platform's async_setup_entry reads its coordinator off the
# entry (the HA runtime-data contract); a forward failure below
# unwinds it through the except path.
entry.runtime_data = runtime_data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_register_regenerate_service(hass)
except BaseException:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
@@ -559,24 +508,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await result
except BaseException:
pass
# A coordinator created before the failure holds entity
# listeners pointed at the database being closed below —
# shut it down FIRST so no scheduled refresh can ever run
# against a closed connection.
if coordinator is not None:
try:
await coordinator.async_shutdown()
except BaseException:
pass
# A platform-forward failure reached this unwind AFTER
# runtime_data was assigned: clear it so no later unload path
# closes the already-closed database through the stale record.
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
await database.close()
raise
assert remove_update_listener is not None
assert remove_time_change_listener is not None
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
options=dict(entry.options),
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
)
entry.runtime_data = runtime_data
hass.data[DOMAIN][entry.entry_id] = runtime_data
return True
@@ -590,20 +533,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.pop(DOMAIN, None)
if runtime_data is None:
runtime_data = getattr(entry, "runtime_data", None)
# Platforms unload FIRST (mirroring HA conventions): entity
# listeners must not outlive the database and coordinator the
# teardown below closes.
platforms_unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if platforms_unloaded is False and runtime_data is not None:
# Home Assistant KEPT one or more platforms loaded — their
# entities still live and must keep their database. Restore
# the runtime record popped above and report failure so HA
# retries the unload later instead of leaving live entities
# backed by a closed database.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
return False
unload_error: BaseException | None = None
if runtime_data is not None:
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
@@ -630,24 +559,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_error is None:
unload_error = err
finally:
# The coordinator's entity listeners must stop BEFORE the
# database closes: a scheduled refresh starting during the
# close await would touch a cleared connection. (The
# platform unload above already removed the entities; this
# clears the coordinator's own remaining listeners.)
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
# The ``regenerate`` service is DOMAIN-global: it is torn down only
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
# never when a sibling entry is still loaded.
if is_last:
_deregister_services(hass)
_deregister_regenerate_service(hass)
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
if unload_error is not None:
@@ -1,202 +0,0 @@
"""Per-child binary sensors (Feature 10, task 4).
Two binary sensors per active child, both reading only the coordinator
snapshot (the same ChildDaySnapshot the day sensors read):
- ``binary_sensor.nestquest_<child>_all_done`` — ON exactly when the
child owes quests today (due today > 0) AND owes none of them any
more (remaining today == 0). A zero-quest day is OFF: the
celebration automation must never fire on an empty day (the
day-complete event carries the same rule, Feature 10 task 7).
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
presence engine resolves the child present on the HA-local date
(schedules and overrides, D-004 anchor-date arithmetic).
Naming and registry identity follow the day sensors' contract: names
slug to the documented entity_id shape; unique_ids are built from the
child's database id, never the display name, so a rename never
orphans an entity's history.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ChildDaySnapshot, NestQuestCoordinator
class _NestQuestChildBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Base for the per-child binary sensors.
Registry identity is built from the child's database id and never
changes; the displayed name and device label re-derive from the
child's CURRENT snapshot on every read, so a rename propagates on
the next refresh without orphaning history. A child absent from
the latest snapshot (a deactivation; removal is a later task's
scope) yields ``None`` states rather than stale values.
"""
_name_suffix: str = ""
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator)
self._child_id = child.child_id
self._fallback_name = child.child_name
@property
def _child(self) -> ChildDaySnapshot | None:
"""Return the child's current day snapshot, or None."""
data = self.coordinator.data
if data is None:
return None
for child in data.children:
if child.child_id == self._child_id:
return child
return None
def _displayed_child_name(self) -> str:
child = self._child
return child.child_name if child is not None else self._fallback_name
@property
def name(self) -> str | None:
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, f"child_{self._child_id}")},
name=f"NestQuest {self._displayed_child_name()}",
manufacturer="NestQuest",
)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
child = self._child
if child is None:
return None
return {
"child_id": child.child_id,
"child_name": child.child_name,
}
class NestQuestAllDoneSensor(_NestQuestChildBinarySensor):
"""ON when the child had quests today and owes none of them."""
_name_suffix = "all done"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_all_done"
@property
def is_on(self) -> bool | None:
child = self._child
if child is None:
return None
# Zero-quest days are OFF, not ON: an empty day must never fire
# the celebration automation.
return child.due_today > 0 and child.remaining_today == 0
class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
"""ON when the child is at this house today (presence engine)."""
_name_suffix = "present today"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_present_today"
@property
def is_on(self) -> bool | None:
child = self._child
if child is None:
return None
return child.present
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Base attributes plus, for a child away today, the ISO date
of their next present day (the panel's away plate renders
``Returns <weekday>, <Mon D>`` from it)."""
base = super().extra_state_attributes
if base is None:
return None
child = self._child
return {**base, "next_present": child.next_present if child else None}
def _child_binary_sensors(
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
) -> tuple[_NestQuestChildBinarySensor, ...]:
"""Build the two documented binary sensors for one active child."""
return (
NestQuestAllDoneSensor(coordinator, child),
NestQuestPresentTodaySensor(coordinator, child),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: Callable[..., None],
) -> None:
"""Create the two binary sensors per active child.
Entities are created for the snapshot's children at platform
setup; a coordinator listener adds sensors for children that first
appear on a later refresh (the day sensors' same pattern, so a new
child's whole entity set lands together). Deactivated children
are NOT removed here — removal is a later task's scope.
"""
coordinator = entry.runtime_data.coordinator
known_child_ids: set[int] = set()
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
new_children = [
child
for child in children
if child.child_id not in known_child_ids
]
if not new_children:
return
known_child_ids.update(child.child_id for child in new_children)
async_add_entities(
[
sensor
for child in new_children
for sensor in _child_binary_sensors(coordinator, child)
]
)
data = coordinator.data
if data is not None:
_add_new_children(data.children)
def _handle_coordinator_update() -> None:
data = coordinator.data
if data is not None:
_add_new_children(data.children)
coordinator.async_add_listener(_handle_coordinator_update)
@@ -1,90 +0,0 @@
blueprint:
name: NestQuest afternoon reminder
description: >-
Sends an afternoon nudge listing ONLY the children who still have
open quests today. Children who are present and all clear are
omitted (the nudge is for stragglers, not a roll call), children
who are not present today are omitted entirely, and the household
rollup sensors are excluded by the same present guard. When every
present child is already done the automation sends NOTHING at all:
a template condition stops the run before the notification. The
notify service is chosen by the household when importing this
blueprint — NestQuest never hard-codes a personal notify target.
domain: automation
input:
afternoon_time:
name: Afternoon time
description: Local time of day the reminder is sent.
default: "15:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the reminder,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_reminder:
name: Send the reminder
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
mode: single
variables:
send_reminder: !input send_reminder
triggers:
- trigger: time
at: !input afternoon_time
conditions:
- condition: template
value_template: "{{ send_reminder | bool }}"
# Send nothing when every present child is already done: the guard is
# false unless at least one present child has remaining quests today.
- condition: template
value_template: |
{%- set ns = namespace(any_open=false) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
{%- set ns.any_open = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- ns.any_open -}}
actions:
- action: !input notify_target
data:
title: NestQuest afternoon reminder
message: |
{%- set ns = namespace(lines=[]) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
{%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- ns.lines | join('\n') -}}
@@ -1,109 +0,0 @@
blueprint:
name: NestQuest day complete celebration
description: >-
Celebrates the moment a child clears every quest for the day: the
automation is driven by the nestquest_child_day_complete event,
which NestQuest only fires when quests were owed and all of them
are complete — a zero-quest day can never trigger this
celebration. Once-per-child-per-day dedupe: point
last_celebrated_helper at ONE input_text helper (create it under
Settings, Devices and Services, Helpers — plain text). The
automation records each celebrated child as
"<child_id>:<today>" inside that helper, and a child who
un-completes a quest and completes it again the same day is
celebrated once, not twice — while a SECOND child clearing their
day the same date is still celebrated in full. The helper is
pruned to today's entries on every celebration, so it never
grows past one entry per child per day. A helper that does not
exist yet, or has never been set, lets the first celebration
through. The notification message may use the event payload's
child_name, quests_due and quests_completed. The notify service
is chosen by the household when importing this blueprint —
NestQuest never hard-codes a personal notify target.
domain: automation
input:
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the celebration,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
celebration_message:
name: Celebration message
description: >-
The message sent when a child clears every quest today. May
use the event payload: trigger.event.data.child_name,
trigger.event.data.quests_due and trigger.event.data.quests_completed.
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
selector:
text: {}
celebrate:
name: Send the celebration
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
last_celebrated_helper:
name: Last-celebrated helper
description: >-
A single input_text helper remembering which children were
already celebrated today, as "<child_id>:<date>" entries
separated by commas. The automation skips a child whose entry
for today is already stored, and rewrites the helper with only
today's entries plus the new one after each celebration.
selector:
entity:
filter:
- domain: input_text
mode: queued
variables:
celebrate: !input celebrate
last_celebrated_helper: !input last_celebrated_helper
triggers:
- trigger: event
event_type: nestquest_child_day_complete
conditions:
- condition: template
value_template: "{{ celebrate | bool }}"
# Once-per-child-per-day dedupe: celebrate only when THIS child has
# no equal entry for today in the helper. Entries compare as whole
# comma-separated tokens — a substring check would wrongly suppress
# child 1 after child 11 was celebrated. An unset or missing helper
# reads as '' and celebrates.
- condition: template
value_template: >-
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
not in ((states(last_celebrated_helper) or '')
.split(',')
| select('ne', '')
| list) }}
actions:
- action: !input notify_target
data:
title: NestQuest day complete
message: !input celebration_message
# Record this child's celebration, keeping only today's entries so the
# helper never outgrows its text limit and stale entries cannot block
# next week's celebrations.
- action: input_text.set_value
target:
entity_id: !input last_celebrated_helper
data:
value: >-
{%- set today = now().strftime('%Y-%m-%d') -%}
{%- set ns = namespace(entries=[]) -%}
{%- for entry in (states(last_celebrated_helper) or '').split(',') -%}
{%- if entry and entry.endswith(':' ~ today) -%}
{%- set ns.entries = ns.entries + [entry] -%}
{%- endif -%}
{%- endfor -%}
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
@@ -1,121 +0,0 @@
blueprint:
name: NestQuest end-of-day report
description: >-
Sends the parent an end-of-day report on how the day went. It runs
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it
reads the same day's data (what is still open at report time, and
how much of the day was completed) rather than tomorrow-morning's
hindsight. Every PRESENT child gets a completion line, so the
parent always receives the day's summary; children who still owe
quests get their open count called out on that line, and children
who are not present today are omitted entirely. A child whose
remaining-count sensor is unavailable is reported with the
completion line only. By default the report is skipped when every
present child is already done — turn on send-even-when-clear to
always get the day's completion summary. The notify service is
chosen by the household when importing this blueprint — NestQuest
never hard-codes a personal notify target.
domain: automation
input:
report_time:
name: Report time
description: >-
Local time of day the report is sent — deliberately before the
midnight missed-quest sweep, so the report reads the same
day's data.
default: "20:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the report,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_report:
name: Send the report
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
send_even_when_clear:
name: Send even when clear
description: >-
Send the report even when every present child has already
finished their quests today. When off (the default), an
all-clear day sends nothing.
default: false
selector:
boolean: {}
mode: single
variables:
send_report: !input send_report
send_even_when_clear: !input send_even_when_clear
triggers:
- trigger: time
at: !input report_time
conditions:
- condition: template
value_template: "{{ send_report | bool }}"
# Skip an all-clear day unless the household opted into
# send-even-when-clear: the guard is false only when every present
# child has a usable remaining count of zero. A MISSING or
# non-numeric remaining sensor reads as -1 and counts as open —
# a degraded state must reach the parent, not look like all-clear.
- condition: template
value_template: |
{%- set ns = namespace(any_open=false) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%}
{%- set ns.any_open = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- send_even_when_clear | bool or ns.any_open -}}
actions:
- action: !input notify_target
data:
title: NestQuest end-of-day report
message: |
{%- set ns = namespace(lines=[]) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set due_count = due.state | int -%}
{%- set completed_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- set completed_count = (completed_values | first | int) if completed_values else 0 -%}
{%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%}
{%- if remaining_count is not none and remaining_count > 0 -%}
{%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%}
{%- endif -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endfor -%}
{{- ns.lines | join('\n') -}}
@@ -1,78 +0,0 @@
blueprint:
name: NestQuest morning summary
description: >-
Sends a morning notification listing what each child still owes
today. Only children whose NestQuest presence sensor is on are
included; children who are not present today are omitted entirely.
The notify service is chosen by the household when importing this
blueprint — NestQuest never hard-codes a personal notify target.
domain: automation
input:
morning_time:
name: Morning time
description: Local time of day the summary is sent.
default: "08:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the summary,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_summary:
name: Send the summary
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
mode: single
variables:
send_summary: !input send_summary
triggers:
- trigger: time
at: !input morning_time
conditions:
- condition: template
value_template: "{{ send_summary | bool }}"
actions:
- action: !input notify_target
data:
title: NestQuest morning summary
message: |
{%- set ns = namespace(lines=[]) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if remaining_values -%}
{%- set remaining = remaining_values | first | int -%}
{%- if remaining > 0 -%}
{%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%}
{%- endif -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%}
{%- endif -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endfor -%}
{%- if ns.lines -%}
{{- ns.lines | join('\n') -}}
{%- else -%}
No children present today.
{%- endif -%}
+18 -72
View File
@@ -128,21 +128,6 @@ def _derived_state(
)
def derive_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Public state derivation for the Feature 10 coordinator.
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
"""
return _derived_state(instance, latest, today)
def _was_on_time(
instance: QuestInstanceRecord,
now: datetime.datetime | None,
@@ -366,36 +351,6 @@ async def instance_state(
return _derived_state(instance, latest, today_date)
class CompletionResult(str):
"""The derived instance state, carrying the append verdict.
A ``str`` subclass so every existing ``result == "done"`` contract
keeps working; ``appended`` reports whether THIS call actually
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op. A completion
also carries the ``was_on_time`` verdict computed under that same
lock, so the event payload never re-reads mutable latest state a
racing reversal could have replaced.
"""
appended: bool
was_on_time: bool | None
def __new__(
cls,
state: str,
*,
appended: bool,
was_on_time: bool | None = None,
) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
obj.was_on_time = was_on_time
return obj
async def complete_instance(
database: NestQuestDatabase,
instance_id: int,
@@ -405,14 +360,14 @@ async def complete_instance(
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
) -> str:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``; ``appended`` reports whether
THIS call appended (False on an already-done no-op). Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
Returns the derived state ``done``. An already-done instance is a
no-op: nothing is appended. Unknown ``instance_id`` raises
ValueError naming the field. Existence, latest-event, and the
append run under one connection lock so a concurrent complete
cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
@@ -434,16 +389,12 @@ async def complete_instance(
instance_id
)
if latest is not None and latest.event_type == EVENT_COMPLETED:
return CompletionResult("done", appended=False)
return "done"
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
# Computed under the completion lock and carried on the result:
# the bus event must report THIS completion's timing, never a
# re-read of the latest event a racing reversal could replace.
on_time = _was_on_time(instance, now, today)
await append_event(
database,
instance.id,
@@ -452,10 +403,10 @@ async def complete_instance(
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=on_time,
was_on_time=_was_on_time(instance, now, today),
now=now,
)
return CompletionResult("done", appended=True, was_on_time=on_time)
return "done"
async def uncomplete_instance(
@@ -467,16 +418,16 @@ async def uncomplete_instance(
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
) -> str:
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
connection lock so a concurrent uncomplete cannot double-append.
The original completed row is not modified.
is past due (``due_date`` before HA-local ``today``). An
already-open instance is a no-op: nothing is appended. Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent uncomplete cannot double-append. The original completed
row is not modified.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
@@ -493,10 +444,7 @@ async def uncomplete_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return CompletionResult(
_derived_state(instance, latest, today_date),
appended=False,
)
return _derived_state(instance, latest, today_date)
if latest.event_type != EVENT_COMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
@@ -513,9 +461,7 @@ async def uncomplete_instance(
was_on_time=None,
now=now,
)
return CompletionResult(
_derived_state(instance, latest, today_date), appended=True
)
return _derived_state(instance, latest, today_date)
async def list_missed_for_child(
+54 -8
View File
@@ -1,10 +1,56 @@
"""Re-export of :mod:`custom_components.nestquest.core.const` for the integration package.
The canonical constants live in :mod:`custom_components.nestquest.core.const` (the storage layer
extracted in task d0b691d8); this module re-exports them so existing
``from .const import ...`` references inside the integration keep
working without touching every import line.
"""
"""Constants for the NestQuest integration."""
from __future__ import annotations
from .core.const import * # noqa: F401,F403
import logging
DOMAIN = "nestquest"
LOGGER_NAME = "custom_components.nestquest"
LOGGER = logging.getLogger(LOGGER_NAME)
PLATFORMS: list[str] = []
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
SERVICE_REGENERATE = "regenerate"
SQLITE_DB_FILENAME = "nestquest.db"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
DEFAULT_PANEL_IDLE_TIMEOUT = 300
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
# services. Stored as IDs, never usernames (feature guardrail). The
# database allowlist is authoritative; the config entry carries the same
# list as a disaster-recovery copy so a lost database can be re-seeded.
CONF_ADMIN_USER_IDS = "admin_user_ids"
# Quest windows (D-008): a definition may declare several day windows;
# 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
# configurable later. Boundaries: a window's end equals the next
# window's start; classification (Feature 07 materialisation) treats a
# due time exactly on the boundary as belonging to the LATER window, so
# 12:00 is afternoon (not morning) and 17:00 is evening (not
# afternoon). Times after 21:00 belong to no window.
WINDOW_MORNING = "morning"
WINDOW_AFTERNOON = "afternoon"
WINDOW_EVENING = "evening"
QUEST_WINDOWS: tuple[str, ...] = (
WINDOW_MORNING,
WINDOW_AFTERNOON,
WINDOW_EVENING,
)
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
WINDOW_MORNING: ("00:00", "11:59"),
WINDOW_AFTERNOON: ("12:00", "17:00"),
WINDOW_EVENING: ("17:00", "21:00"),
}
-292
View File
@@ -1,292 +0,0 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via
:func:`~.completion.derive_state`), each child's presence today, and
per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
host clock and never UTC, matching the materialization and completion
layers.
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
answers ``is this child at this house today`` (D-004 — never ISO week
parity).
``cycle_day`` reports where today falls in the custody cycle of the
first active child (display order) that HAS a schedule — 1-based day
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
anchor date. ``0`` means no active child has a schedule at all, so
the admin header degrades to a harmless zero instead of guessing.
"""
from __future__ import annotations
import asyncio
import datetime
from dataclasses import dataclass
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .completion import derive_state
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .dao_children import ChildrenDao
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
)
@dataclass(frozen=True)
class QuestInstanceView:
"""One instance plus everything the entities render from it."""
instance_id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_date: str
due_time: str | None
state: str # open | done | missed
overdue: bool
completed_at: str | None
was_on_time: bool | None
@dataclass(frozen=True)
class ChildDaySnapshot:
"""One active child's day: instances, presence, and rollup counts."""
child_id: int
child_name: str
instances: tuple[QuestInstanceView, ...]
present: bool
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity.
Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot
passes, or an older pass could publish a stale snapshot AFTER a
newer one and leave entities wrong until the next poll. Real
HA's coordinator already serializes internally; the lock keeps
the same guarantee on the stand-in and costs nothing upstream.
"""
def __init__(
self,
hass: HomeAssistant,
*,
entry_id: str,
database: NestQuestDatabase,
update_interval_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
not isinstance(interval, int)
or isinstance(interval, bool)
or interval < MIN_UPDATE_INTERVAL
):
interval = DEFAULT_UPDATE_INTERVAL
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=datetime.timedelta(seconds=interval),
)
self.entry_id = entry_id
self.database = database
self._refresh_lock = asyncio.Lock()
async def async_refresh(self) -> None:
"""One refresh at a time; a queued refresh runs AFTER the
in-flight one publishes, so the last snapshot always reflects
the latest mutation."""
async with self._refresh_lock:
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:
now = self._local_now()
today = now.date()
today_iso = today.isoformat()
children = await ChildrenDao(self.database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(self.database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
self.database
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
overrides[child.id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(self.database)
events_dao = CompletionEventsDao(self.database)
definitions_dao = QuestDefinitionsDao(self.database)
snapshots: list[ChildDaySnapshot] = []
for child in children:
instances = await instances_dao.list_by_date_range(
child.id, today_iso, today_iso
)
views: list[QuestInstanceView] = []
for instance in instances:
latest = await events_dao.get_latest_for_instance(instance.id)
definition = await definitions_dao.get(instance.definition_id)
state = derive_state(instance, latest, today)
overdue = False
if state == "open" and instance.due_date == today_iso:
if instance.due_time is not None:
due_moment = datetime.datetime.strptime(
instance.due_time, "%H:%M"
).time()
overdue = now.time() > due_moment
views.append(
QuestInstanceView(
instance_id=instance.id,
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
state=state,
overdue=overdue,
completed_at=(
latest.occurred_at
if latest is not None
and latest.event_type == "completed"
else None
),
was_on_time=(
latest.was_on_time
if latest is not None
and latest.event_type == "completed"
else None
),
)
)
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
)
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
@@ -1,11 +0,0 @@
"""NestQuest storage layer.
A self-contained, Home-Assistant-free package holding the NestQuest
SQLite storage layer: schema DDL, the versioned migration runner, the
async connection wrapper, the path resolver, the domain constants and
recurrence model, and the typed DAOs. Nothing in this package imports
``homeassistant``; the integration wires it into HA at its own layer
(constructing :class:`custom_components.nestquest.core.db.NestQuestDatabase`
with an executor adapter backed by ``hass.async_add_executor_job``).
"""
from __future__ import annotations
-119
View File
@@ -1,119 +0,0 @@
"""Constants for the NestQuest integration."""
from __future__ import annotations
import logging
DOMAIN = "nestquest"
LOGGER_NAME = "custom_components.nestquest"
LOGGER = logging.getLogger(LOGGER_NAME)
# Platforms set up per config entry (Feature 10): the per-child day
# sensors and binary sensors.
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
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, ...] = (
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"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
DEFAULT_PANEL_IDLE_TIMEOUT = 300
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
# services. Stored as IDs, never usernames (feature guardrail). The
# database allowlist is authoritative; the config entry carries the same
# list as a disaster-recovery copy so a lost database can be re-seeded.
CONF_ADMIN_USER_IDS = "admin_user_ids"
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
# entity reads one snapshot per refresh; completions push an immediate
# refresh so this poll is only the fallback cadence.
CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
# strict HH:MM local; each of the four automations has an independent
# enable toggle. Per the owner-settled resolution the end-of-day report
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
# and never defaults to the rollover time.
CONF_NOTIFY_TARGET = "notify_target"
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# Quest windows (D-008): a definition may declare several day windows;
# 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
# configurable later. Boundaries: a window's end equals the next
# window's start; classification (Feature 07 materialisation) treats a
# due time exactly on the boundary as belonging to the LATER window, so
# 12:00 is afternoon (not morning) and 17:00 is evening (not
# afternoon). Times after 21:00 belong to no window.
WINDOW_MORNING = "morning"
WINDOW_AFTERNOON = "afternoon"
WINDOW_EVENING = "evening"
QUEST_WINDOWS: tuple[str, ...] = (
WINDOW_MORNING,
WINDOW_AFTERNOON,
WINDOW_EVENING,
)
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
WINDOW_MORNING: ("00:00", "11:59"),
WINDOW_AFTERNOON: ("12:00", "17:00"),
WINDOW_EVENING: ("17:00", "21:00"),
}
@@ -1,319 +0,0 @@
"""Typed DAO layer for the children and admin_users tables.
All SQL for ``children`` and ``admin_users`` lives in this module per
the feature guardrail: callers get typed dataclasses back and never see
raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Error contract: violations surface as the underlying
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
failure) from the execute call; the DAO adds no wrapping exception
type. A get that matches nothing returns ``None``; a mutation that
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
which each method returns where the caller may need it (reorder, and
the admin remove/set paths).
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from .db import NestQuestDatabase
@dataclass(frozen=True)
class ChildRecord:
"""One row of ``children``."""
id: int
display_name: str
colour: str | None
avatar_ref: str | None
sort_order: int
is_active: bool
created_at: str
@dataclass(frozen=True)
class AdminUserRecord:
"""One row of ``admin_users``."""
ha_user_id: str
added_at: str
def _child_from_row(row: tuple) -> ChildRecord:
"""Build a :class:`ChildRecord` from a SELECT row."""
return ChildRecord(
id=row[0],
display_name=row[1],
colour=row[2],
avatar_ref=row[3],
sort_order=row[4],
is_active=bool(row[5]),
created_at=row[6],
)
_CHILD_COLUMNS = (
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
)
#: One asyncio.Lock per (connection wrapper, running loop), shared by
#: every ChildrenDao instance over the same database: the wrapper
#: rejects a second concurrent transaction with RuntimeError, so ALL
#: transactional DAO operations on one connection must queue here, not
#: per-instance. Keyed like the migration runner's lock; entries are
#: never evicted (a config entry holds one wrapper per process).
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _connection_lock(database) -> asyncio.Lock:
"""Return the shared transaction-serializing lock for ``database``."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_CONNECTION_LOCKS[key] = lock
return lock
class ChildrenDao:
"""Typed async data access for the ``children`` table.
Deletion is deliberately absent: children are deactivated, never
deleted, because completion history references them (feature
guardrail).
"""
def __init__(self, database) -> None:
self._database = database
async def create(
self,
display_name: str,
created_at: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
is_active: bool = True,
) -> ChildRecord:
"""Insert one child and return the record as stored."""
result = await self._database.execute(
"INSERT INTO children (display_name, colour, avatar_ref, "
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
display_name,
colour,
avatar_ref,
sort_order,
int(is_active),
created_at,
),
)
child = await self.get(result.lastrowid)
assert child is not None
return child
async def get(self, child_id: int) -> ChildRecord | None:
"""Return the child with ``child_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
(child_id,),
)
return _child_from_row(row) if row is not None else None
async def list_active(self) -> list[ChildRecord]:
"""Return active children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
"ORDER BY sort_order, id"
)
return [_child_from_row(row) for row in rows]
async def list_all(self) -> list[ChildRecord]:
"""Return all children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children "
"ORDER BY sort_order, id"
)
return [record for record in map(_child_from_row, rows)]
async def update(
self,
child_id: int,
*,
display_name: str | None = None,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Only non-None arguments are written, so a caller cannot
accidentally blank a column by omitting it.
"""
assignments: list[str] = []
parameters: list[object] = []
if display_name is not None:
assignments.append("display_name = ?")
parameters.append(display_name)
if colour is not None:
assignments.append("colour = ?")
parameters.append(colour)
if avatar_ref is not None:
assignments.append("avatar_ref = ?")
parameters.append(avatar_ref)
if sort_order is not None:
assignments.append("sort_order = ?")
parameters.append(sort_order)
if not assignments:
return 0
parameters.append(child_id)
result = await self._database.execute(
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
return result.rowcount
async def set_active(self, child_id: int, is_active: bool) -> int:
"""Set the child's active flag; returns rows updated (0 if absent)."""
result = await self._database.execute(
"UPDATE children SET is_active = ? WHERE id = ?",
(int(is_active), child_id),
)
return result.rowcount
async def reorder(self, ordered_ids: list[int]) -> None:
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
The list must be a COMPLETE permutation of the children table:
every existing child id appears exactly once. Partial lists
(missing an existing child) and unknown ids (naming a child
that does not exist) are rejected with ValueError BEFORE any
write, leaving the current order untouched — a silent partial
reorder would leave unlisted children with stale positions and
hide a caller bug. Duplicate ids are rejected up front: they
cannot all hold distinct positions, and silently assigning the
last occurrence's index would hide a caller bug.
Runs inside one transaction; positions are staged at offset
-len (all negative, so any permutation of the same ids never
collides mid-reorder under the wrapper's single-connection
serialization), then finalized 0..n-1.
Concurrent reorder() calls on one connection are serialized by
a connection-scoped asyncio lock (shared across every ChildrenDao
over the same wrapper, however many instances): the wrapper's
transaction() rejects a second concurrent transaction with
RuntimeError, so racing callers would crash instead of
queueing. The lock makes the second call wait, then run
against the first call's final state — which also keeps the
completeness check honest: the re-read of the table happens
inside the same lock and transaction as the writes.
"""
if len(set(ordered_ids)) != len(ordered_ids):
raise ValueError(
"reorder() received duplicate child ids; every id must "
"appear at most once"
)
async with _connection_lock(self._database):
async with self._database.transaction():
rows = await self._database.fetch_all(
f"SELECT id FROM children"
)
existing = {row[0] for row in rows}
listed = set(ordered_ids)
missing = sorted(existing - listed)
unknown = sorted(listed - existing)
if missing or unknown:
details = []
if missing:
details.append(
f"missing child ids {missing} (a partial "
"reorder would leave them with stale "
"positions)"
)
if unknown:
details.append(
f"unknown child ids {unknown}"
)
raise ValueError(
"reorder() must list every child exactly once; "
+ "; ".join(details)
)
offset = len(ordered_ids)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(-(offset + index), child_id)
for index, child_id in enumerate(ordered_ids)
],
)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(index, child_id)
for index, child_id in enumerate(ordered_ids)
],
)
class AdminUsersDao:
"""Typed async access to the ``admin_users`` allowlist.
The allowlist stores HA user IDs, never usernames (feature
guardrail); an empty allowlist means no admin, enforced by callers
resolving through :meth:`exists`.
"""
def __init__(self, database) -> None:
self._database = database
async def add(self, ha_user_id: str, added_at: str) -> bool:
"""Add a user to the allowlist; True if inserted, False if present.
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
NOTHING decides and inserts in one statement (the wrapper
serializes statements, not check-then-insert sequences, so a
SELECT/INSERT pair would let two concurrent adds both see no
row and the second one raise IntegrityError). rowcount reports
whether the insert actually happened.
"""
result = await self._database.execute(
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
"ON CONFLICT (ha_user_id) DO NOTHING",
(ha_user_id, added_at),
)
return result.rowcount > 0
async def remove(self, ha_user_id: str) -> bool:
"""Remove a user from the allowlist; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return result.rowcount > 0
async def list(self) -> list[AdminUserRecord]:
"""Return every allowlisted user, oldest first."""
rows = await self._database.fetch_all(
"SELECT ha_user_id, added_at FROM admin_users "
"ORDER BY added_at, ha_user_id"
)
return [
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
for row in rows
]
async def exists(self, ha_user_id: str) -> bool:
"""Return True if ``ha_user_id`` is allowlisted."""
row = await self._database.fetch_one(
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return row is not None
@@ -1,832 +0,0 @@
"""Typed DAO layer for quest_instances and the append-only completion_events.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Append-only policy (Feature 08, D-005): ``completion_events`` is
strictly append-only. This module is where that property is enforced
mechanically — the completion-events DAO below exposes NO update or
delete method at all; completions append, un-completions append a
separate reversal event, and instance state derives from the latest
event. A package-wide guard test (bottom of the test module) keeps
mutation SQL for this table out of every other module.
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, child_id, due_date, window) — re-running
materialization for a tuple whose instance already exists refreshes
only the generation-time snapshot columns and never duplicates a row.
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
last line of defense. The upsert refuses a child that is not an
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
(D-008 multi-assignee, multi-window model).
``delete_future_uncompleted`` implements the Feature 06/07 rule that
reassignment and schedule edits regenerate future instances: it
removes only instances at or after the cutoff that have NO completion
event, inside one transaction, so an instance completed mid-call can
never be deleted. Instances in the past are never touched (Feature 07
guardrail: never delete history), and no method here deletes a single
instance that has a completion event.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _validate_date, _validate_window
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
#: One accepted shape bounds lexicographic range queries correctly, so
#: the occurred-at scope does not guess at timestamp spellings.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _today() -> datetime.date:
"""Return the current local calendar date (the single date source).
Every "today" read in this module goes through this one function so
the delete clamp and the materialize walk's no-past guard read the
same clock, and a caller or test can pin/roll that clock
deterministically (e.g. a queued-across-midnight regression). Pure
clock read — no business rule lives here.
"""
return datetime.date.today()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the caller-resolved "today", or the host date when absent.
The no-past guard and the delete cutoffs must compare due dates
against the SAME calendar date the materialization horizon was built
from. Home Assistant's configured time zone can lag the host clock
around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous
behavior for callers with no HA-local date.
"""
return _today() if today is None else today
def _reject_past(due_date: str, today_iso: str) -> None:
"""Raise unless ``due_date`` is on or after ``today_iso``.
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
generated in the past, measured against the resolved "today".
"""
if due_date < today_iso:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today_iso!r}"
)
def _validate_utc_timestamp(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
Same strictness policy as _validate_date: parse with the exact
format, then round-trip the parsed value against the input so
non-padded or re-ordered shapes are rejected. The round-trip
strips the UTC offset (isoformat() omits it when the offset is
zero), so the suffix is appended before comparing.
"""
try:
parsed = datetime.datetime.strptime(
value, _UTC_TIMESTAMP_FORMAT
)
except (TypeError, ValueError):
raise ValueError(
f"{field} must be a UTC ISO-8601 timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
) from None
if parsed.isoformat() + "+00:00" != value:
raise ValueError(
f"{field} must be a strict UTC timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
)
def _next_day_start(date_str: str) -> str:
"""Return the UTC timestamp one second past ``date_str``'s last second.
Gives range queries an exact half-open upper bound without
fractional-second guessing.
"""
day = datetime.date.fromisoformat(date_str)
next_day = day + datetime.timedelta(days=1)
return f"{next_day.isoformat()}T00:00:00+00:00"
@dataclass(frozen=True)
class QuestInstanceRecord:
"""One row of ``quest_instances``."""
id: int
definition_id: int
child_id: int
window: str
due_date: str
due_time: str | None
generated_at: str
@dataclass(frozen=True)
class CompletionEventRecord:
"""One row of ``completion_events`` (append-only table)."""
id: int
instance_id: int
child_id: int
event_type: str
actor_source: str
actor_user_id: str | None
actor_child_id: int | None
occurred_at: str
was_on_time: bool | None
_INSTANCE_COLUMNS = (
"id, definition_id, child_id, window, due_date, due_time, generated_at"
)
_EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
#: the backstop, this is the DAO's typed front door.
EVENT_COMPLETED = "completed"
EVENT_UNCOMPLETED = "uncompleted"
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
return QuestInstanceRecord(
id=row[0],
definition_id=row[1],
child_id=row[2],
window=row[3],
due_date=row[4],
due_time=row[5],
generated_at=row[6],
)
def _event_from_row(row: tuple) -> CompletionEventRecord:
return CompletionEventRecord(
id=row[0],
instance_id=row[1],
child_id=row[2],
event_type=row[3],
actor_source=row[4],
actor_user_id=row[5],
actor_child_id=row[6],
occurred_at=row[7],
was_on_time=None if row[8] is None else bool(row[8]),
)
class QuestInstancesDao:
"""Typed async access to the ``quest_instances`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord:
"""Create the dated instance for the widened key idempotently.
The instance key is (definition_id, child_id, due_date, window)
(D-008): running materialization twice for the same tuple
yields ONE row — the ON CONFLICT path refreshes the
generation-time snapshot columns (due_time) and regenerates the
stamp, but can never create a duplicate. The schema's
UNIQUE(definition_id, child_id, due_date, window) backs this
up, so the twice-daily case (two windows, one child, one date)
yields two rows and the shared case (three assignees, one
window) yields three.
Guardrails enforced here:
- ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition.
``today`` optionally pins the resolved HA-local date the
guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around
midnight is not misread as "past".
- ``window`` must be one of ``const.QUEST_WINDOWS``.
- An existing instance WITH a completion event is IMMUTABLE:
the conflict path must not rewrite its snapshot columns
(Feature 07/08). The upsert refuses with ValueError in that
case — silently no-oping would mask a materialization bug.
- The child must be an assignee of the definition (D-008
multi-assignee model).
The definition must exist (validated atomically under the
connection lock so a concurrent definition deactivation or
child deletion cannot slip between check and insert).
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates BEFORE queuing on the
# connection lock (a nice early error); the AUTHORITATIVE
# check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs.
# A caller-supplied ``today`` (HA-local) is used for BOTH
# checks and never re-read, since it is already the anchor the
# batch runs against.
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers. A pinned HA-local
# anchor is exempt from the re-read (see above).
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignee = await self._database.fetch_one(
"SELECT 1 FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
if assignee is None:
raise ValueError(
f"quest definition {definition_id} is not "
f"assigned to child {child_id}; instance must "
"carry an assigned child"
)
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
raise ValueError(
f"instance for (definition {definition_id}, "
f"child {child_id}, due_date {due_date!r}, "
f"window {window!r}) already has a completion "
"event and is immutable; it cannot be regenerated"
)
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def upsert_if_valid(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord | None:
"""Insert one dated instance iff its tuple is still materializable.
This is the materialization walk's single write path. Unlike the
generic :meth:`upsert`, which validates only existence and
assignment, this method re-validates EVERY materialization
precondition in the SAME connection-lock-held transaction as the
INSERT — closing the check-then-insert gap a concurrent config
write could otherwise slip through:
- the definition exists AND is active (a definition deactivated
after the input snapshot is skipped, never written);
- the child exists AND is active (a deactivated child is skipped);
- the (definition, child) assignment link still exists (a removed
assignment is skipped);
- the window is still declared on the definition (a removed window
is skipped);
- the window's live ``due_time`` still EQUALS the caller-supplied
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
with no due time matches a snapshot with no due time). A
window-time edit landed after the input snapshot makes the
caller's ``due_time`` stale, and writing it would overwrite the
newer due_time a concurrent regeneration already upserted — so
the tuple is skipped instead of written.
The due_time check closes the one check-then-insert gap this
method previously left open: it re-validated every OTHER
precondition but then wrote the snapshot's ``due_time``
unconditionally, so a stale materialization could clobber a
newer, concurrently-regenerated due_time.
Returns the stored instance, or ``None`` when a precondition
failed — the tuple no longer materializable, which the walk SKIPS
rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today``
optionally pins the HA-local anchor it compares against. An
instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — the
generic :meth:`upsert` keeps its hard refusal for the
delete/regenerate path.
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates before queuing on the lock;
# the authoritative check re-runs under the lock (see upsert).
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
still_valid = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions d "
"JOIN quest_definition_assignees a "
"ON a.definition_id = d.id "
"JOIN children c ON c.id = a.child_id "
"JOIN quest_definition_windows w "
"ON w.definition_id = d.id "
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
"AND d.is_active = 1 AND c.is_active = 1 "
"AND w.due_time IS ? LIMIT 1",
(definition_id, child_id, window, due_time),
)
if still_valid is None:
# A config change invalidated the tuple since the
# snapshot: skip it rather than abort the batch.
return None
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
# The instance is already completed and therefore
# immutable: the walk SKIPS it (no-op) rather than
# rewriting its snapshot columns or failing the
# batch. This is the materialization walk's skip,
# distinct from the generic :meth:`upsert` refusal.
return None
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def get(
self,
definition_id: int,
child_id: int,
due_date: str,
window: str,
) -> QuestInstanceRecord | None:
"""Return the instance for the widened key tuple, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
"AND window = ?",
(definition_id, child_id, due_date, window),
)
return _instance_from_row(row) if row is not None else None
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
"""Return the instance with ``instance_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
(instance_id,),
)
return _instance_from_row(row) if row is not None else None
async def list_by_child_and_date(
self, child_id: int, due_date: str
) -> list[QuestInstanceRecord]:
"""Return the child's instances due exactly on ``due_date``."""
_validate_date(due_date, "due_date")
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date = ? ORDER BY id",
(child_id, due_date),
)
return [_instance_from_row(row) for row in rows]
async def list_by_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[QuestInstanceRecord]:
"""Return the child's instances with due_date in the closed range."""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
"ORDER BY due_date, id",
(child_id, range_start, range_end),
)
return [_instance_from_row(row) for row in rows]
async def list_missed_unswept(
self, today_iso: str, watermark: str | None
) -> list[QuestInstanceRecord]:
"""Return past-due open instances the sweep has not announced.
Feature 11's missed-sweep query: ``due_date`` strictly before
``today_iso`` (past due), at or after ``watermark`` when one is
recorded, and with NO completion event ever (the Feature 08
append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is
the HA-local date the sweep last ran, and the window is
``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night
rerun (watermark == today) sees an empty window. Read-only;
ordered deterministically by (due_date, id).
"""
_validate_date(today_iso, "today_iso")
if watermark is not None:
_validate_date(watermark, "watermark")
sql = (
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE due_date < ? "
"AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")"
)
parameters: tuple = (today_iso,)
if watermark is not None:
sql += " AND due_date >= ?"
parameters += (watermark,)
sql += " ORDER BY due_date, id"
rows = await self._database.fetch_all(sql, parameters)
return [_instance_from_row(row) for row in rows]
async def delete_future_uncompleted(
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> int:
"""Delete the definition's open instances at/after cutoff.
Implements regeneration after a definition edit or reassignment
(Features 06/07): only instances at or after the cutoff with NO
completion event are removed, inside one transaction — an
instance that gains a completion event mid-flight cannot be
deleted. The cutoff cannot be backdated below today: "future"
is measured against TODAY (materialization regenerates the
rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against
(falling back to the host clock when omitted). Any instance with
a completion event is never touched, regardless of its date.
Returns the number deleted.
"""
_validate_date(cutoff_date, "cutoff_date")
async with _connection_lock(self._database):
async with self._database.transaction():
# "today" is read under the lock: the DELETE actually
# executes on this date, so a caller queued across
# midnight cannot delete instances that became past
# while it waited (backdating is clamped to the
# execution date, not the call date).
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(definition_id, effective_cutoff),
)
return result.rowcount
async def delete_future_uncompleted_for_child(
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> tuple[int, str]:
"""Delete the child's open instances at/after cutoff, across every
definition, returning ``(deleted, effective_cutoff)``.
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
used when a child's presence changes (Feature 09): only instances
at or after the cutoff with NO completion event are removed, inside
one transaction — an instance that gains a completion event
mid-flight cannot be deleted. The cutoff cannot be backdated below
today, for the same reason as the definition-scoped method (see its
docstring). ``today`` optionally pins the HA-local anchor the clamp
compares against (falling back to the host clock when omitted).
Any instance with a completion event is never touched, regardless
of its date.
Returns the number deleted and the effective cutoff that was
actually applied (``max(cutoff_date, today)``, read under the
lock). The caller (:func:`~.materialize.regenerate_for_child`)
passes that effective cutoff back as its re-materialization anchor,
so the delete and the rebuild share ONE execution-day even when the
call is queued across midnight — instead of the caller re-reading a
stale "today" and materializing a window the delete already
superseded.
"""
_validate_date(cutoff_date, "cutoff_date")
async with _connection_lock(self._database):
async with self._database.transaction():
# "today" is read under the lock, matching the
# definition-scoped method: the DELETE executes on this
# date, so a caller queued across midnight cannot delete
# instances that became past while it waited.
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(child_id, effective_cutoff),
)
return result.rowcount, effective_cutoff
class CompletionEventsDao:
"""Typed append-only access to the ``completion_events`` table.
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
three read methods below, and NOTHING else. There is no update,
no delete, no upsert — completions and un-completions both arrive
through :meth:`append` as new rows, and history is immutable
(Feature 08 done-condition: no code path updates or deletes an
event). The package-wide leak-guard test additionally fails if
mutation SQL for this table ever appears in any module.
"""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def append(
self,
instance_id: int,
child_id: int,
event_type: str,
actor_source: str,
occurred_at: str,
was_on_time: bool | None,
*,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event.
``actor_source`` is 'user' (an admin action: ``actor_user_id``
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
id, and ``actor_child_id`` carrying the tapped child profile,
D-008); the actor-pair CHECKs enforce this shape at the schema
level. ``occurred_at`` must be the module's one strict UTC
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
queries stay exact. ``was_on_time`` is required (True/False)
for completions; for un-completions it may be None or the
reversed completion's flag (Feature 08 still deciding). The
instance, child and — for panel events — the tapped child must
exist — validated atomically under the connection lock.
"""
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
)
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got "
f"{actor_source!r}"
)
if actor_source == "user" and not actor_user_id:
raise ValueError(
"actor_source 'user' requires actor_user_id"
)
if actor_source == "panel" and actor_user_id is not None:
raise ValueError(
"actor_source 'panel' must not carry actor_user_id"
)
if actor_source == "panel" and actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id "
"(the tapped child profile)"
)
if actor_source == "user" and actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got "
f"{was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
_validate_utc_timestamp(occurred_at, "occurred_at")
async with _connection_lock(self._database):
async with self._database.transaction():
instance = await self._database.fetch_one(
"SELECT child_id FROM quest_instances WHERE id = ?",
(instance_id,),
)
if instance is None:
raise ValueError(
f"quest instance {instance_id} does not exist"
)
if instance[0] != child_id:
raise ValueError(
f"quest instance {instance_id} belongs to child "
f"{instance[0]}, not {child_id}"
)
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
if actor_child_id is not None:
tapped = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?",
(actor_child_id,),
)
if tapped is None:
raise ValueError(
f"actor_child_id {actor_child_id} does not "
"reference an existing child"
)
result = await self._database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
instance_id,
child_id,
event_type,
actor_source,
actor_user_id,
actor_child_id,
occurred_at,
None if was_on_time is None else int(was_on_time),
),
)
event = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE id = ?",
(result.lastrowid,),
)
assert event is not None
return _event_from_row(event)
async def list_by_instance(
self, instance_id: int
) -> list[CompletionEventRecord]:
"""Return every event for the instance, in append order."""
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id",
(instance_id,),
)
return [_event_from_row(row) for row in rows]
async def list_by_child_and_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
*,
due_date_scope: bool = True,
) -> list[CompletionEventRecord]:
"""Return the child's events for instances due in the closed range.
Scopes by the INSTANCE's due date (the day the task was owed),
not the event's wall-clock timestamp: history reporting asks
"what happened to the tasks due that day" (Feature 13). Set
``due_date_scope=False`` to filter by ``occurred_at`` date
instead. Ordered by instance, then event id.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
if due_date_scope:
predicate = (
"SELECT id FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND due_date <= ?"
)
parameters: list[object] = [child_id, range_start, range_end]
else:
# Occurred-at scope: occurred_at is validated to the one
# accepted UTC shape at append time, so half-open day
# bounds in that same shape are exact (no spelling drift).
occurred_start = f"{range_start}T00:00:00+00:00"
occurred_end = f"{range_end}T23:59:59+00:00"
query = (
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE child_id = ? AND occurred_at >= ? "
"AND occurred_at < ? ORDER BY instance_id, id"
)
rows = await self._database.fetch_all(
query,
[
child_id,
occurred_start,
_next_day_start(range_end),
],
)
return [_event_from_row(row) for row in rows]
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
f"WHERE instance_id IN ({predicate}) "
"ORDER BY instance_id, id",
tuple(parameters),
)
return [_event_from_row(row) for row in rows]
async def get_latest_for_instance(
self, instance_id: int
) -> CompletionEventRecord | None:
"""Return the instance's most recent event, or None if untouched.
Instance current state derives from this event (Feature 08):
'completed' means done, 'uncompleted' as the latest means open
again, None means never completed.
"""
row = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
(instance_id,),
)
return _event_from_row(row) if row is not None else None
@@ -1,44 +0,0 @@
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
Generic persistent key/value bookkeeping that belongs to the
integration itself rather than any domain table — currently the
Feature 11 missed-sweep watermark. Values are plain strings; callers
interpret them. All SQL for this table lives here per the feature
guardrails, and every method runs through
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
"""
from __future__ import annotations
from .dao_children import _connection_lock
from .db import NestQuestDatabase
_TABLE = "nestquest_meta_state"
class MetaStateDao:
"""Typed async access to the ``nestquest_meta_state`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def get(self, key: str) -> str | None:
"""Return the stored value for ``key``, or None when absent."""
row = await self._database.fetch_one(
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
)
return row[0] if row is not None else None
async def set(self, key: str, value: str) -> None:
"""Upsert one key/value row atomically.
The write runs inside one transaction under the shared
connection lock, so a racing reader either sees the old value
or the fully-written new one, never a half-applied state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
@@ -1,343 +0,0 @@
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
start and end (schema CHECK requires end >= start).
Date arguments are validated as strict YYYY-MM-DD per the same policy
the rules DAO applies: the schema's lexical CHECKs cannot prove
calendar validity.
"""
from __future__ import annotations
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _UNSET, _validate_date
from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
id: int
child_id: int
cycle_length_weeks: int
anchor_date: str
pattern: str
@dataclass(frozen=True)
class PresenceOverrideRecord:
"""One row of ``presence_overrides``."""
id: int
child_id: int
start_date: str
end_date: str
is_present: bool
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
)
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
return PresenceOverrideRecord(
id=row[0],
child_id=row[1],
start_date=row[2],
end_date=row[3],
is_present=bool(row[4]),
note=row[5],
)
class PresenceSchedulesDao:
"""Typed async access to the ``presence_schedules`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
(child_id,),
)
return _schedule_from_row(row) if row is not None else None
async def delete(self, child_id: int) -> bool:
"""Remove the child's schedule; True if a row was removed.
After deletion the child is present every day (no row means
no restriction). Deleting a schedule the child never had
returns False.
"""
result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
)
return result.rowcount > 0
class PresenceOverridesDao:
"""Typed async access to the ``presence_overrides`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def create(
self,
child_id: int,
start_date: str,
end_date: str,
is_present: bool,
*,
note: str | None = None,
) -> PresenceOverrideRecord:
"""Insert one override and return the record as stored.
The child must exist (validated atomically under the
connection lock). Date shape is validated strictly, and the
schema CHECK rejects end < start.
Overlap policy (Feature 05): an override for a child may not
overlap another override of the SAME child — two different
present/absent answers for one date are unresolvable. The
overlap check (inclusive boundaries) runs inside the same
transaction as the INSERT, so a racing create cannot slip a
conflicting override between the check and the write; a
conflict raises ValueError naming the conflicting override's
id and range.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
# Only a well-ordered range has overlap semantics:
# inverted ranges fall through to the INSERT and hit
# the schema's end>=start CHECK, preserving the
# IntegrityError contract the matrix tests rely on.
if end_date >= start_date:
conflicting = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} "
"FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? "
"AND start_date <= ? ORDER BY id LIMIT 1",
(child_id, start_date, end_date),
)
else:
conflicting = None
if conflicting is not None:
conflict = _override_from_row(conflicting)
raise ValueError(
f"override for child {child_id} "
f"[{start_date}, {end_date}] overlaps existing "
f"override {conflict.id} "
f"[{conflict.start_date}, {conflict.end_date}] "
f"(is_present={conflict.is_present})"
)
result = await self._database.execute(
"INSERT INTO presence_overrides (child_id, "
"start_date, end_date, is_present, note) "
"VALUES (?, ?, ?, ?, ?)",
(child_id, start_date, end_date, int(is_present), note),
)
override = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE id = ?",
(result.lastrowid,),
)
assert override is not None
return _override_from_row(override)
async def list_by_child_and_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[PresenceOverrideRecord]:
"""Return the child's overrides overlapping [range_start, range_end].
An override overlaps the range when it ends on or after the
range starts and starts on or before the range ends (inclusive
boundary dates, matching the storage model where overrides are
closed date ranges). Ordered by start date.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
"ORDER BY start_date",
(child_id, range_start, range_end),
)
return [_override_from_row(row) for row in rows]
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
"""Return one override by id, or None when it does not exist."""
row = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE id = ?",
(override_id,),
)
return _override_from_row(row) if row is not None else None
async def delete(self, override_id: int) -> bool:
"""Remove one override by id; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM presence_overrides WHERE id = ?",
(override_id,),
)
return result.rowcount > 0
async def read_snapshot_unlocked(
database: NestQuestDatabase,
child_ids: list[int],
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
``child_ids`` must name existing children (the caller derives them
from a trusted snapshot, within the same transaction).
"""
children = sorted(set(child_ids))
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
tuple(children),
)
override_rows = await database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
File diff suppressed because it is too large Load Diff
-436
View File
@@ -1,436 +0,0 @@
"""Async SQLite connection wrapper for the NestQuest integration."""
from __future__ import annotations
import asyncio
import sqlite3
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
T = TypeVar("T")
#: Executor contract: schedule a synchronous callable on a worker
#: thread/futures pool and await its result. Mirrors the shape of
#: ``hass.async_add_executor_job`` so the integration wires it in with a
#: bare method reference, but this package never imports Home Assistant.
Executor = Callable[..., Awaitable[T]]
@dataclass(frozen=True)
class ExecutionResult:
"""Plain-data outcome of one statement.
``rowcount`` and ``lastrowid`` mirror the corresponding
:class:`sqlite3.Cursor` attributes; both are captured on the executor
thread so no sqlite3 object ever crosses back to the event loop.
"""
rowcount: int
lastrowid: int | None
class NestQuestDatabase:
"""Async wrapper around a single SQLite connection.
Every sqlite3 call is delegated to a worker through the ``executor``
callable passed at construction (mirroring the contract of
``hass.async_add_executor_job``) so the event loop is never blocked,
and only plain Python data (row tuples and
:class:`ExecutionResult`) is returned to the event loop.
Every operation is serialized through one asyncio.Lock per wrapper. A
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
interleaved coroutines cannot corrupt the transaction and concurrent
BEGINs are impossible; statements issued while a transaction is open
queue until it completes. Concurrent transactions on one wrapper are
not supported: a second ``transaction()`` entered while one is active
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
and is idempotent.
Every connection-affecting executor job (connect, pragmas, statements,
transaction lifecycle and close) is awaited through the settlement
helper :meth:`_await_settled`: if the awaiting task is cancelled while
a job is in flight, the job still settles on the worker thread before
the cancellation propagates, so the wrapper lock is never released
while the executor thread is mid-job.
"""
def __init__(self, executor: Callable[..., Awaitable[T]]) -> None:
"""Initialize the wrapper around an executor callable.
``executor`` schedules a synchronous callable on a worker and
returns an awaitable of its result — the contract of
``hass.async_add_executor_job``. The integration passes that
method directly; tests pass a helper backed by
:func:`asyncio.to_thread` or an inline runner.
"""
self._executor: Callable[..., Awaitable[Any]] = executor
self._conn: sqlite3.Connection | None = None
self._rowcount: int = -1
self._lastrowid: int | None = None
self._lock: asyncio.Lock | None = None
self._lock_loop: asyncio.AbstractEventLoop | None = None
self._in_transaction = False
self._tx_task: asyncio.Task | None = None
def _get_lock(self) -> asyncio.Lock:
"""Return the per-wrapper lock, (re)bound to the running loop.
HA drives every wrapper call from one event loop, so the lock is
created once and reused; re-binding only when the running loop
differs keeps the wrapper usable from plain synchronous drivers
that run each call on a fresh loop.
"""
loop = asyncio.get_running_loop()
if self._lock is None or self._lock_loop is not loop:
self._lock = asyncio.Lock()
self._lock_loop = loop
return self._lock
@property
def connected(self) -> bool:
"""Return True while the underlying connection is open."""
return self._conn is not None
@property
def rowcount(self) -> int:
"""Row count of the most recent statement, or -1 when unset."""
return self._rowcount
@property
def lastrowid(self) -> int | None:
"""Row id of the most recent insert, or None when unset."""
return self._lastrowid
@asynccontextmanager
async def _serialized(self) -> AsyncIterator[None]:
"""Hold the wrapper lock unless the caller owns the active transaction.
A transaction holds the lock for its whole BEGIN..COMMIT span;
statements issued by the transaction's own task are already
serialized by it and must not re-acquire the non-reentrant lock.
Any other task queues on the lock until the transaction ends.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
yield
return
async with self._get_lock():
yield
async def open(self, path: Path | str) -> NestQuestDatabase:
"""Open the SQLite file with WAL journal mode and foreign keys on.
Cancellation-safe: if this call is cancelled while the connect job
is in flight, the job still settles (see :meth:`_await_settled`).
A connection the settled job created is never adopted by the
cancelled call; it is closed through a settled executor job instead
of being abandoned to garbage collection, and the cancellation
propagates afterwards, so a follow-up ``open()`` starts from a
clean state.
"""
if self._conn is not None:
return self
async with self._get_lock():
if self._conn is not None:
return self
def _open() -> sqlite3.Connection:
conn = sqlite3.connect(
path, check_same_thread=False, isolation_level=None
)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
except BaseException:
conn.close()
raise
return conn
settled, cancelled = await self._await_settled_state(
self._executor(_open)
)
if cancelled:
if isinstance(settled, sqlite3.Connection):
def _close_abandoned() -> None:
settled.close()
await self._await_settled(
self._executor(_close_abandoned)
)
raise asyncio.CancelledError()
self._conn = settled
return self
async def execute(
self, sql: str, parameters: Sequence[Any] = ()
) -> ExecutionResult:
"""Run one statement in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute() -> ExecutionResult:
cursor = conn.execute(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._executor(_execute)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def execute_many(
self, sql: str, parameters: Sequence[Sequence[Any]]
) -> ExecutionResult:
"""Run executemany in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute_many() -> ExecutionResult:
cursor = conn.executemany(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._executor(_execute_many)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def fetch_one(
self, sql: str, parameters: Sequence[Any] = ()
) -> tuple | None:
"""Run a query in the executor and return its first row, or None."""
async with self._serialized():
conn = self._require_conn()
def _fetch_one() -> tuple[tuple | None, int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
row, rowcount, lastrowid = await self._await_settled(
self._executor(_fetch_one)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return row
async def fetch_all(
self, sql: str, parameters: Sequence[Any] = ()
) -> list[tuple]:
"""Run a query in the executor and return all of its rows."""
async with self._serialized():
conn = self._require_conn()
def _fetch_all() -> tuple[list[tuple], int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
rows, rowcount, lastrowid = await self._await_settled(
self._executor(_fetch_all)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return rows
@asynccontextmanager
async def transaction(self) -> AsyncIterator[None]:
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
other statement can interleave inside the transaction. Entering a
second transaction while one is active raises RuntimeError. A
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
a ROLLBACK before the commit error propagates, so the real
connection is never left inside a stray transaction.
The lifecycle is cancellation-safe. Transaction ownership and the
wrapper lock are only released after the executor work has fully
settled, even when the owning task is cancelled mid-flight: if a
cancelled BEGIN or COMMIT still completes on the worker thread,
the corresponding ROLLBACK is shielded and awaited before any state
is cleared. A lifecycle statement that fails plus a failing
follow-up cleanup surface both errors in an ExceptionGroup.
"""
if self._in_transaction:
raise RuntimeError(
"A NestQuest transaction is already active on this "
"connection; concurrent transactions are not supported"
)
async with self._get_lock():
conn = self._require_conn()
self._in_transaction = True
self._tx_task = asyncio.current_task()
try:
def _begin() -> None:
conn.execute("BEGIN")
await self._settle_lifecycle(
conn,
self._executor(_begin),
"BEGIN did not settle cleanly and the follow-up "
"cleanup also raised; the transaction may need manual "
"cleanup",
)
try:
yield
except BaseException:
def _rollback() -> None:
conn.execute("ROLLBACK")
await self._settle_lifecycle(
conn,
self._executor(_rollback),
"ROLLBACK failed and the follow-up cleanup also "
"raised; the transaction may need manual cleanup",
)
raise
def _commit() -> None:
conn.execute("COMMIT")
await self._settle_lifecycle(
conn,
self._executor(_commit),
"COMMIT failed and the follow-up cleanup also raised; "
"the transaction may need manual cleanup",
)
finally:
self._in_transaction = False
self._tx_task = None
async def _settle_lifecycle(
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
) -> None:
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
The job always settles first (see :meth:`_await_settled`), so a
cancelled statement that the worker thread completed anyway is
never abandoned mid-flight. If the job raised, a follow-up
ROLLBACK — itself settled — guarantees the connection ends outside
any SQLite transaction before the error propagates. When both the
job and the cleanup raise, both errors are preserved in an
ExceptionGroup instead of the original being lost.
"""
try:
await self._await_settled(job)
except BaseException as settle_error:
errors: list[BaseException] = [settle_error]
try:
await self._ensure_no_stray_transaction(conn)
except BaseException as cleanup_error:
errors.append(cleanup_error)
if len(errors) == 2:
raise BaseExceptionGroup(message, errors) from settle_error
raise
async def _ensure_no_stray_transaction(
self, conn: sqlite3.Connection
) -> None:
"""Force the connection out of any transaction via ROLLBACK.
Runs after a failed or cancelled lifecycle statement. The worker
thread may still be finishing (or may already have completed) the
very statement whose await was cancelled, so both the check and
the ROLLBACK settle in the executor before the caller releases
transaction ownership.
"""
def _cleanup() -> bool:
if conn.in_transaction:
conn.execute("ROLLBACK")
return conn.in_transaction
stray = await self._await_settled(
self._executor(_cleanup)
)
if stray:
raise RuntimeError(
"The SQLite connection is still inside a transaction after "
"cleanup; the transaction may need manual cleanup"
)
async def _await_settled(self, job: Awaitable[Any]) -> Any:
"""Await ``job``, letting it settle even if this task is cancelled.
Cancellation is observed, not swallowed: it is re-raised only once
the job has fully settled, so the caller can finalize against a
known connection state instead of abandoning in-flight work. The
job is wrapped in a task because a bare coroutine dies un-awaited
once its await is cancelled. A second cancellation during
settlement is absorbed the same way; if the job itself fails after
a cancellation, both errors surface in an ExceptionGroup.
"""
result, cancelled = await self._await_settled_state(job)
if cancelled:
raise asyncio.CancelledError()
return result
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
"""Await ``job`` to settlement and report ``(result, cancelled)``.
Same settlement guarantees as :meth:`_await_settled`, but returns
the settled outcome together with whether the awaiting task was
cancelled, so callers that must act on a settled result before
re-raising cancellation (e.g. closing an abandoned connection)
can do so without losing the result.
"""
cancelled = False
inner = asyncio.ensure_future(job)
while not inner.done():
try:
await asyncio.shield(inner)
except asyncio.CancelledError:
cancelled = True
try:
result = inner.result()
except BaseException as job_error:
if cancelled:
raise BaseExceptionGroup(
"executor job failed while settling after cancellation",
[asyncio.CancelledError(), job_error],
) from job_error
raise
return result, cancelled
async def close(self) -> None:
"""Close the connection in the executor; safe to call repeatedly.
Serialized on the wrapper lock, so an in-flight operation or open
transaction always completes before the connection is closed.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
raise RuntimeError(
"Cannot close the NestQuest database from inside its own "
"transaction; exit the transaction first"
)
async with self._get_lock():
conn, self._conn = self._conn, None
self._rowcount, self._lastrowid = -1, None
if conn is None:
return
def _close() -> None:
conn.close()
await self._await_settled(self._executor(_close))
def _require_conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError(
"The NestQuest database connection is closed; call open() first"
)
return self._conn
@@ -1,593 +0,0 @@
"""Versioned schema migration runner for the NestQuest SQLite database.
The runner owns one metadata table, ``nestquest_schema_version``, holding
a single row with the schema version the database file currently
reflects. Migrations are declared as an ordered list: entry ``n``
upgrades version ``n-1`` to version ``n``. Applying a migration means
running its DDL inside one transaction and stamping the new version in
that same transaction, so a database never persists a half-applied
migration: any failure rolls the statements and the version stamp back
together, leaving the file at its prior version.
Version 0 means "the file exists but carries no NestQuest schema yet"
— a fresh file, or a file this integration has never written. Starting
the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1.
Migration 1 was rewritten pre-release (D-007): nothing was ever
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
instead of the legacy ``task_*`` spellings, and no compatibility shim
exists. Dev machines that already hold a version-1 database with
``task_*`` tables are handled by migration 2, which detects the legacy
table names and ``ALTER TABLE ... RENAME TO``s them to their current
names before anything else assumes the new model. Migration 3 brings
pre-D-008 databases to the multi-assignee model: it creates
``quest_definition_assignees`` and, when ``quest_definitions`` still
carries a single ``child_id`` column, rebuilds the table without it,
copying each definition's assignee across. Migration 4 adds the
windows table, migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Each callable migration runs inside its own
transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking ``(database, target_version)``. Callables
exist for steps whose SQL depends on the database's current state (a
legacy rename only fires when a legacy table is present; a rebuild only
when the old column shape is present) and own their transaction so they
can bracket it with connection pragmas the runner's uniform transaction
cannot express.
Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet.
An UPDATE form would silently match zero rows there and leave the
database migrated but unstamped — reporting version 0 forever, and
re-applying migration 1 on every startup. The DELETE+INSERT shape is
also what makes the stamp a replace, so the row never duplicates.
Idempotence across restarts: a database already at the latest version
has no pending migrations, so the runner opens a read-only check, finds
nothing to do, and leaves the file byte-untouched (no write transaction
is opened, so no WAL churn).
Migration list policy: entries are append-only. Once shipped, a
migration's SQL must never be edited — an old database applying it for
the first time and a fresh database would then diverge. Fix-forward
with a new migration instead. The version stamp lives in the same
SQLite file as the data it describes, so restoring a backup restores
its matching schema version with it.
Corrupt or missing files are not this module's concern: startup
handling of those cases is task d5bca74b, which decides whether to
open fresh or raise ConfigEntryNotReady. This runner assumes it is
handed an open, working connection.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Union
from .db import NestQuestDatabase
from .schema import (
COMPLETION_EVENTS_TABLE_SQL,
QUEST_DEFINITIONS_TABLE_SQL,
QUEST_INSTANCES_TABLE_SQL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V7_META_STATE_DDL,
)
LOGGER = logging.getLogger(__name__)
#: One migration entry: either an ordered sequence of SQL statements or
#: an async callable taking ``(database, target_version)`` that manages
#: its own transaction and stamps via :func:`stamp_schema_version`.
MigrationStep = Union[
Sequence[str],
Callable[[NestQuestDatabase, int], Awaitable[None]],
]
#: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table.
VERSION_TABLE = "nestquest_schema_version"
#: DDL for the version metadata table. The singleton-row invariant is
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
#: PRIMARY KEY, so the table can never hold a second row, and the
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
#: a positive integer: version 0 means "no row" and is never stored,
#: so a negative or zero stored value is corruption, not a state the
#: runner can act on.
VERSION_TABLE_DDL = f"""
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL CHECK (version >= 1)
)
"""
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: The assignees table DDL, shared with migration 1 via the schema
#: module so a rebuilt and a freshly created table cannot drift.
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
#: Legacy (pre-D-007) table name -> current table name. Order matters:
#: the parent table renames first so the child's foreign key reference
#: is rewritten while its parent's new name is already in place.
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
("task_definitions", "quest_definitions"),
("task_instances", "quest_instances"),
)
async def stamp_schema_version(
database: NestQuestDatabase, version: int
) -> None:
"""Write the schema version stamp (inside the caller's transaction).
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
file has no row yet, so UPDATE alone would silently match zero rows
there and leave a migrated-but-unstamped database.
"""
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
(version,),
)
async def _table_has_column(
database: NestQuestDatabase, table: str, column: str
) -> bool:
"""Return True when ``table`` currently carries ``column``."""
rows = await database.fetch_all(f"PRAGMA table_info({table})")
return any(row[1] == column for row in rows)
#: Window assigned to instances migrated from the pre-window instance
#: shape (D-008). Pre-window rows were generated for whole-day
#: obligations; the morning window is the documented default landing
#: spot, and future dates are regenerated with real window declarations
#: by materialization (delete_future_uncompleted + re-run).
LEGACY_MIGRATED_WINDOW = "morning"
async def _rename_legacy_task_tables(
database: NestQuestDatabase, target_version: int
) -> None:
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
Dev-machines-only path (D-007): a database stamped version 1 by the
pre-rename runner holds ``task_definitions``/``task_instances``.
Each legacy name still present is renamed; fresh databases find
nothing and this step is a no-op. Modern SQLite rewrites foreign
key clauses in other tables to follow the rename, so the renamed
schema is indistinguishable from a freshly created one apart from
internal autoindex names. Runs in its own transaction together
with the version stamp, so a crash rolls both back.
"""
async with database.transaction():
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
row = await database.fetch_one(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = ?",
(legacy_name,),
)
if row is not None:
await database.execute(
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
)
LOGGER.info(
"Renamed legacy table %s to %s",
legacy_name,
current_name,
)
await stamp_schema_version(database, target_version)
async def _add_definition_assignees(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring definitions to the multi-assignee model (D-008).
Two shapes arrive here:
- Fresh or renamed databases whose ``quest_definitions`` already
matches the current canonical DDL (no ``child_id`` column): only
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
when migration 1 already made it).
- Databases stamped at version 2 whose ``quest_definitions`` still
carries the single ``child_id`` column: the table is rebuilt via
SQLite's canonical procedure — create the new table, copy rows,
move each definition's assignee into
``quest_definition_assignees``, drop the old table, rename — so
``DROP COLUMN``'s foreign-key restriction never applies and no
definition or instance row is lost.
The copy runs with ``foreign_keys = OFF`` because dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
runs inside the same transaction before the stamp, so a corrupt
copy aborts the whole migration instead of persisting. The pragma
is a connection-level no-op inside a transaction, hence the
off/transaction/on bracket.
"""
legacy_child_id = await _table_has_column(
database, "quest_definitions", "child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
await database.execute(_ASSIGNEES_TABLE_DDL)
if legacy_child_id:
await database.execute(
"CREATE TABLE quest_definitions_rebuilt "
f"{QUEST_DEFINITIONS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_definitions_rebuilt "
"(id, title, description, icon, schedule_rule_id, "
"due_time, is_active, created_at) "
"SELECT id, title, description, icon, "
"schedule_rule_id, due_time, is_active, created_at "
"FROM quest_definitions ORDER BY id"
)
await database.execute(
"INSERT OR IGNORE INTO quest_definition_assignees "
"(definition_id, child_id) "
"SELECT id, child_id FROM quest_definitions"
)
await database.execute("DROP TABLE quest_definitions")
await database.execute(
"ALTER TABLE quest_definitions_rebuilt "
"RENAME TO quest_definitions"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"definition rebuild produced foreign-key "
f"violations: {violations!r}"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _widen_instance_key(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``quest_instances`` to the D-008 instance key.
Databases whose instance table still carries the pre-window shape
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
rebuilt via SQLite's canonical procedure into the widened shape —
UNIQUE (definition_id, child_id, due_date, window) — with every
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
ids (and the completion events referencing them) survive intact.
The old rows were unique per (definition, due_date), so pinning one
window cannot collide with the widened key. Fresh databases whose
table already matches the canonical shape only stamp.
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``completion_events`` foreign key) and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp, so a corrupt copy aborts the whole migration.
"""
legacy_shape = not await _table_has_column(
database, "quest_instances", "window"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE quest_instances_rebuilt "
f"{QUEST_INSTANCES_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_instances_rebuilt "
"(id, definition_id, child_id, window, due_date, "
"due_time, generated_at) "
"SELECT id, definition_id, child_id, ?, due_date, "
"due_time, generated_at "
"FROM quest_instances ORDER BY id",
(LEGACY_MIGRATED_WINDOW,),
)
await database.execute("DROP TABLE quest_instances")
await database.execute(
"ALTER TABLE quest_instances_rebuilt "
"RENAME TO quest_instances"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"instance rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Widened quest_instances key; migrated rows pinned "
"to the %r window",
LEGACY_MIGRATED_WINDOW,
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _add_actor_child_id(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``completion_events`` to the D-008 actor-child shape.
The ``actor_child_id`` column records which panel profile was
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
because the canonical shape also carries an actor-pair coherence
CHECK (panel events require it, user events forbid it), and CHECK
constraints cannot be added to an existing table. Databases with
the legacy shape are therefore rebuilt via SQLite's canonical
procedure, backfilling each legacy panel row's ``actor_child_id``
to ``child_id`` — pre-D-008 panel completions were always recorded
against the tapped child themselves — while admin rows stay NULL.
Row ids are preserved so ``quest_instances`` references and the
append-only history stay intact. Fresh databases already match
the canonical shape and only stamp.
The rebuild runs with ``foreign_keys = OFF`` and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
completion_events table keeps its append-only guarantee.
"""
legacy_shape = not await _table_has_column(
database, "completion_events", "actor_child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE completion_events_rebuilt "
f"{COMPLETION_EVENTS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO completion_events_rebuilt "
"(id, instance_id, child_id, event_type, actor_source, "
"actor_user_id, actor_child_id, occurred_at, "
"was_on_time) "
"SELECT id, instance_id, child_id, event_type, "
"actor_source, actor_user_id, "
"CASE WHEN actor_source = 'panel' THEN child_id "
"ELSE NULL END, "
"occurred_at, was_on_time "
"FROM completion_events ORDER BY id"
)
await database.execute("DROP TABLE completion_events")
await database.execute(
"ALTER TABLE completion_events_rebuilt "
"RENAME TO completion_events"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"completion_events rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Added actor_child_id to completion_events; legacy "
"panel rows backfilled to their own child_id"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
MIGRATIONS: Sequence[MigrationStep] = [
MIGRATION_1_V1_DDL,
_rename_legacy_task_tables,
_add_definition_assignees,
# Migration 4 (D-008): the windows table is a plain new table — a
# CREATE IF NOT EXISTS suffices for both fresh databases (which
# already made it in migration 1) and pre-window databases.
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
# Migration 5 (D-008): rebuild quest_instances onto the widened
# (definition_id, child_id, due_date, window) key.
_widen_instance_key,
# Migration 6 (D-008): rebuild completion_events with the
# actor_child_id column and its actor-pair coherence CHECK,
# backfilling legacy panel rows to their own child_id.
_add_actor_child_id,
# Migration 7 (Feature 11): the generic meta_state key/value table
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
# fresh databases (which already made it in migration 1) and
# pre-v7 databases. Strictly additive; no existing table changes.
list(SCHEMA_V7_META_STATE_DDL),
]
async def _create_version_table(database: NestQuestDatabase) -> None:
"""Create the version metadata table if it does not exist.
The table is created before any version check: it is the runner's
own bookkeeping, and creating it is idempotent. The DDL enforces
the singleton-row invariant (``id`` fixed at 1) and a positive
integer version at the database level.
"""
await database.execute(VERSION_TABLE_DDL)
async def read_schema_version(database: NestQuestDatabase) -> int:
"""Return the schema version recorded in the database.
Returns 0 when the version table or its row is missing — the
"fresh file" case. A stored version higher than the runner knows
is surfaced as-is; the caller decides how to treat a future version.
Reading is deliberately passive: a missing table raises, it is not
swallowed into 0, because :func:`apply_migrations` always creates
the table before reading and a direct reader hitting "no such
table" is a caller bug worth surfacing, not a fresh file.
"""
row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
)
if row is None:
return 0
version = row[0]
if not isinstance(version, int) or isinstance(version, bool):
raise RuntimeError(
f"Schema version metadata is not an integer: {version!r}"
)
if version < 1:
raise RuntimeError(
f"Schema version metadata is invalid (must be >= 1): {version!r}"
)
return version
#: One asyncio.Lock per connection wrapper, created lazily and bound to
#: the running loop, serializing whole apply_migrations runs so two
#: concurrent callers on one connection queue instead of colliding on
#: the wrapper's single-transaction rule.
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the migration lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _MIGRATION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_MIGRATION_LOCKS[key] = lock
return lock
async def apply_migrations(
database: NestQuestDatabase,
migrations: Sequence[MigrationStep] = MIGRATIONS,
) -> int:
"""Bring the database schema up to the latest version.
Reads the current version, applies every pending migration in order
— each inside one transaction that also stamps the new version —
and returns the resulting version. Applying zero pending
migrations (database already current) is a no-op that logs at debug
level and opens no write transaction.
The version is re-read inside each migration transaction before
deciding to apply it: the outer read is advisory only (loop bounds,
no-op fast path), and on one shared connection two interleaved
runners would otherwise both read the same version and both apply
a non-idempotent migration. Under the transaction's lock the
second runner sees the first runner's stamp and skips.
Runners on one connection are serialized by an asyncio lock held
for the whole decide-and-apply span: NestQuestDatabase rejects a
second transaction while one is active (RuntimeError), so
concurrent callers must queue, and the lock makes that queueing
explicit. The loser of the race re-reads the version once it
acquires the lock, sees the winner's stamp, and applies nothing.
Raises whatever the failing migration raised, with the database
rolled back to its prior version and prior rows untouched.
"""
async with _migration_lock(database):
await _create_version_table(database)
current = await read_schema_version(database)
latest = len(migrations)
if current > latest:
raise RuntimeError(
f"Database schema version {current} is newer than this "
f"integration understands (latest known: {latest}); "
"downgrades are not supported"
)
if current == latest:
LOGGER.debug(
"NestQuest schema already at version %d; "
"no migrations to apply",
current,
)
return current
for target in range(current + 1, latest + 1):
applied = False
step = migrations[target - 1]
if callable(step):
# Callable migrations own their transaction: some steps
# (table rebuilds) must run connection pragmas such as
# ``foreign_keys = OFF`` BEFORE opening one, which the
# runner's uniform transaction cannot express, and they
# stamp inside their own transaction so a failure rolls
# the statements and the version stamp back together.
# The version is re-read here under the migration lock:
# runners on one connection are serialized by it, so
# the version cannot change between this read and the
# callable's commit.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is newer "
f"than this integration understands (latest known: "
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
await step(database, target)
applied = True
else:
async with database.transaction():
# Re-read the version INSIDE the write transaction:
# the outer read was advisory, and the wrapper
# serializes individual statements, not the
# read-decide-apply decision. Under the
# transaction's lock the version cannot change
# between the read and the stamp, and the loser of
# an interleaved race (a queued second caller
# re-running apply_migrations after the winner
# already migrated) re-reads here, sees the
# winner's stamp, and skips instead of re-applying
# a non-idempotent migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is "
f"newer than this integration understands "
f"(latest known: {latest}); downgrades are "
f"not supported"
)
if in_tx_version < target:
for sql in step:
await database.execute(sql)
await stamp_schema_version(database, target)
applied = True
applied_statements = len(step)
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d",
target,
)
return latest
@@ -1,621 +0,0 @@
"""The ScheduleRule model: rule types, validation, and serialization.
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
frozen dataclass with no Home Assistant and no database imports. The
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
serializes its dict form into the ``schedule_rules`` table.
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
Tuesday of every month") are distinct types rather than one monthly
type with optional fields, so validation is total: a rule either
constructs, or raises :class:`RuleValidationError` naming the offending
field. Every type carries ONLY the fields its shape needs; carrying
any other shape's field is a construction error.
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
``storage_value`` maps model types onto the schema's CHECK values —
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
disambiguated by which fields are populated; CUSTOM_DAYS stores as
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
table stores (one element for MONTHLY_WEEKDAY, which names the
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
carried through from_dict/to_dict for losslessness and folded into
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
model-side lossless pair; the DB mapping lives in the DAO layer and is
exercised there.
Validation policy (fail fast at construction — the schema's CHECK
constraints are a second line of defense, but invalid rules must never
reach storage):
- ``interval`` >= 1 for every rule.
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
- ``month`` 1..12 is required for YEARLY.
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
when present, on or after ``start_date``.
Month-end policy (implemented in :func:`_occurs_monthly_day` and
documented for Feature 04): in each interval-eligible month, a
MONTHLY_DAY rule for a day that does not exist in that month — the
31st in April, the 30th or 29th in February, February 29th in a
non-leap year — fires on the LAST day of the month instead of being
skipped. A monthly chore still happens in every eligible month,
pinned to the month's end when its nominal date is absent; exactly one
day per eligible month matches the rule (nominal day when it exists,
month end when it does not). The interval anchors on nominal months
elapsed from start_date's month, so clamping never shifts the cadence.
"""
from __future__ import annotations
import datetime
import enum
from dataclasses import dataclass, field
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
_DATE_FORMAT = "%Y-%m-%d"
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
class RuleType(enum.Enum):
"""The six recurrence shapes the engine supports.
``storage_value`` is the string written to the ``schedule_rules``
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
disambiguated by which fields are populated.
"""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY_DAY = "monthly_day"
MONTHLY_WEEKDAY = "monthly_weekday"
YEARLY = "yearly"
CUSTOM_DAYS = "custom_days"
@property
def storage_value(self) -> str:
"""The schema-level rule_type string this model type maps to."""
return {
RuleType.DAILY: "daily",
RuleType.WEEKLY: "weekly",
RuleType.MONTHLY_DAY: "monthly",
RuleType.MONTHLY_WEEKDAY: "monthly",
RuleType.YEARLY: "yearly",
RuleType.CUSTOM_DAYS: "custom",
}[self]
class RuleValidationError(ValueError):
"""Raised when a ScheduleRule field combination is invalid."""
def _validate_date(value: str, field_name: str) -> None:
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise RuleValidationError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise RuleValidationError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
def _validate_weekday_set(weekday_set) -> frozenset[int]:
"""Return a normalized frozenset or raise with a clear message.
Sequence entries are validated BEFORE set() coercion: a list like
[0, False] must not collapse the boolean into 0, and unhashable
entries must raise RuleValidationError, never a raw TypeError.
"""
if isinstance(weekday_set, (list, tuple)):
for entry in weekday_set:
# _is_plain_int: bool and int subclasses (potentially
# unhashable) are rejected before set() coercion.
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
if not isinstance(weekday_set, (set, frozenset)):
raise RuleValidationError(
f"weekday_set must be a set of weekday integers, got "
f"{type(weekday_set).__name__}"
)
if not weekday_set:
raise RuleValidationError(
"weekday_set must not be empty for weekly/custom rules"
)
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
if not 0 <= entry <= 6:
raise RuleValidationError(
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
f"got {entry!r}"
)
return frozenset(weekday_set)
def _is_plain_int(value) -> bool:
"""True only for real ints: bool excluded AND int subclasses too
(an unhashable int subclass would break the frozen-hashable model)."""
return type(value) is int
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
"""Validate the MONTHLY_WEEKDAY position fields."""
if nth_weekday is None or not _is_plain_int(nth_weekday):
raise RuleValidationError(
"nth_weekday must be an integer for monthly_weekday rules, "
f"got {nth_weekday!r}"
)
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
raise RuleValidationError(
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
)
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
raise RuleValidationError(
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
f"(Sunday), got {weekday!r}"
)
@dataclass(frozen=True)
class ScheduleRule:
"""An immutable, validated recurrence rule (see module docstring).
Construct with keyword arguments; invalid combinations raise
:class:`RuleValidationError` at construction time. Fields map
one-to-one onto the ``schedule_rules`` table columns, with
``nth_weekday_weekday`` as the model-only weekday companion to
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
boundary).
"""
rule_type: RuleType
interval: int = 1
weekday_set: frozenset[int] | None = None
day_of_month: int | None = None
nth_weekday: int | None = None
nth_weekday_weekday: int | None = None
month: int | None = None
start_date: str = "1970-01-01"
end_date: str | None = None
def __post_init__(self) -> None:
# Normalize a string rule_type into the enum; anything else
# that is not already a RuleType is rejected.
if isinstance(self.rule_type, str):
# EXACT model names only: no whitespace stripping, no storage
# aliases. The DAO maps storage strings to model fields when
# reading rows; the model boundary stays unambiguous.
candidates = {rt.name.lower(): rt for rt in RuleType}
if self.rule_type in candidates:
object.__setattr__(
self, "rule_type", candidates[self.rule_type]
)
else:
raise RuleValidationError(
f"rule_type must be a RuleType or one of "
f"{sorted(candidates)}, got {self.rule_type!r}"
)
if not isinstance(self.rule_type, RuleType):
raise RuleValidationError(
f"rule_type must be a RuleType or one of its string "
f"values, got {self.rule_type!r}"
)
if not _is_plain_int(self.interval):
raise RuleValidationError(
f"interval must be an integer >= 1, got {self.interval!r}"
)
if self.interval < 1:
raise RuleValidationError(
f"interval must be >= 1, got {self.interval!r}"
)
_validate_date(self.start_date, "start_date")
if self.end_date is not None:
_validate_date(self.end_date, "end_date")
if self.end_date < self.start_date:
raise RuleValidationError(
f"end_date {self.end_date!r} must be on or after "
f"start_date {self.start_date!r}"
)
# Type-exclusive field policy, enforced for every type: build
# the dict of present fields, then reject every field the shape
# must not carry (weekday_set present is detectable via None vs
# set even after normalization below).
shape = self.rule_type
if shape is RuleType.DAILY:
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.WEEKLY:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.CUSTOM_DAYS:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.MONTHLY_DAY:
forbidden_fields = ("weekday_set", "nth_weekday",
"nth_weekday_weekday", "month")
elif shape is RuleType.MONTHLY_WEEKDAY:
forbidden_fields = ("weekday_set", "day_of_month", "month")
elif shape is RuleType.YEARLY:
forbidden_fields = (
"weekday_set",
"nth_weekday",
"nth_weekday_weekday",
)
present_forbidden = [
name
for name in forbidden_fields
if getattr(self, name) is not None
]
if present_forbidden:
raise RuleValidationError(
f"{shape.value} rules must not set "
f"{', '.join(sorted(present_forbidden))}"
)
# Required fields per shape.
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
normalized = _validate_weekday_set(self.weekday_set)
object.__setattr__(self, "weekday_set", normalized)
elif shape is RuleType.MONTHLY_DAY:
if self.day_of_month is None or not _is_plain_int(
self.day_of_month
):
raise RuleValidationError(
"monthly_day rules require day_of_month (1..31)"
)
if not 1 <= self.day_of_month <= 31:
raise RuleValidationError(
f"day_of_month must be 1..31, got {self.day_of_month!r}"
)
elif shape is RuleType.MONTHLY_WEEKDAY:
_validate_monthly_weekday(
self.nth_weekday, self.nth_weekday_weekday
)
elif shape is RuleType.YEARLY:
if self.month is None or not _is_plain_int(self.month):
raise RuleValidationError(
"yearly rules require month (1..12)"
)
if not 1 <= self.month <= 12:
raise RuleValidationError(
f"month must be 1..12, got {self.month!r}"
)
if self.day_of_month is not None and not (
_is_plain_int(self.day_of_month)
and 1 <= self.day_of_month <= 31
):
raise RuleValidationError(
f"day_of_month must be 1..31 for yearly rules, got "
f"{self.day_of_month!r}"
)
def to_dict(self) -> dict:
"""Return the lossless dict form (model-side storage shape).
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
weekday travels inside it (appended) plus
``nth_weekday_weekday`` so the pair round-trips without
ambiguity at the model layer.
"""
return {
"rule_type": self.rule_type.name.lower(), # model name
"interval": self.interval,
"weekday_set": (
sorted(self.weekday_set) if self.weekday_set is not None
else None
),
"day_of_month": self.day_of_month,
"nth_weekday": self.nth_weekday,
"nth_weekday_weekday": self.nth_weekday_weekday,
"month": self.month,
"start_date": self.start_date,
"end_date": self.end_date,
}
@classmethod
def from_dict(cls, data: dict) -> "ScheduleRule":
"""Rebuild a rule from :meth:`to_dict` output.
Raises :class:`RuleValidationError` on unknown keys, missing
keys, or invalid combinations — never a raw TypeError.
"""
if not isinstance(data, dict):
raise RuleValidationError(
f"rule data must be a dict, got {type(data).__name__}"
)
allowed = {
"rule_type",
"interval",
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
"start_date",
"end_date",
}
unknown = [key for key in data if key not in allowed]
if unknown:
raise RuleValidationError(
f"unknown rule fields: {sorted(unknown, key=repr)}"
)
if "rule_type" not in data:
raise RuleValidationError("missing required rule field: rule_type")
weekday_set = data.get("weekday_set")
if isinstance(weekday_set, (list, tuple)):
# Validate entries as plain ints BEFORE set() coercion, so a
# list like [0, False] cannot collapse False into 0 and an
# unhashable/int-subclass entry cannot leak a raw TypeError.
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
return cls(
rule_type=data["rule_type"],
interval=data.get("interval", 1),
weekday_set=weekday_set,
day_of_month=data.get("day_of_month"),
nth_weekday=data.get("nth_weekday"),
nth_weekday_weekday=data.get("nth_weekday_weekday"),
month=data.get("month"),
start_date=data.get("start_date", "1970-01-01"),
end_date=data.get("end_date"),
)
# ---------------------------------------------------------------------------
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
# ---------------------------------------------------------------------------
def _parse_date(value: str) -> datetime.date:
"""Parse a strict ISO date already validated by the model."""
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
"""Day offset from the rule's anchor date to ``target``.
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
first day the rule CAN fire. A negative offset means the target
precedes the rule's window entirely.
"""
return (target - _parse_date(rule.start_date)).days
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
"""True when ``target`` is inside the rule's [start, end] window."""
if target < _parse_date(rule.start_date):
return False
if rule.end_date is not None and target > _parse_date(rule.end_date):
return False
return True
def _month_end(year: int, month: int) -> int:
"""The last calendar day of the given month (leap-aware)."""
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
"""MONTHLY_DAY evaluation with the documented month-end policy.
Policy: in each interval-eligible month, a rule for a day-of-month
that does not exist in that month (the 31st in April, the 30th in
February, the 29th of February in a non-leap year) fires on the
LAST day of that month instead of being skipped — the chore still
happens that month, pinned to the month's end when its nominal date
is absent.
The interval anchors on months ELAPSED from start_date's month: the
rule fires when the months between the anchor month and the target's
month form a multiple of the interval, keeping "every 2 months on
the 15th" aligned to the anchor month even when the firing lands on
a clamped month-end day.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def _nth_weekday_of_month(
year: int, month: int, weekday: int, nth: int
) -> int | None:
"""The day-of-month of the ``nth`` ``weekday``, or None when the
month has no such occurrence (``nth`` 5 beyond four occurrences).
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
end (last occurrence, whatever the total). Pure calendar math.
"""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
first_offset = (weekday - first.weekday()) % 7
day = 1 + first_offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _occurs_monthly_weekday(
rule: ScheduleRule, target: datetime.date
) -> bool:
"""MONTHLY_WEEKDAY evaluation.
Fires when the target is the ``nth`` occurrence of
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
month with fewer occurrences than ``nth`` (5 on a four-occurrence
month) does not fire — n 5 is accepted by the model but skips such
months, matching the done-condition. The interval anchors on
nominal months elapsed from start_date's month.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
if target.weekday() != rule.nth_weekday_weekday:
return False
nth_day = _nth_weekday_of_month(
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
)
return nth_day is not None and target.day == nth_day
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
"""YEARLY evaluation with the documented leap-day clamp policy.
Policy: in each interval-eligible year, the rule fires on
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
still happens every year, pinned to February's end when its
nominal date is absent) rather than silently vanishing. A YEARLY
rule with NO day_of_month fires on the first day of ``month``.
The interval anchors on years elapsed from start_date's year.
"""
anchor = _parse_date(rule.start_date)
years_elapsed = target.year - anchor.year
if years_elapsed % rule.interval != 0:
return False
if target.month != rule.month:
return False
if rule.day_of_month is None:
# Day-less yearly rules fire on the first of the month.
return target.day == 1
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
"""Return True when ``rule`` fires on ``target``.
Pure function: no I/O, no timezone awareness, no mutation. Dates
are plain calendar dates (Feature 04 guardrail); the caller decides
the timezone that produced them.
"""
# Dispatch FIRST: an unimplemented shape must raise loudly even for
# dates outside the window, never silently evaluate to False.
shape = rule.rule_type
if shape not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurs_on for {shape} lands with its own engine task "
"(Features 04 task sequence)"
)
if not _within_window(rule, target):
return False
if shape is RuleType.DAILY:
offset = (target - _parse_date(rule.start_date)).days
return offset % rule.interval == 0
if shape is RuleType.MONTHLY_DAY:
return _occurs_monthly_day(rule, target)
if shape is RuleType.MONTHLY_WEEKDAY:
return _occurs_monthly_weekday(rule, target)
if shape is RuleType.YEARLY:
return _occurs_yearly(rule, target)
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
# parity on January 1st; anchor arithmetic does not).
anchor = _parse_date(rule.start_date)
if shape is RuleType.CUSTOM_DAYS:
interval = 1
else:
interval = rule.interval
week_offset = (target - anchor).days // 7
if week_offset % interval != 0:
return False
return target.weekday() in rule.weekday_set
def occurrences_between(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Return every date string in [start, end] the rule fires on.
Inclusive on both ends. Raises RuleValidationError on malformed or
inverted bounds; a window entirely outside [rule.start_date,
rule.end_date] yields an empty list.
"""
if rule.rule_type not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurrences_between for {rule.rule_type} lands with its own "
"engine task (Features 04 task sequence)"
)
_validate_date(start, "start")
_validate_date(end, "end")
start_date = _parse_date(start)
end_date = _parse_date(end)
if end_date < start_date:
raise RuleValidationError(
f"end {end!r} must be on or after start {start!r}"
)
rule_start = _parse_date(rule.start_date)
if end_date < rule_start:
return []
# Clamp the walk to the rule's own window and the queried range.
effective_start = max(start_date, rule_start)
effective_end = end_date
if rule.end_date is not None:
effective_end = min(effective_end, _parse_date(rule.end_date))
results: list[str] = []
cursor = effective_start
while cursor <= effective_end:
if occurs_on(rule, cursor):
results.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return results
-427
View File
@@ -1,427 +0,0 @@
"""Declarative schema DDL for the NestQuest SQLite database.
Every v1 table is declared here as plain SQL statements so later schema
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
touching this module's tables. All statements use
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
migration runner is a separate later concern.
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
caller/DAO rather than a DB default, per the feature guardrails. No
``length(created_at) = 25`` CHECK is added: the exact string shape
produced by callers is not fixed yet (``datetime.isoformat()`` may
include microseconds, changing the length), so enforcing a fixed length
here would reject valid values. Only non-null is enforced for now.
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
no time component) compared lexically by the ``end_date >= start_date``
CHECK; due_date/due_time string shapes are caller-validated by the rule
engine and DAO layers, not by DDL.
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
because it sorts, compares and parses trivially in SQL. The format is
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
repetition quantifier, so the element count is enumerated; more than 7
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
This keeps the schema layer storage-only — the DAO layer (tasks
8c72300c+) does not need its own validator, it can rely on the CHECK.
Type affinity: SQLite applies column affinity on insert, but CHECKs
still observe text and fractional values ('abc' stays text, '1.5'
becomes REAL), so every INTEGER column carrying rule semantics carries
a ``typeof(...) = 'integer'`` guard in its CHECK.
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
carry none of the month-specific fields. Relaxed deliberately: the DDL
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
rule or a monthly rule could later be pinned to a month) nor extra
fields on weekly/monthly/yearly rules generally, so the CHECKs only
reject what the rule engine definitively cannot express now.
Presence pattern format: ``presence_schedules.pattern`` encodes the
whole N-week repeating cycle in one TEXT column as pipe-separated
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the child is absent every day of that
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
absent every day of every week, which is distinct from having no
schedule row at all (a child with no schedule is present every day, per
the feature guardrails). Chosen over JSON or per-week rows to stay
consistent with the CSV ``weekday_set`` convention and fully checkable
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
custom functions: char-class GLOBs reject every character outside
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(double comma, comma next to a pipe, leading/trailing comma) and
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
separated digits, which caps a segment at 7 elements — the GLOB cannot
match across a ``|``, so cross-segment boundaries stay safe; and the
segment count is pinned to ``cycle_length_weeks`` by
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
weekday_set this is not full CSV-set normalisation (duplicates like
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
the engine treats the segment as a set. A
one-row-per-``week_index`` normalization was considered and rejected:
the flat single-column encoding keeps the done-condition's table shape
and proved fully checkable with plain CHECKs.
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
common case, and a small cap keeps anchor arithmetic and the encoded
pattern human-readable. Revisit the cap — and re-run the pattern
validation matrix in tests — if a longer cycle is ever needed.
``anchor_date`` is an ISO date string pinning the cycle: the week index
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
computed by the presence engine (Feature 05) from anchor-date
arithmetic, never ISO week parity. Like other date columns it carries
no shape CHECK, matching the established date policy.
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
unlike ``schedule_rules.end_date`` — an override is always a concrete
date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text.
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
``child_id`` column. Assignment lives in
``quest_definition_assignees``, one row per (definition, child) with a
composite primary key and foreign keys to the definition and the child,
so "brush teeth" is one definition covering three children. The
migration runner rebuilds a pre-D-008 table into this shape and copies
its single-assignee column across. The composite primary key makes
duplicate assignment impossible at the storage layer.
Quest windows (D-008): ``quest_definition_windows`` declares which day
windows a definition spans — one row per (definition, window), the
composite primary key making a window idempotent per definition.
``window`` is CHECK-constrained to the three spellings ``const`` owns;
``due_time`` optionally pins a due time inside the window and is
caller-validated (strict HH:MM) by the DAO layer, matching the date
policy above. The window clock ranges live in ``const.py``, not here:
this layer stores what the caller declares and never interprets clocks.
Quest instances: ``quest_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the
append-only log stays the single source of truth. ``definition_id``,
``child_id``, ``due_date`` and ``window`` snapshot the
materialization-time facts ("why this instance exists"): assignment
edits and rule edits change future instances only and never rewrite
these rows. ``window`` records which declared day window produced the
instance (D-008), CHECK-constrained to the same three spellings
``const`` owns. ``due_time`` snapshots the window's optional due time
at generation for the same reason — the instance keeps its
generation-time value even if the definition is edited later.
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
timestamp policy (no DB default, so a generation batch stamps one
coherent time).
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
declared as a table-level ``UNIQUE`` constraint rather than a separate
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
required "unique index prevents duplicate instances" — and the
constraint form cannot be silently dropped the way a standalone index
can, so the duplicate door stays shut. Idempotent materialization keys
on exactly this tuple: the twice-daily case (one definition, two
windows, one child, one date) yields two rows and the shared case (one
definition, three assignees, one window) yields three. No secondary
indexes beyond it are declared yet: history and board queries (Features
08/10/13) should justify their indexes as a later migration once their
query shapes exist.
No ``ON DELETE`` clauses are declared on any foreign key in this
module: children and definitions are deactivated, never deleted, so a
deletion that would orphan instances or events is a bug and must fail
loudly rather than cascade.
Completion events: ``completion_events`` is strictly append-only. That
property is enforced at the DAO layer — the completion-events DAO
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
by database triggers, matching the module's no-trigger policy: a RAISE
trigger would be the one exception to "pure CHECK, no triggers",
complicate the later migration runner, and still prove nothing (any
writer can run raw SQL). ``event_type`` is constrained to
``completed``/``uncompleted``; a ``missed`` row is intentionally not
representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via
``quest_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay
self-describing without joins.
Actor policy: ``actor_source`` distinguishes an authenticated HA user
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
panel tap with no user context (``'panel'``, with ``actor_user_id``
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
Other actor sources are rejected; if an unattended system actor is
ever needed, that is a schema migration, not a silent widening.
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
``completed`` events — at completion time the due date is known and the
flag is decidable. For ``uncompleted`` events the flag is optional:
Feature 08 decides whether a reversal carries the reversed completion's
on-time flag or NULL, and the DDL deliberately accepts both rather than
guessing.
"""
from __future__ import annotations
from .db import NestQuestDatabase
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
#: Each element is a single digit 0-6, separated by a literal comma.
#: GLOB has no repetition quantifier, so the element count is enumerated.
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
#: because weekly rules need at least one weekday.
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
"[0-6]",
"[0-6],[0-6]",
"[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
]
SCHEMA_V1_CHILDREN_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
colour TEXT,
avatar_ref TEXT,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)
""",
]
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS admin_users (
ha_user_id TEXT PRIMARY KEY NOT NULL,
added_at TEXT NOT NULL
)
""",
]
_WEEKDAY_SET_CHECK = " OR ".join(
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
)
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS schedule_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
start_date TEXT NOT NULL,
end_date TEXT,
CHECK (end_date IS NULL OR end_date >= start_date),
CHECK (
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
OR (rule_type = 'yearly' AND month IS NOT NULL)
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
)
)
""",
]
#: Column body of ``quest_definitions``, shared between the v1 DDL
#: below and the multi-assignee rebuild migration, which must recreate
#: the table in exactly this shape (D-008: one definition, many
#: assignees via ``quest_definition_assignees`` — no ``child_id``
#: column on the definition itself).
QUEST_DEFINITIONS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
""",
]
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
PRIMARY KEY (definition_id, child_id)
)
""",
]
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_windows (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_time TEXT,
PRIMARY KEY (definition_id, window)
)
""",
]
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
NOT pattern GLOB '*[^0-6,|]*'
AND NOT pattern GLOB '*[0-6][0-6]*'
AND NOT pattern GLOB '*,,*'
AND NOT pattern GLOB '*,|*'
AND NOT pattern GLOB '*|,*'
AND NOT pattern GLOB ',*'
AND NOT pattern GLOB '*,'
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
)
)
""",
]
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS presence_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
note TEXT,
CHECK (end_date >= start_date)
)
""",
]
#: Column body of ``quest_instances``, shared between the v1 DDL
#: below and the instance-key rebuild migration, which must recreate
#: the table in exactly this shape (D-008: the instance key is
#: (definition_id, child_id, due_date, window) — one instance per
#: assigned child per declared window per firing date).
QUEST_INSTANCES_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, child_id, due_date, window)
)"""
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
""",
]
#: Column body of ``completion_events``, shared between the v1 DDL
#: below and the actor-child rebuild migration, which must recreate the
#: table in exactly this shape (D-008: ``actor_child_id`` records which
#: panel profile was tapped — set for panel events, NULL for admin
#: ones; legacy panel rows backfill to ``child_id``, the child they
#: always were).
COMPLETION_EVENTS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
actor_user_id TEXT,
actor_child_id INTEGER REFERENCES children(id),
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(actor_source = 'user' AND actor_child_id IS NULL)
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
)"""
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
""",
]
#: Generic integration-owned key/value state (schema version 7,
#: Feature 11): persistent bookkeeping that belongs to the integration
#: rather than any domain table — currently the missed-sweep
#: watermark. ``value`` is a plain string; callers interpret it.
META_STATE_TABLE_SQL = """(
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)"""
SCHEMA_V7_META_STATE_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
""",
]
SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
*SCHEMA_V7_META_STATE_DDL,
]
async def async_apply_ddl(
database: NestQuestDatabase, statements: list[str]
) -> None:
"""Apply each DDL statement inside one transaction on ``database``.
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
applying the same list twice leaves the schema and its rows intact.
"""
async with database.transaction():
for sql in statements:
await database.execute(sql)
-30
View File
@@ -1,30 +0,0 @@
"""SQLite storage path resolution for the NestQuest storage layer.
Home-Assistant-free: callers resolve the database file location from
their own config (HA resolves it from ``hass.config.path``) and pass
the full path here; this module ensures the parent directory exists
off-thread via :func:`asyncio.to_thread` and returns the absolute path.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
def _ensure_dir(path: Path) -> None:
"""Create the given directory and any missing parents."""
path.mkdir(parents=True, exist_ok=True)
async def async_get_db_path(db_path: Path | str) -> Path:
"""Return the absolute path to the NestQuest SQLite database file.
``db_path`` is the full path to the database file (the integration
resolves it from ``hass.config.path(SQLITE_DB_FILENAME)``); the
parent directory is created off the event loop via
:func:`asyncio.to_thread` so this module never blocks and never
imports Home Assistant.
"""
path = Path(db_path).absolute()
await asyncio.to_thread(_ensure_dir, path.parent)
return path
+315 -8
View File
@@ -1,12 +1,319 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
"""Typed DAO layer for the children and admin_users tables.
The children/admin DAO lives in
:mod:`custom_components.nestquest.core.dao_children` (extracted in
task d0b691d8); this module re-exports it so existing
``from .dao_children import ...`` references inside the integration keep
working.
All SQL for ``children`` and ``admin_users`` lives in this module per
the feature guardrail: callers get typed dataclasses back and never see
raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Error contract: violations surface as the underlying
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
failure) from the execute call; the DAO adds no wrapping exception
type. A get that matches nothing returns ``None``; a mutation that
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
which each method returns where the caller may need it (reorder, and
the admin remove/set paths).
"""
from __future__ import annotations
from .core.dao_children import * # noqa: F401,F403
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
import asyncio
from dataclasses import dataclass
from .db import NestQuestDatabase
@dataclass(frozen=True)
class ChildRecord:
"""One row of ``children``."""
id: int
display_name: str
colour: str | None
avatar_ref: str | None
sort_order: int
is_active: bool
created_at: str
@dataclass(frozen=True)
class AdminUserRecord:
"""One row of ``admin_users``."""
ha_user_id: str
added_at: str
def _child_from_row(row: tuple) -> ChildRecord:
"""Build a :class:`ChildRecord` from a SELECT row."""
return ChildRecord(
id=row[0],
display_name=row[1],
colour=row[2],
avatar_ref=row[3],
sort_order=row[4],
is_active=bool(row[5]),
created_at=row[6],
)
_CHILD_COLUMNS = (
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
)
#: One asyncio.Lock per (connection wrapper, running loop), shared by
#: every ChildrenDao instance over the same database: the wrapper
#: rejects a second concurrent transaction with RuntimeError, so ALL
#: transactional DAO operations on one connection must queue here, not
#: per-instance. Keyed like the migration runner's lock; entries are
#: never evicted (a config entry holds one wrapper per process).
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _connection_lock(database) -> asyncio.Lock:
"""Return the shared transaction-serializing lock for ``database``."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_CONNECTION_LOCKS[key] = lock
return lock
class ChildrenDao:
"""Typed async data access for the ``children`` table.
Deletion is deliberately absent: children are deactivated, never
deleted, because completion history references them (feature
guardrail).
"""
def __init__(self, database) -> None:
self._database = database
async def create(
self,
display_name: str,
created_at: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
is_active: bool = True,
) -> ChildRecord:
"""Insert one child and return the record as stored."""
result = await self._database.execute(
"INSERT INTO children (display_name, colour, avatar_ref, "
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
display_name,
colour,
avatar_ref,
sort_order,
int(is_active),
created_at,
),
)
child = await self.get(result.lastrowid)
assert child is not None
return child
async def get(self, child_id: int) -> ChildRecord | None:
"""Return the child with ``child_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
(child_id,),
)
return _child_from_row(row) if row is not None else None
async def list_active(self) -> list[ChildRecord]:
"""Return active children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
"ORDER BY sort_order, id"
)
return [_child_from_row(row) for row in rows]
async def list_all(self) -> list[ChildRecord]:
"""Return all children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children "
"ORDER BY sort_order, id"
)
return [record for record in map(_child_from_row, rows)]
async def update(
self,
child_id: int,
*,
display_name: str | None = None,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Only non-None arguments are written, so a caller cannot
accidentally blank a column by omitting it.
"""
assignments: list[str] = []
parameters: list[object] = []
if display_name is not None:
assignments.append("display_name = ?")
parameters.append(display_name)
if colour is not None:
assignments.append("colour = ?")
parameters.append(colour)
if avatar_ref is not None:
assignments.append("avatar_ref = ?")
parameters.append(avatar_ref)
if sort_order is not None:
assignments.append("sort_order = ?")
parameters.append(sort_order)
if not assignments:
return 0
parameters.append(child_id)
result = await self._database.execute(
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
return result.rowcount
async def set_active(self, child_id: int, is_active: bool) -> int:
"""Set the child's active flag; returns rows updated (0 if absent)."""
result = await self._database.execute(
"UPDATE children SET is_active = ? WHERE id = ?",
(int(is_active), child_id),
)
return result.rowcount
async def reorder(self, ordered_ids: list[int]) -> None:
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
The list must be a COMPLETE permutation of the children table:
every existing child id appears exactly once. Partial lists
(missing an existing child) and unknown ids (naming a child
that does not exist) are rejected with ValueError BEFORE any
write, leaving the current order untouched — a silent partial
reorder would leave unlisted children with stale positions and
hide a caller bug. Duplicate ids are rejected up front: they
cannot all hold distinct positions, and silently assigning the
last occurrence's index would hide a caller bug.
Runs inside one transaction; positions are staged at offset
-len (all negative, so any permutation of the same ids never
collides mid-reorder under the wrapper's single-connection
serialization), then finalized 0..n-1.
Concurrent reorder() calls on one connection are serialized by
a connection-scoped asyncio lock (shared across every ChildrenDao
over the same wrapper, however many instances): the wrapper's
transaction() rejects a second concurrent transaction with
RuntimeError, so racing callers would crash instead of
queueing. The lock makes the second call wait, then run
against the first call's final state — which also keeps the
completeness check honest: the re-read of the table happens
inside the same lock and transaction as the writes.
"""
if len(set(ordered_ids)) != len(ordered_ids):
raise ValueError(
"reorder() received duplicate child ids; every id must "
"appear at most once"
)
async with _connection_lock(self._database):
async with self._database.transaction():
rows = await self._database.fetch_all(
f"SELECT id FROM children"
)
existing = {row[0] for row in rows}
listed = set(ordered_ids)
missing = sorted(existing - listed)
unknown = sorted(listed - existing)
if missing or unknown:
details = []
if missing:
details.append(
f"missing child ids {missing} (a partial "
"reorder would leave them with stale "
"positions)"
)
if unknown:
details.append(
f"unknown child ids {unknown}"
)
raise ValueError(
"reorder() must list every child exactly once; "
+ "; ".join(details)
)
offset = len(ordered_ids)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(-(offset + index), child_id)
for index, child_id in enumerate(ordered_ids)
],
)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(index, child_id)
for index, child_id in enumerate(ordered_ids)
],
)
class AdminUsersDao:
"""Typed async access to the ``admin_users`` allowlist.
The allowlist stores HA user IDs, never usernames (feature
guardrail); an empty allowlist means no admin, enforced by callers
resolving through :meth:`exists`.
"""
def __init__(self, database) -> None:
self._database = database
async def add(self, ha_user_id: str, added_at: str) -> bool:
"""Add a user to the allowlist; True if inserted, False if present.
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
NOTHING decides and inserts in one statement (the wrapper
serializes statements, not check-then-insert sequences, so a
SELECT/INSERT pair would let two concurrent adds both see no
row and the second one raise IntegrityError). rowcount reports
whether the insert actually happened.
"""
result = await self._database.execute(
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
"ON CONFLICT (ha_user_id) DO NOTHING",
(ha_user_id, added_at),
)
return result.rowcount > 0
async def remove(self, ha_user_id: str) -> bool:
"""Remove a user from the allowlist; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return result.rowcount > 0
async def list(self) -> list[AdminUserRecord]:
"""Return every allowlisted user, oldest first."""
rows = await self._database.fetch_all(
"SELECT ha_user_id, added_at FROM admin_users "
"ORDER BY added_at, ha_user_id"
)
return [
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
for row in rows
]
async def exists(self, ha_user_id: str) -> bool:
"""Return True if ``ha_user_id`` is allowlisted."""
row = await self._database.fetch_one(
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return row is not None
+791 -8
View File
@@ -1,12 +1,795 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
"""Typed DAO layer for quest_instances and the append-only completion_events.
The instances/completion-events DAO lives in
:mod:`custom_components.nestquest.core.dao_instances`
(extracted in task d0b691d8); this module re-exports it so existing
``from .dao_instances import ...`` references inside the integration keep
working.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Append-only policy (Feature 08, D-005): ``completion_events`` is
strictly append-only. This module is where that property is enforced
mechanically — the completion-events DAO below exposes NO update or
delete method at all; completions append, un-completions append a
separate reversal event, and instance state derives from the latest
event. A package-wide guard test (bottom of the test module) keeps
mutation SQL for this table out of every other module.
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, child_id, due_date, window) — re-running
materialization for a tuple whose instance already exists refreshes
only the generation-time snapshot columns and never duplicates a row.
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
last line of defense. The upsert refuses a child that is not an
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
(D-008 multi-assignee, multi-window model).
``delete_future_uncompleted`` implements the Feature 06/07 rule that
reassignment and schedule edits regenerate future instances: it
removes only instances at or after the cutoff that have NO completion
event, inside one transaction, so an instance completed mid-call can
never be deleted. Instances in the past are never touched (Feature 07
guardrail: never delete history), and no method here deletes a single
instance that has a completion event.
"""
from __future__ import annotations
from .core.dao_instances import * # noqa: F401,F403
from .core.dao_instances import _resolve_today # noqa: F401
import datetime
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _validate_date, _validate_window
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
#: One accepted shape bounds lexicographic range queries correctly, so
#: the occurred-at scope does not guess at timestamp spellings.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _today() -> datetime.date:
"""Return the current local calendar date (the single date source).
Every "today" read in this module goes through this one function so
the delete clamp and the materialize walk's no-past guard read the
same clock, and a caller or test can pin/roll that clock
deterministically (e.g. a queued-across-midnight regression). Pure
clock read — no business rule lives here.
"""
return datetime.date.today()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the caller-resolved "today", or the host date when absent.
The no-past guard and the delete cutoffs must compare due dates
against the SAME calendar date the materialization horizon was built
from. Home Assistant's configured time zone can lag the host clock
around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous
behavior for callers with no HA-local date.
"""
return _today() if today is None else today
def _reject_past(due_date: str, today_iso: str) -> None:
"""Raise unless ``due_date`` is on or after ``today_iso``.
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
generated in the past, measured against the resolved "today".
"""
if due_date < today_iso:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today_iso!r}"
)
def _validate_utc_timestamp(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
Same strictness policy as _validate_date: parse with the exact
format, then round-trip the parsed value against the input so
non-padded or re-ordered shapes are rejected. The round-trip
strips the UTC offset (isoformat() omits it when the offset is
zero), so the suffix is appended before comparing.
"""
try:
parsed = datetime.datetime.strptime(
value, _UTC_TIMESTAMP_FORMAT
)
except (TypeError, ValueError):
raise ValueError(
f"{field} must be a UTC ISO-8601 timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
) from None
if parsed.isoformat() + "+00:00" != value:
raise ValueError(
f"{field} must be a strict UTC timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
)
def _next_day_start(date_str: str) -> str:
"""Return the UTC timestamp one second past ``date_str``'s last second.
Gives range queries an exact half-open upper bound without
fractional-second guessing.
"""
day = datetime.date.fromisoformat(date_str)
next_day = day + datetime.timedelta(days=1)
return f"{next_day.isoformat()}T00:00:00+00:00"
@dataclass(frozen=True)
class QuestInstanceRecord:
"""One row of ``quest_instances``."""
id: int
definition_id: int
child_id: int
window: str
due_date: str
due_time: str | None
generated_at: str
@dataclass(frozen=True)
class CompletionEventRecord:
"""One row of ``completion_events`` (append-only table)."""
id: int
instance_id: int
child_id: int
event_type: str
actor_source: str
actor_user_id: str | None
actor_child_id: int | None
occurred_at: str
was_on_time: bool | None
_INSTANCE_COLUMNS = (
"id, definition_id, child_id, window, due_date, due_time, generated_at"
)
_EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
#: the backstop, this is the DAO's typed front door.
EVENT_COMPLETED = "completed"
EVENT_UNCOMPLETED = "uncompleted"
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
return QuestInstanceRecord(
id=row[0],
definition_id=row[1],
child_id=row[2],
window=row[3],
due_date=row[4],
due_time=row[5],
generated_at=row[6],
)
def _event_from_row(row: tuple) -> CompletionEventRecord:
return CompletionEventRecord(
id=row[0],
instance_id=row[1],
child_id=row[2],
event_type=row[3],
actor_source=row[4],
actor_user_id=row[5],
actor_child_id=row[6],
occurred_at=row[7],
was_on_time=None if row[8] is None else bool(row[8]),
)
class QuestInstancesDao:
"""Typed async access to the ``quest_instances`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord:
"""Create the dated instance for the widened key idempotently.
The instance key is (definition_id, child_id, due_date, window)
(D-008): running materialization twice for the same tuple
yields ONE row — the ON CONFLICT path refreshes the
generation-time snapshot columns (due_time) and regenerates the
stamp, but can never create a duplicate. The schema's
UNIQUE(definition_id, child_id, due_date, window) backs this
up, so the twice-daily case (two windows, one child, one date)
yields two rows and the shared case (three assignees, one
window) yields three.
Guardrails enforced here:
- ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition.
``today`` optionally pins the resolved HA-local date the
guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around
midnight is not misread as "past".
- ``window`` must be one of ``const.QUEST_WINDOWS``.
- An existing instance WITH a completion event is IMMUTABLE:
the conflict path must not rewrite its snapshot columns
(Feature 07/08). The upsert refuses with ValueError in that
case — silently no-oping would mask a materialization bug.
- The child must be an assignee of the definition (D-008
multi-assignee model).
The definition must exist (validated atomically under the
connection lock so a concurrent definition deactivation or
child deletion cannot slip between check and insert).
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates BEFORE queuing on the
# connection lock (a nice early error); the AUTHORITATIVE
# check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs.
# A caller-supplied ``today`` (HA-local) is used for BOTH
# checks and never re-read, since it is already the anchor the
# batch runs against.
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers. A pinned HA-local
# anchor is exempt from the re-read (see above).
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignee = await self._database.fetch_one(
"SELECT 1 FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
if assignee is None:
raise ValueError(
f"quest definition {definition_id} is not "
f"assigned to child {child_id}; instance must "
"carry an assigned child"
)
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
raise ValueError(
f"instance for (definition {definition_id}, "
f"child {child_id}, due_date {due_date!r}, "
f"window {window!r}) already has a completion "
"event and is immutable; it cannot be regenerated"
)
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def upsert_if_valid(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord | None:
"""Insert one dated instance iff its tuple is still materializable.
This is the materialization walk's single write path. Unlike the
generic :meth:`upsert`, which validates only existence and
assignment, this method re-validates EVERY materialization
precondition in the SAME connection-lock-held transaction as the
INSERT — closing the check-then-insert gap a concurrent config
write could otherwise slip through:
- the definition exists AND is active (a definition deactivated
after the input snapshot is skipped, never written);
- the child exists AND is active (a deactivated child is skipped);
- the (definition, child) assignment link still exists (a removed
assignment is skipped);
- the window is still declared on the definition (a removed window
is skipped);
- the window's live ``due_time`` still EQUALS the caller-supplied
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
with no due time matches a snapshot with no due time). A
window-time edit landed after the input snapshot makes the
caller's ``due_time`` stale, and writing it would overwrite the
newer due_time a concurrent regeneration already upserted — so
the tuple is skipped instead of written.
The due_time check closes the one check-then-insert gap this
method previously left open: it re-validated every OTHER
precondition but then wrote the snapshot's ``due_time``
unconditionally, so a stale materialization could clobber a
newer, concurrently-regenerated due_time.
Returns the stored instance, or ``None`` when a precondition
failed — the tuple no longer materializable, which the walk SKIPS
rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today``
optionally pins the HA-local anchor it compares against. An
instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — the
generic :meth:`upsert` keeps its hard refusal for the
delete/regenerate path.
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates before queuing on the lock;
# the authoritative check re-runs under the lock (see upsert).
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
still_valid = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions d "
"JOIN quest_definition_assignees a "
"ON a.definition_id = d.id "
"JOIN children c ON c.id = a.child_id "
"JOIN quest_definition_windows w "
"ON w.definition_id = d.id "
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
"AND d.is_active = 1 AND c.is_active = 1 "
"AND w.due_time IS ? LIMIT 1",
(definition_id, child_id, window, due_time),
)
if still_valid is None:
# A config change invalidated the tuple since the
# snapshot: skip it rather than abort the batch.
return None
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
# The instance is already completed and therefore
# immutable: the walk SKIPS it (no-op) rather than
# rewriting its snapshot columns or failing the
# batch. This is the materialization walk's skip,
# distinct from the generic :meth:`upsert` refusal.
return None
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def get(
self,
definition_id: int,
child_id: int,
due_date: str,
window: str,
) -> QuestInstanceRecord | None:
"""Return the instance for the widened key tuple, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
"AND window = ?",
(definition_id, child_id, due_date, window),
)
return _instance_from_row(row) if row is not None else None
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
"""Return the instance with ``instance_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
(instance_id,),
)
return _instance_from_row(row) if row is not None else None
async def list_by_child_and_date(
self, child_id: int, due_date: str
) -> list[QuestInstanceRecord]:
"""Return the child's instances due exactly on ``due_date``."""
_validate_date(due_date, "due_date")
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date = ? ORDER BY id",
(child_id, due_date),
)
return [_instance_from_row(row) for row in rows]
async def list_by_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[QuestInstanceRecord]:
"""Return the child's instances with due_date in the closed range."""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
"ORDER BY due_date, id",
(child_id, range_start, range_end),
)
return [_instance_from_row(row) for row in rows]
async def delete_future_uncompleted(
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> int:
"""Delete the definition's open instances at/after cutoff.
Implements regeneration after a definition edit or reassignment
(Features 06/07): only instances at or after the cutoff with NO
completion event are removed, inside one transaction — an
instance that gains a completion event mid-flight cannot be
deleted. The cutoff cannot be backdated below today: "future"
is measured against TODAY (materialization regenerates the
rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against
(falling back to the host clock when omitted). Any instance with
a completion event is never touched, regardless of its date.
Returns the number deleted.
"""
_validate_date(cutoff_date, "cutoff_date")
async with _connection_lock(self._database):
async with self._database.transaction():
# "today" is read under the lock: the DELETE actually
# executes on this date, so a caller queued across
# midnight cannot delete instances that became past
# while it waited (backdating is clamped to the
# execution date, not the call date).
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(definition_id, effective_cutoff),
)
return result.rowcount
async def delete_future_uncompleted_for_child(
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> tuple[int, str]:
"""Delete the child's open instances at/after cutoff, across every
definition, returning ``(deleted, effective_cutoff)``.
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
used when a child's presence changes (Feature 09): only instances
at or after the cutoff with NO completion event are removed, inside
one transaction — an instance that gains a completion event
mid-flight cannot be deleted. The cutoff cannot be backdated below
today, for the same reason as the definition-scoped method (see its
docstring). ``today`` optionally pins the HA-local anchor the clamp
compares against (falling back to the host clock when omitted).
Any instance with a completion event is never touched, regardless
of its date.
Returns the number deleted and the effective cutoff that was
actually applied (``max(cutoff_date, today)``, read under the
lock). The caller (:func:`~.materialize.regenerate_for_child`)
passes that effective cutoff back as its re-materialization anchor,
so the delete and the rebuild share ONE execution-day even when the
call is queued across midnight — instead of the caller re-reading a
stale "today" and materializing a window the delete already
superseded.
"""
_validate_date(cutoff_date, "cutoff_date")
async with _connection_lock(self._database):
async with self._database.transaction():
# "today" is read under the lock, matching the
# definition-scoped method: the DELETE executes on this
# date, so a caller queued across midnight cannot delete
# instances that became past while it waited.
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(child_id, effective_cutoff),
)
return result.rowcount, effective_cutoff
class CompletionEventsDao:
"""Typed append-only access to the ``completion_events`` table.
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
three read methods below, and NOTHING else. There is no update,
no delete, no upsert — completions and un-completions both arrive
through :meth:`append` as new rows, and history is immutable
(Feature 08 done-condition: no code path updates or deletes an
event). The package-wide leak-guard test additionally fails if
mutation SQL for this table ever appears in any module.
"""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def append(
self,
instance_id: int,
child_id: int,
event_type: str,
actor_source: str,
occurred_at: str,
was_on_time: bool | None,
*,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event.
``actor_source`` is 'user' (an admin action: ``actor_user_id``
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
id, and ``actor_child_id`` carrying the tapped child profile,
D-008); the actor-pair CHECKs enforce this shape at the schema
level. ``occurred_at`` must be the module's one strict UTC
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
queries stay exact. ``was_on_time`` is required (True/False)
for completions; for un-completions it may be None or the
reversed completion's flag (Feature 08 still deciding). The
instance, child and — for panel events — the tapped child must
exist — validated atomically under the connection lock.
"""
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
)
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got "
f"{actor_source!r}"
)
if actor_source == "user" and not actor_user_id:
raise ValueError(
"actor_source 'user' requires actor_user_id"
)
if actor_source == "panel" and actor_user_id is not None:
raise ValueError(
"actor_source 'panel' must not carry actor_user_id"
)
if actor_source == "panel" and actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id "
"(the tapped child profile)"
)
if actor_source == "user" and actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got "
f"{was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
_validate_utc_timestamp(occurred_at, "occurred_at")
async with _connection_lock(self._database):
async with self._database.transaction():
instance = await self._database.fetch_one(
"SELECT child_id FROM quest_instances WHERE id = ?",
(instance_id,),
)
if instance is None:
raise ValueError(
f"quest instance {instance_id} does not exist"
)
if instance[0] != child_id:
raise ValueError(
f"quest instance {instance_id} belongs to child "
f"{instance[0]}, not {child_id}"
)
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
if actor_child_id is not None:
tapped = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?",
(actor_child_id,),
)
if tapped is None:
raise ValueError(
f"actor_child_id {actor_child_id} does not "
"reference an existing child"
)
result = await self._database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
instance_id,
child_id,
event_type,
actor_source,
actor_user_id,
actor_child_id,
occurred_at,
None if was_on_time is None else int(was_on_time),
),
)
event = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE id = ?",
(result.lastrowid,),
)
assert event is not None
return _event_from_row(event)
async def list_by_instance(
self, instance_id: int
) -> list[CompletionEventRecord]:
"""Return every event for the instance, in append order."""
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id",
(instance_id,),
)
return [_event_from_row(row) for row in rows]
async def list_by_child_and_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
*,
due_date_scope: bool = True,
) -> list[CompletionEventRecord]:
"""Return the child's events for instances due in the closed range.
Scopes by the INSTANCE's due date (the day the task was owed),
not the event's wall-clock timestamp: history reporting asks
"what happened to the tasks due that day" (Feature 13). Set
``due_date_scope=False`` to filter by ``occurred_at`` date
instead. Ordered by instance, then event id.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
if due_date_scope:
predicate = (
"SELECT id FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND due_date <= ?"
)
parameters: list[object] = [child_id, range_start, range_end]
else:
# Occurred-at scope: occurred_at is validated to the one
# accepted UTC shape at append time, so half-open day
# bounds in that same shape are exact (no spelling drift).
occurred_start = f"{range_start}T00:00:00+00:00"
occurred_end = f"{range_end}T23:59:59+00:00"
query = (
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE child_id = ? AND occurred_at >= ? "
"AND occurred_at < ? ORDER BY instance_id, id"
)
rows = await self._database.fetch_all(
query,
[
child_id,
occurred_start,
_next_day_start(range_end),
],
)
return [_event_from_row(row) for row in rows]
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
f"WHERE instance_id IN ({predicate}) "
"ORDER BY instance_id, id",
tuple(parameters),
)
return [_event_from_row(row) for row in rows]
async def get_latest_for_instance(
self, instance_id: int
) -> CompletionEventRecord | None:
"""Return the instance's most recent event, or None if untouched.
Instance current state derives from this event (Feature 08):
'completed' means done, 'uncompleted' as the latest means open
again, None means never completed.
"""
row = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
(instance_id,),
)
return _event_from_row(row) if row is not None else None
-11
View File
@@ -1,11 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
The meta-state DAO lives in
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_meta import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_meta import * # noqa: F401,F403
+330 -7
View File
@@ -1,11 +1,334 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
The presence DAOs live in
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
task d0b691d8); this module re-exports them so existing
``from .dao_presence import ...`` references inside the integration keep
working.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
start and end (schema CHECK requires end >= start).
Date arguments are validated as strict YYYY-MM-DD per the same policy
the rules DAO applies: the schema's lexical CHECKs cannot prove
calendar validity.
"""
from __future__ import annotations
from .core.dao_presence import * # noqa: F401,F403
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _UNSET, _validate_date
from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
id: int
child_id: int
cycle_length_weeks: int
anchor_date: str
pattern: str
@dataclass(frozen=True)
class PresenceOverrideRecord:
"""One row of ``presence_overrides``."""
id: int
child_id: int
start_date: str
end_date: str
is_present: bool
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
)
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
return PresenceOverrideRecord(
id=row[0],
child_id=row[1],
start_date=row[2],
end_date=row[3],
is_present=bool(row[4]),
note=row[5],
)
class PresenceSchedulesDao:
"""Typed async access to the ``presence_schedules`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
(child_id,),
)
return _schedule_from_row(row) if row is not None else None
async def delete(self, child_id: int) -> bool:
"""Remove the child's schedule; True if a row was removed.
After deletion the child is present every day (no row means
no restriction). Deleting a schedule the child never had
returns False.
"""
result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
)
return result.rowcount > 0
class PresenceOverridesDao:
"""Typed async access to the ``presence_overrides`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def create(
self,
child_id: int,
start_date: str,
end_date: str,
is_present: bool,
*,
note: str | None = None,
) -> PresenceOverrideRecord:
"""Insert one override and return the record as stored.
The child must exist (validated atomically under the
connection lock). Date shape is validated strictly, and the
schema CHECK rejects end < start.
Overlap policy (Feature 05): an override for a child may not
overlap another override of the SAME child — two different
present/absent answers for one date are unresolvable. The
overlap check (inclusive boundaries) runs inside the same
transaction as the INSERT, so a racing create cannot slip a
conflicting override between the check and the write; a
conflict raises ValueError naming the conflicting override's
id and range.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
# Only a well-ordered range has overlap semantics:
# inverted ranges fall through to the INSERT and hit
# the schema's end>=start CHECK, preserving the
# IntegrityError contract the matrix tests rely on.
if end_date >= start_date:
conflicting = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} "
"FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? "
"AND start_date <= ? ORDER BY id LIMIT 1",
(child_id, start_date, end_date),
)
else:
conflicting = None
if conflicting is not None:
conflict = _override_from_row(conflicting)
raise ValueError(
f"override for child {child_id} "
f"[{start_date}, {end_date}] overlaps existing "
f"override {conflict.id} "
f"[{conflict.start_date}, {conflict.end_date}] "
f"(is_present={conflict.is_present})"
)
result = await self._database.execute(
"INSERT INTO presence_overrides (child_id, "
"start_date, end_date, is_present, note) "
"VALUES (?, ?, ?, ?, ?)",
(child_id, start_date, end_date, int(is_present), note),
)
override = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE id = ?",
(result.lastrowid,),
)
assert override is not None
return _override_from_row(override)
async def list_by_child_and_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[PresenceOverrideRecord]:
"""Return the child's overrides overlapping [range_start, range_end].
An override overlaps the range when it ends on or after the
range starts and starts on or before the range ends (inclusive
boundary dates, matching the storage model where overrides are
closed date ranges). Ordered by start date.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
"ORDER BY start_date",
(child_id, range_start, range_end),
)
return [_override_from_row(row) for row in rows]
async def delete(self, override_id: int) -> bool:
"""Remove one override by id; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM presence_overrides WHERE id = ?",
(override_id,),
)
return result.rowcount > 0
async def read_snapshot_unlocked(
database: NestQuestDatabase,
child_ids: list[int],
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
``child_ids`` must name existing children (the caller derives them
from a trusted snapshot, within the same transaction).
"""
children = sorted(set(child_ids))
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
tuple(children),
)
override_rows = await database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
File diff suppressed because it is too large Load Diff
+414 -23
View File
@@ -1,31 +1,422 @@
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
an executor callable rather than a ``hass`` object. This module
re-exports it and provides :func:`make_database`, a thin adapter that
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
integration constructs the database the same way it always did.
"""
"""Async SQLite connection wrapper for the NestQuest integration."""
from __future__ import annotations
from typing import Any, TypeVar
import asyncio
import sqlite3
from collections.abc import AsyncIterator, Awaitable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from homeassistant.core import HomeAssistant
from .core.db import ExecutionResult, NestQuestDatabase
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
@dataclass(frozen=True)
class ExecutionResult:
"""Plain-data outcome of one statement.
T = TypeVar("T")
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
wrapper's executor is ``hass.async_add_executor_job``, so every
sqlite3 call runs on the HA executor thread and the event loop
never blocks.
``rowcount`` and ``lastrowid`` mirror the corresponding
:class:`sqlite3.Cursor` attributes; both are captured on the executor
thread so no sqlite3 object ever crosses back to the event loop.
"""
return NestQuestDatabase(hass.async_add_executor_job)
rowcount: int
lastrowid: int | None
class NestQuestDatabase:
"""Async wrapper around a single SQLite connection.
Every sqlite3 call is delegated to the Home Assistant executor through
``hass.async_add_executor_job`` so the event loop is never blocked, and
only plain Python data (row tuples and :class:`ExecutionResult`) is
returned to the event loop.
Every operation is serialized through one asyncio.Lock per wrapper. A
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
interleaved coroutines cannot corrupt the transaction and concurrent
BEGINs are impossible; statements issued while a transaction is open
queue until it completes. Concurrent transactions on one wrapper are
not supported: a second ``transaction()`` entered while one is active
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
and is idempotent.
Every connection-affecting executor job (connect, pragmas, statements,
transaction lifecycle and close) is awaited through the settlement
helper :meth:`_await_settled`: if the awaiting task is cancelled while
a job is in flight, the job still settles on the worker thread before
the cancellation propagates, so the wrapper lock is never released
while the executor thread is mid-job.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper around a Home Assistant instance."""
self._hass = hass
self._conn: sqlite3.Connection | None = None
self._rowcount: int = -1
self._lastrowid: int | None = None
self._lock: asyncio.Lock | None = None
self._lock_loop: asyncio.AbstractEventLoop | None = None
self._in_transaction = False
self._tx_task: asyncio.Task | None = None
def _get_lock(self) -> asyncio.Lock:
"""Return the per-wrapper lock, (re)bound to the running loop.
HA drives every wrapper call from one event loop, so the lock is
created once and reused; re-binding only when the running loop
differs keeps the wrapper usable from plain synchronous drivers
that run each call on a fresh loop.
"""
loop = asyncio.get_running_loop()
if self._lock is None or self._lock_loop is not loop:
self._lock = asyncio.Lock()
self._lock_loop = loop
return self._lock
@property
def connected(self) -> bool:
"""Return True while the underlying connection is open."""
return self._conn is not None
@property
def rowcount(self) -> int:
"""Row count of the most recent statement, or -1 when unset."""
return self._rowcount
@property
def lastrowid(self) -> int | None:
"""Row id of the most recent insert, or None when unset."""
return self._lastrowid
@asynccontextmanager
async def _serialized(self) -> AsyncIterator[None]:
"""Hold the wrapper lock unless the caller owns the active transaction.
A transaction holds the lock for its whole BEGIN..COMMIT span;
statements issued by the transaction's own task are already
serialized by it and must not re-acquire the non-reentrant lock.
Any other task queues on the lock until the transaction ends.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
yield
return
async with self._get_lock():
yield
async def open(self, path: Path | str) -> NestQuestDatabase:
"""Open the SQLite file with WAL journal mode and foreign keys on.
Cancellation-safe: if this call is cancelled while the connect job
is in flight, the job still settles (see :meth:`_await_settled`).
A connection the settled job created is never adopted by the
cancelled call; it is closed through a settled executor job instead
of being abandoned to garbage collection, and the cancellation
propagates afterwards, so a follow-up ``open()`` starts from a
clean state.
"""
if self._conn is not None:
return self
async with self._get_lock():
if self._conn is not None:
return self
def _open() -> sqlite3.Connection:
conn = sqlite3.connect(
path, check_same_thread=False, isolation_level=None
)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
except BaseException:
conn.close()
raise
return conn
settled, cancelled = await self._await_settled_state(
self._hass.async_add_executor_job(_open)
)
if cancelled:
if isinstance(settled, sqlite3.Connection):
def _close_abandoned() -> None:
settled.close()
await self._await_settled(
self._hass.async_add_executor_job(_close_abandoned)
)
raise asyncio.CancelledError()
self._conn = settled
return self
async def execute(
self, sql: str, parameters: Sequence[Any] = ()
) -> ExecutionResult:
"""Run one statement in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute() -> ExecutionResult:
cursor = conn.execute(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def execute_many(
self, sql: str, parameters: Sequence[Sequence[Any]]
) -> ExecutionResult:
"""Run executemany in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute_many() -> ExecutionResult:
cursor = conn.executemany(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute_many)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def fetch_one(
self, sql: str, parameters: Sequence[Any] = ()
) -> tuple | None:
"""Run a query in the executor and return its first row, or None."""
async with self._serialized():
conn = self._require_conn()
def _fetch_one() -> tuple[tuple | None, int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
row, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_one)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return row
async def fetch_all(
self, sql: str, parameters: Sequence[Any] = ()
) -> list[tuple]:
"""Run a query in the executor and return all of its rows."""
async with self._serialized():
conn = self._require_conn()
def _fetch_all() -> tuple[list[tuple], int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
rows, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_all)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return rows
@asynccontextmanager
async def transaction(self) -> AsyncIterator[None]:
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
other statement can interleave inside the transaction. Entering a
second transaction while one is active raises RuntimeError. A
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
a ROLLBACK before the commit error propagates, so the real
connection is never left inside a stray transaction.
The lifecycle is cancellation-safe. Transaction ownership and the
wrapper lock are only released after the executor work has fully
settled, even when the owning task is cancelled mid-flight: if a
cancelled BEGIN or COMMIT still completes on the worker thread,
the corresponding ROLLBACK is shielded and awaited before any state
is cleared. A lifecycle statement that fails plus a failing
follow-up cleanup surface both errors in an ExceptionGroup.
"""
if self._in_transaction:
raise RuntimeError(
"A NestQuest transaction is already active on this "
"connection; concurrent transactions are not supported"
)
async with self._get_lock():
conn = self._require_conn()
self._in_transaction = True
self._tx_task = asyncio.current_task()
try:
def _begin() -> None:
conn.execute("BEGIN")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_begin),
"BEGIN did not settle cleanly and the follow-up "
"cleanup also raised; the transaction may need manual "
"cleanup",
)
try:
yield
except BaseException:
def _rollback() -> None:
conn.execute("ROLLBACK")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_rollback),
"ROLLBACK failed and the follow-up cleanup also "
"raised; the transaction may need manual cleanup",
)
raise
def _commit() -> None:
conn.execute("COMMIT")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_commit),
"COMMIT failed and the follow-up cleanup also raised; "
"the transaction may need manual cleanup",
)
finally:
self._in_transaction = False
self._tx_task = None
async def _settle_lifecycle(
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
) -> None:
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
The job always settles first (see :meth:`_await_settled`), so a
cancelled statement that the worker thread completed anyway is
never abandoned mid-flight. If the job raised, a follow-up
ROLLBACK — itself settled — guarantees the connection ends outside
any SQLite transaction before the error propagates. When both the
job and the cleanup raise, both errors are preserved in an
ExceptionGroup instead of the original being lost.
"""
try:
await self._await_settled(job)
except BaseException as settle_error:
errors: list[BaseException] = [settle_error]
try:
await self._ensure_no_stray_transaction(conn)
except BaseException as cleanup_error:
errors.append(cleanup_error)
if len(errors) == 2:
raise BaseExceptionGroup(message, errors) from settle_error
raise
async def _ensure_no_stray_transaction(
self, conn: sqlite3.Connection
) -> None:
"""Force the connection out of any transaction via ROLLBACK.
Runs after a failed or cancelled lifecycle statement. The worker
thread may still be finishing (or may already have completed) the
very statement whose await was cancelled, so both the check and
the ROLLBACK settle in the executor before the caller releases
transaction ownership.
"""
def _cleanup() -> bool:
if conn.in_transaction:
conn.execute("ROLLBACK")
return conn.in_transaction
stray = await self._await_settled(
self._hass.async_add_executor_job(_cleanup)
)
if stray:
raise RuntimeError(
"The SQLite connection is still inside a transaction after "
"cleanup; the transaction may need manual cleanup"
)
async def _await_settled(self, job: Awaitable[Any]) -> Any:
"""Await ``job``, letting it settle even if this task is cancelled.
Cancellation is observed, not swallowed: it is re-raised only once
the job has fully settled, so the caller can finalize against a
known connection state instead of abandoning in-flight work. The
job is wrapped in a task because a bare coroutine dies un-awaited
once its await is cancelled. A second cancellation during
settlement is absorbed the same way; if the job itself fails after
a cancellation, both errors surface in an ExceptionGroup.
"""
result, cancelled = await self._await_settled_state(job)
if cancelled:
raise asyncio.CancelledError()
return result
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
"""Await ``job`` to settlement and report ``(result, cancelled)``.
Same settlement guarantees as :meth:`_await_settled`, but returns
the settled outcome together with whether the awaiting task was
cancelled, so callers that must act on a settled result before
re-raising cancellation (e.g. closing an abandoned connection)
can do so without losing the result.
"""
cancelled = False
inner = asyncio.ensure_future(job)
while not inner.done():
try:
await asyncio.shield(inner)
except asyncio.CancelledError:
cancelled = True
try:
result = inner.result()
except BaseException as job_error:
if cancelled:
raise BaseExceptionGroup(
"executor job failed while settling after cancellation",
[asyncio.CancelledError(), job_error],
) from job_error
raise
return result, cancelled
async def close(self) -> None:
"""Close the connection in the executor; safe to call repeatedly.
Serialized on the wrapper lock, so an in-flight operation or open
transaction always completes before the connection is closed.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
raise RuntimeError(
"Cannot close the NestQuest database from inside its own "
"transaction; exit the transaction first"
)
async with self._get_lock():
conn, self._conn = self._conn, None
self._rowcount, self._lastrowid = -1, None
if conn is None:
return
def _close() -> None:
conn.close()
await self._await_settled(self._hass.async_add_executor_job(_close))
def _require_conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError(
"The NestQuest database connection is closed; call open() first"
)
return self._conn
-130
View File
@@ -1,130 +0,0 @@
"""HA bus events fired by the NestQuest service paths (Feature 10).
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
in :mod:`~.const`): ``nestquest_quest_completed`` and
``nestquest_quest_uncompleted`` fire from the completion service
handlers on an actual transition (a no-op re-complete or re-uncomplete
fires nothing), and ``nestquest_child_day_complete`` fires when a
completion clears the child's whole day (quests were owed and none
remain — a zero-quest day can never fire it, matching the all-done
binary sensor's rule). ``nestquest_quest_missed`` is the documented
contract Feature 11's nightly sweep fires; nothing here fires it.
Payload policy: one dict carrying ``child_id``, ``child_name``,
``instance_id``, ``quest_title``, the instance's ``window``/
``due_date``/``due_time`` so automation authors can filter without a
database lookup, and ``occurred_at`` — the event's own strict UTC
ISO-8601 timestamp. Completion payloads additionally carry
``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from .completion import derive_state
from .const import (
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_UNCOMPLETED,
)
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
#: explicit offset, second precision.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
async def _instance_payload(
database: NestQuestDatabase, instance_id: int, now_stamp: str
) -> dict:
"""Build the shared event payload for one quest instance."""
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
child = await ChildrenDao(database).get(instance.child_id)
definition = await QuestDefinitionsDao(database).get(
instance.definition_id
)
return {
"child_id": instance.child_id,
"child_name": child.display_name if child else None,
"instance_id": instance.id,
"quest_title": definition.title if definition else None,
"window": instance.window,
"due_date": instance.due_date,
"due_time": instance.due_time,
"occurred_at": now_stamp,
}
async def fire_quest_completed(
hass,
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
) -> None:
"""Fire ``nestquest_quest_completed`` and, when the completion
cleared the child's whole day, ``nestquest_child_day_complete``.
Called by the complete_quest service handler AFTER an actual
completion transition, with the completion's ``was_on_time`` —
computed under the completion lock and carried on the result — so
the payload never re-reads mutable latest state a racing reversal
could have replaced. A no-op re-complete never reaches here.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
payload["was_on_time"] = was_on_time
hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload)
# Day-complete: quests were owed today and none remain (the same
# rule as the all-done binary sensor; a zero-quest day never fires).
# Only a completion of TODAY'S instance evaluates it: completing a
# back-dated or future instance must not announce a cleared day.
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
if payload["due_date"] != today.isoformat():
return
instances = await QuestInstancesDao(database).list_by_date_range(
payload["child_id"], today.isoformat(), today.isoformat()
)
events = CompletionEventsDao(database)
remaining = 0
for instance in instances:
latest_for_instance = await events.get_latest_for_instance(
instance.id
)
if derive_state(instance, latest_for_instance, today) != "done":
remaining += 1
if instances and remaining == 0:
hass.bus.async_fire(
EVENT_CHILD_DAY_COMPLETE,
{
"child_id": payload["child_id"],
"child_name": payload["child_name"],
"quests_due": len(instances),
"quests_completed": len(instances),
"occurred_at": now_stamp,
},
)
async def fire_quest_uncompleted(
hass, database: NestQuestDatabase, instance_id: int
) -> None:
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
payload = await _instance_payload(database, instance_id, _now_stamp())
hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload)
-35
View File
@@ -1,35 +0,0 @@
"""Lovelace card bundle registration for NestQuest."""
from __future__ import annotations
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
WWW_DIR = Path(__file__).parent / "www"
FRONTEND_URL_BASE = "/nestquest-static"
BUNDLE_FILENAME = "nestquest-cards.js"
BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}"
_REGISTERED_ATTR = "_nestquest_frontend_registered"
async def async_register_frontend(hass: HomeAssistant) -> None:
"""Serve the card bundle and inject it into every Lovelace page.
Idempotent across config entries: static paths and extra JS URLs are
process-global, so a second entry must not register them again.
"""
if getattr(hass, _REGISTERED_ATTR, False):
return
await hass.http.async_register_static_paths(
[
StaticPathConfig(
FRONTEND_URL_BASE,
str(WWW_DIR),
cache_headers=False,
)
]
)
add_extra_js_url(hass, BUNDLE_URL)
setattr(hass, _REGISTERED_ATTR, True)
+3 -7
View File
@@ -5,14 +5,10 @@
"@overseer"
],
"config_flow": true,
"dependencies": [
"frontend",
"http"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"documentation": "https://github.com/talon2king/NestQuest",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"issue_tracker": "https://github.com/talon2king/NestQuest/issues",
"requirements": [],
"version": "0.5.0"
"version": "0.3.0"
}
+580 -7
View File
@@ -1,11 +1,584 @@
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
"""Versioned schema migration runner for the NestQuest SQLite database.
The versioned migration runner lives in
:mod:`custom_components.nestquest.core.migrations`
(extracted in task d0b691d8); this module re-exports it so existing
``from .migrations import ...`` references inside the integration keep
working.
The runner owns one metadata table, ``nestquest_schema_version``, holding
a single row with the schema version the database file currently
reflects. Migrations are declared as an ordered list: entry ``n``
upgrades version ``n-1`` to version ``n``. Applying a migration means
running its DDL inside one transaction and stamping the new version in
that same transaction, so a database never persists a half-applied
migration: any failure rolls the statements and the version stamp back
together, leaving the file at its prior version.
Version 0 means "the file exists but carries no NestQuest schema yet"
— a fresh file, or a file this integration has never written. Starting
the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1.
Migration 1 was rewritten pre-release (D-007): nothing was ever
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
instead of the legacy ``task_*`` spellings, and no compatibility shim
exists. Dev machines that already hold a version-1 database with
``task_*`` tables are handled by migration 2, which detects the legacy
table names and ``ALTER TABLE ... RENAME TO``s them to their current
names before anything else assumes the new model. Migration 3 brings
pre-D-008 databases to the multi-assignee model: it creates
``quest_definition_assignees`` and, when ``quest_definitions`` still
carries a single ``child_id`` column, rebuilds the table without it,
copying each definition's assignee across. Migration 4 adds the
windows table, migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Each callable migration runs inside its own transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking ``(database, target_version)``. Callables
exist for steps whose SQL depends on the database's current state (a
legacy rename only fires when a legacy table is present; a rebuild only
when the old column shape is present) and own their transaction so they
can bracket it with connection pragmas the runner's uniform transaction
cannot express.
Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet.
An UPDATE form would silently match zero rows there and leave the
database migrated but unstamped — reporting version 0 forever, and
re-applying migration 1 on every startup. The DELETE+INSERT shape is
also what makes the stamp a replace, so the row never duplicates.
Idempotence across restarts: a database already at the latest version
has no pending migrations, so the runner opens a read-only check, finds
nothing to do, and leaves the file byte-untouched (no write transaction
is opened, so no WAL churn).
Migration list policy: entries are append-only. Once shipped, a
migration's SQL must never be edited — an old database applying it for
the first time and a fresh database would then diverge. Fix-forward
with a new migration instead. The version stamp lives in the same
SQLite file as the data it describes, so restoring a backup restores
its matching schema version with it.
Corrupt or missing files are not this module's concern: startup
handling of those cases is task d5bca74b, which decides whether to
open fresh or raise ConfigEntryNotReady. This runner assumes it is
handed an open, working connection.
"""
from __future__ import annotations
from .core.migrations import * # noqa: F401,F403
import asyncio
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Union
from .db import NestQuestDatabase
from .schema import (
COMPLETION_EVENTS_TABLE_SQL,
QUEST_DEFINITIONS_TABLE_SQL,
QUEST_INSTANCES_TABLE_SQL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
)
LOGGER = logging.getLogger(__name__)
#: One migration entry: either an ordered sequence of SQL statements or
#: an async callable taking ``(database, target_version)`` that manages
#: its own transaction and stamps via :func:`stamp_schema_version`.
MigrationStep = Union[
Sequence[str],
Callable[[NestQuestDatabase, int], Awaitable[None]],
]
#: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table.
VERSION_TABLE = "nestquest_schema_version"
#: DDL for the version metadata table. The singleton-row invariant is
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
#: PRIMARY KEY, so the table can never hold a second row, and the
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
#: a positive integer: version 0 means "no row" and is never stored,
#: so a negative or zero stored value is corruption, not a state the
#: runner can act on.
VERSION_TABLE_DDL = f"""
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL CHECK (version >= 1)
)
"""
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: The assignees table DDL, shared with migration 1 via the schema
#: module so a rebuilt and a freshly created table cannot drift.
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
#: Legacy (pre-D-007) table name -> current table name. Order matters:
#: the parent table renames first so the child's foreign key reference
#: is rewritten while its parent's new name is already in place.
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
("task_definitions", "quest_definitions"),
("task_instances", "quest_instances"),
)
async def stamp_schema_version(
database: NestQuestDatabase, version: int
) -> None:
"""Write the schema version stamp (inside the caller's transaction).
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
file has no row yet, so UPDATE alone would silently match zero rows
there and leave a migrated-but-unstamped database.
"""
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
(version,),
)
async def _table_has_column(
database: NestQuestDatabase, table: str, column: str
) -> bool:
"""Return True when ``table`` currently carries ``column``."""
rows = await database.fetch_all(f"PRAGMA table_info({table})")
return any(row[1] == column for row in rows)
#: Window assigned to instances migrated from the pre-window instance
#: shape (D-008). Pre-window rows were generated for whole-day
#: obligations; the morning window is the documented default landing
#: spot, and future dates are regenerated with real window declarations
#: by materialization (delete_future_uncompleted + re-run).
LEGACY_MIGRATED_WINDOW = "morning"
async def _rename_legacy_task_tables(
database: NestQuestDatabase, target_version: int
) -> None:
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
Dev-machines-only path (D-007): a database stamped version 1 by the
pre-rename runner holds ``task_definitions``/``task_instances``.
Each legacy name still present is renamed; fresh databases find
nothing and this step is a no-op. Modern SQLite rewrites foreign
key clauses in other tables to follow the rename, so the renamed
schema is indistinguishable from a freshly created one apart from
internal autoindex names. Runs in its own transaction together
with the version stamp, so a crash rolls both back.
"""
async with database.transaction():
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
row = await database.fetch_one(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = ?",
(legacy_name,),
)
if row is not None:
await database.execute(
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
)
LOGGER.info(
"Renamed legacy table %s to %s",
legacy_name,
current_name,
)
await stamp_schema_version(database, target_version)
async def _add_definition_assignees(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring definitions to the multi-assignee model (D-008).
Two shapes arrive here:
- Fresh or renamed databases whose ``quest_definitions`` already
matches the current canonical DDL (no ``child_id`` column): only
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
when migration 1 already made it).
- Databases stamped at version 2 whose ``quest_definitions`` still
carries the single ``child_id`` column: the table is rebuilt via
SQLite's canonical procedure — create the new table, copy rows,
move each definition's assignee into
``quest_definition_assignees``, drop the old table, rename — so
``DROP COLUMN``'s foreign-key restriction never applies and no
definition or instance row is lost.
The copy runs with ``foreign_keys = OFF`` because dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
runs inside the same transaction before the stamp, so a corrupt
copy aborts the whole migration instead of persisting. The pragma
is a connection-level no-op inside a transaction, hence the
off/transaction/on bracket.
"""
legacy_child_id = await _table_has_column(
database, "quest_definitions", "child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
await database.execute(_ASSIGNEES_TABLE_DDL)
if legacy_child_id:
await database.execute(
"CREATE TABLE quest_definitions_rebuilt "
f"{QUEST_DEFINITIONS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_definitions_rebuilt "
"(id, title, description, icon, schedule_rule_id, "
"due_time, is_active, created_at) "
"SELECT id, title, description, icon, "
"schedule_rule_id, due_time, is_active, created_at "
"FROM quest_definitions ORDER BY id"
)
await database.execute(
"INSERT OR IGNORE INTO quest_definition_assignees "
"(definition_id, child_id) "
"SELECT id, child_id FROM quest_definitions"
)
await database.execute("DROP TABLE quest_definitions")
await database.execute(
"ALTER TABLE quest_definitions_rebuilt "
"RENAME TO quest_definitions"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"definition rebuild produced foreign-key "
f"violations: {violations!r}"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _widen_instance_key(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``quest_instances`` to the D-008 instance key.
Databases whose instance table still carries the pre-window shape
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
rebuilt via SQLite's canonical procedure into the widened shape —
UNIQUE (definition_id, child_id, due_date, window) — with every
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
ids (and the completion events referencing them) survive intact.
The old rows were unique per (definition, due_date), so pinning one
window cannot collide with the widened key. Fresh databases whose
table already matches the canonical shape only stamp.
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``completion_events`` foreign key) and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp, so a corrupt copy aborts the whole migration.
"""
legacy_shape = not await _table_has_column(
database, "quest_instances", "window"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE quest_instances_rebuilt "
f"{QUEST_INSTANCES_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_instances_rebuilt "
"(id, definition_id, child_id, window, due_date, "
"due_time, generated_at) "
"SELECT id, definition_id, child_id, ?, due_date, "
"due_time, generated_at "
"FROM quest_instances ORDER BY id",
(LEGACY_MIGRATED_WINDOW,),
)
await database.execute("DROP TABLE quest_instances")
await database.execute(
"ALTER TABLE quest_instances_rebuilt "
"RENAME TO quest_instances"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"instance rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Widened quest_instances key; migrated rows pinned "
"to the %r window",
LEGACY_MIGRATED_WINDOW,
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _add_actor_child_id(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``completion_events`` to the D-008 actor-child shape.
The ``actor_child_id`` column records which panel profile was
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
because the canonical shape also carries an actor-pair coherence
CHECK (panel events require it, user events forbid it), and CHECK
constraints cannot be added to an existing table. Databases with
the legacy shape are therefore rebuilt via SQLite's canonical
procedure, backfilling each legacy panel row's ``actor_child_id``
to ``child_id`` — pre-D-008 panel completions were always recorded
against the tapped child themselves — while admin rows stay NULL.
Row ids are preserved so ``quest_instances`` references and the
append-only history stay intact. Fresh databases already match
the canonical shape and only stamp.
The rebuild runs with ``foreign_keys = OFF`` and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
completion_events table keeps its append-only guarantee.
"""
legacy_shape = not await _table_has_column(
database, "completion_events", "actor_child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE completion_events_rebuilt "
f"{COMPLETION_EVENTS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO completion_events_rebuilt "
"(id, instance_id, child_id, event_type, actor_source, "
"actor_user_id, actor_child_id, occurred_at, "
"was_on_time) "
"SELECT id, instance_id, child_id, event_type, "
"actor_source, actor_user_id, "
"CASE WHEN actor_source = 'panel' THEN child_id "
"ELSE NULL END, "
"occurred_at, was_on_time "
"FROM completion_events ORDER BY id"
)
await database.execute("DROP TABLE completion_events")
await database.execute(
"ALTER TABLE completion_events_rebuilt "
"RENAME TO completion_events"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"completion_events rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Added actor_child_id to completion_events; legacy "
"panel rows backfilled to their own child_id"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
MIGRATIONS: Sequence[MigrationStep] = [
MIGRATION_1_V1_DDL,
_rename_legacy_task_tables,
_add_definition_assignees,
# Migration 4 (D-008): the windows table is a plain new table — a
# CREATE IF NOT EXISTS suffices for both fresh databases (which
# already made it in migration 1) and pre-window databases.
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
# Migration 5 (D-008): rebuild quest_instances onto the widened
# (definition_id, child_id, due_date, window) key.
_widen_instance_key,
# Migration 6 (D-008): rebuild completion_events with the
# actor_child_id column and its actor-pair coherence CHECK,
# backfilling legacy panel rows to their own child_id.
_add_actor_child_id,
]
async def _create_version_table(database: NestQuestDatabase) -> None:
"""Create the version metadata table if it does not exist.
The table is created before any version check: it is the runner's
own bookkeeping, and creating it is idempotent. The DDL enforces
the singleton-row invariant (``id`` fixed at 1) and a positive
integer version at the database level.
"""
await database.execute(VERSION_TABLE_DDL)
async def read_schema_version(database: NestQuestDatabase) -> int:
"""Return the schema version recorded in the database.
Returns 0 when the version table or its row is missing — the
"fresh file" case. A stored version higher than the runner knows
is surfaced as-is; the caller decides how to treat a future version.
Reading is deliberately passive: a missing table raises, it is not
swallowed into 0, because :func:`apply_migrations` always creates
the table before reading and a direct reader hitting "no such
table" is a caller bug worth surfacing, not a fresh file.
"""
row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
)
if row is None:
return 0
version = row[0]
if not isinstance(version, int) or isinstance(version, bool):
raise RuntimeError(
f"Schema version metadata is not an integer: {version!r}"
)
if version < 1:
raise RuntimeError(
f"Schema version metadata is invalid (must be >= 1): {version!r}"
)
return version
#: One asyncio.Lock per connection wrapper, created lazily and bound to
#: the running loop, serializing whole apply_migrations runs so two
#: concurrent callers on one connection queue instead of colliding on
#: the wrapper's single-transaction rule.
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the migration lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _MIGRATION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_MIGRATION_LOCKS[key] = lock
return lock
async def apply_migrations(
database: NestQuestDatabase,
migrations: Sequence[MigrationStep] = MIGRATIONS,
) -> int:
"""Bring the database schema up to the latest version.
Reads the current version, applies every pending migration in order
— each inside one transaction that also stamps the new version —
and returns the resulting version. Applying zero pending
migrations (database already current) is a no-op that logs at debug
level and opens no write transaction.
The version is re-read inside each migration transaction before
deciding to apply it: the outer read is advisory only (loop bounds,
no-op fast path), and on one shared connection two interleaved
runners would otherwise both read the same version and both apply
a non-idempotent migration. Under the transaction's lock the
second runner sees the first runner's stamp and skips.
Runners on one connection are serialized by an asyncio lock held
for the whole decide-and-apply span: NestQuestDatabase rejects a
second transaction while one is active (RuntimeError), so
concurrent callers must queue, and the lock makes that queueing
explicit. The loser of the race re-reads the version once it
acquires the lock, sees the winner's stamp, and applies nothing.
Raises whatever the failing migration raised, with the database
rolled back to its prior version and prior rows untouched.
"""
async with _migration_lock(database):
await _create_version_table(database)
current = await read_schema_version(database)
latest = len(migrations)
if current > latest:
raise RuntimeError(
f"Database schema version {current} is newer than this "
f"integration understands (latest known: {latest}); "
"downgrades are not supported"
)
if current == latest:
LOGGER.debug(
"NestQuest schema already at version %d; "
"no migrations to apply",
current,
)
return current
for target in range(current + 1, latest + 1):
applied = False
step = migrations[target - 1]
if callable(step):
# Callable migrations own their transaction: some steps
# (table rebuilds) must run connection pragmas such as
# ``foreign_keys = OFF`` BEFORE opening one, which the
# runner's uniform transaction cannot express, and they
# stamp inside their own transaction so a failure rolls
# the statements and the version stamp back together.
# The version is re-read here under the migration lock:
# runners on one connection are serialized by it, so
# the version cannot change between this read and the
# callable's commit.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is newer "
f"than this integration understands (latest known: "
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
await step(database, target)
applied = True
else:
async with database.transaction():
# Re-read the version INSIDE the write transaction:
# the outer read was advisory, and the wrapper
# serializes individual statements, not the
# read-decide-apply decision. Under the
# transaction's lock the version cannot change
# between the read and the stamp, and the loser of
# an interleaved race (a queued second caller
# re-running apply_migrations after the winner
# already migrated) re-reads here, sees the
# winner's stamp, and skips instead of re-applying
# a non-idempotent migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is "
f"newer than this integration understands "
f"(latest known: {latest}); downgrades are "
f"not supported"
)
if in_tx_version < target:
for sql in step:
await database.execute(sql)
await stamp_schema_version(database, target)
applied = True
applied_statements = len(step)
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d",
target,
)
return latest
+1 -137
View File
@@ -11,28 +11,13 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ADMIN_USER_IDS,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_CELEBRATION_ENABLED,
CONF_DAY_ROLLOVER_TIME,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
@@ -71,63 +56,9 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
):
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
# The coordinator refresh interval (Feature 10) is OPTIONAL in raw
# submits: a missing key keeps the current value, so pre-Feature-10
# form payloads (and API callers) are unaffected. Schema-validated
# submits always carry it.
update_interval = user_input.get(CONF_UPDATE_INTERVAL)
if update_interval is not None and (
isinstance(update_interval, bool)
or not isinstance(update_interval, int)
or update_interval < MIN_UPDATE_INTERVAL
):
errors[CONF_UPDATE_INTERVAL] = "invalid"
# The notification section (Feature 11) is OPTIONAL in raw submits
# 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
# service name; the toggles are real booleans.
for time_key in (
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
):
time_value = user_input.get(time_key)
if time_value is not None and (
not isinstance(time_value, str) or not _parse_time(time_value)
):
errors[time_key] = "invalid_time"
notify_target = user_input.get(CONF_NOTIFY_TARGET)
if notify_target is not None and not isinstance(notify_target, str):
errors[CONF_NOTIFY_TARGET] = "invalid"
elif isinstance(notify_target, str) and notify_target != (
notify_target.strip()
):
# An EMPTY target is valid — it means "no notifications
# configured yet" and is the shipped default; a value that
# differs from its own trim (padded or whitespace-only) is
# malformed and rejected.
errors[CONF_NOTIFY_TARGET] = "invalid"
for toggle_key in (
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
toggle_value = user_input.get(toggle_key)
if toggle_value is not None and not isinstance(toggle_value, bool):
errors[toggle_key] = "invalid"
return errors
def _strict_bool(value: object) -> bool:
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
if not isinstance(value, bool):
raise vol.Invalid(f"expected a boolean, got {value!r}")
return value
def _build_schema(
current: dict[str, Any],
*,
@@ -139,10 +70,7 @@ def _build_schema(
The admin picker is a multi-select of EXISTING Home Assistant users
(never a free-text field — the allowlist stores user IDs); its
default is the current database allowlist, so an untouched submit
re-saves exactly who is already allowed. The notification section
(Feature 11) carries the household's notify target, the three
automation times, and four enable toggles — defaults from
:mod:`~.const`, never a hard-coded personal target.
re-saves exactly who is already allowed.
"""
fields: dict[Any, Any] = {
vol.Required(
@@ -154,40 +82,6 @@ def _build_schema(
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
vol.Required(
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
): str,
vol.Required(
CONF_MORNING_SUMMARY_TIME,
default=current[CONF_MORNING_SUMMARY_TIME],
): str,
vol.Required(
CONF_AFTERNOON_REMINDER_TIME,
default=current[CONF_AFTERNOON_REMINDER_TIME],
): str,
vol.Required(
CONF_END_OF_DAY_REPORT_TIME,
default=current[CONF_END_OF_DAY_REPORT_TIME],
): str,
vol.Required(
CONF_MORNING_SUMMARY_ENABLED,
default=current[CONF_MORNING_SUMMARY_ENABLED],
): _strict_bool,
vol.Required(
CONF_AFTERNOON_REMINDER_ENABLED,
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
): _strict_bool,
vol.Required(
CONF_END_OF_DAY_REPORT_ENABLED,
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
): _strict_bool,
vol.Required(
CONF_CELEBRATION_ENABLED,
default=current[CONF_CELEBRATION_ENABLED],
): _strict_bool,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
@@ -316,28 +210,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
CONF_UPDATE_INTERVAL: user_input.get(
CONF_UPDATE_INTERVAL,
self._current_values()[CONF_UPDATE_INTERVAL],
),
}
# The notification section (Feature 11) stores the household's
# choices the same fall-back-to-current way, so raw submits
# that predate the section keep their stored values.
current = self._current_values()
for notification_key in (
CONF_NOTIFY_TARGET,
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
data[notification_key] = user_input.get(
notification_key, current[notification_key]
)
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
elif admin_default:
@@ -358,15 +231,6 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
(CONF_NOTIFY_TARGET, ""),
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
):
if key in options:
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
+617 -6
View File
@@ -1,10 +1,621 @@
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
"""The ScheduleRule model: rule types, validation, and serialization.
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
(extracted in task d0b691d8); this module re-exports it so existing
``from .recurrence import ...`` references inside the integration keep
working.
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
frozen dataclass with no Home Assistant and no database imports. The
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
serializes its dict form into the ``schedule_rules`` table.
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
Tuesday of every month") are distinct types rather than one monthly
type with optional fields, so validation is total: a rule either
constructs, or raises :class:`RuleValidationError` naming the offending
field. Every type carries ONLY the fields its shape needs; carrying
any other shape's field is a construction error.
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
``storage_value`` maps model types onto the schema's CHECK values —
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
disambiguated by which fields are populated; CUSTOM_DAYS stores as
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
table stores (one element for MONTHLY_WEEKDAY, which names the
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
carried through from_dict/to_dict for losslessness and folded into
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
model-side lossless pair; the DB mapping lives in the DAO layer and is
exercised there.
Validation policy (fail fast at construction — the schema's CHECK
constraints are a second line of defense, but invalid rules must never
reach storage):
- ``interval`` >= 1 for every rule.
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
- ``month`` 1..12 is required for YEARLY.
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
when present, on or after ``start_date``.
Month-end policy (implemented in :func:`_occurs_monthly_day` and
documented for Feature 04): in each interval-eligible month, a
MONTHLY_DAY rule for a day that does not exist in that month — the
31st in April, the 30th or 29th in February, February 29th in a
non-leap year — fires on the LAST day of the month instead of being
skipped. A monthly chore still happens in every eligible month,
pinned to the month's end when its nominal date is absent; exactly one
day per eligible month matches the rule (nominal day when it exists,
month end when it does not). The interval anchors on nominal months
elapsed from start_date's month, so clamping never shifts the cadence.
"""
from __future__ import annotations
from .core.recurrence import * # noqa: F401,F403
import datetime
import enum
from dataclasses import dataclass, field
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
_DATE_FORMAT = "%Y-%m-%d"
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
class RuleType(enum.Enum):
"""The six recurrence shapes the engine supports.
``storage_value`` is the string written to the ``schedule_rules``
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
disambiguated by which fields are populated.
"""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY_DAY = "monthly_day"
MONTHLY_WEEKDAY = "monthly_weekday"
YEARLY = "yearly"
CUSTOM_DAYS = "custom_days"
@property
def storage_value(self) -> str:
"""The schema-level rule_type string this model type maps to."""
return {
RuleType.DAILY: "daily",
RuleType.WEEKLY: "weekly",
RuleType.MONTHLY_DAY: "monthly",
RuleType.MONTHLY_WEEKDAY: "monthly",
RuleType.YEARLY: "yearly",
RuleType.CUSTOM_DAYS: "custom",
}[self]
class RuleValidationError(ValueError):
"""Raised when a ScheduleRule field combination is invalid."""
def _validate_date(value: str, field_name: str) -> None:
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise RuleValidationError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise RuleValidationError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
def _validate_weekday_set(weekday_set) -> frozenset[int]:
"""Return a normalized frozenset or raise with a clear message.
Sequence entries are validated BEFORE set() coercion: a list like
[0, False] must not collapse the boolean into 0, and unhashable
entries must raise RuleValidationError, never a raw TypeError.
"""
if isinstance(weekday_set, (list, tuple)):
for entry in weekday_set:
# _is_plain_int: bool and int subclasses (potentially
# unhashable) are rejected before set() coercion.
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
if not isinstance(weekday_set, (set, frozenset)):
raise RuleValidationError(
f"weekday_set must be a set of weekday integers, got "
f"{type(weekday_set).__name__}"
)
if not weekday_set:
raise RuleValidationError(
"weekday_set must not be empty for weekly/custom rules"
)
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
if not 0 <= entry <= 6:
raise RuleValidationError(
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
f"got {entry!r}"
)
return frozenset(weekday_set)
def _is_plain_int(value) -> bool:
"""True only for real ints: bool excluded AND int subclasses too
(an unhashable int subclass would break the frozen-hashable model)."""
return type(value) is int
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
"""Validate the MONTHLY_WEEKDAY position fields."""
if nth_weekday is None or not _is_plain_int(nth_weekday):
raise RuleValidationError(
"nth_weekday must be an integer for monthly_weekday rules, "
f"got {nth_weekday!r}"
)
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
raise RuleValidationError(
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
)
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
raise RuleValidationError(
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
f"(Sunday), got {weekday!r}"
)
@dataclass(frozen=True)
class ScheduleRule:
"""An immutable, validated recurrence rule (see module docstring).
Construct with keyword arguments; invalid combinations raise
:class:`RuleValidationError` at construction time. Fields map
one-to-one onto the ``schedule_rules`` table columns, with
``nth_weekday_weekday`` as the model-only weekday companion to
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
boundary).
"""
rule_type: RuleType
interval: int = 1
weekday_set: frozenset[int] | None = None
day_of_month: int | None = None
nth_weekday: int | None = None
nth_weekday_weekday: int | None = None
month: int | None = None
start_date: str = "1970-01-01"
end_date: str | None = None
def __post_init__(self) -> None:
# Normalize a string rule_type into the enum; anything else
# that is not already a RuleType is rejected.
if isinstance(self.rule_type, str):
# EXACT model names only: no whitespace stripping, no storage
# aliases. The DAO maps storage strings to model fields when
# reading rows; the model boundary stays unambiguous.
candidates = {rt.name.lower(): rt for rt in RuleType}
if self.rule_type in candidates:
object.__setattr__(
self, "rule_type", candidates[self.rule_type]
)
else:
raise RuleValidationError(
f"rule_type must be a RuleType or one of "
f"{sorted(candidates)}, got {self.rule_type!r}"
)
if not isinstance(self.rule_type, RuleType):
raise RuleValidationError(
f"rule_type must be a RuleType or one of its string "
f"values, got {self.rule_type!r}"
)
if not _is_plain_int(self.interval):
raise RuleValidationError(
f"interval must be an integer >= 1, got {self.interval!r}"
)
if self.interval < 1:
raise RuleValidationError(
f"interval must be >= 1, got {self.interval!r}"
)
_validate_date(self.start_date, "start_date")
if self.end_date is not None:
_validate_date(self.end_date, "end_date")
if self.end_date < self.start_date:
raise RuleValidationError(
f"end_date {self.end_date!r} must be on or after "
f"start_date {self.start_date!r}"
)
# Type-exclusive field policy, enforced for every type: build
# the dict of present fields, then reject every field the shape
# must not carry (weekday_set present is detectable via None vs
# set even after normalization below).
shape = self.rule_type
if shape is RuleType.DAILY:
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.WEEKLY:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.CUSTOM_DAYS:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.MONTHLY_DAY:
forbidden_fields = ("weekday_set", "nth_weekday",
"nth_weekday_weekday", "month")
elif shape is RuleType.MONTHLY_WEEKDAY:
forbidden_fields = ("weekday_set", "day_of_month", "month")
elif shape is RuleType.YEARLY:
forbidden_fields = (
"weekday_set",
"nth_weekday",
"nth_weekday_weekday",
)
present_forbidden = [
name
for name in forbidden_fields
if getattr(self, name) is not None
]
if present_forbidden:
raise RuleValidationError(
f"{shape.value} rules must not set "
f"{', '.join(sorted(present_forbidden))}"
)
# Required fields per shape.
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
normalized = _validate_weekday_set(self.weekday_set)
object.__setattr__(self, "weekday_set", normalized)
elif shape is RuleType.MONTHLY_DAY:
if self.day_of_month is None or not _is_plain_int(
self.day_of_month
):
raise RuleValidationError(
"monthly_day rules require day_of_month (1..31)"
)
if not 1 <= self.day_of_month <= 31:
raise RuleValidationError(
f"day_of_month must be 1..31, got {self.day_of_month!r}"
)
elif shape is RuleType.MONTHLY_WEEKDAY:
_validate_monthly_weekday(
self.nth_weekday, self.nth_weekday_weekday
)
elif shape is RuleType.YEARLY:
if self.month is None or not _is_plain_int(self.month):
raise RuleValidationError(
"yearly rules require month (1..12)"
)
if not 1 <= self.month <= 12:
raise RuleValidationError(
f"month must be 1..12, got {self.month!r}"
)
if self.day_of_month is not None and not (
_is_plain_int(self.day_of_month)
and 1 <= self.day_of_month <= 31
):
raise RuleValidationError(
f"day_of_month must be 1..31 for yearly rules, got "
f"{self.day_of_month!r}"
)
def to_dict(self) -> dict:
"""Return the lossless dict form (model-side storage shape).
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
weekday travels inside it (appended) plus
``nth_weekday_weekday`` so the pair round-trips without
ambiguity at the model layer.
"""
return {
"rule_type": self.rule_type.name.lower(), # model name
"interval": self.interval,
"weekday_set": (
sorted(self.weekday_set) if self.weekday_set is not None
else None
),
"day_of_month": self.day_of_month,
"nth_weekday": self.nth_weekday,
"nth_weekday_weekday": self.nth_weekday_weekday,
"month": self.month,
"start_date": self.start_date,
"end_date": self.end_date,
}
@classmethod
def from_dict(cls, data: dict) -> "ScheduleRule":
"""Rebuild a rule from :meth:`to_dict` output.
Raises :class:`RuleValidationError` on unknown keys, missing
keys, or invalid combinations — never a raw TypeError.
"""
if not isinstance(data, dict):
raise RuleValidationError(
f"rule data must be a dict, got {type(data).__name__}"
)
allowed = {
"rule_type",
"interval",
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
"start_date",
"end_date",
}
unknown = [key for key in data if key not in allowed]
if unknown:
raise RuleValidationError(
f"unknown rule fields: {sorted(unknown, key=repr)}"
)
if "rule_type" not in data:
raise RuleValidationError("missing required rule field: rule_type")
weekday_set = data.get("weekday_set")
if isinstance(weekday_set, (list, tuple)):
# Validate entries as plain ints BEFORE set() coercion, so a
# list like [0, False] cannot collapse False into 0 and an
# unhashable/int-subclass entry cannot leak a raw TypeError.
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
return cls(
rule_type=data["rule_type"],
interval=data.get("interval", 1),
weekday_set=weekday_set,
day_of_month=data.get("day_of_month"),
nth_weekday=data.get("nth_weekday"),
nth_weekday_weekday=data.get("nth_weekday_weekday"),
month=data.get("month"),
start_date=data.get("start_date", "1970-01-01"),
end_date=data.get("end_date"),
)
# ---------------------------------------------------------------------------
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
# ---------------------------------------------------------------------------
def _parse_date(value: str) -> datetime.date:
"""Parse a strict ISO date already validated by the model."""
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
"""Day offset from the rule's anchor date to ``target``.
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
first day the rule CAN fire. A negative offset means the target
precedes the rule's window entirely.
"""
return (target - _parse_date(rule.start_date)).days
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
"""True when ``target`` is inside the rule's [start, end] window."""
if target < _parse_date(rule.start_date):
return False
if rule.end_date is not None and target > _parse_date(rule.end_date):
return False
return True
def _month_end(year: int, month: int) -> int:
"""The last calendar day of the given month (leap-aware)."""
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
"""MONTHLY_DAY evaluation with the documented month-end policy.
Policy: in each interval-eligible month, a rule for a day-of-month
that does not exist in that month (the 31st in April, the 30th in
February, the 29th of February in a non-leap year) fires on the
LAST day of that month instead of being skipped — the chore still
happens that month, pinned to the month's end when its nominal date
is absent.
The interval anchors on months ELAPSED from start_date's month: the
rule fires when the months between the anchor month and the target's
month form a multiple of the interval, keeping "every 2 months on
the 15th" aligned to the anchor month even when the firing lands on
a clamped month-end day.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def _nth_weekday_of_month(
year: int, month: int, weekday: int, nth: int
) -> int | None:
"""The day-of-month of the ``nth`` ``weekday``, or None when the
month has no such occurrence (``nth`` 5 beyond four occurrences).
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
end (last occurrence, whatever the total). Pure calendar math.
"""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
first_offset = (weekday - first.weekday()) % 7
day = 1 + first_offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _occurs_monthly_weekday(
rule: ScheduleRule, target: datetime.date
) -> bool:
"""MONTHLY_WEEKDAY evaluation.
Fires when the target is the ``nth`` occurrence of
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
month with fewer occurrences than ``nth`` (5 on a four-occurrence
month) does not fire — n 5 is accepted by the model but skips such
months, matching the done-condition. The interval anchors on
nominal months elapsed from start_date's month.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
if target.weekday() != rule.nth_weekday_weekday:
return False
nth_day = _nth_weekday_of_month(
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
)
return nth_day is not None and target.day == nth_day
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
"""YEARLY evaluation with the documented leap-day clamp policy.
Policy: in each interval-eligible year, the rule fires on
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
still happens every year, pinned to February's end when its
nominal date is absent) rather than silently vanishing. A YEARLY
rule with NO day_of_month fires on the first day of ``month``.
The interval anchors on years elapsed from start_date's year.
"""
anchor = _parse_date(rule.start_date)
years_elapsed = target.year - anchor.year
if years_elapsed % rule.interval != 0:
return False
if target.month != rule.month:
return False
if rule.day_of_month is None:
# Day-less yearly rules fire on the first of the month.
return target.day == 1
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
"""Return True when ``rule`` fires on ``target``.
Pure function: no I/O, no timezone awareness, no mutation. Dates
are plain calendar dates (Feature 04 guardrail); the caller decides
the timezone that produced them.
"""
# Dispatch FIRST: an unimplemented shape must raise loudly even for
# dates outside the window, never silently evaluate to False.
shape = rule.rule_type
if shape not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurs_on for {shape} lands with its own engine task "
"(Features 04 task sequence)"
)
if not _within_window(rule, target):
return False
if shape is RuleType.DAILY:
offset = (target - _parse_date(rule.start_date)).days
return offset % rule.interval == 0
if shape is RuleType.MONTHLY_DAY:
return _occurs_monthly_day(rule, target)
if shape is RuleType.MONTHLY_WEEKDAY:
return _occurs_monthly_weekday(rule, target)
if shape is RuleType.YEARLY:
return _occurs_yearly(rule, target)
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
# parity on January 1st; anchor arithmetic does not).
anchor = _parse_date(rule.start_date)
if shape is RuleType.CUSTOM_DAYS:
interval = 1
else:
interval = rule.interval
week_offset = (target - anchor).days // 7
if week_offset % interval != 0:
return False
return target.weekday() in rule.weekday_set
def occurrences_between(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Return every date string in [start, end] the rule fires on.
Inclusive on both ends. Raises RuleValidationError on malformed or
inverted bounds; a window entirely outside [rule.start_date,
rule.end_date] yields an empty list.
"""
if rule.rule_type not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurrences_between for {rule.rule_type} lands with its own "
"engine task (Features 04 task sequence)"
)
_validate_date(start, "start")
_validate_date(end, "end")
start_date = _parse_date(start)
end_date = _parse_date(end)
if end_date < start_date:
raise RuleValidationError(
f"end {end!r} must be on or after start {start!r}"
)
rule_start = _parse_date(rule.start_date)
if end_date < rule_start:
return []
# Clamp the walk to the rule's own window and the queried range.
effective_start = max(start_date, rule_start)
effective_end = end_date
if rule.end_date is not None:
effective_end = min(effective_end, _parse_date(rule.end_date))
results: list[str] = []
cursor = effective_start
while cursor <= effective_end:
if occurs_on(rule, cursor):
results.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return results
+407 -7
View File
@@ -1,11 +1,411 @@
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
"""Declarative schema DDL for the NestQuest SQLite database.
The declarative schema DDL lives in
:mod:`custom_components.nestquest.core.schema` (extracted in
task d0b691d8); this module re-exports it so existing
``from .schema import ...`` references inside the integration keep
working.
Every v1 table is declared here as plain SQL statements so later schema
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
touching this module's tables. All statements use
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
migration runner is a separate later concern.
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
caller/DAO rather than a DB default, per the feature guardrails. No
``length(created_at) = 25`` CHECK is added: the exact string shape
produced by callers is not fixed yet (``datetime.isoformat()`` may
include microseconds, changing the length), so enforcing a fixed length
here would reject valid values. Only non-null is enforced for now.
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
no time component) compared lexically by the ``end_date >= start_date``
CHECK; due_date/due_time string shapes are caller-validated by the rule
engine and DAO layers, not by DDL.
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
because it sorts, compares and parses trivially in SQL. The format is
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
repetition quantifier, so the element count is enumerated; more than 7
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
This keeps the schema layer storage-only — the DAO layer (tasks
8c72300c+) does not need its own validator, it can rely on the CHECK.
Type affinity: SQLite applies column affinity on insert, but CHECKs
still observe text and fractional values ('abc' stays text, '1.5'
becomes REAL), so every INTEGER column carrying rule semantics carries
a ``typeof(...) = 'integer'`` guard in its CHECK.
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
carry none of the month-specific fields. Relaxed deliberately: the DDL
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
rule or a monthly rule could later be pinned to a month) nor extra
fields on weekly/monthly/yearly rules generally, so the CHECKs only
reject what the rule engine definitively cannot express now.
Presence pattern format: ``presence_schedules.pattern`` encodes the
whole N-week repeating cycle in one TEXT column as pipe-separated
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the child is absent every day of that
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
absent every day of every week, which is distinct from having no
schedule row at all (a child with no schedule is present every day, per
the feature guardrails). Chosen over JSON or per-week rows to stay
consistent with the CSV ``weekday_set`` convention and fully checkable
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
custom functions: char-class GLOBs reject every character outside
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(double comma, comma next to a pipe, leading/trailing comma) and
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
separated digits, which caps a segment at 7 elements — the GLOB cannot
match across a ``|``, so cross-segment boundaries stay safe; and the
segment count is pinned to ``cycle_length_weeks`` by
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
weekday_set this is not full CSV-set normalisation (duplicates like
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
the engine treats the segment as a set. A
one-row-per-``week_index`` normalization was considered and rejected:
the flat single-column encoding keeps the done-condition's table shape
and proved fully checkable with plain CHECKs.
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
common case, and a small cap keeps anchor arithmetic and the encoded
pattern human-readable. Revisit the cap — and re-run the pattern
validation matrix in tests — if a longer cycle is ever needed.
``anchor_date`` is an ISO date string pinning the cycle: the week index
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
computed by the presence engine (Feature 05) from anchor-date
arithmetic, never ISO week parity. Like other date columns it carries
no shape CHECK, matching the established date policy.
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
unlike ``schedule_rules.end_date`` — an override is always a concrete
date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text.
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
``child_id`` column. Assignment lives in
``quest_definition_assignees``, one row per (definition, child) with a
composite primary key and foreign keys to the definition and the child,
so "brush teeth" is one definition covering three children. The
migration runner rebuilds a pre-D-008 table into this shape and copies
its single-assignee column across. The composite primary key makes
duplicate assignment impossible at the storage layer.
Quest windows (D-008): ``quest_definition_windows`` declares which day
windows a definition spans — one row per (definition, window), the
composite primary key making a window idempotent per definition.
``window`` is CHECK-constrained to the three spellings ``const`` owns;
``due_time`` optionally pins a due time inside the window and is
caller-validated (strict HH:MM) by the DAO layer, matching the date
policy above. The window clock ranges live in ``const.py``, not here:
this layer stores what the caller declares and never interprets clocks.
Quest instances: ``quest_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the
append-only log stays the single source of truth. ``definition_id``,
``child_id``, ``due_date`` and ``window`` snapshot the
materialization-time facts ("why this instance exists"): assignment
edits and rule edits change future instances only and never rewrite
these rows. ``window`` records which declared day window produced the
instance (D-008), CHECK-constrained to the same three spellings
``const`` owns. ``due_time`` snapshots the window's optional due time
at generation for the same reason — the instance keeps its
generation-time value even if the definition is edited later.
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
timestamp policy (no DB default, so a generation batch stamps one
coherent time).
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
declared as a table-level ``UNIQUE`` constraint rather than a separate
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
required "unique index prevents duplicate instances" — and the
constraint form cannot be silently dropped the way a standalone index
can, so the duplicate door stays shut. Idempotent materialization keys
on exactly this tuple: the twice-daily case (one definition, two
windows, one child, one date) yields two rows and the shared case (one
definition, three assignees, one window) yields three. No secondary
indexes beyond it are declared yet: history and board queries (Features
08/10/13) should justify their indexes as a later migration once their
query shapes exist.
No ``ON DELETE`` clauses are declared on any foreign key in this
module: children and definitions are deactivated, never deleted, so a
deletion that would orphan instances or events is a bug and must fail
loudly rather than cascade.
Completion events: ``completion_events`` is strictly append-only. That
property is enforced at the DAO layer — the completion-events DAO
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
by database triggers, matching the module's no-trigger policy: a RAISE
trigger would be the one exception to "pure CHECK, no triggers",
complicate the later migration runner, and still prove nothing (any
writer can run raw SQL). ``event_type`` is constrained to
``completed``/``uncompleted``; a ``missed`` row is intentionally not
representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via
``quest_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay
self-describing without joins.
Actor policy: ``actor_source`` distinguishes an authenticated HA user
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
panel tap with no user context (``'panel'``, with ``actor_user_id``
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
Other actor sources are rejected; if an unattended system actor is
ever needed, that is a schema migration, not a silent widening.
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
``completed`` events — at completion time the due date is known and the
flag is decidable. For ``uncompleted`` events the flag is optional:
Feature 08 decides whether a reversal carries the reversed completion's
on-time flag or NULL, and the DDL deliberately accepts both rather than
guessing.
"""
from __future__ import annotations
from .core.schema import * # noqa: F401,F403
from .db import NestQuestDatabase
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
#: Each element is a single digit 0-6, separated by a literal comma.
#: GLOB has no repetition quantifier, so the element count is enumerated.
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
#: because weekly rules need at least one weekday.
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
"[0-6]",
"[0-6],[0-6]",
"[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
]
SCHEMA_V1_CHILDREN_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
colour TEXT,
avatar_ref TEXT,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)
""",
]
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS admin_users (
ha_user_id TEXT PRIMARY KEY NOT NULL,
added_at TEXT NOT NULL
)
""",
]
_WEEKDAY_SET_CHECK = " OR ".join(
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
)
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS schedule_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
start_date TEXT NOT NULL,
end_date TEXT,
CHECK (end_date IS NULL OR end_date >= start_date),
CHECK (
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
OR (rule_type = 'yearly' AND month IS NOT NULL)
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
)
)
""",
]
#: Column body of ``quest_definitions``, shared between the v1 DDL
#: below and the multi-assignee rebuild migration, which must recreate
#: the table in exactly this shape (D-008: one definition, many
#: assignees via ``quest_definition_assignees`` — no ``child_id``
#: column on the definition itself).
QUEST_DEFINITIONS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
""",
]
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
PRIMARY KEY (definition_id, child_id)
)
""",
]
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_windows (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_time TEXT,
PRIMARY KEY (definition_id, window)
)
""",
]
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
NOT pattern GLOB '*[^0-6,|]*'
AND NOT pattern GLOB '*[0-6][0-6]*'
AND NOT pattern GLOB '*,,*'
AND NOT pattern GLOB '*,|*'
AND NOT pattern GLOB '*|,*'
AND NOT pattern GLOB ',*'
AND NOT pattern GLOB '*,'
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
)
)
""",
]
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS presence_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
note TEXT,
CHECK (end_date >= start_date)
)
""",
]
#: Column body of ``quest_instances``, shared between the v1 DDL
#: below and the instance-key rebuild migration, which must recreate
#: the table in exactly this shape (D-008: the instance key is
#: (definition_id, child_id, due_date, window) — one instance per
#: assigned child per declared window per firing date).
QUEST_INSTANCES_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, child_id, due_date, window)
)"""
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
""",
]
#: Column body of ``completion_events``, shared between the v1 DDL
#: below and the actor-child rebuild migration, which must recreate the
#: table in exactly this shape (D-008: ``actor_child_id`` records which
#: panel profile was tapped — set for panel events, NULL for admin
#: ones; legacy panel rows backfill to ``child_id``, the child they
#: always were).
COMPLETION_EVENTS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
actor_user_id TEXT,
actor_child_id INTEGER REFERENCES children(id),
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(actor_source = 'user' AND actor_child_id IS NULL)
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
)"""
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
""",
]
SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
]
async def async_apply_ddl(
database: NestQuestDatabase, statements: list[str]
) -> None:
"""Apply each DDL statement inside one transaction on ``database``.
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
applying the same list twice leaves the schema and its rows intact.
"""
async with database.transaction():
for sql in statements:
await database.execute(sql)
-533
View File
@@ -1,533 +0,0 @@
"""Per-child daily count and percentage sensors (Feature 10, task 2).
Naming contract (design/ENTITIES-AND-SERVICES.md §1): the design table
documents the entity_id shape ``sensor.nestquest_<child>_quests_due_today``.
Home Assistant derives entity_id from the entity NAME and exact
entity_id strings are not directly controllable, so names are spelled
to slug into the documented shape — ``NestQuest Ada quests due today``
slugs to ``sensor.nestquest_ada_quests_due_today`` — while the
registry identity (``unique_id``) IS exact: built from the child's
database id, never the display name, so a rename never orphans an
entity's history. The design table is the visual contract; this
module spells names so HA's default slugging produces it.
Every sensor reads ONLY ``coordinator.data`` (one
:class:`~.coordinator.ChildDaySnapshot` per active child); no entity
queries the database, so one refresh updates the whole board
coherently. The due-today sensor additionally carries the child's
day payload as attributes: ``instances`` is the panel shape of
ENTITIES-AND-SERVICES.md §1 (state ``open``/``completed``) OMITTING
missed instances, and ``admin_instances`` includes them (D-009).
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import (
ChildDaySnapshot,
NestQuestCoordinator,
QuestInstanceView,
)
#: Home Assistant's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
#: are numeric, but the guard is applied uniformly: a payload-bearing
#: sensor can never crash the state write, and later string-state
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
def _state_within_limit(state: Any) -> Any:
"""Return ``state`` unchanged, refusing strings over HA's limit.
Numeric states pass through untouched; a string state longer than
:data:`MAX_STATE_LENGTH` fails fast with ValueError at the property
read — before HA's state machine rejects the write.
"""
if isinstance(state, str) and len(state) > MAX_STATE_LENGTH:
raise ValueError(
f"state exceeds Home Assistant's {MAX_STATE_LENGTH}-character "
f"limit ({len(state)} characters)"
)
return state
def _instance_payload(
instances: Iterable[QuestInstanceView], *, include_missed: bool
) -> list[dict[str, Any]]:
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
``state`` maps the coordinator's derived ``done`` to the panel's
``completed`` spelling; ``missed`` instances are omitted unless
``include_missed`` (the admin payload, D-009, keeps them with
their ``missed`` state).
"""
payload: list[dict[str, Any]] = []
for view in instances:
if view.state == "missed" and not include_missed:
continue
payload.append(
{
"id": view.instance_id,
"definition_id": view.definition_id,
"child_id": view.child_id,
"title": view.title,
"icon": view.icon,
"window": view.window,
"due_time": view.due_time,
"state": (
"completed" if view.state == "done" else view.state
),
"overdue": view.overdue,
"completed_at": view.completed_at,
"on_time": view.was_on_time,
}
)
return payload
#: State reported by the next-quest sensor when the child owes nothing
#: else today. A documented sentinel, never an empty string (an empty
#: state reads as "sensor went unavailable" in HA).
NEXT_QUEST_NONE_STATE = "none"
def _next_open_quest(
child: ChildDaySnapshot,
) -> QuestInstanceView | None:
"""Return the child's earliest open quest from today's snapshot.
The snapshot carries TODAY's instances, so "next" is the earliest
open instance by (due_date, due_time), with ``due_time`` ordering
ahead of ``None`` (a window with no due time comes first only
within the same date — the materializer snapshots each window's
due_time, so a missing one means the window is unscheduled).
Missed and completed instances never qualify.
"""
open_views = [
view
for view in child.instances
if view.state == "open"
]
if not open_views:
return None
return min(
open_views,
key=lambda view: (
view.due_date,
view.due_time if view.due_time is not None else "",
),
)
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors.
Registry identity is built from the child's database id and never
changes; the DISPLAYED name and device label are derived from the
child's CURRENT snapshot on every read, so a rename through
``manage_child`` propagates on the next refresh without orphaning
the entity's history. A child absent from the latest snapshot (a
deactivation; removal is a later task's scope) yields ``None``
states rather than stale counts.
"""
#: Per-subclass label appended to the name (e.g. ``quests due
#: today``), set as a class attribute so ``name`` composes from the
#: current snapshot.
_name_suffix: str = ""
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator)
self._child_id = child.child_id
self._fallback_name = child.child_name
self._attr_unique_id = None # set by the subclass
@property
def _child(self) -> ChildDaySnapshot | None:
"""Return the child's current day snapshot, or None."""
data = self.coordinator.data
if data is None:
return None
for child in data.children:
if child.child_id == self._child_id:
return child
return None
def _displayed_child_name(self) -> str:
child = self._child
return child.child_name if child is not None else self._fallback_name
@property
def name(self) -> str | None:
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, f"child_{self._child_id}")},
name=f"NestQuest {self._displayed_child_name()}",
manufacturer="NestQuest",
)
class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
"""How many quests the child has due today, with the day payload."""
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = "quests due today"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_quests_due_today"
)
@property
def native_value(self) -> int | None:
child = self._child
if child is None:
return None
return _state_within_limit(child.due_today)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
child = self._child
if child is None:
return None
return {
"instances": _instance_payload(
child.instances, include_missed=False
),
"admin_instances": _instance_payload(
child.instances, include_missed=True
),
"child_id": child.child_id,
"child_name": child.child_name,
"present": child.present,
}
class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor):
"""How many of the child's quests were completed today."""
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = "quests completed today"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_quests_completed_today"
)
@property
def native_value(self) -> int | None:
child = self._child
if child is None:
return None
return _state_within_limit(child.completed_today)
class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor):
"""How many of the child's quests are still owed today."""
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = "quests remaining today"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_quests_remaining_today"
)
@property
def native_value(self) -> int | None:
child = self._child
if child is None:
return None
return _state_within_limit(child.remaining_today)
class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
"""The child's completion percentage for today (100 on a zero-quest day)."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = "%"
_name_suffix = "completion pct today"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_completion_pct_today"
)
@property
def native_value(self) -> int | None:
child = self._child
if child is None:
return None
return _state_within_limit(child.completion_pct)
class NestQuestNextQuestSensor(_NestQuestChildDaySensor):
"""The child's next open quest today (title in the state).
State is the quest TITLE, truncated to HA's 255-character state
limit (attributes always carry the full title); when the child
owes nothing else today the state is the documented sentinel
``none`` (NEXT_QUEST_NONE_STATE), never an empty string.
"""
_attr_state_class = None # a label, not a measurement
_name_suffix = "next quest"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_next_quest"
)
@property
def native_value(self) -> str | None:
child = self._child
if child is None:
return None
quest = _next_open_quest(child)
if quest is None:
return NEXT_QUEST_NONE_STATE
return quest.title[:MAX_STATE_LENGTH]
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
child = self._child
if child is None:
return None
quest = _next_open_quest(child)
if quest is None:
return {"child_id": child.child_id, "child_name": child.child_name}
return {
"instance_id": quest.instance_id,
"definition_id": quest.definition_id,
"child_id": quest.child_id,
"child_name": child.child_name,
"title": quest.title,
"icon": quest.icon,
"window": quest.window,
"due_date": quest.due_date,
"due_time": quest.due_time,
"overdue": quest.overdue,
}
def _child_day_sensors(
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
) -> tuple[_NestQuestChildDaySensor, ...]:
"""Build the per-child day sensors for one active child."""
return (
NestQuestQuestsDueTodaySensor(coordinator, child),
NestQuestQuestsCompletedTodaySensor(coordinator, child),
NestQuestQuestsRemainingTodaySensor(coordinator, child),
NestQuestCompletionPctTodaySensor(coordinator, child),
NestQuestNextQuestSensor(coordinator, child),
)
class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
"""Base for the household-level rollup sensors.
Aggregates the ACTIVE children's snapshots only (the snapshot
already excludes inactive children), matching the per-child
sensors exactly — a household number is always the sum of the
per-child numbers. Grouped under one household device so the
three household sensors render together.
"""
_attr_name: str
def __init__(self, coordinator: NestQuestCoordinator) -> None:
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "household")},
name="NestQuest Household",
manufacturer="NestQuest",
)
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"""Total quests due today across active children."""
_attr_name = "NestQuest household quests due today"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_unique_id = f"{DOMAIN}_household_quests_due_today"
@property
def native_value(self) -> int | None:
data = self.coordinator.data
if data is None:
return None
return _state_within_limit(
sum(child.due_today for child in data.children)
)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
data = self.coordinator.data
if data is None:
return None
return {
"children": {
str(child.child_id): child.due_today
for child in data.children
}
}
class NestQuestHouseholdCompletedTodaySensor(_NestQuestHouseholdSensor):
"""Total quests completed today across active children."""
_attr_name = "NestQuest household quests completed today"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_unique_id = f"{DOMAIN}_household_quests_completed_today"
@property
def native_value(self) -> int | None:
data = self.coordinator.data
if data is None:
return None
return _state_within_limit(
sum(child.completed_today for child in data.children)
)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
data = self.coordinator.data
if data is None:
return None
return {
"children": {
str(child.child_id): child.completed_today
for child in data.children
}
}
class NestQuestCycleDaySensor(_NestQuestHouseholdSensor):
"""Cycle day N of the custody cycle (0 when nobody is scheduled).
Computed by the coordinator from the first active child with a
presence schedule, anchor-date arithmetic only (D-004 — never ISO
week parity), so a 53-week year cannot silently invert it.
"""
_attr_name = "NestQuest cycle day"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_unique_id = f"{DOMAIN}_cycle_day"
@property
def native_value(self) -> int | None:
data = self.coordinator.data
if data is None:
return None
return _state_within_limit(data.cycle_day)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
data = self.coordinator.data
if data is None:
return None
return {"cycle_day": data.cycle_day, "today": data.today_iso}
def _household_sensors(
coordinator: NestQuestCoordinator,
) -> tuple[_NestQuestHouseholdSensor, ...]:
"""Build the three household-level sensors (one set per entry)."""
return (
NestQuestHouseholdDueTodaySensor(coordinator),
NestQuestHouseholdCompletedTodaySensor(coordinator),
NestQuestCycleDaySensor(coordinator),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: Callable[..., None],
) -> None:
"""Create the five day sensors per active child.
Entities are created for the snapshot's children at platform
setup; a coordinator listener adds sensors for children that
first appear on a later refresh. Deactivated children are NOT
removed here — their sensors hold ``None`` states until the
entity-removal task lands.
"""
coordinator = entry.runtime_data.coordinator
known_child_ids: set[int] = set()
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
new_children = [
child
for child in children
if child.child_id not in known_child_ids
]
if not new_children:
return
known_child_ids.update(child.child_id for child in new_children)
async_add_entities(
[
sensor
for child in new_children
for sensor in _child_day_sensors(coordinator, child)
]
)
data = coordinator.data
if data is not None:
_add_new_children(data.children)
# The household rollups are child-independent: created exactly
# once at platform setup, they aggregate whatever the snapshot
# carries (empty included — an empty household is a valid 0).
async_add_entities(_household_sensors(coordinator))
def _handle_coordinator_update() -> None:
data = coordinator.data
if data is not None:
_add_new_children(data.children)
coordinator.async_add_listener(_handle_coordinator_update)
@@ -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)}"
)
-636
View File
@@ -1,636 +0,0 @@
"""Domain-global NestQuest service schemas and handlers (Feature 09).
Registration is domain-scoped and happens once (see
:func:`async_register_services`); handlers resolve a live database at
call time through the injected ``find_runtime`` callable. Permission
checks are Feature 09 later tasks — this module only validates payload
shape and forwards to the Feature 03/06/08 business layers (and the
presence DAOs). ``export_history_csv`` is registered with a schema but
raises until Feature 13. ``regenerate`` is supplied by the caller so
the Feature 07 handler stays the one in ``__init__``.
"""
from __future__ import annotations
import datetime
from collections.abc import Awaitable, Callable
from typing import Any
from zoneinfo import ZoneInfo
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import children as children_layer
from . import completion as completion_layer
from . import quest_definitions as quest_definitions_layer
from .const import (
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
DOMAIN,
DOMAIN_SERVICES,
QUEST_WINDOWS,
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 .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .events import fire_quest_completed, fire_quest_uncompleted
from .materialize import regenerate_for_child
from .permissions import permission_gate
from .presence import PresenceSchedule
from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
FindRuntime = Callable[[HomeAssistant], Any]
ServiceHandler = Callable[[Any], Awaitable[None]]
ACTOR_PANEL = "panel"
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:
"""Reject bools and non-ints; voluptuous ``int`` would accept True."""
if isinstance(value, bool) or not isinstance(value, int):
raise vol.Invalid(f"expected int, got {value!r}")
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(
{
vol.Required("instance_id"): _strict_int,
vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]),
vol.Optional("actor_child_id"): _strict_int,
vol.Optional("actor_user_id"): str,
}
)
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):
runtime = find_runtime(hass)
if runtime is None:
raise HomeAssistantError(
"NestQuest has no live database; cannot run this service"
)
return runtime
async def _refresh_entities(runtime: Any) -> None:
"""Force an immediate coordinator refresh after a service mutation.
Only the SERVICE paths call this — the scheduled materialize and
rollover internals are direct function calls and never widen this
gate — so entities reflect a completion the moment the service
call returns instead of waiting for the poll interval.
"""
coordinator = getattr(runtime, "coordinator", None)
if coordinator is not None:
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()
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
horizon_days = DEFAULT_HORIZON_DAYS
else:
horizon_days = value
return {"today": today, "horizon_days": horizon_days}
def _actor_kwargs(call: Any) -> dict[str, Any]:
actor = call.data["actor"]
if actor == ACTOR_PANEL:
actor_child_id = call.data.get("actor_child_id")
if actor_child_id is None:
raise HomeAssistantError(
"actor_child_id is required when actor is panel"
)
return {
"actor_source": ACTOR_PANEL,
"actor_child_id": actor_child_id,
"actor_user_id": None,
}
context = getattr(call, "context", None)
user_id = getattr(context, "user_id", None) if context is not None else None
if not user_id:
user_id = call.data.get("actor_user_id")
return {
"actor_source": ACTOR_USER,
"actor_user_id": user_id,
"actor_child_id": None,
}
def _with_errors(handler: ServiceHandler) -> ServiceHandler:
async def _wrapped(call: Any) -> None:
try:
await handler(call)
except HomeAssistantError:
raise
except ValueError as err:
raise HomeAssistantError(str(err)) from err
return _wrapped
def _complete_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"]
# ``appended`` is decided under the same lock as the write, so
# a concurrent duplicate call (a double tap) can not produce a
# second transition event: the loser appends nothing.
result = await completion_layer.complete_instance(
runtime.database,
instance_id,
**_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
schedule = PresenceSchedule(
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
)
await PresenceSchedulesDao(runtime.database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
runtime.database,
schedule.child_id,
**_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 PresenceOverridesDao(runtime.database).create(
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
)
await regenerate_for_child(
runtime.database,
data["child_id"],
**_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)
dao = PresenceOverridesDao(runtime.database)
override_id = call.data["override_id"]
override = await dao.get(override_id)
if override is None:
raise HomeAssistantError(
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
runtime.database,
override.child_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(
"ordered_ids is required when action is reorder"
)
await children_layer.reorder_children(database, ordered_ids)
return _with_errors(_handler)
def async_register_services(
hass: HomeAssistant,
*,
find_runtime: FindRuntime,
regenerate_handler: ServiceHandler,
) -> None:
"""Register every canonical service once at domain scope.
Every service — regenerate included — passes through the Feature
09 permission gate before its handler runs; the gate reads
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
registered exempt from the policy.
"""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
gate = permission_gate
specs: list[tuple[str, ServiceHandler, object]] = [
(SERVICE_REGENERATE, regenerate_handler, None),
(
SERVICE_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:
"""Remove every canonical service when the last entry unloads."""
for service in DOMAIN_SERVICES:
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
+1 -315
View File
@@ -9,318 +9,4 @@ regenerate:
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:
name: Complete quest
description: Mark a quest instance as completed.
fields:
instance_id:
name: Instance ID
description: The quest instance to complete.
required: true
selector:
number:
mode: box
actor:
name: Actor
description: Who completed the quest (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:
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:
touched.
+10 -22
View File
@@ -1,32 +1,20 @@
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
The path resolver lives in
:mod:`custom_components.nestquest.core.store` (extracted in task
d0b691d8) and is Home-Assistant-free:
:func:`custom_components.nestquest.core.store.async_get_db_path`
takes the full database path. This module re-exports it and provides
the original ``async_get_db_path(hass)`` signature so the integration
resolves the path from ``hass.config.path`` exactly as before.
"""
"""SQLite storage path resolution for the NestQuest integration."""
from __future__ import annotations
from pathlib import Path
from homeassistant.core import HomeAssistant
from .core.const import SQLITE_DB_FILENAME
from .core.store import async_get_db_path as _core_async_get_db_path
from .core.store import _ensure_dir
from .const import SQLITE_DB_FILENAME
__all__ = ["async_get_db_path", "_ensure_dir"]
def _ensure_dir(path: Path) -> None:
"""Create the given directory and any missing parents."""
path.mkdir(parents=True, exist_ok=True)
async def async_get_db_path(hass: HomeAssistant) -> Path:
"""Return the absolute path to the NestQuest SQLite database file.
Resolves the database file from ``hass.config.path`` and delegates to
:func:`custom_components.nestquest.core.store.async_get_db_path`,
which ensures the parent directory exists off the event loop via
:func:`asyncio.to_thread`.
"""
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
"""Return the absolute path to the NestQuest SQLite database file."""
db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute()
await hass.async_add_executor_job(_ensure_dir, db_path.parent)
return db_path
-132
View File
@@ -1,132 +0,0 @@
"""The nightly missed-quest sweep (Feature 11).
At the configured day-rollover time (the same HA-local listener that
re-materializes the rolling horizon) this job finds every instance
whose ``due_date`` is before the HA-local date, that has NO completion
event, and that falls after the persisted sweep watermark — and fires
ONE ``nestquest_quest_missed`` event per instance with the documented
payload. The sweep is read-only over the domain tables: it never
mutates an instance and never writes a completion event (``missed``
is derived state, never an event_type — Feature 08's contract).
Idempotency is the watermark: after a run, ``nestquest_meta_state``
records the HA-local date the sweep ran, and the next run only
sweeps instances due in ``[watermark, today)``. A second run the
same night therefore fires nothing new, and a run after days of
downtime sweeps exactly the accumulated window once — no
per-instance journal, no re-announcing old misses. An instance swept
as missed that is 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.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from zoneinfo import ZoneInfo
from .const import EVENT_QUEST_MISSED
from .dao_children import ChildrenDao
from .dao_instances import QuestInstancesDao
from .dao_meta import MetaStateDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: meta_state key holding the HA-local date the sweep last ran.
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the migration runner's pattern: the whole watermark read → query →
#: fire → 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] = {}
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the sweep lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SWEEP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SWEEP_LOCKS[key] = lock
return lock
#: UTC ISO-8601 timestamp policy shared with the DAO layers.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
async def run_missed_sweep(
hass,
database: NestQuestDatabase,
*,
today: datetime.date | None = None,
) -> int:
"""Fire one missed event per unswept past-due open instance.
``today`` optionally pins the HA-local date (threaded like the
materialization and completion layers); when omitted it is read
from ``hass.config.time_zone``. Returns the number of events
fired. Fires nothing and returns 0 when the watermark already
covers ``today`` (a same-night rerun).
The whole watermark read → query → fire → 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.
"""
today_date = (
today
if today is not None
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
)
today_iso = today_date.isoformat()
dao = MetaStateDao(database)
async with _sweep_lock(database):
watermark = await dao.get(SWEEP_WATERMARK_KEY)
if watermark == today_iso:
LOGGER.debug(
"NestQuest missed sweep already ran for %s", today_iso
)
return 0
instances_dao = QuestInstancesDao(database)
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
children_dao = ChildrenDao(database)
definitions_dao = QuestDefinitionsDao(database)
now_stamp = _now_stamp()
fired = 0
for instance in rows:
child = await children_dao.get(instance.child_id)
definition = await definitions_dao.get(instance.definition_id)
hass.bus.async_fire(
EVENT_QUEST_MISSED,
{
"child_id": instance.child_id,
"child_name": child.display_name if child else None,
"instance_id": instance.id,
"quest_title": definition.title if definition else None,
"window": instance.window,
"due_date": instance.due_date,
"due_time": instance.due_time,
"occurred_at": now_stamp,
},
)
fired += 1
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
LOGGER.info(
"NestQuest missed sweep for %s fired %d event(s)", today_iso, fired
)
return fired
@@ -1,76 +0,0 @@
/* ============================================================
NestQuest — Parent admin card tokens
Surface: phone, 390x844 reference. Plain CubeCraft styling,
no tabletop treatment, plain domain words in the UI.
Scope on the card host: nestquest-admin-card.
Derived from the approved mockups (admin screens 1d-1i).
Two densities were built; COMPACT is the default. Flip
--nq-a-density-* to the roomy values for the alternative.
============================================================ */
:host {
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-a-page: #F4F4F6;
--nq-a-surface: #FFFFFF;
--nq-a-surface-subtle: #FAFAFA;
--nq-a-selected: #EBEBF8; /* row being edited */
--nq-a-border: #E4E4EA;
--nq-a-divider: #EDEDF1;
--nq-a-ink: #18181D;
--nq-a-ink-secondary: #52525E;
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
--nq-a-success: #15803D;
--nq-a-danger: #DC2626;
--nq-a-danger-strong: #B91C1C;
--nq-a-danger-bg: #FEE2E2;
--nq-a-warning-ink: #92400E;
--nq-a-warning-ink-2: #78350F;
--nq-a-warning-bg: #FEF3C7;
--nq-a-warning-border: rgba(217,119,6,.35);
--nq-a-info-bg: #DBEAFE;
--nq-a-info-ink: #1E3A8A;
--nq-a-info-icon: #1D4ED8;
--nq-a-reversal: #7D28A0; /* un-complete events in history */
/* ── Type ─────────────────────────────────────────────── */
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px; /* 800, screen title */
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
--nq-a-size-card-title: 19px; /* 800, roomy child card */
--nq-a-size-stat: 22px; /* 800, compact stat number */
--nq-a-size-row: 13.5px;/* 700, list row title */
--nq-a-size-meta: 11px; /* 600, row meta */
--nq-a-size-label: 10px; /* 700, uppercase field label */
--nq-a-track-label: .10em;
/* ── Density: COMPACT (default) ───────────────────────── */
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
--nq-a-density-gap: 10px;
--nq-a-density-card-pad: 13px;
--nq-a-density-row-pad: 11px 13px;
--nq-a-density-radius: 8px;
--nq-a-density-shadow: none;
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
/* ── Metrics ──────────────────────────────────────────── */
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
--nq-a-tap-min: 44px; /* every row/control clears this */
--nq-a-radius-pill: 9999px;
--nq-a-radius-sheet: 20px 20px 0 0;
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
/* ── Motion ───────────────────────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel";
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel Decorative";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2");
}
@font-face {
font-family: "Cinzel Decorative";
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2");
}
@@ -1,96 +0,0 @@
/* ============================================================
NestQuest — Kids' panel card tokens
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
viewed from ~3 ft, no login, read/complete only.
Scope these on the card host: nestquest-quest-log-card,
nestquest-party-board-card.
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
============================================================ */
:host {
/* ── Parchment ground ─────────────────────────────────── */
--nq-p-parchment-top: #f0e4c7;
--nq-p-parchment-bottom: #ddcca3;
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
/* Card surfaces sitting on parchment */
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
--nq-p-card-border: rgba(120,88,48,.38);
--nq-p-panel-border: rgba(92,62,26,.40);
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
--nq-p-rule: 2px solid rgba(92,62,26,.35);
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
--nq-p-ink: #2b1f14; /* titles, quest names */
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
--nq-p-ink-away: #4d4433; /* away adventurer name */
--nq-p-ink-late: #8f1526; /* overdue line */
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
--nq-p-icon-tile-done: rgba(92,62,26,.16);
--nq-p-crest: var(--nq-brand-gradient);
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
/* ── Wax seal (completed marker) ──────────────────────── */
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
/* ── Dock (bottom strip, non-interactive) ─────────────── */
--nq-p-dock-bg: rgba(43,31,20,.90);
--nq-p-dock-ink: #f7efdb;
--nq-p-dock-ink-secondary: #d8c9a6;
--nq-p-dock-divider: rgba(233,220,189,.30);
--nq-p-dock-height: 84px;
/* ── Type ─────────────────────────────────────────────── */
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
--nq-p-size-wordmark: 66px; /* 900 weight */
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
--nq-p-size-name: 48px; /* 700, adventurer name */
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
--nq-p-size-quest: 30px; /* 700, quest title */
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
--nq-p-track-label: .14em;
--nq-p-track-kicker: .30em;
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
--nq-p-quest-pad: 22px; /* padding inside a quest card */
--nq-p-tile-size: 64px; /* quest icon tile */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
--nq-p-radius-card: 12px;
--nq-p-radius-panel: 20px;
--nq-p-radius-pill: 9999px;
--nq-p-page-inset: 62px;
--nq-p-frame-inset: 26px;
/* ── Motion (CubeCraft easing) ────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
-1
View File
@@ -90,7 +90,6 @@ child_order: [declan, jordyn, chloe]
# kids' panel, view 2
type: custom:nestquest-quest-log-card
board_path: /nestquest/board
weather_entity: weather.home # optional; dock falls back to date/time
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
confirm_timeout_seconds: 15
complete_screen_seconds: 12
-104
View File
@@ -1,104 +0,0 @@
# Panel empty & error states — design addendum
Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
states are unspecified by request (child with no quests, everyone away, integration
unconfigured, DB unreachable). Panel must not render a blank parchment — decide
before shipping the card."*
All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
The frame, wordmark row, kicker and dock always render; only the content area
changes. **No state renders a blank parchment.**
## 1. What the data can say
The cards read `sensor.nestquest_<child>_quests_due_today` (plus siblings). One of
four things comes back, and the card must tell them apart:
| Backend condition | What the card sees | Card's interpretation |
|---|---|---|
| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
| Integration not configured | entity does not exist at all | **not set up** |
| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
An away child (presence `present: false`, from the due-sensor attributes with the
presence binary sensor as fallback) is *not* an error: it is a first-class panel
state ("On travels"). Away children get no instances (materialiser skips them), so
away is detected from the presence flag, never from a zero quest count.
## 2. Quest log — state priority
`nestquest-quest-log-card` resolves exactly one state, in this order:
1. **No adventurer in the path** (direct navigation to a URL with no child slug —
misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
body *"Open The Party and tap your crest to open your quest log."*
2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
*"A parent needs to finish setting up NestQuest."*
3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
be reached"*, body *"The party's records are quiet right now. NestQuest will
return shortly."*
4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
gradient), tent icon, *"<Name> is on travels"*, *"The quest log unlocks when
they return."* and, when `next_present` is known, *"Returns <weekday>, <Mon D>"*.
Direct navigation is the one path that can reach this state (the board's away
plate is not tappable); the log answers it honestly instead of showing an empty
column set. Header renders with the crest in away colours and the sub line
`… · On travels`; the remaining-count block is hidden (it is meaningless while
away).
5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
today"*, body *"Nothing was posted for today, <Name>. Enjoy the day's rest!"* —
never three bare empty columns. The header and remaining block stay (0 quests
left, party progress), so the log still reads like the log.
6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
`--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
— the same phrase screen 1c uses — and body *"Every quest is claimed, <Name>.
The seal is set for today. Returning to The Party shortly."* The idle return
(§3.5) is what actually navigates back; 1c remains the designed takeover.
7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
window with no quests still renders its head alone, exactly as the spec says.
Notices sit in the columns' content area, vertically centred. Error notices (states
1–3) replace the header entirely — there is no honest name to headline when the
records do not answer — while states 4–6 keep the full header.
## 3. Party board — state priority
`nestquest-party-board-card` resolves per configured child, then at board level:
1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
not set up yet"*, body *"A parent needs to finish setting up NestQuest before
the party can gather."*
2. **Every configured child unresolvable** → one board notice instead of a grid of
broken plates: *"NestQuest is not set up yet"* when the entities are missing,
*"The records cannot be reached"* when they answer `unavailable`/`unknown`.
3. **Partial** (some children resolve, some do not) → the board still renders the
resolving plates; an unresolvable child renders a muted, dashed plate in the
away geometry with a compass icon and the pill label **"Unknown"**, progress
line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
and must not lie "On travels": the records simply do not say.
4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
The hint line ("Tap your crest…") is hidden in board-level notice states — there
is nothing to tap.
## 4. Shared rules
- **Never a blank parchment.** Every render path emits the frames, wordmark,
kicker, dock, and either content or a notice panel. The dock already degrades to
date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
(`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
(`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
error says the records cannot be reached, not that everything is fine. Error
copy names the actor who can fix it (a parent) or promises the return of the
data — no stack traces, no entity IDs.
- **No interaction in error/away states.** No confirm dialog, no idle return
changes, no navigation attempts. Notices are static.
- Both cards implement the same resolution rules, so the board and the log never
disagree about whether the integration is up.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

-2
View File
@@ -77,8 +77,6 @@
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
--nq-p-quest-pad: 22px; /* padding inside a quest card */
--nq-p-tile-size: 64px; /* quest icon tile */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
-1232
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
{
"name": "nestquest-cards",
"private": true,
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"lit": "^3.3.0"
},
"devDependencies": {
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
}
-38
View File
@@ -1,38 +0,0 @@
import { LitElement, html, unsafeCSS } from "lit";
import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
export class NestQuestAdminCard extends LitElement {
static properties = {
hass: { attribute: false },
_config: { state: true },
};
static styles = [unsafeCSS(adminTokens)];
hass?: HassLike;
_config?: CardConfig;
setConfig(config: CardConfig): void {
if (!config || typeof config !== "object") {
throw new Error("Invalid configuration");
}
this._config = config;
}
getCardSize(): number {
return 8;
}
render() {
return html`<div>NestQuest Admin</div>`;
}
}
customElements.define("nestquest-admin-card", NestQuestAdminCard);
registerCustomCard({
type: "nestquest-admin-card",
name: "NestQuest Admin",
description: "Parent phone view for today, tasks, schedule, and history.",
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
import "./cards/party-board";
import "./cards/quest-log";
import "./cards/admin";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = FONTS_HREF;
link.dataset.nqFonts = "";
document.head.appendChild(link);
}
-20
View File
@@ -1,20 +0,0 @@
export type HassLike = Record<string, unknown>;
export type CardConfig = Record<string, unknown>;
export type CustomCardInfo = {
type: string;
name: string;
description: string;
};
declare global {
interface Window {
customCards?: CustomCardInfo[];
}
}
export function registerCustomCard(info: CustomCardInfo): void {
window.customCards = window.customCards ?? [];
window.customCards.push(info);
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-13
View File
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src"]
}
-19
View File
@@ -1,19 +0,0 @@
import { defineConfig } from "vite";
export default defineConfig({
build: {
outDir: "../custom_components/nestquest/www",
emptyOutDir: false,
sourcemap: false,
lib: {
entry: "src/nestquest-cards.ts",
formats: ["es"],
fileName: () => "nestquest-cards.js",
},
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});
+1 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.5.0"
version = "0.3.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
@@ -8,10 +8,8 @@ dependencies = []
[dependency-groups]
dev = [
"jinja2>=3",
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"voluptuous>=0.14",
]
-206
View File
@@ -1,206 +0,0 @@
"""Shared harness for the NestQuest automation-blueprint tests.
The blueprints are YAML data consumed by Home Assistant's blueprint
engine; the integration itself never imports yaml or jinja2. The
blueprint tests therefore exercise them exactly the way HA would:
parse the YAML (with a loader that understands ``!input`` tags), then
render the message/condition templates with a plain jinja2 environment
over a fixture state set shaped like HA's template state objects.
The harness is mock-only (see conftest.py), so the fixtures model just
the surface the templates touch: ``states.binary_sensor`` /
``states.sensor`` lists of objects carrying ``entity_id``, ``state``
and ``attributes`` — plus, for the celebration blueprint, a callable
``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's
``search``/``match`` selectattr tests are Home Assistant additions,
registered here explicitly.
This module is plain helper code (deliberately not named ``test_*`` so
pytest never collects it); every blueprint test file imports from it.
"""
from __future__ import annotations
import re
from pathlib import Path
import jinja2
import yaml
REPO_ROOT = Path(__file__).parent.parent
BLUEPRINT_DIR = (
REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation"
)
class BlueprintInput(str):
"""Marker for a parsed blueprint ``!input <name>`` reference."""
def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput:
return BlueprintInput(loader.construct_scalar(node))
class _BlueprintLoader(yaml.SafeLoader):
"""SafeLoader extended with Home Assistant's ``!input`` blueprint tag."""
_BlueprintLoader.add_constructor("!input", _input_constructor)
def load_blueprints() -> dict[str, dict]:
"""Parse every automation blueprint, asserting the directory exists."""
assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}"
paths = sorted(BLUEPRINT_DIR.glob("*.yaml"))
assert paths, f"no blueprints under {BLUEPRINT_DIR}"
return {
path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader)
for path in paths
}
def blueprint(name: str) -> dict:
"""Return one parsed blueprint by its file stem."""
blueprints = load_blueprints()
assert name in blueprints, f"{name}.yaml is missing"
return blueprints[name]
def morning_summary() -> dict:
"""Return the parsed morning-summary blueprint."""
return blueprint("morning_summary")
def single_action(bp: dict) -> dict:
"""Return the single action of a one-action blueprint."""
actions = bp["actions"]
assert isinstance(actions, list) and len(actions) == 1
return actions[0]
def morning_action(bp: dict) -> dict:
"""Return the single notify action of the morning summary."""
return single_action(bp)
def notify_data(bp: dict, index: int = 0) -> dict:
"""Return a notify action's service ``data`` (title/message live
there in the modern HA action schema, not at the action's top level)."""
action = bp["actions"][index]
assert isinstance(action.get("data"), dict), (
"notify payload fields must nest under data:"
)
return action["data"]
def iter_strings(node: object):
"""Yield every string value in a parsed YAML structure, recursively."""
if isinstance(node, str):
yield node
elif isinstance(node, dict):
for value in node.values():
yield from iter_strings(value)
elif isinstance(node, list):
for value in node:
yield from iter_strings(value)
class TemplateState:
"""Stand-in for an HA template state object: entity_id/state/attributes."""
def __init__(self, entity_id: str, state: str, attributes: dict | None = None):
self.entity_id = entity_id
self.state = state
self.attributes = dict(attributes or {})
class StatesFixture:
"""HA's template ``states`` object over a fixture entity set.
Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``,
``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is
callable as ``states(entity_id)`` returning the entity's state
string — or ``None`` when the fixture holds no such entity, the
way HA returns ``None`` for a nonexistent entity id.
"""
def __init__(
self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
):
self.binary_sensor = list(binary_sensors)
self.sensor = list(sensors)
self.input_datetime = list(input_datetimes)
self.input_text = list(input_texts)
self._all = (
self.binary_sensor
+ self.sensor
+ self.input_datetime
+ self.input_text
)
def __call__(self, entity_id: str):
"""Return the entity's state string, or None when absent."""
for entity in self._all:
if entity.entity_id == entity_id:
return entity.state
return None
def make_states(
binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
) -> StatesFixture:
"""Build the ``states`` fixture: per-domain lists of TemplateState."""
return StatesFixture(
binary_sensors, sensors, input_datetimes, input_texts
)
def make_template_environment() -> jinja2.Environment:
"""A plain jinja2 environment with HA's regex tests and bool filter."""
env = jinja2.Environment()
env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None
env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None
def _bool(value, default=False):
if isinstance(value, str):
return value.strip().lower() in ("true", "on", "open", "yes", "1")
return bool(value) or default
env.filters["bool"] = _bool
return env
def render(template: str, **variables) -> str:
"""Render a template the way HA would substitute its variables."""
return make_template_environment().from_string(template).render(**variables)
# ---------------------------------------------------------------------------
# Entity-reference discipline (Feature 10 shapes only). The scanners
# are shared so every blueprint test file can assert the same rules;
# the directory-wide tests in test_blueprints.py apply them to every
# blueprint file automatically.
# ---------------------------------------------------------------------------
#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py).
ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple(
re.compile(pattern)
for pattern in (
r"sensor\.nestquest_[a-z0-9_]+_quests_due_today",
r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today",
r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today",
r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today",
r"sensor\.nestquest_[a-z0-9_]+_next_quest",
r"binary_sensor\.nestquest_[a-z0-9_]+_all_done",
r"binary_sensor\.nestquest_[a-z0-9_]+_present_today",
r"sensor\.nestquest_household_quests_due_today",
r"sensor\.nestquest_household_quests_completed_today",
r"sensor\.nestquest_cycle_day",
)
)
#: An unescaped, id-like entity reference: any found must be a full
#: match for one of the Feature 10 shapes above.
_ENTITY_LITERAL_RE = re.compile(r"(?<![\\\w])(?:sensor|binary_sensor)\.[A-Za-z0-9_]+")
#: A domain-dot mention, ignoring the template's escaped-dot regexes
#: (one backslash is stripped so ``binary_sensor\\.`` counts too).
_MENTION_RE = re.compile(r"(?:sensor|binary_sensor)\.([A-Za-z0-9_]+)")
+6 -419
View File
@@ -27,7 +27,6 @@ import inspect
import sys
import tempfile
import weakref
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -45,14 +44,7 @@ _HA_MODULES = (
"homeassistant.exceptions",
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.entity",
"homeassistant.helpers.event",
"homeassistant.helpers.update_coordinator",
"homeassistant.components",
"homeassistant.components.sensor",
"homeassistant.components.binary_sensor",
"homeassistant.components.http",
"homeassistant.components.frontend",
)
def _find_spec(name: str):
@@ -92,14 +84,7 @@ for _parent, _child in (
("homeassistant", "exceptions"),
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "entity"),
("homeassistant.helpers", "event"),
("homeassistant.helpers", "update_coordinator"),
("homeassistant", "components"),
("homeassistant.components", "sensor"),
("homeassistant.components", "binary_sensor"),
("homeassistant.components", "http"),
("homeassistant.components", "frontend"),
):
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
@@ -192,11 +177,7 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
_data_entry_flow_mock.FlowResult = dict
class HomeAssistantError(Exception):
"""Stand-in mirroring homeassistant.exceptions.HomeAssistantError."""
class ConfigEntryNotReady(HomeAssistantError):
class ConfigEntryNotReady(Exception):
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
HA retries setup when this is raised; the tests assert the corrupt
@@ -204,256 +185,7 @@ class ConfigEntryNotReady(HomeAssistantError):
"""
class Unauthorized(HomeAssistantError):
"""Stand-in mirroring homeassistant.exceptions.Unauthorized."""
_exceptions_mock.HomeAssistantError = HomeAssistantError
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_exceptions_mock.Unauthorized = Unauthorized
@dataclass
class DeviceInfo:
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
The kwargs-only shape the integration builds per-child devices
with: ``identifiers``, ``name``, ``manufacturer``, ``model``.
"""
identifiers: object = None
name: str | None = None
manufacturer: str | None = None
model: str | None = None
class Entity:
"""Stand-in for homeassistant.helpers.entity.Entity.
Mirrors the ``_attr_*`` attribute contract through the ``name``,
``unique_id``, ``device_info`` and ``extra_state_attributes``
properties, and ``async_write_ha_state`` registering the entity
into the hass entity registry (``hass.entities``, a dict keyed by
``unique_id`` storing the live object) — the harness's stand-in
for HA's state machine, so tests enumerate entities and read
their state directly off the registered object.
"""
hass = None
_attr_name: str | None = None
_attr_unique_id: str | None = None
_attr_device_info: DeviceInfo | None = None
_attr_extra_state_attributes: dict | None = None
@property
def name(self) -> str | None:
return self._attr_name
@property
def unique_id(self) -> str | None:
return self._attr_unique_id
@property
def device_info(self) -> DeviceInfo | None:
return self._attr_device_info
@property
def extra_state_attributes(self) -> dict | None:
return self._attr_extra_state_attributes
def async_write_ha_state(self) -> None:
"""Register the entity under its unique_id (the mock state write)."""
if self.hass is not None and self.unique_id is not None:
self.hass.entities[self.unique_id] = self
class CoordinatorEntity(Entity):
"""Stand-in for homeassistant.helpers.update_coordinator.CoordinatorEntity.
Real HA subscribes the entity to coordinator notifications in the
platform-scheduler-driven ``async_added_to_hass`` lifecycle hook;
the stand-in subscribes eagerly in ``__init__`` so the synchronous
``AddEntitiesCallback`` surface stays synchronous (the mock
harness has no lifecycle scheduler). ``_handle_coordinator_update``
mirrors HA: one state write per coordinator notification.
"""
def __init__(self, coordinator) -> None:
self.coordinator = coordinator
self.coordinator.async_add_listener(self._handle_coordinator_update)
def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()
class SensorStateClass:
"""Stand-in namespace for homeassistant.components.sensor.SensorStateClass.
Only the members the integration uses (plus TOTAL for the
long-history sensors later tasks add).
"""
MEASUREMENT = "measurement"
TOTAL = "total"
class SensorDeviceClass:
"""Stand-in namespace for homeassistant.components.sensor.SensorDeviceClass.
Only the members the integration uses.
"""
PERCENTAGE = "percentage"
class SensorEntity(Entity):
"""Stand-in for homeassistant.components.sensor.SensorEntity.
The ``_attr_*`` passthrough for ``native_value``,
``native_unit_of_measurement``, ``state_class``, ``device_class``
and ``suggested_display_precision``.
"""
_attr_native_value: object = None
_attr_native_unit_of_measurement: str | None = None
_attr_state_class: str | None = None
_attr_device_class: str | None = None
_attr_suggested_display_precision: int | None = None
@property
def native_value(self) -> object:
return self._attr_native_value
@property
def native_unit_of_measurement(self) -> str | None:
return self._attr_native_unit_of_measurement
@property
def state_class(self) -> str | None:
return self._attr_state_class
@property
def device_class(self) -> str | None:
return self._attr_device_class
@property
def suggested_display_precision(self) -> int | None:
return self._attr_suggested_display_precision
class BinarySensorEntity(Entity):
"""Stand-in for homeassistant.components.binary_sensor.BinarySensorEntity.
Wired now so PLATFORMS can grow (task 4's all-done / present
sensors) without more harness surgery; only the surface the
integration uses.
"""
_attr_is_on: bool | None = None
_attr_device_class: str | None = None
@property
def is_on(self) -> bool | None:
return self._attr_is_on
@property
def device_class(self) -> str | None:
return self._attr_device_class
class DataUpdateCoordinator:
"""Functional stand-in for HA's DataUpdateCoordinator.
Mirrors the surface NestQuest's coordinator subclass uses:
``__init__(hass, logger, name=..., update_interval=...)``,
``async_add_listener``/``async_update_listeners`` for entity
subscription, ``async_refresh``/``async_config_entry_first_refresh``
driving the subclass's ``_async_update_data``, ``last_update_success``,
and ``data``. Interval scheduling is NOT simulated (no real clock
ticks in tests) — tests drive refreshes directly.
"""
def __init__(self, hass, logger, *, name=None, update_interval=None):
self.hass = hass
self.logger = logger
self.name = name
self.update_interval = update_interval
self.data = None
self.last_update_success = False
self._listeners = []
def async_add_listener(self, update_callback, context=None):
"""Subscribe; returns an unsubscribe callable like HA's."""
entry = (update_callback, context)
self._listeners.append(entry)
def _remove():
if entry in self._listeners:
self._listeners.remove(entry)
return _remove
def async_update_listeners(self):
"""Notify every listener (entities schedule their writes)."""
for callback, _context in list(self._listeners):
callback()
async def async_refresh(self):
"""Run one update pass and notify listeners."""
try:
self.data = await self._async_update_data()
self.last_update_success = True
except BaseException:
self.last_update_success = False
raise
self.async_update_listeners()
async def async_config_entry_first_refresh(self):
"""First refresh at config-entry setup (alias of refresh)."""
await self.async_refresh()
async def async_shutdown(self):
"""Release listeners on teardown."""
self._listeners.clear()
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
_entity_mock = _ha_mock("homeassistant.helpers.entity")
_entity_mock.DeviceInfo = DeviceInfo
_entity_mock.Entity = Entity
_sensor_mock = _ha_mock("homeassistant.components.sensor")
_sensor_mock.SensorEntity = SensorEntity
_sensor_mock.SensorDeviceClass = SensorDeviceClass
_sensor_mock.SensorStateClass = SensorStateClass
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
@dataclass(frozen=True)
class StaticPathConfig:
"""Stand-in mirroring homeassistant.components.http.StaticPathConfig."""
url_path: str
path: str
cache_headers: bool = True
def _add_extra_js_url(hass, url, es5=False):
"""Stand-in mirroring homeassistant.components.frontend.add_extra_js_url."""
urls = getattr(hass, "extra_js_urls", None)
if urls is None:
hass.extra_js_urls = []
urls = hass.extra_js_urls
urls.append(url)
_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig
_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url
import voluptuous as vol
@@ -575,41 +307,6 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
class HttpRegistry:
"""Models HA's ``hass.http`` static-path registration."""
def __init__(self):
self.static_paths: list[StaticPathConfig] = []
async def async_register_static_paths(self, configs):
self.static_paths.extend(list(configs))
class EventBus:
"""Models HA's ``hass.bus`` event bus.
``async_fire`` is SYNCHRONOUS (matching real HA) and records
``(event_type, payload)`` pairs in order so tests can assert
exactly which events fired with which payloads; there is no
listener dispatch (entities poll the coordinator, and the cards
are not part of this harness).
"""
def __init__(self):
self.events: list[tuple[str, dict]] = []
def async_fire(self, event_type: str, payload=None):
self.events.append((event_type, dict(payload or {})))
def fired(self, event_type: str) -> list[dict]:
"""Return every payload fired for ``event_type``, in order."""
return [
payload
for fired_type, payload in self.events
if fired_type == event_type
]
class ServiceRegistry:
"""Models HA's ``hass.services`` service registry.
@@ -628,7 +325,7 @@ class ServiceRegistry:
self._services: dict[tuple[str, str], object] = {}
def async_register(self, domain, service, func, schema=None, supports_response=None):
self._services[(domain, service)] = (func, schema)
self._services[(domain, service)] = func
return None
def has_service(self, domain, service):
@@ -638,26 +335,11 @@ class ServiceRegistry:
self._services.pop((domain, service), None)
return None
async def call(self, domain, service, data=None, context=None):
registered = self._services.get((domain, service))
if registered is None:
async def call(self, domain, service, data=None):
func = self._services.get((domain, service))
if func is None:
raise KeyError(f"service {domain}.{service} not registered")
func, schema = registered
payload = dict(data or {})
if schema is not None:
payload = schema(payload)
if context is None:
context_obj = SimpleNamespace(user_id=None)
elif isinstance(context, dict):
context_obj = SimpleNamespace(**context)
else:
context_obj = context
call = SimpleNamespace(
domain=domain,
service=service,
data=payload,
context=context_obj,
)
call = SimpleNamespace(domain=domain, service=service, data=data or {})
result = func(call)
if inspect.isawaitable(result):
await result
@@ -732,87 +414,10 @@ def make_hass() -> tuple:
registry = ListenerRegistry(hass)
hass.time_change = TimeChangeRegistry(hass)
hass.services = ServiceRegistry(hass)
hass.bus = EventBus()
hass.http = HttpRegistry()
hass.extra_js_urls: list[str] = []
# A valid IANA time zone (the day-rollover listener reads it to
# compute "today" in HA local time).
hass.config.time_zone = "UTC"
# The minimal entity registry: entities self-register through
# Entity.async_write_ha_state keyed by unique_id, storing the live
# object; tests enumerate created entities and read their state
# directly. entities_by_entry mirrors HA's per-entry platform
# scoping: unloading an entry's platforms removes exactly that
# entry's entities.
hass.entities: dict[str, Entity] = {}
hass.entities_by_entry: dict[str, dict[str, Entity]] = {}
def _make_add_entities(entry):
entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {})
def _async_add_entities(new_entities, update_before_add=False):
"""Mirror HA's AddEntitiesCallback: bind hass and register.
Accepts a single entity or an iterable, mirroring HA's
permissive call shape; registration goes through each
entity's async_write_ha_state (the mock state write) and
is recorded per entry so an unload removes exactly this
entry's entities.
"""
if isinstance(new_entities, Entity):
new_entities = [new_entities]
for entity in new_entities:
entity.hass = hass
entity.async_write_ha_state()
if entity.unique_id is not None:
entry_entities[entity.unique_id] = entity
return _async_add_entities
async def _async_forward_entry_setups(entry, platforms):
"""Mirror hass.config_entries.async_forward_entry_setups.
HA's contract, loosely: import each platform module of the
entry's integration and drive its ``async_setup_entry`` with a
per-entry AddEntitiesCallback. A stub entry without a domain
defaults to this harness's single integration (NestQuest).
"""
for platform in list(platforms):
domain = getattr(entry, "domain", "nestquest")
module = importlib.import_module(
f"custom_components.{domain}.{platform}"
)
await module.async_setup_entry(
hass, entry, _make_add_entities(entry)
)
hass.config_entries.forwarded_platforms.append(platform)
async def _async_unload_platforms(entry, platforms):
"""Mirror hass.config_entries.async_unload_platforms.
Runs each platform module's ``async_unload_entry`` when it
defines one (per-platform unload is optional, like HA's), then
removes the entry's entities — unloading an entry's platforms
removes exactly that entry's entities, never another entry's.
"""
for platform in list(platforms):
domain = getattr(entry, "domain", "nestquest")
module = importlib.import_module(
f"custom_components.{domain}.{platform}"
)
unload = getattr(module, "async_unload_entry", None)
if unload is not None:
await unload(hass, entry)
hass.config_entries.unloaded_platforms.append(platform)
for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}):
hass.entities.pop(unique_id, None)
hass.config_entries.forwarded_platforms: list[str] = []
hass.config_entries.unloaded_platforms: list[str] = []
hass.config_entries.async_forward_entry_setups = _async_forward_entry_setups
hass.config_entries.async_unload_platforms = _async_unload_platforms
async def _async_reload(entry_id: str) -> None:
registry.reloaded.append(entry_id)
@@ -829,24 +434,6 @@ def make_hass() -> tuple:
return hass, registry
def executor_for(hass):
"""Return a dynamic executor callable backed by ``hass.async_add_executor_job``.
The wrapper resolves ``hass.async_add_executor_job`` on every call, so a
test that swaps it after constructing the database (to gate or fail
specific jobs) is observed without rebuilding the wrapper — mirroring
the pre-extraction db.py, which read the attribute dynamically off
``hass``. Use this for cancellation/gating tests that reassign
``hass.async_add_executor_job``; plain call sites can pass
``hass.async_add_executor_job`` directly.
"""
async def _executor(fn, *args, **kwargs):
return await hass.async_add_executor_job(fn, *args, **kwargs)
return _executor
@pytest.fixture
async def hass():
"""Provide a standard-shaped hass fixture bound to the running test loop.
+1 -1
View File
@@ -31,7 +31,7 @@ def _make_hass_mock():
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / name)
try:
await apply_migrations(database)
-184
View File
@@ -1,184 +0,0 @@
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
from __future__ import annotations
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child, list_children
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
from custom_components.nestquest.completion import complete_instance
SEED_HORIZON_DAYS = 3
def _binary(hass, child_id: int, kind: str):
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
async def _setup_entry(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
return entry
async def _seed_two_children(database) -> tuple:
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
return ada, bo
async def test_all_done_off_with_open_quests_and_zero_quests(
hass, make_entry
) -> None:
"""all_done is OFF while quests are owed AND on a zero-quest day;
a child with no quests never fires the celebration automation."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory") # zero quests
await coordinator.async_refresh()
assert _binary(hass, ada.id, "all_done").is_on is False
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
assert _binary(hass, cory.child_id, "all_done").is_on is False
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
snapshot_ada = [
child for child in coordinator.data.children
if child.child_id == ada.id
][0]
instance_id = snapshot_ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "all_done").is_on is True
async def test_present_today_on_without_schedule_off_when_absent(
hass, make_entry
) -> None:
"""No schedule means present every day; an all-absent pattern
today reports OFF."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory")
today = datetime.date.today()
await coordinator.async_refresh()
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
await PresenceSchedulesDao(database).upsert_by_child(
cory.child_id, 1, today.isoformat(), ""
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "present_today").is_on is True
assert _binary(hass, cory.child_id, "present_today").is_on is False
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
all_done = _binary(hass, ada.id, "all_done")
present = _binary(hass, ada.id, "present_today")
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
assert all_done.name == "NestQuest Ada all done"
assert present.name == "NestQuest Ada present today"
for entity in (all_done, present):
assert entity.device_info.name == "NestQuest Ada"
assert all_done.extra_state_attributes == {
"child_id": ada.id,
"child_name": "Ada",
}
assert present.extra_state_attributes == {
"child_id": ada.id,
"child_name": "Ada",
"next_present": None,
}
async def test_present_today_carries_next_present_when_away(
hass, make_entry
) -> None:
"""A child away today carries the ISO date of their next present
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
from it); an always-absent pattern carries ``None``."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory")
today = datetime.date.today()
pattern = ",".join(
str(day) for day in range(7) if day != today.weekday()
)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), pattern
)
await PresenceSchedulesDao(database).upsert_by_child(
await _cory_id(database), 1, today.isoformat(), ""
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "present_today").is_on is False
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
"next_present"
] == (today + datetime.timedelta(days=1)).isoformat()
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
assert _binary(hass, cory.child_id, "present_today").is_on is False
assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[
"next_present"
] is None
async def _cory_id(database) -> int:
for child in await list_children(database):
if child.display_name == "Cory":
return child.id
raise AssertionError("Cory was not created")
-268
View File
@@ -1,268 +0,0 @@
"""Tests for the NestQuest afternoon-reminder automation blueprint.
The afternoon reminder nudges only the stragglers: a child appears
exactly when they are PRESENT today and still have open quests.
Present-and-clear children, absent children, and the household rollup
sensors are all omitted, and when nobody qualifies the automation
sends nothing at all — the all-clear template condition stops the run
before the notification. The harness (YAML ``!input`` loader, state
fixtures, jinja rendering) is shared in
:mod:`tests.blueprint_helpers`; the directory-wide shape and
entity-discipline tests in test_blueprints.py cover this file too.
"""
from __future__ import annotations
import re
from custom_components.nestquest.const import DEFAULT_AFTERNOON_REMINDER_TIME
from tests.blueprint_helpers import (
notify_data,
BlueprintInput,
blueprint,
iter_strings,
make_states,
render,
single_action,
TemplateState,
)
AFTERNOON_TITLE = "NestQuest afternoon reminder"
def afternoon_reminder() -> dict:
"""Return the parsed afternoon-reminder blueprint."""
return blueprint("afternoon_reminder")
def afternoon_conditions(bp: dict) -> list[dict]:
"""Return the blueprint's conditions: toggle first, guard second."""
conditions = bp["conditions"]
assert isinstance(conditions, list) and len(conditions) == 2
return conditions
def all_clear_guard(bp: dict) -> str:
"""Return the template that is false when every present child is done."""
return afternoon_conditions(bp)[1]["value_template"]
# ---------------------------------------------------------------------------
# Inputs, defaults, and wiring.
# ---------------------------------------------------------------------------
def test_afternoon_reminder_declares_inputs_with_selectors() -> None:
"""The three inputs exist, carry selectors, and default sanely."""
inputs = afternoon_reminder()["blueprint"]["input"]
expected = {"afternoon_time", "notify_target", "send_reminder"}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["afternoon_time"]["default"] == DEFAULT_AFTERNOON_REMINDER_TIME
assert DEFAULT_AFTERNOON_REMINDER_TIME == "15:00"
assert inputs["send_reminder"]["default"] is True
assert "time" in inputs["afternoon_time"]["selector"]
assert "text" in inputs["notify_target"]["selector"]
assert "boolean" in inputs["send_reminder"]["selector"]
def test_afternoon_reminder_wires_inputs_not_literals() -> None:
"""Time trigger, toggle and guard conditions, and notify action use !input."""
bp = afternoon_reminder()
assert bp["variables"]["send_reminder"] == BlueprintInput("send_reminder")
trigger = bp["triggers"][0]
assert trigger["trigger"] == "time"
assert trigger["at"] == BlueprintInput("afternoon_time")
toggle, guard = afternoon_conditions(bp)
assert toggle["condition"] == "template"
assert "send_reminder" in toggle["value_template"]
assert guard["condition"] == "template"
action = single_action(bp)
assert action["action"] == BlueprintInput("notify_target")
data = notify_data(afternoon_reminder())
assert data["title"] == AFTERNOON_TITLE
assert isinstance(data["message"], str) and data["message"].strip()
# ---------------------------------------------------------------------------
# Rendering: only present children with open quests are listed.
# ---------------------------------------------------------------------------
def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> None:
"""Present+open Alice is listed; present+clear Carol, absent Bob,
and the household rollup are all omitted."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"2",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_carol_quests_due_today",
"2",
{"child_name": "Carol", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_carol_quests_remaining_today",
"0",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"2",
{"child_name": "Bob", "child_id": 3, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"1",
{"child_id": 3},
),
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
],
)
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
assert rendered == "Alice still has 2 quest(s) open."
assert "Carol" not in rendered
assert "Bob" not in rendered
assert "household" not in rendered
def test_afternoon_reminder_all_clear_guard_renders_false() -> None:
"""With every present child done (and absent children owing), the
guard condition is false: the automation sends nothing at all."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"2",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"0",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"2",
{"child_name": "Bob", "child_id": 2, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"1",
{"child_id": 2},
),
TemplateState("sensor.nestquest_household_quests_due_today", "2"),
],
)
guard = all_clear_guard(afternoon_reminder())
assert render(guard, states=states).strip() == "False"
def test_afternoon_reminder_guard_renders_true_when_a_present_child_owes() -> None:
"""A present child with open quests makes the guard true."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"1",
{"child_id": 1},
),
],
)
guard = all_clear_guard(afternoon_reminder())
assert render(guard, states=states).strip() == "True"
def test_afternoon_reminder_send_reminder_toggle_renders() -> None:
"""The enable condition renders truthy/false from the input value."""
toggle = afternoon_conditions(afternoon_reminder())[0]
assert render(toggle["value_template"], send_reminder=True).strip() == "True"
assert render(toggle["value_template"], send_reminder=False).strip() == "False"
def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None:
"""Two children sharing a display name (HA registry suffix _2 on the
second one's entity ids) both appear, each with their own count."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_ada_quests_due_today",
"2",
{"child_name": "Ada", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_ada_quests_due_today_2",
"3",
{"child_name": "Ada", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today_2",
"3",
{"child_id": 2},
),
],
)
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
assert rendered.split("\n") == [
"Ada still has 1 quest(s) open.",
"Ada still has 3 quest(s) open.",
]
def test_afternoon_reminder_never_hard_codes_notify_target() -> None:
"""No notify service literal outside the input's placeholder text."""
bp = afternoon_reminder()
spec = bp["blueprint"]
assert not re.search(r"notify\.[a-z_]+", spec["description"])
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
for value in iter_strings(outside_input):
assert not re.search(r"notify\.[a-z_.]+", value), value
target_description = spec["input"]["notify_target"]["description"]
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert single_action(bp)["action"] == BlueprintInput("notify_target")
def test_afternoon_reminder_absent_child_regression() -> None:
"""The Feature 11 guardrail, stated as one regression test: no
reminder message is ever produced for an absent child."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"3",
{"child_name": "Bob", "child_id": 1, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"3",
{"child_id": 1},
),
],
)
guard = afternoon_conditions(afternoon_reminder())[-1]
assert render(guard["value_template"], states=states).strip() == "False"
message = render(
notify_data(afternoon_reminder())["message"], states=states
)
assert "Bob" not in message
-313
View File
@@ -1,313 +0,0 @@
"""Tests for the NestQuest day-complete celebration automation blueprint.
The celebration fires on the ``nestquest_child_day_complete`` event —
which NestQuest only fires when quests were owed and all are complete,
so a zero-quest day can never celebrate. Dedupe design (per-child,
per-day): the household points ONE ``input_text`` helper at the
blueprint; the condition celebrates only when THIS child has no
``<child_id>:<today>`` entry stored, and the action rewrites the
helper with only today's entries plus the new one — so a child who
un-completes and re-completes the same day is celebrated once, not
twice, while a second child clearing the same day is still celebrated.
The harness (YAML ``!input`` loader, state fixtures, jinja rendering)
is shared in :mod:`tests.blueprint_helpers`; the directory-wide shape
and entity-discipline tests in test_blueprints.py cover this file
too. ``now()`` in the dedupe condition is injected by the test as a
fixed callable, keeping the render deterministic.
"""
from __future__ import annotations
import datetime
import re
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
from tests.blueprint_helpers import (
BlueprintInput,
notify_data,
blueprint,
iter_strings,
make_states,
render,
TemplateState,
)
CELEBRATION_TITLE = "NestQuest day complete"
HELPER_ENTITY = "input_text.nestquest_last_celebrated"
#: The fixed "now" every dedupe render uses: 2026-09-19 15:00 local.
FIXED_NOW = datetime.datetime(2026, 9, 19, 15, 0, 0)
def celebration() -> dict:
"""Return the parsed day-complete-celebration blueprint."""
return blueprint("day_complete_celebration")
def celebration_conditions(bp: dict) -> list[dict]:
"""Return the blueprint's conditions: toggle first, dedupe second."""
conditions = bp["conditions"]
assert isinstance(conditions, list) and len(conditions) == 2
return conditions
def dedupe_condition(bp: dict) -> str:
"""Return the once-per-child-per-day dedupe template (true = celebrate)."""
return celebration_conditions(bp)[1]["value_template"]
def stamp_value_template(bp: dict) -> str:
"""Return the helper-stamp action's value template."""
stamp = bp["actions"][1]
return stamp["data"]["value"]
def trigger_payload(child_id: int, child_name: str) -> dict:
"""A nestquest_child_day_complete trigger fixture: HA exposes an
event trigger's payload as trigger.event.data."""
return {
"event": {
"data": {
"child_id": child_id,
"child_name": child_name,
"quests_due": 3,
"quests_completed": 3,
"occurred_at": "2026-09-19T15:00:00+00:00",
}
}
}
def render_dedupe(
helper_state: str | None, child_id: int = 1
) -> str:
"""Render the dedupe condition with the helper holding helper_state.
``None`` models a helper entity the fixture does not hold (never
created or not yet set), which HA exposes as a missing state.
"""
states = make_states(
input_texts=(
[]
if helper_state is None
else [TemplateState(HELPER_ENTITY, helper_state)]
)
)
return render(
dedupe_condition(celebration()),
states=states,
last_celebrated_helper=HELPER_ENTITY,
now=lambda: FIXED_NOW,
trigger=trigger_payload(child_id, "Ada"),
).strip()
def render_stamp(helper_state: str | None, child_id: int = 1) -> str:
"""Render the stamp action's value with the helper holding helper_state."""
states = make_states(
input_texts=(
[]
if helper_state is None
else [TemplateState(HELPER_ENTITY, helper_state)]
)
)
return render(
stamp_value_template(celebration()),
states=states,
last_celebrated_helper=HELPER_ENTITY,
now=lambda: FIXED_NOW,
trigger=trigger_payload(child_id, "Ada"),
).strip()
# ---------------------------------------------------------------------------
# Trigger, inputs, defaults, and wiring.
# ---------------------------------------------------------------------------
def test_celebration_triggers_on_the_child_day_complete_event() -> None:
"""The event trigger references the real nestquest_child_day_complete."""
trigger = celebration()["triggers"][0]
assert trigger["trigger"] == "event"
assert trigger["event_type"] == "nestquest_child_day_complete"
assert trigger["event_type"] == EVENT_CHILD_DAY_COMPLETE
def test_celebration_declares_inputs_with_selectors() -> None:
"""The four inputs exist, carry selectors, and default sanely."""
bp = celebration()
inputs = bp["blueprint"]["input"]
expected = {
"notify_target",
"celebration_message",
"celebrate",
"last_celebrated_helper",
}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["celebrate"]["default"] is True
assert "text" in inputs["notify_target"]["selector"]
assert "text" in inputs["celebration_message"]["selector"]
assert "boolean" in inputs["celebrate"]["selector"]
assert inputs["last_celebrated_helper"]["selector"]["entity"]["filter"] == [
{"domain": "input_text"}
]
# The description documents the zero-quest-day rule and the dedupe.
description = bp["blueprint"]["description"]
assert "zero-quest day" in description
assert "once" in description.lower()
def test_celebration_wires_inputs_not_literals() -> None:
"""Notify and stamp actions both use !input; the dedupe reads the helper."""
bp = celebration()
assert bp["variables"]["celebrate"] == BlueprintInput("celebrate")
assert bp["variables"]["last_celebrated_helper"] == BlueprintInput(
"last_celebrated_helper"
)
toggle, dedupe = celebration_conditions(bp)
assert toggle["condition"] == "template"
assert "celebrate" in toggle["value_template"]
assert dedupe["condition"] == "template"
assert "last_celebrated_helper" in dedupe["value_template"]
assert "child_id" in dedupe["value_template"], (
"the dedupe must be per child, not per household"
)
notify_action, stamp_action = bp["actions"]
assert notify_action["action"] == BlueprintInput("notify_target")
data = notify_data(bp)
assert data["title"] == CELEBRATION_TITLE
assert data["message"] == BlueprintInput("celebration_message")
assert stamp_action["action"] == "input_text.set_value"
assert stamp_action["target"]["entity_id"] == BlueprintInput(
"last_celebrated_helper"
)
assert "now()" in stamp_action["data"]["value"]
def test_celebration_message_default_renders_the_child_name() -> None:
"""The default message renders the trigger payload's child_name."""
default_message = celebration()["blueprint"]["input"]["celebration_message"][
"default"
]
rendered = render(default_message, trigger=trigger_payload(1, "Ada"))
assert rendered == "Legendary! Ada cleared every quest today!"
# ---------------------------------------------------------------------------
# The once-per-child-per-day dedupe condition.
# ---------------------------------------------------------------------------
def test_celebration_dedupe_true_when_child_not_celebrated_today() -> None:
"""A helper without THIS child's entry for today lets the
celebration through — including when another child was celebrated
earlier the same day."""
assert render_dedupe(None) == "True"
assert render_dedupe("") == "True"
assert render_dedupe("2:2026-09-19") == "True", (
"a second child clearing the same day must still celebrate"
)
assert render_dedupe("1:2026-09-18") == "True", (
"yesterday's entry never blocks today"
)
def test_celebration_dedupe_false_when_child_already_celebrated_today() -> None:
"""A child already celebrated today is suppressed — the un-complete
and re-complete the same day case."""
assert render_dedupe("1:2026-09-19") == "False"
assert render_dedupe("2:2026-09-19,1:2026-09-19") == "False"
def test_celebration_stamp_prunes_to_today_and_appends() -> None:
"""The stamp keeps only today's entries plus the new one: yesterday's
entries are dropped (the helper never outgrows its text limit and
stale entries cannot block next week), and the celebrated child is
recorded."""
assert render_stamp(None, child_id=1) == "1:2026-09-19"
assert render_stamp("1:2026-09-18", child_id=2) == "2:2026-09-19"
assert render_stamp("1:2026-09-19", child_id=2) == (
"1:2026-09-19,2:2026-09-19"
)
assert render_stamp("1:2026-09-18,2:2026-09-18", child_id=3) == (
"3:2026-09-19"
)
def test_celebration_celebrate_toggle_renders() -> None:
"""The enable condition renders truthy/false from the input value."""
toggle = celebration_conditions(celebration())[0]
assert render(toggle["value_template"], celebrate=True).strip() == "True"
assert render(toggle["value_template"], celebrate=False).strip() == "False"
def test_celebration_never_hard_codes_notify_target() -> None:
"""No notify service literal outside the input's placeholder text."""
bp = celebration()
spec = bp["blueprint"]
assert not re.search(r"notify\.[a-z_]+", spec["description"])
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
for value in iter_strings(outside_input):
assert not re.search(r"notify\.[a-z_.]+", value), value
target_description = spec["input"]["notify_target"]["description"]
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert bp["actions"][0]["action"] == BlueprintInput("notify_target")
def test_celebration_dedupe_entry_equality_not_substring() -> None:
"""Child ids sharing a prefix must not suppress each other: after
child 11 is celebrated, child 1 still celebrates (whole-entry
comparison, not substring)."""
assert render_dedupe("11:2026-09-19", child_id=11) == "False"
assert render_dedupe("11:2026-09-19", child_id=1) == "True", (
"child 1's entry must not be found inside child 11's entry"
)
assert render_dedupe("1:2026-09-19", child_id=11) == "True"
def test_celebration_mode_is_queued() -> None:
"""Two children completing at nearly the same time must both be
processed: queued mode does not drop the second event."""
assert celebration()["mode"] == "queued"
def test_every_blueprint_event_reference_is_real() -> None:
"""Every event a blueprint triggers on is one NestQuest actually
fires or defines (Feature 10/11) — no invented event names."""
from tests.blueprint_helpers import load_blueprints
real_events = {
"nestquest_quest_completed",
"nestquest_quest_uncompleted",
"nestquest_quest_missed",
"nestquest_child_day_complete",
}
for name, bp in load_blueprints().items():
for trigger in bp.get("triggers", []):
if trigger.get("trigger") == "event":
assert trigger["event_type"] in real_events, (
f"{name}: invented event {trigger['event_type']!r}"
)
assert celebration()["triggers"][0]["event_type"] == (
"nestquest_child_day_complete"
)
def test_celebration_uses_the_event_trigger_data_path() -> None:
"""HA exposes an event trigger's payload as trigger.event.data —
never trigger.payload (Codex review regression)."""
bp = celebration()
for value in iter_strings(bp):
assert "trigger.payload" not in value, value
default_message = bp["blueprint"]["input"]["celebration_message"][
"default"
]
assert "trigger.event.data.child_name" in default_message
for condition in celebration_conditions(bp):
assert "trigger.payload" not in condition["value_template"]
-338
View File
@@ -1,338 +0,0 @@
"""Tests for the NestQuest end-of-day report automation blueprint.
The end-of-day report closes the day for the parent: every PRESENT
child gets a completion line (so the parent always receives the day's
summary when the report runs), children who still owe quests get their
open count called out on that line, and absent children are omitted
entirely. The report runs deliberately before the midnight
missed-quest sweep so it reads the same day's data, and by default an
all-clear day sends nothing — unless the household opts into
send-even-when-clear. The harness (YAML ``!input`` loader, state
fixtures, jinja rendering) is shared in
:mod:`tests.blueprint_helpers`; the directory-wide shape and
entity-discipline tests in test_blueprints.py cover this file too.
"""
from __future__ import annotations
import re
from custom_components.nestquest.const import DEFAULT_END_OF_DAY_REPORT_TIME
from tests.blueprint_helpers import (
BlueprintInput,
notify_data,
blueprint,
iter_strings,
make_states,
render,
single_action,
TemplateState,
)
REPORT_TITLE = "NestQuest end-of-day report"
def end_of_day_report() -> dict:
"""Return the parsed end-of-day-report blueprint."""
return blueprint("end_of_day_report")
def report_conditions(bp: dict) -> list[dict]:
"""Return the blueprint's conditions: toggle first, guard second."""
conditions = bp["conditions"]
assert isinstance(conditions, list) and len(conditions) == 2
return conditions
def clear_guard(bp: dict) -> str:
"""Return the template guarding an all-clear day (false = no report)."""
return report_conditions(bp)[1]["value_template"]
def owing_clear_absent_fixture():
"""Alice owing (1/3 done, 2 open), Carol present and clear, Bob absent."""
return make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"2",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_alice_quests_completed_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_carol_quests_due_today",
"2",
{"child_name": "Carol", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_carol_quests_remaining_today",
"0",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_carol_quests_completed_today",
"2",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"2",
{"child_name": "Bob", "child_id": 3, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"1",
{"child_id": 3},
),
TemplateState(
"sensor.nestquest_bob_quests_completed_today",
"1",
{"child_id": 3},
),
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
],
)
def all_clear_fixture():
"""Alice present with everything done; nobody owes anything."""
return make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"0",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_alice_quests_completed_today",
"3",
{"child_id": 1},
),
TemplateState("sensor.nestquest_household_quests_due_today", "3"),
],
)
# ---------------------------------------------------------------------------
# Inputs, defaults, and wiring.
# ---------------------------------------------------------------------------
def test_end_of_day_report_declares_inputs_with_selectors() -> None:
"""The four inputs exist, carry selectors, and default sanely."""
bp = end_of_day_report()
inputs = bp["blueprint"]["input"]
expected = {"report_time", "notify_target", "send_report", "send_even_when_clear"}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["report_time"]["default"] == DEFAULT_END_OF_DAY_REPORT_TIME
assert DEFAULT_END_OF_DAY_REPORT_TIME == "20:00"
assert inputs["send_report"]["default"] is True
assert inputs["send_even_when_clear"]["default"] is False
assert "time" in inputs["report_time"]["selector"]
assert "text" in inputs["notify_target"]["selector"]
assert "boolean" in inputs["send_report"]["selector"]
assert "boolean" in inputs["send_even_when_clear"]["selector"]
# The description must note the deliberate ordering: the report runs
# before the midnight missed sweep, reading the same day's data.
description = bp["blueprint"]["description"].lower()
assert "before" in description
assert "sweep" in description
assert "same day" in description
def test_end_of_day_report_wires_inputs_not_literals() -> None:
"""Time trigger, toggle and guard conditions, and notify action use !input."""
bp = end_of_day_report()
assert bp["variables"]["send_report"] == BlueprintInput("send_report")
assert bp["variables"]["send_even_when_clear"] == BlueprintInput(
"send_even_when_clear"
)
trigger = bp["triggers"][0]
assert trigger["trigger"] == "time"
assert trigger["at"] == BlueprintInput("report_time")
toggle, guard = report_conditions(bp)
assert toggle["condition"] == "template"
assert "send_report" in toggle["value_template"]
assert guard["condition"] == "template"
assert "send_even_when_clear" in guard["value_template"]
action = single_action(bp)
assert action["action"] == BlueprintInput("notify_target")
data = notify_data(end_of_day_report())
assert data["title"] == REPORT_TITLE
assert isinstance(data["message"], str) and data["message"].strip()
# ---------------------------------------------------------------------------
# Rendering the day's summary.
# ---------------------------------------------------------------------------
def test_end_of_day_report_renders_owing_clear_and_absent_children() -> None:
"""The owing child's open count is called out with the completion
ratio; the present-clear child still gets the day's completion
line; the absent child is omitted entirely."""
rendered = render(
notify_data(end_of_day_report())["message"],
states=owing_clear_absent_fixture(),
)
assert rendered.split("\n") == [
"Alice: 2 still open (1/3 done).",
"Carol: 2/2 done.",
]
assert "Bob" not in rendered
assert "household" not in rendered
def test_end_of_day_report_all_clear_guard_renders_false_by_default() -> None:
"""An all-clear day with send_even_when_clear off sends nothing:
the guard condition is false."""
rendered = render(
clear_guard(end_of_day_report()),
states=all_clear_fixture(),
send_even_when_clear=False,
)
assert rendered.strip() == "False"
def test_end_of_day_report_all_clear_sends_summary_when_opted_in() -> None:
"""With send_even_when_clear on, the guard lets an all-clear day
through and the message renders the day's completion summary."""
guard = clear_guard(end_of_day_report())
assert (
render(guard, states=all_clear_fixture(), send_even_when_clear=True).strip()
== "True"
)
message = render(
notify_data(end_of_day_report())["message"],
states=all_clear_fixture(),
)
assert message == "Alice: 3/3 done."
def test_end_of_day_report_guard_true_when_a_present_child_owes() -> None:
"""A present child with open quests passes the guard even with
send_even_when_clear off."""
rendered = render(
clear_guard(end_of_day_report()),
states=owing_clear_absent_fixture(),
send_even_when_clear=False,
)
assert rendered.strip() == "True"
def test_end_of_day_report_collision_suffixed_duplicate_names() -> None:
"""Two children sharing a display name (HA registry suffix _2 on the
second one's entity ids) both appear, each with their own counts."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_ada_quests_due_today",
"2",
{"child_name": "Ada", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_ada_quests_completed_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_ada_quests_due_today_2",
"3",
{"child_name": "Ada", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today_2",
"0",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_ada_quests_completed_today_2",
"3",
{"child_id": 2},
),
],
)
rendered = render(notify_data(end_of_day_report())["message"], states=states)
assert rendered.split("\n") == [
"Ada: 1 still open (1/2 done).",
"Ada: 3/3 done.",
]
def test_end_of_day_report_never_hard_codes_notify_target() -> None:
"""No notify service literal outside the input's placeholder text."""
bp = end_of_day_report()
spec = bp["blueprint"]
assert not re.search(r"notify\.[a-z_]+", spec["description"])
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
for value in iter_strings(outside_input):
assert not re.search(r"notify\.[a-z_.]+", value), value
target_description = spec["input"]["notify_target"]["description"]
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert single_action(bp)["action"] == BlueprintInput("notify_target")
def test_end_of_day_report_guard_true_when_remaining_unavailable() -> None:
"""A present child whose remaining sensor is missing or reads
'unavailable' must NOT look like all-clear: the degraded state
reaches the parent (Codex review regression)."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"unavailable",
{"child_id": 1},
),
],
)
rendered = render(
clear_guard(end_of_day_report()),
states=states,
send_even_when_clear=False,
)
assert rendered.strip() == "True"
# A missing remaining sensor entirely: also report-worthy.
states_missing = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
],
)
rendered_missing = render(
clear_guard(end_of_day_report()),
states=states_missing,
send_even_when_clear=False,
)
assert rendered_missing.strip() == "True"
-336
View File
@@ -1,336 +0,0 @@
"""Tests for the NestQuest morning-summary automation blueprint.
The blueprints are YAML data consumed by Home Assistant's blueprint
engine; the integration itself never imports yaml or jinja2. These
tests therefore exercise them exactly the way HA would: parse the YAML
(with a loader that understands ``!input`` tags), then render the
message template with a plain jinja2 environment over a fixture state
set shaped like HA's template state objects — all via the shared
harness in :mod:`tests.blueprint_helpers`.
This module also carries the directory-wide blueprint discipline tests
(entity-id shapes, domain mentions, notify-target hygiene), which scan
EVERY blueprint file under the automation directory, so new blueprint
files are covered automatically.
Rendering policy under test (settled for the morning summary): a
PRESENT child always appears — with remaining quests owed, or as "all
clear" at zero remaining — and a child who is not present that day is
omitted entirely, never listed with zero quests.
"""
from __future__ import annotations
import re
from types import SimpleNamespace
from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME
from tests.blueprint_helpers import (
notify_data,
ALLOWED_ENTITY_SHAPES,
BlueprintInput,
_ENTITY_LITERAL_RE,
_MENTION_RE,
iter_strings,
load_blueprints,
make_states,
morning_action,
morning_summary,
render,
TemplateState,
)
MORNING_TITLE = "NestQuest morning summary"
# ---------------------------------------------------------------------------
# YAML parsing and blueprint shape.
# ---------------------------------------------------------------------------
def test_blueprints_parse_and_declare_valid_shape() -> None:
"""Every blueprint parses and carries the required blueprint keys."""
blueprints = load_blueprints()
assert blueprints, "no blueprints found"
for name, bp in blueprints.items():
assert isinstance(bp, dict), f"{name} did not parse to a mapping"
spec = bp["blueprint"]
assert isinstance(spec["name"], str) and spec["name"].strip()
assert spec["domain"] == "automation"
assert isinstance(spec["input"], dict) and spec["input"]
for key in ("triggers", "conditions", "actions"):
assert isinstance(bp[key], list) and bp[key], f"{name}: empty {key}"
def test_morning_summary_declares_inputs_with_selectors() -> None:
"""The three blueprint inputs exist, carry selectors, and default sanely."""
inputs = morning_summary()["blueprint"]["input"]
expected = {"morning_time", "notify_target", "send_summary"}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["morning_time"]["default"] == DEFAULT_MORNING_SUMMARY_TIME
assert inputs["send_summary"]["default"] is True
assert "time" in inputs["morning_time"]["selector"]
assert "text" in inputs["notify_target"]["selector"]
assert "boolean" in inputs["send_summary"]["selector"]
def test_morning_summary_wires_inputs_not_literals() -> None:
"""Time trigger, toggle condition, and notify action all use !input."""
bp = morning_summary()
assert bp["variables"]["send_summary"] == BlueprintInput("send_summary")
trigger = bp["triggers"][0]
assert trigger["trigger"] == "time"
assert trigger["at"] == BlueprintInput("morning_time")
condition = bp["conditions"][0]
assert condition["condition"] == "template"
assert "send_summary" in condition["value_template"]
action = morning_action(bp)
assert action["action"] == BlueprintInput("notify_target")
data = notify_data(bp)
assert data["title"] == MORNING_TITLE
assert isinstance(data["message"], str) and data["message"].strip()
# ---------------------------------------------------------------------------
# Entity-reference discipline (Feature 10 shapes only). These tests
# scan EVERY blueprint in the directory, so new blueprint files are
# automatically held to the same rules.
# ---------------------------------------------------------------------------
def test_entity_literals_match_feature_10_shapes() -> None:
"""Every id-like entity string in the YAML is a Feature 10 shape."""
for name, bp in load_blueprints().items():
for value in iter_strings(bp):
for literal in _ENTITY_LITERAL_RE.findall(value):
assert any(
shape.fullmatch(literal) for shape in ALLOWED_ENTITY_SHAPES
), f"{name}: invented entity {literal!r}"
# The validator itself must separate Feature 10 ids from lookalikes.
assert any(
shape.fullmatch("binary_sensor.nestquest_alice_present_today")
for shape in ALLOWED_ENTITY_SHAPES
)
assert any(
shape.fullmatch("sensor.nestquest_bob_quests_remaining_today")
for shape in ALLOWED_ENTITY_SHAPES
)
for foreign in (
"binary_sensor.someone_else_present_today",
"binary_sensor.nestquest_alice_at_home",
"sensor.nestquest_alice_streak",
"vacuum.nestquest_robot",
):
assert not any(shape.fullmatch(foreign) for shape in ALLOWED_ENTITY_SHAPES)
def test_domain_mentions_are_nestquest_prefixed() -> None:
"""Every sensor/binary_sensor mention (regex or literal) is nestquest_."""
for name, bp in load_blueprints().items():
for value in iter_strings(bp):
flattened = value.replace("\\", "")
for tail in _MENTION_RE.findall(flattened):
assert tail.startswith("nestquest_"), (
f"{name}: non-nestquest entity mention in {value!r}"
)
def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None:
"""The extracted due-sensor regex matches Feature 10 due ids —
including HA collision-suffixed duplicates (a second child with
the same name gets entity ids ending _2) — and rejects everything
else."""
message = notify_data(morning_summary())["message"]
match = re.search(
r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message
)
assert match is not None, "due-sensor pattern missing from the template"
pattern = match.group(1).replace("\\\\", "\\")
assert pattern.endswith("$")
assert re.search(pattern, "sensor.nestquest_alice_quests_due_today")
assert re.search(pattern, "sensor.nestquest_alice_quests_due_today_2")
assert re.search(pattern, "sensor.nestquest_bob_quests_remaining_today") is None
# The household rollup due sensor unavoidably matches the pattern;
# the template's present-attribute guard excludes it (proven in the
# render test below).
assert re.search(pattern, "sensor.nestquest_household_quests_due_today")
assert re.search(pattern, "sensor.carol_unrelated") is None
def test_morning_summary_never_hard_codes_notify_target() -> None:
"""No notify service literal outside input descriptions; the choice is text."""
bp = morning_summary()
spec = bp["blueprint"]
assert "hard-code" in spec["description"]
assert not re.search(r"notify\.[a-z_]+", spec["description"])
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
for value in iter_strings(outside_input):
assert not re.search(r"notify\.[a-z_.]+", value), value
target_description = spec["input"]["notify_target"]["description"]
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert morning_action(bp)["action"] == BlueprintInput("notify_target")
# ---------------------------------------------------------------------------
# Message rendering against fixture state sets.
# ---------------------------------------------------------------------------
def morning_fixture() -> SimpleNamespace:
"""Two children — Alice present (2 owed), Bob absent — plus decoys."""
return make_states(
binary_sensors=[
TemplateState("binary_sensor.nestquest_alice_present_today", "on"),
TemplateState("binary_sensor.nestquest_bob_present_today", "off"),
TemplateState("binary_sensor.motion_present_today", "on"),
TemplateState("binary_sensor.nestquest_dave_all_done", "on"),
TemplateState("binary_sensor.nestquest_erin_present_today", "unavailable"),
],
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"2",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"2",
{"child_name": "Bob", "child_id": 2, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"1",
{"child_id": 2},
),
TemplateState("sensor.nestquest_household_quests_due_today", "5"),
TemplateState("sensor.nestquest_carol_unrelated", "7"),
],
)
def test_morning_summary_lists_present_child_and_omits_absent() -> None:
"""Present Alice appears with her count; absent Bob and decoys never do."""
message = notify_data(morning_summary())["message"]
rendered = render(message, states=morning_fixture())
assert rendered == "Alice owes 2 quest(s) today."
assert "Bob" not in rendered
for absent in ("motion", "Dave", "Erin", "household", "carol"):
assert absent not in rendered
def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None:
"""A present child always appears — zero remaining reads as all clear."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_carol_quests_due_today",
"2",
{"child_name": "Carol", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_carol_quests_remaining_today",
"0",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"2",
{"child_id": 2},
),
],
)
rendered = render(notify_data(morning_summary())["message"], states=states)
assert "Alice owes 2 quest(s) today." in rendered
assert "Carol is all clear today (0 quests remaining)." in rendered
def test_morning_summary_present_child_without_sensors_is_flagged() -> None:
"""A present child whose remaining-count sensor is missing (the
platform failed to add it) still gets a line, flagged unavailable."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_frances_quests_due_today",
"2",
{"child_name": "Frances", "child_id": 1, "present": True},
),
],
)
rendered = render(notify_data(morning_summary())["message"], states=states)
assert rendered == "Frances's quest count is unavailable today."
def test_morning_summary_collision_suffixed_duplicate_names() -> None:
"""Two children sharing a display name get HA registry suffixes on
their entity ids (..._2); both must appear, each with their own
count (Codex review regression)."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_ada_quests_due_today",
"2",
{"child_name": "Ada", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_ada_quests_due_today_2",
"2",
{"child_name": "Ada", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today_2",
"2",
{"child_id": 2},
),
],
)
rendered = render(notify_data(morning_summary())["message"], states=states)
lines = rendered.split("\n")
assert lines == [
"Ada owes 1 quest(s) today.",
"Ada owes 2 quest(s) today.",
]
def test_morning_summary_no_present_children() -> None:
"""With nobody present the body says so rather than listing zeros."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": False},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"3",
{"child_id": 1},
),
],
)
rendered = render(notify_data(morning_summary())["message"], states=states)
assert rendered == "No children present today."
def test_morning_summary_send_summary_toggle_renders() -> None:
"""The enable condition renders truthy/false from the input value."""
condition = morning_summary()["conditions"][0]
assert render(condition["value_template"], send_summary=True).strip() == "True"
assert render(condition["value_template"], send_summary=False).strip() == "False"
+4 -4
View File
@@ -33,7 +33,7 @@ def _make_hass_mock():
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / name)
try:
await apply_migrations(database)
@@ -360,7 +360,7 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "create-race.db")
try:
await apply_migrations(database)
@@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "edit-race.db")
try:
await apply_migrations(database)
@@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "active-race.db")
try:
await apply_migrations(database)
+3 -3
View File
@@ -61,7 +61,7 @@ D1 = _future_day(1)
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "complete-race.db")
try:
await apply_migrations(database)
@@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "uncomplete-race.db")
try:
await apply_migrations(database)
+1 -1
View File
@@ -140,7 +140,7 @@ def test_flow_strings_match_translations(
def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
"""Ensure no module other than const.py hardcodes the domain or db filename."""
pkg_dir = Path(config_flow.__file__).parent
py_files = [f for f in pkg_dir.rglob("*.py") if f.name not in ("const.py",)]
py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)]
assert len(py_files) > 0, "No python modules found in package to check"
+3 -3
View File
@@ -28,10 +28,10 @@ def test_logger() -> None:
def test_platforms() -> None:
"""Verify PLATFORMS is defined as the sensor + binary_sensor list."""
"""Verify PLATFORMS is defined as an empty list."""
assert hasattr(const, "PLATFORMS")
assert isinstance(const.PLATFORMS, list)
assert const.PLATFORMS == ["sensor", "binary_sensor"]
assert const.PLATFORMS == []
def test_sqlite_db_filename() -> None:
@@ -106,7 +106,7 @@ def test_window_clock_ranges_cover_every_window() -> None:
def test_no_hardcoded_domain_or_db_filename_in_modules() -> None:
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
pkg_dir = Path(const.__file__).parent
py_files = [f for f in pkg_dir.rglob("*.py") if f.name != "const.py"]
py_files = [f for f in pkg_dir.glob("*.py") if f.name != "const.py"]
assert len(py_files) > 0, "No python modules found in package to check"
-346
View File
@@ -1,346 +0,0 @@
"""Tests for the shared NestQuest coordinator (Feature 10, task 1)."""
from __future__ import annotations
import datetime
import pytest
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_HORIZON_DAYS,
DOMAIN,
)
from custom_components.nestquest.coordinator import NestQuestCoordinator
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
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"}
def _today_iso() -> str:
return datetime.date.today().isoformat()
async def _seed_day(database, *, completed: bool = False) -> None:
"""Two active children + one inactive, one daily definition each."""
await create_child(database, "Ada")
await create_child(database, "Bo")
inactive = await create_child(database, "Ghost")
await set_child_active(database, inactive.id, False)
today = _today_iso()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}),
[child.id for child in await list_children(database, active_only=True)],
["morning"],
)
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
await materialize(database, today, end, today=datetime.date.today())
async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator is not None
database = entry.runtime_data.database
await _seed_day(database)
snapshot = await coordinator._async_update_data()
assert snapshot.today_iso == _today_iso()
assert len(snapshot.children) == 2, "inactive child must be absent"
by_name = {child.child_name: child for child in snapshot.children}
ada = by_name["Ada"]
assert ada.due_today == 1
assert ada.completed_today == 0
assert ada.remaining_today == 1
assert ada.completion_pct == 0
instance = ada.instances[0]
assert instance.state == "open"
assert instance.overdue is False
assert instance.title == "Brush teeth"
assert instance.window == "morning"
assert ada.present is True, "no schedule means present every day"
assert ada.next_present is None, "present child has no returns date"
assert snapshot.cycle_day == 0, "no schedule anywhere"
async def test_coordinator_next_present_for_away_child(
hass, make_entry
) -> None:
"""A child absent today carries the ISO date of their next present
day; an always-absent pattern carries None instead."""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
pattern = ",".join(
str(day) for day in range(7) if day != today.weekday()
)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), pattern
)
await create_child(database, "Bo")
bo = (await list_children(database))[1]
await PresenceSchedulesDao(database).upsert_by_child(
bo.id, 1, today.isoformat(), ""
)
snapshot = await coordinator._async_update_data()
by_name = {child.child_name: child for child in snapshot.children}
assert by_name["Ada"].present is False
assert by_name["Ada"].next_present == (
today + datetime.timedelta(days=1)
).isoformat()
assert by_name["Bo"].present is False
assert by_name["Bo"].next_present is None
async def test_coordinator_reflects_completion_and_uncompletion(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await _seed_day(database)
ada = (
await coordinator._async_update_data()
).children[0]
instance_id = ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.child_id,
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 1
assert child.remaining_today == 0
assert child.completion_pct == 100
view = child.instances[0]
assert view.state == "done"
assert view.completed_at is not None
assert view.was_on_time is not None
from custom_components.nestquest.completion import uncomplete_instance
await uncomplete_instance(
database, instance_id, actor_source="user", actor_user_id="admin-1"
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 0
assert child.remaining_today == 1
assert child.instances[0].state == "open"
async def test_coordinator_zero_quests_reports_full_percentage(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
await create_child(entry.runtime_data.database, "Ada")
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.due_today == 0
assert child.completion_pct == 100, "zero quests is a perfect day"
assert child.instances == ()
async def test_coordinator_cycle_day_from_first_scheduled_child(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# A 2-week cycle anchored 3 days ago: today is day 4.
anchor = today - datetime.timedelta(days=3)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|"
)
snapshot = await coordinator._async_update_data()
assert snapshot.cycle_day == 4
async def test_coordinator_presence_resolves_schedule_and_override(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# Scheduled absent today (empty weekday set for this cycle week).
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), ""
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is False
# An override beats the pattern.
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
await PresenceOverridesDao(database).create(
ada.id, today.isoformat(), today.isoformat(), True
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is True
async def test_coordinator_next_present_honours_future_override(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
# Scheduled present tomorrow (single-day cycle, weekday set set below
# covers every day), but overridden absent tomorrow.
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6"
)
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
await PresenceOverridesDao(database).create(
ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.present is True
assert child.next_present != tomorrow.isoformat(), (
"a future absent override must remove tomorrow from the preview"
)
async def test_coordinator_derives_missed_for_past_due_open(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
snapshot = await coordinator._async_update_data()
# No children: empty but valid snapshot.
assert snapshot.children == ()
async def test_setup_creates_coordinator_with_configured_interval(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(
make_entry(options={CONF_UPDATE_INTERVAL: 60}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert isinstance(coordinator, NestQuestCoordinator)
assert coordinator.update_interval == datetime.timedelta(seconds=60)
assert coordinator.data is not None, "first refresh ran at setup"
async def test_setup_defaults_to_five_minute_interval(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator.update_interval == datetime.timedelta(
seconds=DEFAULT_UPDATE_INTERVAL
)
async def test_unload_shuts_coordinator_down(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
torn_down = []
async def _shutdown():
torn_down.append(coordinator)
coordinator.async_shutdown = _shutdown
assert await async_unload_entry(hass, entry) is True
assert torn_down == [coordinator]
async def test_coordinator_notifies_listeners_on_refresh(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
called = []
coordinator.async_add_listener(lambda: called.append("entity"))
await coordinator.async_refresh()
assert called == ["entity"]
assert coordinator.last_update_success is True
async def test_failed_first_refresh_shuts_coordinator_down(
hass, make_entry, monkeypatch
) -> None:
"""A setup failure after coordinator creation must not leak its
listeners against the database the except path closes."""
from custom_components.nestquest import async_setup_entry
import custom_components.nestquest.coordinator as coordinator_module
shutdown_calls = []
async def _record_shutdown(self):
shutdown_calls.append(self.entry_id)
monkeypatch.setattr(
coordinator_module.NestQuestCoordinator,
"async_shutdown",
_record_shutdown,
)
async def _boom(self):
raise RuntimeError("refresh failed")
monkeypatch.setattr(
coordinator_module.NestQuestCoordinator,
"_async_update_data",
_boom,
)
entry = wire_entry_to_registry(make_entry(), hass.registry)
with pytest.raises(RuntimeError, match="refresh failed"):
await async_setup_entry(hass, entry)
assert shutdown_calls == [entry.entry_id]
assert getattr(entry, "runtime_data", None) is None
-119
View File
@@ -1,119 +0,0 @@
"""Invariant: the bundled core package is self-contained.
Every module under ``custom_components/nestquest/core/`` must be free of
Home Assistant and must not climb out of the core package: no
``homeassistant`` import, no relative import with level > 1, no absolute
import of ``custom_components.nestquest.<something other than core>``,
and no bare ``import core`` / ``from core``. This guards the
storage-layer extraction (task d0b691d8) from regressing into a
HA-coupled or integration-coupled bundle.
"""
from __future__ import annotations
import ast
from pathlib import Path
CORE_PKG = Path(
__import__(
"custom_components.nestquest.core", fromlist=["__file__"]
).__file__
).parent
def _module_chain(base: str, name: str) -> str:
"""Combined dotted path for ``from <base> import <name>``."""
return f"{base}.{name}" if base else name
def _is_homeassistant(module: str) -> bool:
return module == "homeassistant" or module.startswith("homeassistant.")
def _climbs_out_of_core(module: str) -> bool:
"""An absolute path that reaches the integration package but not core."""
if module == "custom_components.nestquest":
return True
if module.startswith("custom_components.nestquest."):
rest = module[len("custom_components.nestquest."):]
head = rest.split(".", 1)[0]
return head != "core"
return False
def _is_bare_core(module: str) -> bool:
"""A bare ``core`` / ``core.*`` absolute reference (not the bundle)."""
return module == "core" or module.startswith("core.")
def _scan(path: Path) -> list[str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
offenders: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
mod = alias.name
if _is_homeassistant(mod):
offenders.append(
f"{path.name}:{node.lineno} homeassistant import: import {mod}"
)
if _is_bare_core(mod):
offenders.append(
f"{path.name}:{node.lineno} bare core import: import {mod}"
)
if _climbs_out_of_core(mod):
offenders.append(
f"{path.name}:{node.lineno} climbs out of core: import {mod}"
)
elif isinstance(node, ast.ImportFrom):
if node.level and node.level > 1:
offenders.append(
f"{path.name}:{node.lineno} relative import climbs out "
f"of core (level {node.level})"
)
continue
if node.level:
# relative level 1 stays inside core; nothing to check.
continue
base = node.module or ""
if _is_homeassistant(base):
offenders.append(
f"{path.name}:{node.lineno} homeassistant import: from {base}"
)
if _is_bare_core(base):
offenders.append(
f"{path.name}:{node.lineno} bare core import: from {base}"
)
if _climbs_out_of_core(base):
offenders.append(
f"{path.name}:{node.lineno} climbs out of core: from {base}"
)
for alias in node.names:
combined = _module_chain(base, alias.name)
if _is_homeassistant(combined):
offenders.append(
f"{path.name}:{node.lineno} homeassistant import: "
f"from {base} import {alias.name}"
)
if _is_bare_core(combined):
offenders.append(
f"{path.name}:{node.lineno} bare core import: "
f"from {base} import {alias.name}"
)
if _climbs_out_of_core(combined):
offenders.append(
f"{path.name}:{node.lineno} climbs out of core: "
f"from {base} import {alias.name}"
)
return offenders
def test_core_package_is_self_contained() -> None:
"""No core module imports homeassistant or climbs out of the core package."""
py_files = sorted(CORE_PKG.rglob("*.py"))
assert py_files, "core package not found under custom_components/nestquest/core"
all_offenders: list[str] = []
for py_file in py_files:
all_offenders.extend(_scan(py_file))
assert all_offenders == [], (
"core package isolation violated:\n" + "\n".join(all_offenders)
)
+9 -9
View File
@@ -30,7 +30,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
_run(database.open(path))
_run(apply_migrations(database))
return database
@@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
RuntimeError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / "reorder-concurrent.db")
try:
await apply_migrations(database)
@@ -377,7 +377,7 @@ def test_admin_add_concurrent_calls_are_idempotent(tmp_path) -> None:
'see no row' and one of them raise IntegrityError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / "admin-race.db")
try:
await apply_migrations(database)
@@ -487,17 +487,17 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/core/dao_children.py", # the DAO itself
"custom_components/nestquest/core/dao_rules.py", # validates child
"custom_components/nestquest/dao_children.py", # the DAO itself
"custom_components/nestquest/dao_rules.py", # validates child
# activeness on assignment (FK to children, done-condition
# 'validates that the assigned child is active')
"custom_components/nestquest/core/dao_presence.py", # validates the
"custom_components/nestquest/dao_presence.py", # validates the
# schedule/override child exists before writing (same FK shape
# as dao_rules: one existence SELECT per write)
"custom_components/nestquest/core/dao_instances.py", # validates the
"custom_components/nestquest/dao_instances.py", # validates the
# instance/event child exists before writing (same FK shape)
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_children.py", # this file, scanned separately
+7 -7
View File
@@ -71,7 +71,7 @@ D20 = _future_day(20)
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(
"custom_components.nestquest.core.dao_instances.datetime",
"custom_components.nestquest.dao_instances.datetime",
_ShiftingModule(),
)
try:
@@ -362,7 +362,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
# the lock, the IN-LOCK re-check sees tomorrow and must
# reject the insert — the stale call-date pre-check alone
# would have let it through.
import custom_components.nestquest.core.dao_instances as mod
import custom_components.nestquest.dao_instances as mod
async def _lock_holder():
# Hold the CONNECTION lock across a transaction until
@@ -402,7 +402,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
return None
async def _main():
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / "upsert-rollover.db")
try:
await apply_migrations(database)
@@ -1030,8 +1030,8 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
# INSERT statements are stripped before scanning so only genuine
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
insert_exempt = {
"custom_components/nestquest/core/dao_instances.py",
"custom_components/nestquest/core/migrations.py", # the rebuild
"custom_components/nestquest/dao_instances.py",
"custom_components/nestquest/migrations.py", # the rebuild
# migrations re-create the table via INSERT..SELECT (append-
# order preserved); UPDATE/DELETE stays forbidden everywhere
"tests/test_schema.py",
@@ -1141,7 +1141,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
def test_append_concurrent_events_serialize(tmp_path) -> None:
"""Concurrent appends for the same instance all land, in order."""
async def _main():
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / "append-race.db")
try:
await apply_migrations(database)
+2 -2
View File
@@ -118,7 +118,7 @@ class World:
async def _world(path) -> World:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
world = World(database)
@@ -441,7 +441,7 @@ def test_completion_events_absolutely_no_mutation_path() -> None:
)
offenders = [
py.name
for py in sorted(package.rglob("*.py"))
for py in sorted(package.glob("*.py"))
if py.name != "__pycache__" and mutation.search(py.read_text())
]
assert offenders == []
+6 -20
View File
@@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
schedules = PresenceSchedulesDao(database)
@@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
return fn(*args)
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "upsert-race.db")
try:
await apply_migrations(database)
@@ -455,20 +455,6 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
def test_override_get_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True, note="camp"
)
found = await overrides.get(record.id)
assert found == record
assert await overrides.get(999) is None
return found
found = _with_db(tmp_path, "override-get.db")(_body)
assert found.note == "camp"
def test_override_delete_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await overrides.create(
@@ -553,9 +539,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/core/dao_presence.py", # this DAO
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"custom_components/nestquest/dao_presence.py", # this DAO
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_presence.py", # this file, scanned separately
@@ -766,7 +752,7 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
return fn(*args)
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "override-race.db")
try:
await apply_migrations(database)
+6 -6
View File
@@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
rules = ScheduleRulesDao(database)
@@ -325,7 +325,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
create's validation pauses mid-flight before the delete starts.
"""
async def _main(order: str):
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / f"rule-delete-race-{order}.db")
try:
await apply_migrations(database)
@@ -1218,12 +1218,12 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/core/dao_rules.py", # this DAO
"custom_components/nestquest/core/dao_instances.py", # validates the
"custom_components/nestquest/dao_rules.py", # this DAO
"custom_components/nestquest/dao_instances.py", # validates the
# definition exists + fetches its assignee before generating an
# instance (the instance must snapshot the definition's child)
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_rules.py", # this file, scanned separately
+26 -28
View File
@@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from conftest import executor_for, make_hass
from conftest import make_hass
import custom_components.nestquest.core.db as db_module
from custom_components.nestquest import db as db_module
from custom_components.nestquest.db import NestQuestDatabase
@@ -29,7 +29,7 @@ def _make_hass_mock():
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
hass = _make_hass_mock()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
_run(database.open(path))
return database, hass
@@ -55,7 +55,7 @@ def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
def test_open_delegates_connection_to_executor(tmp_path) -> None:
hass = _make_hass_mock()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
_run(database.open(tmp_path / "exec.db"))
first_call = hass.async_add_executor_job.call_args_list[0]
assert callable(first_call.args[0])
@@ -65,7 +65,7 @@ def test_open_delegates_connection_to_executor(tmp_path) -> None:
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
path = tmp_path / "once.db"
hass = _make_hass_mock()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
first = _run(database.open(path))
second = _run(database.open(path))
assert first is database
@@ -111,7 +111,7 @@ def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
"""The executor surface is awaited per wrapper call on a live loop."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "loop.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
@@ -129,7 +129,7 @@ async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "plain.db")
result = await database.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
@@ -156,7 +156,7 @@ async def test_public_methods_return_only_plain_data(tmp_path) -> None:
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
"""With the conftest hass (real run_in_executor), queries still succeed."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "real.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
assert await database.fetch_one("SELECT 1") == (1,)
@@ -210,7 +210,7 @@ async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) ->
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "threads.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
@@ -250,9 +250,7 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
A function is executor-delegating when it (or, for nested closures, any of
its enclosing functions) passes work to the wrapper's ``self._executor``
callable (the Home-Assistant-free contract that mirrors
``hass.async_add_executor_job``).
its enclosing functions) passes work to ``hass.async_add_executor_job``.
"""
source = Path(db_module.__file__).read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(db_module.__file__))
@@ -264,7 +262,7 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
and any(
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "_executor"
and sub.func.attr == "async_add_executor_job"
for sub in ast.walk(node)
)
}
@@ -377,7 +375,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works(
must run cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "deferred.db")
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
await database.execute(
@@ -418,7 +416,7 @@ async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None:
propagates, and a follow-up open() must succeed cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(executor_for(hass))
database = NestQuestDatabase(hass)
open_started = asyncio.Event()
release_open = asyncio.Event()
@@ -483,7 +481,7 @@ async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None:
clear its state, and stay usable for the next transaction.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(executor_for(hass))
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-begin.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
@@ -545,7 +543,7 @@ async def test_cancel_during_commit_settles_then_propagates_cancellation(
transaction, and the wrapper must stay usable.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(executor_for(hass))
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-commit.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -605,7 +603,7 @@ async def test_cancel_during_in_transaction_execute_settles_then_rolls_back(
not persist.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(executor_for(hass))
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-execute.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
@@ -668,7 +666,7 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
commit failure behind the rollback failure.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(executor_for(hass))
database = NestQuestDatabase(hass)
await database.open(tmp_path / "both-fail.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
@@ -700,14 +698,14 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
async def test_transaction_persists_across_connections(tmp_path) -> None:
"""A committed transaction is visible to a brand-new connection."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "persist.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
await database.close()
other = NestQuestDatabase(hass.async_add_executor_job)
other = NestQuestDatabase(hass)
await other.open(tmp_path / "persist.db")
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
await other.close()
@@ -728,7 +726,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
while a transaction is open queue behind it on the wrapper lock.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "serial.db")
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
tx_rounds, writers, writer_rounds = 6, 5, 8
@@ -768,7 +766,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
"""A second transaction caller is rejected with a clear error."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "reject.db")
await database.execute("CREATE TABLE t (id INTEGER)")
@@ -793,7 +791,7 @@ async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> Non
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
"""A statement issued during an open transaction queues behind it."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "block.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -832,7 +830,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
) -> None:
"""A second concurrent transaction() gets a clear RuntimeError."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "concurrent-tx.db")
await database.execute("CREATE TABLE t (id INTEGER)")
@@ -855,7 +853,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
"""close() serializes: an open transaction finishes before shutdown."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "closerace.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -877,7 +875,7 @@ async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
await asyncio.gather(txn_task, close_task)
assert database.connected is False
witness = NestQuestDatabase(hass.async_add_executor_job)
witness = NestQuestDatabase(hass)
await witness.open(tmp_path / "closerace.db")
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
await witness.close()
@@ -888,7 +886,7 @@ async def test_close_during_transaction_from_same_task_is_rejected(
) -> None:
"""close() inside its own transaction body raises instead of deadlocking."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass.async_add_executor_job)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "selfclose.db")
await database.execute("CREATE TABLE t (id INTEGER)")
with pytest.raises(RuntimeError, match="transaction"):
-187
View File
@@ -1,187 +0,0 @@
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
from __future__ import annotations
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
SEED_HORIZON_DAYS = 3
PER_CHILD_KINDS = (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
"next_quest",
"all_done",
"present_today",
)
HOUSEHOLD_UNIQUE_IDS = (
"nestquest_household_quests_due_today",
"nestquest_household_quests_completed_today",
"nestquest_cycle_day",
)
async def _reload(hass, entry) -> None:
"""Unload then set up: the harness's restart/reload equivalent."""
assert await async_unload_entry(hass, entry) is True
assert await async_setup_entry(hass, entry) is True
def _child_unique_ids(child_id: int) -> set[str]:
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
async def _setup_and_seed(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, ada, bo
async def test_deactivated_child_entities_removed_on_reload(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
assert _child_unique_ids(ada.id) <= set(hass.entities)
assert _child_unique_ids(bo.id) <= set(hass.entities)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert _child_unique_ids(ada.id) <= set(hass.entities), (
"the surviving child's entities must be untouched"
)
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
"the deactivated child's entities must be gone after reload"
)
async def test_reactivation_restores_entities_with_original_unique_ids(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
original = _child_unique_ids(bo.id)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert not (original & set(hass.entities))
await set_child_active(entry.runtime_data.database, bo.id, True)
await _reload(hass, entry)
assert original <= set(hass.entities), (
"reactivation must restore the exact original unique_ids"
)
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
assert bo_due.native_value == 1, "restored entities carry live state"
async def test_new_child_gets_entities_without_disturbing_existing(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
cory = await create_child(entry.runtime_data.database, "Cory")
await _reload(hass, entry)
assert before_ada <= set(hass.entities), "existing entities untouched"
assert _child_unique_ids(cory.id) <= set(hass.entities), (
"the new child's entities appear after reload"
)
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
async def test_restart_equivalent_restores_all_entity_states(
hass, make_entry
) -> None:
"""Unload + setup (the restart equivalent) restores every active
child's entities with correct states and the household rollups."""
from custom_components.nestquest.completion import complete_instance
entry, ada, bo = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
snapshot_ada = next(
child
for child in coordinator.data.children
if child.child_id == ada.id
)
await complete_instance(
entry.runtime_data.database,
snapshot_ada.instances[0].instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1
await _reload(hass, entry)
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1, "completed count survives the restart"
assert hass.entities[
f"nestquest_child_{bo.id}_quests_completed_today"
].native_value == 0
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
assert hass.entities[
"nestquest_household_quests_completed_today"
].native_value == 1
async def test_unload_shuts_coordinator_before_closing_database(
hass, make_entry
) -> None:
"""The coordinator's listeners are cleared while the database is
still connected: a scheduled refresh must never race the close."""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
seen_connected = []
async def _probe_shutdown():
seen_connected.append(database.connected)
original_close = database.close
async def _recording_close():
seen_connected.append("closing")
await original_close()
coordinator.async_shutdown = _probe_shutdown
database.close = _recording_close
assert await async_unload_entry(hass, entry) is True
assert seen_connected == [True, "closing"], (
"coordinator shutdown must precede the database close"
)
-247
View File
@@ -1,247 +0,0 @@
"""The Feature 10 entity-and-event matrix (task 9).
One fixture drives every assertion the done-condition names: correct
values for every sensor and binary sensor (including the zero-quest
child and the 255-character state limit), the three events firing
exactly once with schema-valid payloads, the instances payload
omitting missed instances while admin_instances includes them, and no
entities for an inactive child. The per-task test files cover each
behavior in depth; this file is the single pass that proves them all
against ONE fixture, so a regression in any contract fails here too.
"""
from __future__ import annotations
import datetime
import re
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.completion import derive_state
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_UNCOMPLETED,
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_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
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
async def _fixture(hass, make_entry):
"""Two quest children, one zero-quest child, one inactive child."""
entry = wire_entry_to_registry(
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
cory = await create_child(database, "Cory")
ghost = await create_child(database, "Ghost")
await set_child_active(database, ghost.id, False)
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning", "evening"],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, (ada, bo, cory, ghost)
def _sensor(hass, child_id, kind):
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
async def test_entity_and_event_matrix(hass, make_entry) -> None:
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
coordinator = entry.runtime_data.coordinator
# --- Every active child has the full entity set; the inactive
# child has none.
kinds = (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
"next_quest",
"all_done",
"present_today",
)
for child in (ada, bo, cory):
for kind in kinds:
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
for kind in kinds:
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
"inactive children must have no entities"
)
# --- Correct values against the fixture: Ada and Bo owe two
# quests (morning + evening), Cory owes none and is still 100%.
for child in (ada, bo):
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
assert (
_sensor(hass, child.id, "quests_completed_today").native_value == 0
)
assert (
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
)
assert (
_sensor(hass, child.id, "completion_pct_today").native_value == 0
)
assert _sensor(hass, child.id, "next_quest").native_value == (
"Brush teeth"
)
assert _sensor(hass, child.id, "all_done").is_on is False
assert _sensor(hass, child.id, "present_today").is_on is True
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
assert _sensor(hass, cory.id, "all_done").is_on is False
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
# --- Household rollups equal the per-child sums.
assert hass.entities[
"nestquest_household_quests_due_today"
].native_value == 4
assert hass.entities[
"nestquest_household_quests_completed_today"
].native_value == 0
# --- The 255-character limit holds for every registered entity.
for unique_id, entity in hass.entities.items():
state = getattr(entity, "native_value", None)
if state is None:
state = getattr(entity, "is_on", None)
assert state is None or not (
isinstance(state, str) and len(state) > 255
), unique_id
# --- Completing both of Ada's quests fires the events exactly
# once each and flips her entities immediately.
instances = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": ada.id,
},
)
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
assert _TIMESTAMP.match(payload["occurred_at"])
assert isinstance(payload["was_on_time"], bool)
assert payload["child_name"] in ("Ada",)
assert payload["quest_title"] == "Brush teeth"
assert isinstance(payload["instance_id"], int)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
assert _sensor(hass, ada.id, "all_done").is_on is True
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
assert (
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
)
# --- Reversing one quest fires the uncompleted event exactly once
# and restores the owed counts.
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": instances[0].id, "actor": "user"},
context={"user_id": "admin-1"},
)
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
assert _sensor(hass, ada.id, "all_done").is_on is False
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
# --- The instances payload omits missed instances while
# admin_instances includes them (D-009): pin the derive clock
# forward in the TEST ONLY so today's open instances read missed.
original_refresh = coordinator._async_update_data
async def _future_dated_refresh():
import custom_components.nestquest.coordinator as coordinator_module
real_derive = coordinator_module.derive_state
def _derive(instance, latest, today):
return real_derive(instance, latest, today + datetime.timedelta(days=1))
coordinator_module.derive_state = _derive
try:
return await original_refresh()
finally:
coordinator_module.derive_state = real_derive
coordinator._async_update_data = _future_dated_refresh
await coordinator.async_refresh()
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
assert attributes["instances"] == [], "panel payload omits missed"
assert len(attributes["admin_instances"]) == 2
assert all(
entry["state"] == "missed" for entry in attributes["admin_instances"]
)
coordinator._async_update_data = original_refresh
await coordinator.async_refresh()
async def test_payload_schema_matches_the_documented_contract(
hass, make_entry
) -> None:
"""Every fired payload carries the documented fields with the
documented types."""
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
instances = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instances[0].id,
"actor": "panel",
"actor_child_id": ada.id,
},
)
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
for key, check in (
("child_id", lambda v: isinstance(v, int)),
("child_name", lambda v: isinstance(v, str)),
("instance_id", lambda v: isinstance(v, int)),
("quest_title", lambda v: isinstance(v, str)),
("window", lambda v: v in ("morning", "evening")),
("due_date", lambda v: isinstance(v, str)),
("due_time", lambda v: v is None or isinstance(v, str)),
("occurred_at", lambda v: bool(_TIMESTAMP.match(v))),
("was_on_time", lambda v: isinstance(v, bool)),
):
assert key in payload, key
assert check(payload[key]), key
-379
View File
@@ -1,379 +0,0 @@
"""Tests for the NestQuest bus events (Feature 10, task 6)."""
from __future__ import annotations
import re
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_MISSED,
EVENT_QUEST_UNCOMPLETED,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_UNCOMPLETE_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"}
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
entry = wire_entry_to_registry(
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = None
from custom_components.nestquest.children import list_children
child = (await list_children(database))[0]
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()}
),
[child.id],
list(windows),
)
import datetime
today = datetime.date.today().isoformat()
end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat()
await materialize(database, today, end, today=datetime.date.today())
instances = await QuestInstancesDao(database).list_by_date_range(
child.id, today, today
)
return entry, child, instances
async def test_quest_completed_fires_with_documented_payload(
hass, make_entry
) -> None:
entry, child, instances = await _setup_and_seed(hass, make_entry)
instance_id = instances[0].id
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
assert len(fired) == 1
payload = fired[0]
assert payload["child_id"] == child.id
assert payload["child_name"] == "Ada"
assert payload["instance_id"] == instance_id
assert payload["quest_title"] == "Brush teeth"
assert payload["window"] == "morning"
assert payload["was_on_time"] is not None
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None:
entry, child, instances = await _setup_and_seed(hass, make_entry)
instance_id = instances[0].id
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
async def test_child_day_complete_fires_when_day_clears(
hass, make_entry
) -> None:
entry, child, instances = await _setup_and_seed(hass, make_entry)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
)
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
assert len(day_complete) == 1, "exactly one day-complete per cleared day"
payload = day_complete[0]
assert payload["child_id"] == child.id
assert payload["child_name"] == "Ada"
assert payload["quests_due"] == len(instances)
assert _TIMESTAMP.match(payload["occurred_at"])
async def test_child_day_complete_does_not_fire_partway(
hass, make_entry
) -> None:
entry, child, instances = await _setup_and_seed(
hass, make_entry, windows=("morning", "evening")
)
assert len(instances) == 2
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instances[0].id,
"actor": "panel",
"actor_child_id": child.id,
},
)
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instances[1].id,
"actor": "panel",
"actor_child_id": child.id,
},
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None:
entry, child, instances = await _setup_and_seed(hass, make_entry)
instance_id = instances[0].id
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
# Un-completing an open instance is a no-op and fires nothing.
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": instance_id, "actor": "user"},
context=ADMIN_CTX,
)
fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED)
assert len(fired) == 1
payload = fired[0]
assert payload["instance_id"] == instance_id
assert payload["quest_title"] == "Brush teeth"
assert _TIMESTAMP.match(payload["occurred_at"])
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": instance_id, "actor": "user"},
context=ADMIN_CTX,
)
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
def test_missed_event_name_is_documented_contract() -> None:
"""Feature 10 defines the missed event Feature 11's sweep fires."""
assert EVENT_QUEST_MISSED == "nestquest_quest_missed"
async def test_appended_verdict_is_atomic_under_duplicate_calls(
hass, make_entry
) -> None:
"""Concurrent duplicate completes decide appended under the same
lock as the write: exactly one fires the transition event."""
import asyncio
entry, child, instances = await _setup_and_seed(hass, make_entry)
instance_id = instances[0].id
calls = [
hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance_id,
"actor": "panel",
"actor_child_id": child.id,
},
)
for _ in range(2)
]
await asyncio.gather(*calls)
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
async def test_day_complete_ignored_for_backdated_completion(
hass, make_entry
) -> None:
"""Completing an instance NOT due today never evaluates the
child's day-complete, so a cleared day is announced at most once
per day and only by that day's completions."""
import datetime
from custom_components.nestquest.completion import (
complete_instance as _complete,
)
entry, child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
# A future instance (tomorrow) completed after today's day is
# already clear.
today = datetime.date.today()
end = (today + datetime.timedelta(days=3)).isoformat()
all_instances = await QuestInstancesDao(database).list_by_date_range(
child.id, today.isoformat(), end
)
today_ids = {instance.id for instance in instances}
tomorrow_instance = next(
instance
for instance in all_instances
if instance.id not in today_ids
)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
await _complete(
database,
tomorrow_instance.id,
actor_source="user",
actor_user_id="admin-1",
)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": tomorrow_instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, (
"a back-dated/future completion must not re-announce today"
)
# The direct business-layer call fired nothing (only the service
# path fires), and the follow-up service call on the now-done
# future instance was a no-op: today's single completion stands.
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances)
async def test_completion_updates_entities_immediately(
hass, make_entry
) -> None:
"""The remaining-today sensor reflects the completion the moment
the service call returns — no manual refresh — and the day-complete
event fired exactly once when the day became clear."""
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
entry, child, instances = await _setup_and_seed(hass, make_entry)
from conftest import wire_entry_to_registry # noqa: F401
coordinator = entry.runtime_data.coordinator
# Entities only exist after one refresh since they were seeded
# after setup; create them, then prove the service path refreshes.
await coordinator.async_refresh()
remaining_before = hass.entities[
f"nestquest_child_{child.id}_quests_remaining_today"
].native_value
assert remaining_before == len(instances)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instances[0].id,
"actor": "panel",
"actor_child_id": child.id,
},
)
remaining_after = hass.entities[
f"nestquest_child_{child.id}_quests_remaining_today"
].native_value
assert remaining_after == len(instances) - 1, (
"sensor must reflect the completion without a manual refresh"
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
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(
hass, make_entry
) -> None:
"""Overlapping forced refreshes never interleave: a slow pass
pauses mid-snapshot, and the queued pass still publishes AFTER
it, so the last published snapshot reflects the latest mutation."""
import asyncio
entry, child, instances = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
order: list[str] = []
async def _slow_update():
order.append("start")
await asyncio.sleep(0.01)
order.append("end")
return coordinator.data
coordinator._async_update_data = _slow_update
await asyncio.gather(coordinator.async_refresh(), coordinator.async_refresh())
assert order == ["start", "end", "start", "end"], (
"overlapping refreshes must serialize: " + str(order)
)
-181
View File
@@ -1,181 +0,0 @@
"""Tests for the Lovelace card toolchain and auto-registered resource."""
from __future__ import annotations
import json
from pathlib import Path
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup, async_setup_entry
from custom_components.nestquest.frontend import (
BUNDLE_FILENAME,
BUNDLE_URL,
FRONTEND_URL_BASE,
WWW_DIR,
async_register_frontend,
)
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_DIR = REPO_ROOT / "frontend"
BUNDLE_PATH = WWW_DIR / BUNDLE_FILENAME
CARD_TYPES = (
"nestquest-party-board-card",
"nestquest-quest-log-card",
"nestquest-admin-card",
)
def test_frontend_package_documents_vite_lit_build() -> None:
package = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8"))
assert package["scripts"]["build"] == "vite build"
assert "lit" in package["dependencies"]
assert "typescript" in package["devDependencies"]
assert "vite" in package["devDependencies"]
def test_card_modules_exist_as_separate_sources() -> None:
src = FRONTEND_DIR / "src"
assert (src / "nestquest-cards.ts").is_file()
assert (src / "cards" / "party-board.ts").is_file()
assert (src / "cards" / "quest-log.ts").is_file()
assert (src / "cards" / "admin.ts").is_file()
entry = (src / "nestquest-cards.ts").read_text(encoding="utf-8")
assert "./cards/party-board" in entry
assert "./cards/quest-log" in entry
assert "./cards/admin" in entry
def test_card_sources_have_no_visual_editor() -> None:
for path in (FRONTEND_DIR / "src" / "cards").glob("*.ts"):
text = path.read_text(encoding="utf-8")
assert "getConfigElement" not in text
assert "setConfig" in text
assert "getCardSize" in text
def test_built_bundle_contains_all_three_cards() -> None:
assert BUNDLE_PATH.is_file()
bundle = BUNDLE_PATH.read_text(encoding="utf-8")
for card_type in CARD_TYPES:
assert card_type in bundle
async def test_setup_registers_static_path_and_extra_js(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(hass.http.static_paths) == 1
registered = hass.http.static_paths[0]
assert registered.url_path == FRONTEND_URL_BASE
assert registered.path == str(WWW_DIR)
assert hass.extra_js_urls == [BUNDLE_URL]
async def test_async_setup_registers_frontend_without_a_config_entry(hass) -> None:
assert await async_setup(hass, {}) is True
assert hass.extra_js_urls == [BUNDLE_URL]
assert hass.http.static_paths[0].url_path == FRONTEND_URL_BASE
async def test_frontend_registration_is_idempotent(hass, make_entry) -> None:
await async_register_frontend(hass)
await async_register_frontend(hass)
entry_a = wire_entry_to_registry(make_entry(entry_id="a"), hass.registry)
entry_b = wire_entry_to_registry(make_entry(entry_id="b"), hass.registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
assert len(hass.http.static_paths) == 1
assert hass.extra_js_urls == [BUNDLE_URL]
def test_readme_documents_npm_build() -> None:
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
assert "npm run build" in readme
assert "nestquest-cards.js" in readme
def test_readme_documents_panel_resolution_and_touchhub_dock() -> None:
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
assert "1920×1080" in readme
assert "TouchHub" in readme
assert "dock" in readme
assert "pointer-events: none" in readme
WOFF2_FILES = (
"nunito-latin-400-normal.woff2",
"nunito-latin-600-normal.woff2",
"nunito-latin-700-normal.woff2",
"nunito-latin-800-normal.woff2",
"cinzel-latin-600-normal.woff2",
"cinzel-latin-700-normal.woff2",
"cinzel-latin-900-normal.woff2",
"cinzel-decorative-latin-700-normal.woff2",
"cinzel-decorative-latin-900-normal.woff2",
)
CDN_MARKERS = (
"fonts.googleapis.com",
"fonts.gstatic.com",
"cdn.jsdelivr.net",
"unpkg.com",
"cdnjs.cloudflare.com",
)
def test_www_contains_required_woff2_fonts() -> None:
fonts_dir = WWW_DIR / "fonts"
for name in WOFF2_FILES:
path = fonts_dir / name
assert path.is_file(), name
assert path.read_bytes()[:4] == b"wOF2"
def test_font_css_declares_swap_and_local_urls() -> None:
css = (WWW_DIR / "nestquest-fonts.css").read_text(encoding="utf-8")
assert css.count("font-display: swap") == 9
assert css.count("@font-face") == 9
assert 'font-family: "Nunito"' in css
assert 'font-family: "Cinzel"' in css
assert 'font-family: "Cinzel Decorative"' in css
for name in WOFF2_FILES:
assert f"./fonts/{name}" in css
assert "http://" not in css
assert "https://" not in css
def test_token_css_copied_verbatim_from_design() -> None:
design = REPO_ROOT / "design" / "tokens"
for name in ("nestquest-panel-tokens.css", "nestquest-admin-tokens.css"):
expected = (design / name).read_bytes()
actual = (WWW_DIR / name).read_bytes()
assert actual == expected
def test_frontend_and_www_have_no_cdn_font_references() -> None:
roots = (FRONTEND_DIR, WWW_DIR)
for root in roots:
for path in root.rglob("*"):
if not path.is_file():
continue
if "node_modules" in path.parts:
continue
if path.suffix.lower() in {".woff2", ".png", ".jpg"}:
continue
text = path.read_text(encoding="utf-8", errors="ignore")
for marker in CDN_MARKERS:
assert marker not in text, f"{path}: {marker}"
def test_readme_documents_the_touchhub_setup_runbook() -> None:
readme = " ".join(
(REPO_ROOT / "README.md").read_text(encoding="utf-8").split()
)
for required in ("TouchHub", "kiosk"):
assert required in readme, required
assert "not in the NestQuest admin allowlist" in readme
assert "contains only the NestQuest panel card" in readme
assert "nestquest-party-board-card" in readme
assert "nestquest-quest-log-card" in readme
assert "Panel" in readme
assert "Auto-Return" in readme
assert "use_x_frame_options" not in readme
assert "clickjacking" not in readme
+2 -2
View File
@@ -67,9 +67,9 @@ def test_readme_exists_and_contains_required_sections() -> None:
def test_readme_hacs_points_at_public_github_mirror() -> None:
"""Verify README directs HACS users to the public GitHub mirror, not the private Gitea repo."""
content = README_PATH.read_text(encoding="utf-8")
assert "https://github.com/Maniacs-Mansion/NestQuest" in content
assert "https://github.com/talon2king/NestQuest" in content
assert "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest" in content
assert content.index("https://github.com/Maniacs-Mansion/NestQuest") < content.index(
assert content.index("https://github.com/talon2king/NestQuest") < content.index(
"https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest"
)
-17
View File
@@ -31,23 +31,6 @@ def test_mock_only_harness_contract(monkeypatch):
assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
# Entity-platform stand-ins are wired with the shaped classes, and
# parent-package attributes resolve to the mocked submodules.
entity_module = sys.modules["homeassistant.helpers.entity"]
assert entity_module.DeviceInfo is conftest.DeviceInfo
assert entity_module.Entity is conftest.Entity
sensor_module = sys.modules["homeassistant.components.sensor"]
assert sensor_module.SensorEntity is conftest.SensorEntity
assert sensor_module.SensorStateClass.MEASUREMENT == "measurement"
assert sensor_module.SensorDeviceClass.PERCENTAGE == "percentage"
assert sys.modules[
"homeassistant.components.binary_sensor"
].BinarySensorEntity is conftest.BinarySensorEntity
assert sys.modules[
"homeassistant.helpers.update_coordinator"
].CoordinatorEntity is conftest.CoordinatorEntity
assert sys.modules["homeassistant"].components.sensor is sensor_module
# No homeassistant distribution is installed in this suite's own venv.
with pytest.raises(importlib.metadata.PackageNotFoundError):
importlib.metadata.version("homeassistant")
+10 -23
View File
@@ -9,7 +9,6 @@ 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.const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
@@ -17,13 +16,6 @@ from custom_components.nestquest.const import (
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):
return wire_entry_to_registry(entry, registry)
@@ -472,7 +464,7 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
# Seed the database BEFORE setup: a restart reopens an existing file that
# already holds definitions. The startup backfill must pick them up.
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
pre = NestQuestDatabase(hass)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
@@ -499,7 +491,7 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
pre = NestQuestDatabase(hass)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
@@ -554,7 +546,7 @@ async def test_regenerate_service_registered_and_materializes(
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(data=_admin_data()), hass.registry)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
@@ -563,7 +555,7 @@ async def test_regenerate_service_registered_and_materializes(
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)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
@@ -582,15 +574,14 @@ async def test_regenerate_service_uses_configured_horizon_days(
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
hass.registry,
make_entry(options={CONF_HORIZON_DAYS: 5}), 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)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
@@ -631,12 +622,8 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
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
)
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
@@ -647,7 +634,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
child_id = await _seed_daily_child_and_definition(
database_b, _local_today(hass)
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(database_b).list_by_date_range(
@@ -660,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
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)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
# Unloading the final entry removes the domain-global service.
assert await async_unload_entry(hass, entry_b) is True

Some files were not shown because too many files have changed in this diff Show More