Compare commits

...
3 changed files with 255 additions and 0 deletions
+79
View File
@@ -155,6 +155,85 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_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.
+16
View File
@@ -163,3 +163,19 @@ def test_frontend_and_www_have_no_cdn_font_references() -> None:
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
+160
View File
@@ -0,0 +1,160 @@
"""Source-level tests for the kid panel cards.
The panel cards are Lit/TypeScript modules bundled by Vite; these tests assert
the contract by reading the card sources and the built bundle, in the same
spirit as test_frontend.py. They cover the states decided in
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
bundles against ever gaining a parent-only service call.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
# Parent-only NestQuest services. The party board and quest log are the
# kid-facing panel: they may complete a quest for the tapped child, and nothing
# else. Any of these names appearing in a panel source or bundle means an admin
# control has leaked into the kid panel.
ADMIN_ONLY_SERVICES = (
"uncomplete_quest",
"create_quest_definition",
"update_quest_definition",
"set_quest_definition_active",
"set_presence_pattern",
"create_presence_override",
"delete_presence_override",
"export_history_csv",
"manage_child",
"regenerate",
)
WINDOW_KEYS = ("morning", "afternoon", "evening")
WINDOW_NAMES = ("Morning", "Afternoon", "Evening")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_party_board_renders_present_and_away_plates() -> None:
source = _read(PARTY_BOARD)
assert "nestquest-party-board-card" in source
# Present adventurers get the tappable crest plate.
assert "button.plate" in source
assert 'class="plate"' in source
assert "Home today" in source
assert "aria-label=\"Open ${plate.name}'s Quest Log\"" in source
# Away adventurers get the dashed On travels plate, not a tappable one.
assert ".plate.away" in source
assert 'class="plate away"' in source
assert "On travels" in source
assert "next_present" in source
assert "Returns ${formatDay(returnsDate" in source
def test_quest_log_renders_three_window_columns() -> None:
source = _read(QUEST_LOG)
for key, name in zip(WINDOW_KEYS, WINDOW_NAMES):
assert f'key: "{key}"' in source, key
assert f'name: "{name}"' in source, name
assert "grid-template-columns: repeat(3, 1fr)" in source
assert 'class="column-head"' in source
assert 'class="column-names"' in source
assert 'class="column-range"' in source
def test_empty_window_still_renders_its_column_head() -> None:
"""A window with no quests renders the head alone (PANEL-SPEC §3.1)."""
source = _read(QUEST_LOG)
column = source[source.index("private _renderColumn(windowDef: WindowDef)") :]
column = column[: column.index("private _renderQuest(")]
# The head is emitted unconditionally, before the stack is built.
assert 'class="column-head"' in column
assert "sealed.length}/${instances.length}" in column
# There is no early return that would drop the head on an empty window.
assert "instances.length === 0" not in column
assert "return nothing" not in column
def test_complete_quest_is_optimistic_with_rollback() -> None:
source = _read(QUEST_LOG)
assert "_optimistic" in source
assert "new Date().toISOString()" in source
assert ".set(instance.id, stampedAt)" in source
# The overlay is applied to the rendered instance before the service answers.
assert 'instance.state = "completed"' in source
# A rejected call reverts the overlay and raises the parchment toast.
assert "reverted.delete(instance.id)" in source
assert "NestQuest could not set the seal just now. Please try again." in source
assert "_showToast(" in source
assert "await this._callCompleteQuest(instance)" in source
assert '"nestquest", "complete_quest"' in source
def test_idle_return_to_board() -> None:
source = _read(QUEST_LOG)
assert "idle_return_seconds" in source
assert "_armIdle" in source
assert "_clearIdle" in source
assert "this._idleSeconds() * 1000" in source
assert "board_path" in source
assert "_returnToBoard" in source
assert 'window.dispatchEvent(new Event("location-changed"))' in source
def test_decided_empty_and_error_states_are_present() -> None:
log = _read(QUEST_LOG)
board = _read(PARTY_BOARD)
for kind in (
"no-adventurer",
"not-set-up",
"unreachable",
"away",
"empty-day",
"complete-day",
):
assert f'kind: "{kind}"' in log, kind
assert "PANEL-EMPTY-STATES" in log
assert "No adventurer chosen" in log
assert "NestQuest is not set up yet" in log
assert "The records cannot be reached" in log
assert "No quests today" in log
assert 'role="status"' in log
# The board distinguishes the same setup/unreachable cases at board level.
assert "NestQuest is not set up yet" in board
assert "The records cannot be reached" in board
assert "BOARD_NOTICE_NOT_SET_UP" in board
assert "BOARD_NOTICE_UNREACHABLE" in board
def test_quest_complete_trigger() -> None:
source = _read(QUEST_LOG)
assert "binary_sensor.nestquest_${slug}_all_done" in source
assert "nestquest_child_day_complete" in source
assert "NESTQUEST_EVENT_TYPES" in source
assert 'class="complete-screen"' in source
assert "Quest complete" in source
assert "Returning to The Party in" in source
assert "_armCompleteTimer" in source
assert "complete_screen_seconds" in source
def test_panel_cards_reference_no_admin_only_services() -> None:
# Only the kid panel sources are scanned: the bundle also contains the
# sibling admin card, so its parent-only service names are expected there.
for path in PANEL_SOURCES:
text = _read(path)
for service in ADMIN_ONLY_SERVICES:
assert service not in text, f"{path}: {service}"
# The bundle is still built and carries both panel card custom elements.
assert BUNDLE_PATH.is_file()
bundle = _read(BUNDLE_PATH)
assert "nestquest-party-board-card" in bundle
assert "nestquest-quest-log-card" in bundle