diff --git a/.gitignore b/.gitignore index cbd657e..59c3a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ # digest-engine per-run output (rendered artifacts, cached ingestion context) digest-engine/output/ +# admin-canvas: the shared output/ volume it writes into (current canvas state + +# uploaded media) — same handling as digest-engine/output/ above. +admin-canvas/output/ + # digest-engine: the real OPNsense IDS config carries an API key/secret. Only # IDSconf.json.example is tracked, matching the digest-engine.env.example pattern. IDSconf.json @@ -54,5 +58,12 @@ hosts/thin-client/**/tls_key.pem # password above — only gallery-credentials.example is tracked. gallery-credentials +# firmware/esp32-s3-touch-lcd-1.85c: Wi-Fi/API/OTA credentials + the household's +# entity IDs, same never-commit handling as digest-engine.env/admin-canvas.env — +# only secrets.yaml.example is tracked. ESPHome's own build cache, never useful +# to commit either. +firmware/esp32-s3-touch-lcd-1.85c/secrets.yaml +firmware/**/.esphome/ + __pycache__/ *.pyc diff --git a/README.md b/README.md index 7f3909f..6b8bea3 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,17 @@ hosts/ firmware/ ruview/ RuView ESP32-S3 CSI presence node configs esphome-ble-proxy/ ESPHome configs for Bermuda BLE proxy nodes + esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD, + media/cover-art priority over an idle weather/time/ + date cycle, voice-state visualizer) identity/ Face<->MAC<->name correlation logic (Node-RED flow export once stabilized, or a Python service) digest-engine/ Quarter-daily LLM digest: mail/message/news/financial ingestion, LLM synthesis, digest-canvas SDK rendering +admin-canvas/ On-demand sys-admin-llm display surface for the thin + clients: stats/graphics/media, pushed on demand + rather than on a schedule (write API + admin-web + static serving) ``` ## Status @@ -42,8 +49,10 @@ digest-engine/ Quarter-daily LLM digest: mail/message/news/financia - [ ] CalDAV / Nextcloud calendar integration notes - [ ] Identity correlation flow (Node-RED) - [x] Sway thin-client ISO (live-build) + thinclient-agent — built, not yet boot-tested on real hardware; RDP replaced by wayvnc (resolved), remaining open items (mic-enabled rooms, exact hardware target, wayvnc password provisioning) in `docs/project-plan.md` §4 -- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons) +- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons), capture-card ("receiver box") video source selection on a new `5:capture` workspace — built, not yet tried against real capture-card hardware, see `hosts/thin-client/README.md` - [x] Quarter-daily digest engine (mail/Signal/Telegram/Discord/WhatsApp, news, financial ingestion; LLM synthesis; digest-canvas SDK) — built and wired into `setup-container-host.sh` (`ENABLE_DIGEST_ENGINE`, off by default), not yet run against real credentials; household/calendar ingest (CalDAV/Grocy) still needs a real data source wired in, see `docs/project-plan.md` §4 +- [x] admin-canvas + admin-web (sys-admin-llm on-demand display surface for the thin clients) — built and wired into `setup-container-host.sh` (`ENABLE_ADMIN_CANVAS`, off by default); the HA-side tool/rest_command wiring and the specific entities it surfaces (e.g. power-monitoring) are still undecided, see `docs/project-plan.md` §4 +- [ ] ESP32-S3-Touch-LCD-1.85C-V2 voice satellite + status display (`firmware/esp32-s3-touch-lcd-1.85c/`) — ESPHome config written and passes `esphome config`, not yet flashed to real hardware; `media_player`/`weather` entity IDs still need to be chosen, see `docs/project-plan.md` §4 ## Quick start diff --git a/admin-canvas/.dockerignore b/admin-canvas/.dockerignore new file mode 100644 index 0000000..94cda2e --- /dev/null +++ b/admin-canvas/.dockerignore @@ -0,0 +1,6 @@ +output/ +render/ +README.md +admin-canvas.env.example +**/__pycache__/ +**/*.pyc diff --git a/admin-canvas/Dockerfile b/admin-canvas/Dockerfile new file mode 100644 index 0000000..f896aae --- /dev/null +++ b/admin-canvas/Dockerfile @@ -0,0 +1,17 @@ +# admin-canvas — a small, always-on write API (docs/project-plan.md Phase 13). +# Unlike digest-engine's oneshot, this is `restart: unless-stopped`: it has to be up +# whenever HA might want to push new content to a thin client's admin canvas. +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +# stdlib only — no requirements.txt. See server.py's module docstring for why this +# stays a plain http.server instead of pulling in a web framework. +COPY server.py ./ + +RUN mkdir -p /output /output/media + +CMD ["python", "server.py"] diff --git a/admin-canvas/README.md b/admin-canvas/README.md new file mode 100644 index 0000000..396ac83 --- /dev/null +++ b/admin-canvas/README.md @@ -0,0 +1,153 @@ +# admin-canvas + +The sys-admin-llm's display surface, from [Phase 13 of the project plan](../docs/project-plan.md). + +Where `digest-engine`/`digest-web` (Phase 12) render a scheduled 4x/day synthesis run, +this is the on-demand equivalent: whenever Home Assistant's tool-calling LLM (the +household's "sys-admin-llm", in its admin/ops-facing role — e.g. answering "show me +the kitchen outlet's power draw") decides something should be shown, it pushes a small +JSON document here and the thin client's admin workspace (`4:admin`, see +`hosts/thin-client/agent/thinclient_agent/admin_canvas.py`) picks it up on its next +poll. + +Same read/write split as the digest, for the same reason (the write side needs to +validate untrusted-ish input; the read side is dumb, static, and LAN-published): + +- **`admin-canvas`** (this directory) — a small always-on Python HTTP service. + Accepts `POST /show` and `POST /media/`, both bearer-token gated, + writes to a shared `output/` volume. Has **no published port** — reachable only + from other containers on the compose network (i.e. Home Assistant), the same + trust boundary `mosquitto`/`homeassistant` already share. +- **`admin-web`** (nginx, defined in `setup-container-host.sh`, not here) — serves + `output/`, `render/canvas-sdk/`, and `render/templates/canvas.html` read-only to + the LAN, exactly like `digest-web`. + +**This does not add a network path to the thin client.** The MQTT "Show admin +canvas" button only ever tells the thin client to switch workspace and open its +fixed, locally-configured `canvas.html` URL — see the security-boundary note in +`hosts/thin-client/README.md`. Content reaches that page over a completely separate +path: sys-admin-llm → HA service call → here → `admin-web` → the browser's own poll. + +## Configure + +```sh +cp admin-canvas/admin-canvas.env.example /opt/smart-home/admin-canvas/admin-canvas.env +openssl rand -hex 32 # put the result in ADMIN_CANVAS_TOKEN below +chmod 600 /opt/smart-home/admin-canvas/admin-canvas.env +$EDITOR /opt/smart-home/admin-canvas/admin-canvas.env +``` + +`ADMIN_CANVAS_TOKEN` is required — the service fails closed (rejects every request) +while it is empty, not "auth optional". The same value has to be pasted into HA's +`rest_command:` config below; there is no way for this repo to push it there for you, +same as every other credential pair in this project that spans two machines. + +## The `/show` schema + +``` +POST /show +Authorization: Bearer +Content-Type: application/json + +{ + "windows": [ { "kind": "...", "title": "...", "content": {...} }, ... ] +} +``` + +Overwrites `output/latest.json` wholesale — there is no history, no merge, no +per-run directories like the digest has; this is a single "what's on screen right +now" document, because unlike the digest, nothing here needs to know whether a +previous push was ever viewed. `admin-canvas/server.py`'s `validate_show_payload()` +rejects anything that doesn't match this shape (HTTP 400) before it is ever written. + +### `stat` — a big number + +```json +{ "kind": "stat", "title": "Kitchen fridge", + "content": { "value": 42, "unit": "W", "label": "current draw", "trend": "up" } } +``` +`trend` is optional: `"up"` / `"down"` / anything else renders as flat. + +### `chart` — dependency-free inline SVG (bar or sparkline) + +```json +{ "kind": "chart", "title": "Kitchen outlets — right now", + "content": { "type": "bar", "unit": "W", + "points": [ { "label": "Fridge", "value": 42 }, { "label": "Kettle", "value": 1800 } ] } } +``` +```json +{ "kind": "chart", "title": "Living room draw — last 6h", + "content": { "type": "sparkline", "unit": "W", "points": [180, 172, 190, 640, 210, 205] } } +``` + +### `image` / `video` — media already uploaded via `POST /media/` + +```json +{ "kind": "image", "title": "CPU — last 24h (Netdata)", + "content": { "src": "media/netdata-cpu.png", "alt": "CPU utilization graph" } } +``` +`content.src` **must** match `media/` — no scheme, no leading `/`, no +`..`. This is the same "a payload never becomes a URL host" invariant +`thinclient_agent/mqtt_discovery.py` documents for the thin client's own control +surface, applied here to what a browser is told to fetch. Upload the file first: + +```sh +curl -X POST "http://:8092/media/netdata-cpu.png" \ + -H "Authorization: Bearer $ADMIN_CANVAS_TOKEN" \ + --data-binary @netdata-cpu.png +``` + +Allowed extensions: `.png .jpg .jpeg .gif .webp .mp4 .webm`. Filenames are +allowlist-validated (`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$` plus that extension list) — +anything else is refused with HTTP 400, specifically so nothing that isn't a media +file can ever land in the directory `admin-web` serves statically. + +### No `kind` — plain text/markdown-ish content + +Omit `kind` entirely to reuse the exact same tiny renderer the digest windows use +(bold/italic/inline-code/paragraphs, or a bulleted list if `content` is an array). + +All kinds accept optional `x`/`y`/`w`/`h` (percentages) for a deliberately composed +layout; omitted, a window just takes its place in the canvas's normal grid flow. + +## Example: wiring this to Home Assistant + +**Nothing under this repo builds the HA side** — same convention as every other HA +integration point in this project (see `hosts/thin-client/README.md`'s note on the +Lovelace card nobody here builds either). This is what the household's own HA config +needs, sketched for reference: + +```yaml +# configuration.yaml +rest_command: + admin_canvas_show: + url: "http://admin-canvas:8092/show" + method: POST + headers: + Authorization: "Bearer !secret admin_canvas_token" + Content-Type: "application/json" + payload: "{{ payload }}" +``` + +A sys-admin-llm tool call (AI Task / Assist) that wants to show the kitchen outlet's +current draw would then resolve the reading from whatever HA entity/history holds +it, build the `stat` JSON above, and call +`rest_command.admin_canvas_show(payload=)`. Getting the thin client to +actually display it is a second, separate step — the existing "Show admin canvas" +MQTT button/service call documented in `hosts/thin-client/README.md`. + +## Local testing without HA + +```sh +cd admin-canvas +ADMIN_CANVAS_TOKEN=devtoken ADMIN_CANVAS_OUTPUT_DIR=/tmp/admin-canvas-output python3 server.py & + +curl -X POST http://localhost:8092/show \ + -H "Authorization: Bearer devtoken" -H "Content-Type: application/json" \ + -d '{"windows":[{"kind":"stat","title":"Kitchen fridge","content":{"value":42,"unit":"W","label":"current draw"}}]}' + +python3 -m http.server 8094 --directory /tmp/admin-canvas-output & # crude stand-in for admin-web +``` + +Then open `render/templates/canvas.html` directly in a browser (or serve `render/` +alongside the directory above) to see it render — no container host required. diff --git a/admin-canvas/admin-canvas.env.example b/admin-canvas/admin-canvas.env.example new file mode 100644 index 0000000..86f944f --- /dev/null +++ b/admin-canvas/admin-canvas.env.example @@ -0,0 +1,29 @@ +# admin-canvas configuration template. +# +# Copy this to the container host as (for example) +# /opt/smart-home/admin-canvas/admin-canvas.env, fill in a real token, and chmod 600 +# it. Same never-commit handling as digest-engine.env / the wayvnc password — the +# repo .gitignore already covers .env / *.env. + +# --------------------------------------------------------------------------- +# Auth — required. admin-canvas fails closed (rejects every request) while this is +# empty; it is not an optional "auth off" toggle. Generate one with, e.g.: +# openssl rand -hex 32 +# The same value goes into the HA-side rest_command's Authorization header — see +# admin-canvas/README.md for the worked example. There is no way for this repo to +# push that value into HA's config for you; both sides are set by hand from the +# same generated value, same as the MQTT credentials elsewhere in this project. +# --------------------------------------------------------------------------- +ADMIN_CANVAS_TOKEN= + +# --------------------------------------------------------------------------- +# Run behaviour +# --------------------------------------------------------------------------- +ADMIN_CANVAS_PORT=8092 +ADMIN_CANVAS_OUTPUT_DIR=/output +LOG_LEVEL=INFO + +# Caps on what a single POST may contain. /show is JSON only (no inline media, see +# README) so its cap is small; /media/ takes the raw image/video bytes. +ADMIN_CANVAS_MAX_SHOW_KB=256 +ADMIN_CANVAS_MAX_MEDIA_MB=25 diff --git a/admin-canvas/render/canvas-sdk/glow.css b/admin-canvas/render/canvas-sdk/glow.css new file mode 100644 index 0000000..e29bbf3 --- /dev/null +++ b/admin-canvas/render/canvas-sdk/glow.css @@ -0,0 +1,256 @@ +/* + * admin-canvas SDK styling — the whole SDK's CSS, in one file, no build step. + * Loaded with a plain ; nothing here reaches the network. + * + * A near-verbatim copy of digest-engine/render/digest-canvas-sdk/glow.css (docs/ + * project-plan.md Phase 12), duplicated per Phase 13's decision to keep the two + * canvases decoupled — the `digest-` prefix is renamed to `admin-` throughout, the + * globe-specific rules are dropped (no globe here), and stat/chart/image/video + * rules are added at the bottom. + */ + +:root { + --admin-bg: #070b12; + --admin-panel: rgba(16, 24, 38, 0.82); + --admin-edge: rgba(120, 180, 255, 0.28); + --admin-text: #d7e3f4; + --admin-muted: #8ba0bd; + --admin-accent: #8ab4ff; + --admin-good: #35d488; + --admin-warn: #e0a000; + --admin-glow-color: var(--admin-accent); +} + +/* --------------------------------------------------------------------------- + * Glow / holo utilities — shared by window chrome and the new window kinds. + * --------------------------------------------------------------------------- */ + +.admin-glow { + text-shadow: + 0 0 4px var(--admin-glow-color), + 0 0 12px var(--admin-glow-color); + animation: admin-pulse 3.2s ease-in-out infinite; +} + +.admin-glow-box { + box-shadow: + 0 0 0 1px var(--admin-edge), + 0 0 24px -6px var(--admin-glow-color), + 0 18px 40px -24px rgba(0, 0, 0, 0.9); +} + +@keyframes admin-pulse { + 0%, 100% { filter: brightness(1); } + 50% { filter: brightness(1.45); } +} + +@media (prefers-reduced-motion: reduce) { + .admin-glow { animation: none; } +} + +/* --------------------------------------------------------------------------- + * Canvas + * --------------------------------------------------------------------------- */ + +.admin-canvas { + position: relative; + box-sizing: border-box; + color: var(--admin-text); + background: radial-gradient(circle at 50% 0%, #0d1626 0%, var(--admin-bg) 70%); + font-family: "Inter", "Noto Sans", "DejaVu Sans", system-ui, sans-serif; +} + +.admin-canvas-empty, +.admin-canvas-raw { + margin: 1rem; + padding: 1rem; + border: 1px solid var(--admin-edge); + border-radius: 8px; + color: var(--admin-muted); + white-space: pre-wrap; + word-break: break-word; + font-family: "DejaVu Sans Mono", ui-monospace, monospace; + font-size: 0.8rem; + line-height: 1.5; +} + +/* --------------------------------------------------------------------------- + * Window chrome + * --------------------------------------------------------------------------- */ + +.admin-window { + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid var(--admin-edge); + border-radius: 12px; + background: var(--admin-panel); + backdrop-filter: blur(6px); + box-shadow: + 0 0 24px -10px var(--admin-accent), + 0 20px 46px -28px rgba(0, 0, 0, 0.95); + overflow: hidden; +} + +.admin-window-positioned { + position: absolute; +} + +.admin-window-titlebar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--admin-edge); + background: linear-gradient(180deg, rgba(120, 180, 255, 0.12), rgba(120, 180, 255, 0.02)); +} + +.admin-window-dots { + flex: none; + width: 34px; + height: 8px; + background-image: radial-gradient(circle, var(--admin-accent) 3px, transparent 3px); + background-size: 12px 8px; + background-repeat: repeat-x; + opacity: 0.6; +} + +.admin-window-title { + margin: 0; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--admin-accent); +} + +.admin-window-body { + flex: 1 1 auto; + padding: 0.8rem 0.95rem 1rem; + font-size: 0.92rem; + line-height: 1.55; + overflow: auto; +} + +.admin-window-body p { margin: 0 0 0.7rem; } +.admin-window-body p:last-child { margin-bottom: 0; } +.admin-window-body code { + padding: 0.05em 0.35em; + border-radius: 4px; + background: rgba(120, 180, 255, 0.12); + font-family: "DejaVu Sans Mono", ui-monospace, monospace; + font-size: 0.88em; +} + +.admin-window-list { + margin: 0; + padding-left: 1.1rem; +} +.admin-window-list li { margin-bottom: 0.4rem; } + +.admin-window-error { + margin: 0; + padding: 0.8rem 0.95rem; + color: var(--admin-muted); + white-space: pre-wrap; + word-break: break-word; + font-family: "DejaVu Sans Mono", ui-monospace, monospace; + font-size: 0.78rem; +} + +/* --------------------------------------------------------------------------- + * Stat cards — the 'stat' window kind (see render.js). + * --------------------------------------------------------------------------- */ + +.admin-window-stat .admin-window-body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 0.2rem; + padding-top: 1.4rem; + padding-bottom: 1.4rem; +} + +.admin-stat-value { + font-size: 2.6rem; + font-weight: 700; + line-height: 1; + color: var(--admin-accent); +} + +.admin-stat-unit { + font-size: 1.1rem; + font-weight: 500; + margin-left: 0.25rem; + color: var(--admin-muted); +} + +.admin-stat-label { + font-size: 0.85rem; + color: var(--admin-muted); +} + +.admin-stat-trend-up { color: var(--admin-warn); } +.admin-stat-trend-down { color: var(--admin-good); } +.admin-stat-trend-flat { color: var(--admin-muted); } + +/* --------------------------------------------------------------------------- + * Image / video — the 'image' and 'video' window kinds. + * --------------------------------------------------------------------------- */ + +.admin-media { + display: block; + width: 100%; + max-height: 60vh; + object-fit: contain; + border-radius: 6px; +} + +/* --------------------------------------------------------------------------- + * Chart — the 'chart' window kind: a dependency-free inline SVG bar/sparkline. + * --------------------------------------------------------------------------- */ + +.admin-chart-svg { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.admin-chart-bar { + fill: var(--admin-accent); + opacity: 0.85; +} + +.admin-chart-bar-label, +.admin-chart-bar-value { + fill: var(--admin-muted); + font-size: 6px; + font-family: "DejaVu Sans Mono", ui-monospace, monospace; +} + +.admin-chart-sparkline-path { + fill: none; + stroke: var(--admin-accent); + stroke-width: 1.6; + stroke-linejoin: round; + stroke-linecap: round; + filter: drop-shadow(0 0 3px var(--admin-accent)); +} + +.admin-chart-sparkline-fill { + fill: url(#admin-chart-sparkline-gradient); + opacity: 0.25; + stroke: none; +} + +.admin-window-body-caption { + margin: 0.5rem 0 0; + font-size: 0.78rem; + color: var(--admin-muted); + text-align: center; +} diff --git a/admin-canvas/render/canvas-sdk/render.js b/admin-canvas/render/canvas-sdk/render.js new file mode 100644 index 0000000..573409d --- /dev/null +++ b/admin-canvas/render/canvas-sdk/render.js @@ -0,0 +1,372 @@ +/* + * AdminRender — turns admin-canvas JSON into a canvas of AdminWindows. + * + * A trimmed sibling of digest-engine/render/digest-canvas-sdk/render.js: no + * section-list/detail-level resolution (one flat {"windows": [...]} document, not + * three sections at two detail levels) and no globe kind (nothing here is a lat/lon + * marker). Same degrade-instead-of-throw philosophy as the digest, applied to four + * new kinds instead of one — admin-canvas/server.py already rejects malformed + * writes before they ever reach output/latest.json, but this is the second, + * independent line of defense on the read side: + * + * - unparseable input -> a
 dump of the raw text
+ *   - not a {"windows": [...]}   -> a 
 dump of whatever was there instead
+ *   - a window that throws       -> a 
 dump of that window, siblings still render
+ *   - a chart/stat with bad data -> the same per-window 
 dump, not a broken canvas
+ *
+ * A blank page is the one outcome that must never happen — same rule as the digest.
+ */
+
+(function (global) {
+  'use strict';
+
+  var SVG_NS = 'http://www.w3.org/2000/svg';
+
+  // Mirrors admin-canvas/server.py's SRC_RE exactly. This is defense in depth, not
+  // the primary check — the server already refuses to write a 'show' payload whose
+  // image/video src doesn't match this shape — but a page that trusted the server
+  // unconditionally would have no second line of defense if that check ever
+  // regressed. src is always relative to output/, added below.
+  var SRC_RE = /^media\/[A-Za-z0-9_.-]+$/;
+
+  var TREND_CLASSES = { up: 'up', down: 'down', flat: 'flat' };
+
+  function rawDump(container, value, note) {
+    var pre = document.createElement('pre');
+    pre.className = 'admin-canvas-raw';
+    var text = typeof value === 'string' ? value : safeStringify(value);
+    pre.textContent = (note ? note + '\n\n' : '') + text;
+    container.appendChild(pre);
+    return pre;
+  }
+
+  function safeStringify(value) {
+    try {
+      return JSON.stringify(value, null, 2);
+    } catch (err) {
+      return String(value);
+    }
+  }
+
+  function parse(input) {
+    if (typeof input !== 'string') { return input; }
+    try {
+      return JSON.parse(input);
+    } catch (err) {
+      return { __unparseable: input };
+    }
+  }
+
+  function formatStatValue(value) {
+    if (typeof value === 'number') {
+      return Number.isInteger(value) ? String(value) : value.toFixed(1);
+    }
+    return value === undefined || value === null ? '—' : String(value);
+  }
+
+  function renderStatWindow(container, win) {
+    var content = win.content || {};
+    var mount = document.createElement('div');
+    mount.className = 'admin-stat';
+
+    var valueLine = document.createElement('div');
+
+    var valueSpan = document.createElement('span');
+    valueSpan.className = 'admin-stat-value admin-glow';
+    valueSpan.textContent = formatStatValue(content.value);
+    valueLine.appendChild(valueSpan);
+
+    if (content.unit) {
+      var unitSpan = document.createElement('span');
+      unitSpan.className = 'admin-stat-unit';
+      unitSpan.textContent = String(content.unit);
+      valueLine.appendChild(unitSpan);
+    }
+
+    if (content.trend) {
+      var trend = String(content.trend).toLowerCase();
+      var arrow = trend === 'up' ? '▲' : trend === 'down' ? '▼' : '▬';
+      var trendSpan = document.createElement('span');
+      trendSpan.className = 'admin-stat-trend-' + (TREND_CLASSES[trend] || 'flat');
+      trendSpan.textContent = ' ' + arrow;
+      valueLine.appendChild(trendSpan);
+    }
+
+    mount.appendChild(valueLine);
+
+    if (content.label) {
+      var label = document.createElement('div');
+      label.className = 'admin-stat-label';
+      label.textContent = String(content.label);
+      mount.appendChild(label);
+    }
+
+    return global.AdminWindow.open({
+      title: win.title || '',
+      content: mount,
+      container: container,
+      variant: 'stat',
+      x: win.x,
+      y: win.y,
+      w: win.w,
+      h: win.h
+    });
+  }
+
+  function renderMediaWindow(container, win) {
+    var content = win.content || {};
+    var src = content.src;
+    if (typeof src !== 'string' || !SRC_RE.test(src)) {
+      throw new Error((win.kind || 'media') + " window needs content.src matching 'media/'");
+    }
+
+    // admin-web (nginx) serves the shared output/ volume this service writes into
+    // under the 'output/' path, same convention as digest-web's output/latest.json
+    // — the JSON only ever names paths relative to that directory.
+    var el = document.createElement(win.kind === 'video' ? 'video' : 'img');
+    el.className = 'admin-media';
+    el.src = 'output/' + src;
+
+    if (win.kind === 'video') {
+      el.controls = true;
+      el.muted = content.muted !== false;
+      el.loop = !!content.loop;
+      el.autoplay = content.autoplay !== false;
+      el.playsInline = true;
+    } else {
+      el.alt = content.alt || win.title || '';
+    }
+
+    return global.AdminWindow.open({
+      title: win.title || '',
+      content: el,
+      container: container,
+      variant: win.kind,
+      x: win.x,
+      y: win.y,
+      w: win.w,
+      h: win.h
+    });
+  }
+
+  function renderBarChart(mount, content) {
+    var points = Array.isArray(content.points) ? content.points : [];
+    if (!points.length) { throw new Error('bar chart has no points'); }
+    var values = points.map(function (p) { return Number(p && p.value); });
+    if (values.some(function (v) { return !isFinite(v); })) {
+      throw new Error('bar chart has a non-numeric point value');
+    }
+
+    var w = 100, h = 56, padTop = 6, padBottom = 14, gap = 3;
+    var barW = (w - gap * (points.length + 1)) / points.length;
+    var chartH = h - padTop - padBottom;
+    var max = Math.max.apply(null, values.concat([0]));
+
+    var svg = document.createElementNS(SVG_NS, 'svg');
+    svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
+    svg.setAttribute('class', 'admin-chart-svg');
+    svg.setAttribute('role', 'img');
+
+    points.forEach(function (p, i) {
+      var value = values[i];
+      var barH = max > 0 ? (value / max) * chartH : 0;
+      var x = gap + i * (barW + gap);
+      var y = padTop + (chartH - barH);
+
+      var rect = document.createElementNS(SVG_NS, 'rect');
+      rect.setAttribute('class', 'admin-chart-bar');
+      rect.setAttribute('x', x);
+      rect.setAttribute('y', y);
+      rect.setAttribute('width', barW);
+      rect.setAttribute('height', Math.max(barH, 0.5));
+      svg.appendChild(rect);
+
+      var valueLabel = document.createElementNS(SVG_NS, 'text');
+      valueLabel.setAttribute('class', 'admin-chart-bar-value');
+      valueLabel.setAttribute('x', x + barW / 2);
+      valueLabel.setAttribute('y', Math.max(y - 1.5, 5));
+      valueLabel.setAttribute('text-anchor', 'middle');
+      valueLabel.textContent = formatStatValue(value);
+      svg.appendChild(valueLabel);
+
+      var label = document.createElementNS(SVG_NS, 'text');
+      label.setAttribute('class', 'admin-chart-bar-label');
+      label.setAttribute('x', x + barW / 2);
+      label.setAttribute('y', h - 3);
+      label.setAttribute('text-anchor', 'middle');
+      label.textContent = String((p && p.label) || '');
+      svg.appendChild(label);
+    });
+
+    mount.appendChild(svg);
+  }
+
+  function renderSparkline(mount, content) {
+    var values = (Array.isArray(content.points) ? content.points : []).map(Number);
+    if (!values.length || values.some(function (v) { return !isFinite(v); })) {
+      throw new Error('sparkline has no valid numeric points');
+    }
+
+    var w = 100, h = 40, pad = 3;
+    var min = Math.min.apply(null, values);
+    var max = Math.max.apply(null, values);
+    var range = max - min || 1;
+
+    var coords = values.map(function (v, i) {
+      var x = values.length > 1 ? (i / (values.length - 1)) * (w - pad * 2) + pad : w / 2;
+      var y = h - pad - ((v - min) / range) * (h - pad * 2);
+      return [x, y];
+    });
+
+    var linePath = coords.map(function (c, i) {
+      return (i === 0 ? 'M' : 'L') + c[0].toFixed(2) + ',' + c[1].toFixed(2);
+    }).join(' ');
+    var last = coords[coords.length - 1];
+    var first = coords[0];
+    var fillPath = linePath +
+      ' L' + last[0].toFixed(2) + ',' + (h - pad) +
+      ' L' + first[0].toFixed(2) + ',' + (h - pad) + ' Z';
+
+    var svg = document.createElementNS(SVG_NS, 'svg');
+    svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
+    svg.setAttribute('class', 'admin-chart-svg');
+    svg.setAttribute('role', 'img');
+
+    var defs = document.createElementNS(SVG_NS, 'defs');
+    var gradient = document.createElementNS(SVG_NS, 'linearGradient');
+    gradient.setAttribute('id', 'admin-chart-sparkline-gradient');
+    gradient.setAttribute('x1', '0');
+    gradient.setAttribute('x2', '0');
+    gradient.setAttribute('y1', '0');
+    gradient.setAttribute('y2', '1');
+    var stop1 = document.createElementNS(SVG_NS, 'stop');
+    stop1.setAttribute('offset', '0%');
+    stop1.style.stopColor = 'var(--admin-accent)';
+    stop1.style.stopOpacity = '0.9';
+    var stop2 = document.createElementNS(SVG_NS, 'stop');
+    stop2.setAttribute('offset', '100%');
+    stop2.style.stopColor = 'var(--admin-accent)';
+    stop2.style.stopOpacity = '0';
+    gradient.appendChild(stop1);
+    gradient.appendChild(stop2);
+    defs.appendChild(gradient);
+    svg.appendChild(defs);
+
+    var fill = document.createElementNS(SVG_NS, 'path');
+    fill.setAttribute('class', 'admin-chart-sparkline-fill');
+    fill.setAttribute('d', fillPath);
+    svg.appendChild(fill);
+
+    var line = document.createElementNS(SVG_NS, 'path');
+    line.setAttribute('class', 'admin-chart-sparkline-path');
+    line.setAttribute('d', linePath);
+    svg.appendChild(line);
+
+    mount.appendChild(svg);
+  }
+
+  function renderChartWindow(container, win) {
+    var content = win.content || {};
+    var mount = document.createElement('div');
+    mount.className = 'admin-chart';
+
+    if (content.type === 'sparkline') {
+      renderSparkline(mount, content);
+    } else {
+      renderBarChart(mount, content);
+    }
+
+    if (content.unit) {
+      var caption = document.createElement('p');
+      caption.className = 'admin-window-body-caption';
+      caption.textContent = content.unit;
+      mount.appendChild(caption);
+    }
+
+    return global.AdminWindow.open({
+      title: win.title || '',
+      content: mount,
+      container: container,
+      variant: 'chart',
+      x: win.x,
+      y: win.y,
+      w: win.w,
+      h: win.h
+    });
+  }
+
+  function renderWindow(container, win) {
+    if (!win || typeof win !== 'object') {
+      return rawDump(container, win, '// malformed window');
+    }
+    switch (win.kind) {
+      case 'stat':
+        return renderStatWindow(container, win);
+      case 'image':
+      case 'video':
+        return renderMediaWindow(container, win);
+      case 'chart':
+        return renderChartWindow(container, win);
+      default:
+        // No kind (or an unrecognized one, kept here as a soft landing rather than
+        // a hard failure) -> plain markdown-ish content, same as a digest window.
+        return global.AdminWindow.open({
+          title: win.title || '',
+          content: win.content,
+          container: container,
+          x: win.x,
+          y: win.y,
+          w: win.w,
+          h: win.h
+        });
+    }
+  }
+
+  var AdminRender = {
+    render: function (container, input) {
+      container.innerHTML = '';
+      container.classList.add('admin-canvas');
+
+      var parsed = parse(input);
+
+      if (parsed && parsed.__unparseable !== undefined) {
+        rawDump(container, parsed.__unparseable, '// admin canvas JSON did not parse');
+        return;
+      }
+
+      if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.windows)) {
+        rawDump(container, parsed, '// expected {"windows": [...]}, got something else');
+        return;
+      }
+
+      parsed.windows.forEach(function (win) {
+        try {
+          renderWindow(container, win);
+        } catch (err) {
+          if (global.console) { global.console.warn('admin-canvas: window fell back to plain text', err); }
+          rawDump(container, win, '// window failed to render: ' + err);
+        }
+      });
+    },
+
+    // Convenience used by canvas.html: fetch, render, and put the failure on
+    // screen rather than only in the console if the fetch itself fails.
+    load: function (container, url) {
+      return global.fetch(url, { cache: 'no-store' })
+        .then(function (response) {
+          if (!response.ok) { throw new Error(response.status + ' ' + response.statusText); }
+          return response.text();
+        })
+        .then(function (text) {
+          AdminRender.render(container, text);
+        })
+        .catch(function (err) {
+          container.innerHTML = '';
+          container.classList.add('admin-canvas');
+          rawDump(container, String(err), '// could not load ' + url);
+        });
+    }
+  };
+
+  global.AdminRender = AdminRender;
+})(window);
diff --git a/admin-canvas/render/canvas-sdk/window-chrome.js b/admin-canvas/render/canvas-sdk/window-chrome.js
new file mode 100644
index 0000000..8412f24
--- /dev/null
+++ b/admin-canvas/render/canvas-sdk/window-chrome.js
@@ -0,0 +1,121 @@
+/*
+ * AdminWindow — floating panel chrome for the admin canvas.
+ *
+ * A near-verbatim copy of digest-engine/render/digest-canvas-sdk/window-chrome.js
+ * (docs/project-plan.md Phase 12), duplicated rather than shared per Phase 13's
+ * decision to keep the two canvases fully decoupled. Only the `digest-` class/name
+ * prefix has been renamed to `admin-` throughout; the behaviour is identical.
+ *
+ * Windows look like windows (title bar, rounded corners, shadow, glow accent) but
+ * are not actually draggable. The canvas is read on a wall-mounted kiosk; there is
+ * no pointer doing window management, and real drag-and-drop would only add state
+ * that nothing persists.
+ *
+ * x/y/w/h are optional. Given, the window is absolutely positioned (percentages of
+ * the canvas) — that is the escape hatch for a deliberately composed layout.
+ * Omitted, the window participates in the canvas's normal flow/grid layout, which
+ * is what render.js does by default so the layout survives any window count.
+ */
+
+(function (global) {
+  'use strict';
+
+  var counter = 0;
+
+  function escapeHtml(text) {
+    return String(text)
+      .replace(/&/g, '&')
+      .replace(//g, '>');
+  }
+
+  // Intentionally tiny: bold, italic, inline code and paragraph breaks — pulling in
+  // a markdown parser for three inline forms would violate this project's
+  // no-unnecessary-deps rule.
+  function renderInline(text) {
+    return escapeHtml(text)
+      .replace(/`([^`]+)`/g, '$1')
+      .replace(/\*\*([^*]+)\*\*/g, '$1')
+      .replace(/(^|[\s(])\*([^*]+)\*/g, '$1$2');
+  }
+
+  function renderContent(content) {
+    var body = document.createElement('div');
+    body.className = 'admin-window-body';
+
+    if (Array.isArray(content)) {
+      var list = document.createElement('ul');
+      list.className = 'admin-window-list';
+      content.forEach(function (item) {
+        var li = document.createElement('li');
+        li.innerHTML = renderInline(item);
+        list.appendChild(li);
+      });
+      body.appendChild(list);
+      return body;
+    }
+
+    // Duck-typed rather than `instanceof Node`: render.js hands stat/chart windows
+    // their mount point through here, and an identity check against a global that
+    // may not exist in every embedding context is a needless way to lose it.
+    if (content && typeof content.nodeType === 'number') {
+      body.appendChild(content);
+      return body;
+    }
+
+    String(content === undefined || content === null ? '' : content)
+      .split(/\n{2,}/)
+      .forEach(function (block) {
+        if (!block.trim()) { return; }
+        var p = document.createElement('p');
+        p.innerHTML = renderInline(block).replace(/\n/g, '
'); + body.appendChild(p); + }); + + return body; + } + + var AdminWindow = { + open: function (options) { + options = options || {}; + + var container = options.container || document.body; + + var el = document.createElement('section'); + el.className = 'admin-window'; + el.id = options.id || ('admin-window-' + (++counter)); + if (options.variant) { + el.classList.add('admin-window-' + options.variant); + } + + var bar = document.createElement('header'); + bar.className = 'admin-window-titlebar'; + + var dots = document.createElement('span'); + dots.className = 'admin-window-dots'; + dots.setAttribute('aria-hidden', 'true'); + bar.appendChild(dots); + + var title = document.createElement('h2'); + title.className = 'admin-window-title'; + title.textContent = options.title || ''; + bar.appendChild(title); + + el.appendChild(bar); + el.appendChild(renderContent(options.content)); + + if (typeof options.x === 'number' && typeof options.y === 'number') { + el.classList.add('admin-window-positioned'); + el.style.left = options.x + '%'; + el.style.top = options.y + '%'; + } + if (typeof options.w === 'number') { el.style.width = options.w + '%'; } + if (typeof options.h === 'number') { el.style.height = options.h + '%'; } + + container.appendChild(el); + return el; + } + }; + + global.AdminWindow = AdminWindow; +})(window); diff --git a/admin-canvas/render/templates/canvas.html b/admin-canvas/render/templates/canvas.html new file mode 100644 index 0000000..9b4b769 --- /dev/null +++ b/admin-canvas/render/templates/canvas.html @@ -0,0 +1,61 @@ + + + + + +Admin canvas + + + + +
+ + + + + + diff --git a/admin-canvas/server.py b/admin-canvas/server.py new file mode 100644 index 0000000..31c7b5d --- /dev/null +++ b/admin-canvas/server.py @@ -0,0 +1,224 @@ +"""admin-canvas — the write side of the sys-admin-llm's display surface. + +Companion to digest-engine/digest-web (docs/project-plan.md Phase 12), but for ad hoc +content instead of a scheduled synthesis run: statistics, power-consumption readings, +charts, snapshots — anything Home Assistant's tool-calling LLM wants to put on a thin +client's admin canvas (Phase 13), on demand rather than 4x/day. + +SECURITY BOUNDARY. This is a real network listener — unlike digest-engine, which is a +oneshot batch job with no listening socket at all. It must stay reachable only from +the container host's internal Docker network (HA and nothing else — no `ports:` entry +in the compose block that starts this service), and every request additionally +requires the bearer token below, as defense in depth in case that network boundary is +ever loosened by mistake. This module knows nothing about Home Assistant, tool calls, +or the LLM that ultimately triggers a write — it only ever validates and stores what +it is given. Reads are somebody else's job entirely: admin-web (nginx, read-only, +LAN-published) serves output/, exactly mirroring how digest-web serves digest-engine's +output/ — this process never serves a GET. + +Two endpoints, both POST, both token-gated: + - /show JSON {"windows": [...]}, overwrites output/latest.json + - /media/ raw bytes, written to output/media/ + +See admin-canvas/README.md for the window schema (stat/image/video/chart kinds) and a +worked example of the intended HA-side `rest_command` -> here call. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import sys +import threading +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlsplit + +LOG = logging.getLogger("admin-canvas") + +OUTPUT_DIR = Path(os.environ.get("ADMIN_CANVAS_OUTPUT_DIR", "/output")) +MEDIA_DIR = OUTPUT_DIR / "media" + +# Fails closed: an unset token means every request is rejected, never "auth is +# optional". See admin-canvas.env.example. +TOKEN = os.environ.get("ADMIN_CANVAS_TOKEN", "") + +MAX_SHOW_BYTES = int(os.environ.get("ADMIN_CANVAS_MAX_SHOW_KB", "256")) * 1024 +MAX_MEDIA_BYTES = int(os.environ.get("ADMIN_CANVAS_MAX_MEDIA_MB", "25")) * 1024 * 1024 + +# `kind` may also be omitted entirely for plain markdown-ish content — handled +# client-side by DigestWindow.open exactly as a digest window is today. +KNOWN_KINDS = {"stat", "image", "video", "chart"} + +# No leading "/", no "..", no scheme — a relative path under the media/ directory +# this service itself writes to, and nothing else. Mirrors the "a payload never +# becomes a URL host" invariant thinclient_agent/mqtt_discovery.py documents for the +# thin client's own control surface; the trust boundary just lives here instead, +# since this is the component that now decides what a browser is told to fetch. +SRC_RE = re.compile(r"^media/[A-Za-z0-9_.-]+$") + +MEDIA_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") +MEDIA_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".webm"} + +_write_lock = threading.Lock() + + +def validate_show_payload(payload) -> str | None: + """Returns an error string, or None if payload is acceptable.""" + if not isinstance(payload, dict): + return "body must be a JSON object" + windows = payload.get("windows") + if not isinstance(windows, list): + return "'windows' must be a list" + for i, win in enumerate(windows): + if not isinstance(win, dict): + return f"windows[{i}] must be an object" + kind = win.get("kind") + if kind is not None and kind not in KNOWN_KINDS: + return ( + f"windows[{i}].kind {kind!r} is not one of {sorted(KNOWN_KINDS)} " + "(omit 'kind' entirely for plain text/markdown-ish content)" + ) + if kind in ("image", "video"): + content = win.get("content") + src = content.get("src") if isinstance(content, dict) else None + if not isinstance(src, str) or not SRC_RE.match(src): + return ( + f"windows[{i}].content.src must be a 'media/' path " + "already uploaded via POST /media/" + ) + return None + + +class Handler(BaseHTTPRequestHandler): + server_version = "admin-canvas/1" + + def log_message(self, format, *args): # noqa: A002 - must match BaseHTTPRequestHandler's signature + LOG.info("%s - %s", self.address_string(), format % args) + + def _authorized(self) -> bool: + if not TOKEN: + return False + return self.headers.get("Authorization", "") == f"Bearer {TOKEN}" + + def _respond(self, status: HTTPStatus, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body(self, max_bytes: int) -> bytes: + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + raise ValueError("missing or invalid Content-Length") from None + if length <= 0: + return b"" + if length > max_bytes: + raise ValueError(f"body too large ({length} > {max_bytes} bytes)") + return self.rfile.read(length) + + def do_GET(self): # noqa: N802 - stdlib method name + self._respond( + HTTPStatus.NOT_FOUND, + {"error": "admin-canvas only accepts writes; reads are served by admin-web"}, + ) + + def do_POST(self): # noqa: N802 - stdlib method name + if not self._authorized(): + self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"}) + return + + path = urlsplit(self.path).path + if path == "/show": + self._handle_show() + elif path.startswith("/media/"): + self._handle_media(path[len("/media/"):]) + else: + self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) + + def _handle_show(self) -> None: + try: + raw = self._read_body(MAX_SHOW_BYTES) + except ValueError as exc: + self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)}) + return + + try: + payload = json.loads(raw or b"{}") + except ValueError: + self._respond(HTTPStatus.BAD_REQUEST, {"error": "body is not valid JSON"}) + return + + error = validate_show_payload(payload) + if error: + self._respond(HTTPStatus.BAD_REQUEST, {"error": error}) + return + + payload["generated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + with _write_lock: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + tmp = OUTPUT_DIR / "latest.json.tmp" + tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(OUTPUT_DIR / "latest.json") + + window_count = len(payload.get("windows", [])) + LOG.info("wrote %d window(s) to output/latest.json", window_count) + self._respond(HTTPStatus.OK, {"ok": True, "windows": window_count}) + + def _handle_media(self, name: str) -> None: + if not MEDIA_NAME_RE.match(name) or Path(name).suffix.lower() not in MEDIA_EXTENSIONS: + self._respond( + HTTPStatus.BAD_REQUEST, + {"error": "invalid media filename — see admin-canvas/README.md for the allowed pattern"}, + ) + return + + try: + data = self._read_body(MAX_MEDIA_BYTES) + except ValueError as exc: + self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)}) + return + if not data: + self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty body"}) + return + + with _write_lock: + MEDIA_DIR.mkdir(parents=True, exist_ok=True) + (MEDIA_DIR / name).write_bytes(data) + + LOG.info("wrote media/%s (%d bytes)", name, len(data)) + self._respond(HTTPStatus.OK, {"ok": True, "path": f"media/{name}"}) + + +def main() -> int: + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + ) + + if not TOKEN: + LOG.error( + "ADMIN_CANVAS_TOKEN is not set — every request will be rejected until it is. " + "See admin-canvas.env.example." + ) + + port = int(os.environ.get("ADMIN_CANVAS_PORT", "8092")) + server = ThreadingHTTPServer(("0.0.0.0", port), Handler) + LOG.info("admin-canvas listening on :%d (output dir: %s)", port, OUTPUT_DIR) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/project-plan.md b/docs/project-plan.md index 8ed4689..95179b0 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -83,7 +83,12 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato | Keyboard + mouse or remote | €20–40 | Local fallback input — primary control is HA/MQTT + wayvnc, not this | | USB mic + speaker (mic-enabled rooms only) | €25–50/room | Only for the specific rooms chosen for voice interactivity — see §3 Phase 11 open decisions | -*(No new hardware for Phase 12 — `digest-engine`/`digest-web` run as containers on the existing container-host from Phase 1.)* +*(No new hardware for Phase 12 or Phase 13 — `digest-engine`/`digest-web` and `admin-canvas`/`admin-web` all run as containers on the existing container-host from Phase 1.)* + +### 1.12 ESP32-S3 voice/display satellite hardware (Phase 14) +| Item | Est. Price (EUR) | Notes | +|---|---|---| +| Waveshare ESP32-S3-Touch-LCD-1.85C, **V2 revision** | €35–45 | Round 360×360 LCD + dual mic (ES7210, with echo-cancellation reference path) + speaker (ES8311) + 8Ω 2W speaker. **Must be V2** — V1 has no AEC circuit and different audio pins, see `firmware/esp32-s3-touch-lcd-1.85c/README.md`. An alternative to Home Assistant Voice PE for rooms that also want a status display, not a wholesale replacement of §1.11's mic-enabled-room hardware | --- @@ -135,6 +140,10 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato | Digest ingestion — news | **feedparser** | Curated OPML feed list, including `https://www.marxist.com/feed/rss` | | Digest ingestion — financial | **FRED API** + **Stooq** | Macro/unemployment indicators + stock/oil/commodity data | | Digest rendering | **digest-canvas SDK** (custom, vendored) | Offline globe (`addMarker(lat, lon, {icon, color, glow})`), window/panel chrome, and glow/holo CSS primitives the LLM composes against each run | +| Admin canvas write API | **admin-canvas** (custom Python, stdlib `http.server`) | Small always-on service, `POST /show` + `POST /media/`, bearer-token gated, no published port — reachable only from Home Assistant on the compose network. The sys-admin-llm's on-demand counterpart to the scheduled digest-engine | +| Admin canvas static serving | **admin-web** (nginx:alpine) | Serves the admin canvas's rendered JSON + uploaded media read-only to the thin client — same role as digest-web, separate instance | +| Admin canvas rendering | **canvas-sdk** (custom, vendored, duplicated from digest-canvas SDK) | Same window/panel chrome and glow theme, minus the globe, plus `stat`/`image`/`video`/`chart` window kinds (the `chart` kind is a dependency-free inline-SVG bar/sparkline) | +| Voice/display satellite firmware | **ESPHome** (custom config, `firmware/esp32-s3-touch-lcd-1.85c/`) | Display (LVGL), on-device wake word (`micro_wake_word`, `okay_nabu`), `voice_assistant` streaming into the existing Phase 3 Assist pipeline, and a media/cover-art-over-idle-weather-time-date priority display | --- @@ -240,6 +249,29 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato 7. **Live follow-up voice Q&A**: persist each run's actually-used ingested-context bundle (not full raw content) as `digest-engine/output//context.json`. Expose a small HA tool (`digest_followup_query`) so a spoken follow-up ("tell me more about the unemployment numbers") feeds the cached context + question back into Ollama for a grounded, low-latency answer — no fresh ingestion pass. The answer can push a new small window/card onto the already-open thin-client canvas via a websocket, keeping the "flexible windows" idea alive live, not just at generation time. 8. **Security/scope principle**: `digest-engine` is the first component in this project with routine WAN egress (mail, message platforms, news, financial APIs). Run it as its own compose service, no inbound port exposure beyond `digest-web`'s read-only LAN serving; credentials in a git-ignored `.env`. **Everything here is read-only summarization — it must never perform a write action anywhere** (no auto-reply, no mail archive/delete beyond what IMAP fetch requires, no CalDAV/Grocy writes, no message-platform writes), extending the Phase 6/8 "no silent mutation" precedent to its logical extreme: no mutation path exists at all. +### Phase 13 — On-demand admin canvas (sys-admin-llm display surface) + +No new hardware — this reuses the Phase 11 thin client and Phase 12's container host. + +1. New top-level `admin-canvas/` directory (`admin-canvas/README.md`): a small stdlib-only Python HTTP service (`server.py`), the write-side counterpart to `digest-engine` — except long-running (`restart: unless-stopped`) rather than a oneshot, since content here arrives whenever Home Assistant's tool-calling LLM (the household's "sys-admin-llm", in its admin/ops-facing role — distinct from digest-engine's own synthesis LLM) decides to push something, not on a schedule. Two bearer-token-gated endpoints: `POST /show` (JSON `{"windows": [...]}`, overwrites `output/latest.json`, no run history — this is "what's on screen right now", not a scheduled artifact) and `POST /media/` (raw image/video bytes, filename allowlist-validated by both pattern and extension before being written under `output/media/`). +2. **No published port on `admin-canvas` itself.** It is reachable only from other containers on the container host's compose network — i.e. Home Assistant — the same trust boundary `mosquitto`/`homeassistant` already share. A companion `admin-web` (nginx:alpine, LAN-published, read-only, wired into `setup-container-host.sh` behind `ENABLE_ADMIN_CANVAS`) serves the shared `output/` volume plus the vendored SDK, exactly mirroring `digest-web`. +3. Rendering: `admin-canvas/render/canvas-sdk/` is a **duplicated**, not shared, copy of the digest-canvas SDK's window chrome and glow theme (renamed `digest-` → `admin-` throughout) — a deliberate choice to keep the two canvases fully decoupled rather than extracting a shared library out of a working, already-documented Phase 12 component. The globe kind is dropped (nothing here is a lat/lon marker); four kinds are added instead: `stat` (a big number/label/unit/trend), `image` and `video` (same "no scheme, no leading `/`, no `..`" src validation as the thin-client's own MQTT-payload invariant below, just relocated to this write API's trust boundary), and `chart` (dependency-free inline SVG — bar or sparkline, no charting library, consistent with this project's existing no-unnecessary-deps calls). Same degrade-instead-of-throw philosophy as the digest's renderer: a malformed window falls back to a `
` dump, never a blank page.
+4. Thin client: a fourth Sway workspace `4:admin` (`hosts/thin-client/configs/sway/config`), a new `thinclient_agent/admin_canvas.py` module (sibling of `digest_canvas.py`, but simpler — it never inspects its own MQTT payload at all, since there is nothing content-specific for it to decide), and a single new HA button entity **"Show admin canvas"** (`mqtt_discovery.py`'s `register_admin_canvas`). Unlike the digest workspace, `4:admin` is **not** auto-launched at session start — this surface is on-demand by nature, so it starts empty until the first "Show admin canvas" command. Its own Firefox profile and launcher script (`admin-browser`), on the same kill-and-relaunch approach as `digest-browser`, with both scripts' pkill/pgrep patterns scoped to their own `--profile` path specifically so the two can never kill or race against each other.
+5. **Security principle, unchanged from Phase 11.4**: this does not add a network path to the thin client. "Show admin canvas" only ever switches workspace and opens a fixed, locally-configured URL (`ADMIN_WEB_URL/canvas.html`) — identical in shape to "Show digest canvas". All actual *content* takes a completely separate path that never touches the thin client's MQTT surface: sys-admin-llm → HA tool call → HA `rest_command` → `admin-canvas`'s write API → `admin-web` → the browser's own poll (every 15s, shorter than the digest's 5 minutes since this is meant to feel closer to live).
+6. **Nothing under this repo builds the HA side** — same convention as every other HA integration point in this project (Node-RED flows, CalDAV/Grocy wiring, the Lovelace card mentioned in `hosts/thin-client/README.md`). `admin-canvas/README.md` documents the expected `rest_command:` shape and worked example JSON for each window kind, but the actual HA config, the specific tool/intent definition, and which entities/history it reads to answer something like "show me the kitchen outlet's power draw" are the household's own to build.
+
+### Phase 14 — ESP32-S3 voice/display satellite
+
+New hardware: §1.12, **V2 revision specifically** — V1 has no echo-cancellation circuit and different audio pins, and this phase's firmware will not work on it.
+
+1. New top-level `firmware/esp32-s3-touch-lcd-1.85c/` directory — the first ESPHome firmware this repo actually ships (`firmware/ruview/` and `firmware/esphome-ble-proxy/` remain unbuilt placeholders from Phase 2). `voice-display.yaml`'s display/touch/audio hardware bring-up (the ST77916 QSPI init sequence, CST816 touch, PCA9554 reset-pin wiring, I2S mic/speaker pins) is adapted from a community-verified config for this exact board rather than re-derived, since a wrong register sequence just shows static; see the file's own header comment and `firmware/esp32-s3-touch-lcd-1.85c/README.md` for the source and the cross-checked V2-specific quirks (EXIO2 reset, 80MHz data rate).
+2. **Wake word stays on-device**, consistent with Phase 11.8's principle: ESPHome's `micro_wake_word` component (`okay_nabu` model — the same phrase as the thin client's `VOICE_WAKE_WORD`, so the household has one wake phrase regardless of which kind of satellite answers) runs TensorFlow Lite wake-word detection on the ESP32-S3 itself and explicitly starts a `voice_assistant` session on detection, rather than streaming continuously to Home Assistant for server-side spotting. Everything after the wake word streams into the **existing** Phase 3 Wyoming faster-whisper/Piper Assist pipeline — no new STT/TTS infrastructure, same as Phase 11.8's `wyoming-satellite` rooms.
+3. **Screen priority, in order**: (1) a media/cover-art page, shown the instant the configured `media_player` entity's state becomes `playing` — cover art fetched via ESPHome's `online_image` platform (`entity_picture` resolved against a configured `ha_base_url`, since the device fetches it directly over HTTP rather than through the HA connection); (2) failing that, an idle page cycling every 8 seconds between a clock and the weather; (3) a voice-state visualizer (a colour-coded ring, listening/thinking/replying/error) as an LVGL `top_layer` overlay, shown regardless of which of the above is underneath — "a visualizer when speaking" is an overlay concern, not a fourth competing page.
+4. **Both HA entities this device mirrors are placeholders** (`media_player_entity_id`, `weather_entity_id` in `secrets.yaml`, see `secrets.yaml.example`) — no real data source is picked yet, same "don't build against a guess" rule as Phase 12's calendar/Grocy sourcing and Phase 13's power-monitoring entity.
+5. **This is a per-room device — media status must be that specific room's, never any other room's.** A `room` substitution (used in the device's hostname/AP name/friendly name) and a per-unit `secrets.yaml` (never shared across units) are both required, mirroring the thin client's per-image `THINCLIENT_NAME` convention. For rooms with more than one real audio source (thin client + a Spotify Connect speaker + a cast device, say), `media_player_entity_id` should point at a Home Assistant [Universal Media Player](https://www.home-assistant.io/integrations/universal/) that aggregates that room's real entities, rather than any single hardcoded device — see `firmware/esp32-s3-touch-lcd-1.85c/README.md`'s "Multiple rooms" section. This firmware has no way to detect a misconfigured `media_player_entity_id` pointed at the wrong room; it is a configuration invariant, not something the code can verify at runtime.
+6. **Nothing under this repo builds the HA side** here either — the device is a standard ESPHome device once flashed and adopted (Settings → Devices → Add Device → ESPHome), and plugs into whichever Assist pipeline Phase 3 already has configured. No new add-on, no new container.
+7. Validated so far with `esphome config voice-display.yaml` (ESPHome's own schema validator — passes cleanly), **not flashed to real hardware**. See the itemized unverified list in `firmware/esp32-s3-touch-lcd-1.85c/README.md`.
+
 ### Testing checklist before calling any phase "done"
 - Does the reactive path (presence → light on) work with the LLM host powered off? (It must.)
 - Does a bad/slow LLM response ever block a light switch? (It must not.)
@@ -259,10 +291,20 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato
 - Does the counter run actually drop a fabricated quote/figure/theoretical connection, rather than waving it through? (It must drop it.)
 - Does the counter run ever flag a correctly-grounded piece of Marxist analysis as "unverifiable" for being theoretical rather than a bare fact? (It must not — see synth/prompts/counter_run.md.)
 - If the counter run itself fails to reach the LLM host, is the original document kept and marked unverified, rather than either passed through silently or blanked? (It must be marked, not silently either extreme.)
+- Does `admin-canvas` ever accept a request without a valid bearer token? (It must not — an unset `ADMIN_CANVAS_TOKEN` must fail closed, reject everything, not "auth optional".)
+- Is `admin-canvas`'s write port ever published to the LAN in the generated compose file? (It must not be — reachable only from other containers on the compose network.)
+- Does `admin-canvas` ever accept an `image`/`video` window whose `src` isn't a bare `media/` path (no scheme, no leading `/`, no `..`)? (It must not — same invariant as `mqtt_discovery.py`'s "a payload never becomes a URL host", enforced server-side and again client-side.)
+- Can the sys-admin-llm reach the thin client through any path other than "Show admin canvas" → MQTT → `thinclient-agent`, with all actual *content* arriving via the separate `admin-canvas` write API instead? (It must not — same boundary as the digest canvas.)
+- Does opening the admin canvas ever kill the digest canvas's kiosk Firefox window, or vice versa? (It must not — both `digest-browser` and `admin-browser`'s pkill/pgrep patterns are scoped to their own `--profile` path.)
+- If `admin-canvas`'s `output/latest.json` holds a malformed or unrecognized window, does the admin canvas fall back to plain text instead of a broken/blank page? (Same rule as the digest's canvas-SDK renderer.)
+- Does the voice/display satellite's wake-word spotting ever stream continuously to Home Assistant instead of triggering locally? (It must not — on-device `micro_wake_word`, same Phase 11.8 principle as the thin client's `wyoming-satellite` rooms.)
+- Does the idle weather/time/date cycle ever show *over* an active media page, or does media ever fail to take priority the instant playback starts? (It must not — media priority is the one hard behavioral requirement of Phase 14.)
+- Does the voice-state visualizer ever replace or hide the underlying page's content instead of overlaying it? (It must not — it's a `top_layer` overlay by design, never a page swap.)
+- Is the ESP32-S3-Touch-LCD-1.85C firmware ever flashed onto a V1 board? (It must not be — V1 has no AEC circuit and different audio pins; this phase's config assumes V2 throughout.)
 
 ---
 
-## 4. Open decisions (Phases 11–12)
+## 4. Open decisions (Phases 11–14)
 
 These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item.
 
@@ -278,3 +320,5 @@ These need a decision before their respective implementation steps can be built
 10. **HA has no core MQTT `media_player` platform** (new, found during Phase 11 implementation) — `thinclient-agent` publishes the `media_player` discovery payload as specified, but stock Home Assistant ignores it without the HACS "MQTT Media Player" custom integration installed. `button`/`sensor`/`number` entities are also published as a fallback that works on a plain HA install; decide whether to install the HACS integration or keep relying on the fallback entities.
 11. **Several package-availability items still need verification on real hardware** before first boot, all flagged in-code rather than guessed: the Steam Link Flatpak app ID (`com.valvesoftware.SteamLink`), spotifyd/librespot packaging on Debian bookworm (not in main — three fallback install routes documented, none wired to a hardcoded download URL), and `mpv-mpris` packaging.
 12. **wayvnc ships with no password** — `start-wayvnc` fails closed on a sentinel value (`CHANGEME-SET-ON-FIRST-BOOT`) rather than serving unauthenticated VNC; a real password must be generated on the booted machine before wayvnc will start (see `hosts/thin-client/README.md`).
+13. **The sys-admin-llm's HA-side wiring has no real data source or tool definition yet** (new, Phase 13) — same shape as open decision #9 above: `admin-canvas/README.md` documents the expected `rest_command:`/tool-call contract and worked example JSON, but which HA entities/history back something like "the kitchen outlet's power draw" is unresolved, and no metering-capable Zigbee smart plug is in this plan's hardware list (§1.4) yet. Needs a hardware decision (a power-monitoring outlet) and an actual HA tool/script, neither of which exists in this repo by design — see the Phase 13 "nothing under this repo builds the HA side" note.
+14. **The voice/display satellite has no real hardware verification, and two of its data sources are unpicked** (new, Phase 14) — `firmware/esp32-s3-touch-lcd-1.85c/voice-display.yaml` passes ESPHome's own config validator but has never been flashed to a physical unit; the display init sequence, the AEC audio path, and wake-word sensitivity are all adapted/assumed, not measured (see the itemized list in that directory's README). Separately, `media_player_entity_id` and `weather_entity_id` are placeholders — which media player this unit should mirror needs a decision if the household ends up with more than one active at a time.
diff --git a/firmware/esp32-s3-touch-lcd-1.85c/README.md b/firmware/esp32-s3-touch-lcd-1.85c/README.md
new file mode 100644
index 0000000..ed9aa68
--- /dev/null
+++ b/firmware/esp32-s3-touch-lcd-1.85c/README.md
@@ -0,0 +1,177 @@
+# ESP32-S3-Touch-LCD-1.85C-V2 — voice + status display
+
+Phase 14 of [`docs/project-plan.md`](../../docs/project-plan.md). A DIY voice
+satellite + round-screen status display built on the Waveshare
+ESP32-S3-Touch-LCD-1.85C-V2, evaluated in this project as a possible alternative to
+the Home Assistant Voice PE.
+
+**Media status always takes priority on screen, and it's always THIS room's
+media status.** Whenever the configured `media_player` entity is `playing`, the
+display shows its cover art, title and artist. Otherwise it idles, cycling every 8
+seconds between a clock (time + date) and the weather. A ring visualizer overlays
+whichever of those is showing whenever the assistant is listening, thinking, or
+replying — it never replaces the page underneath, it just sits on top of it. See
+[Multiple rooms](#multiple-rooms) below for what actually guarantees the "this
+room's" part, since ESPHome has no built-in concept of "the media player in my
+room" — that has to be set up deliberately, per unit.
+
+## ⚠️ This needs the V2 hardware revision specifically
+
+Waveshare shipped two hardware revisions of this board. **V1 has no echo-
+cancellation circuit and different audio pins** — this config's I2S pin numbers,
+the ES7210/ES8311 audio path, and the display's reset wiring are all V2-specific
+and will not work on a V1 unit. Confirm which you have before flashing (see
+`docs/project-plan.md`'s Phase 14 hardware note for how): PCB silkscreen showing
+"Rev2.0", factory firmware reporting "Rev2.0", or a "V2" QC sticker on the case.
+
+## Before you flash
+
+1. Install ESPHome (`pip install esphome`, or use the Home Assistant ESPHome
+   add-on / dashboard instead — either works, this repo doesn't assume which).
+2. Copy the secrets template and fill it in:
+   ```sh
+   cp secrets.yaml.example secrets.yaml
+   ```
+   `secrets.yaml` is git-ignored (repo `.gitignore` covers it) — never commit a
+   real one. Two of its values are **not filled in by anyone yet, on purpose**:
+   - `media_player_entity_id` — which Home Assistant `media_player.*` entity this
+     unit mirrors. **This must be the entity that reflects THIS unit's own
+     room** — see [Multiple rooms](#multiple-rooms) below, it's not just a
+     "pick any one" placeholder.
+   - `weather_entity_id` — whichever `weather.*` entity your Home Assistant
+     already has.
+   Both entities need to be exposed to the Home Assistant API (the default for
+   most entities; check Settings → Voice assistants → Expose if either doesn't
+   show up).
+3. `api_encryption_key` — generate one:
+   ```sh
+   python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"
+   ```
+4. This device plugs into the **existing** Phase 3 Assist pipeline (Wyoming
+   faster-whisper + Piper) — there's nothing new to stand up on the Home
+   Assistant side beyond adding the device itself (Settings → Devices → Add
+   Device → ESPHome, after the first flash below) and picking an Assist
+   pipeline for it, same as any other ESPHome voice satellite.
+
+## Flashing
+
+```sh
+esphome run voice-display.yaml
+```
+
+First flash needs USB-C (OTA needs the device already on Wi-Fi, which needs the
+first flash). Subsequent updates can go over OTA (`esphome run` auto-detects a
+device already on the network).
+
+## Multiple rooms
+
+This is a per-room device, exactly like the thin client's per-image
+`THINCLIENT_NAME`/`DIGEST_WEB_URL` (`hosts/thin-client/scripts/build-thin-client-iso.sh`).
+Two separate things both have to be set correctly, per physical unit, for "media
+status always on the specific room the device is in" to actually hold:
+
+1. **`room` in `voice-display.yaml`'s `substitutions:`** — sets the device's own
+   hostname/AP name/friendly name (`voice-display-`), so it's identifiable
+   in Home Assistant's device list and on the Wi-Fi fallback AP. This is purely
+   identity — changing it doesn't change what the screen shows.
+2. **`media_player_entity_id` in `secrets.yaml`** — this is the one that actually
+   controls what's on screen, and it's the one you can genuinely get wrong. Build
+   a **separate `secrets.yaml` per unit** (or a separate entry per device if
+   you're using the ESPHome dashboard), never one shared file — flashing two
+   units from the same secrets means both show the same room's media.
+
+   - **If a room only ever has one real audio source** (e.g. just the thin
+     client's own `media_player`, see
+     `hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py`'s
+     `register_media_player`), point straight at that entity's ID and you're
+     done.
+   - **If a room can have more than one active source** (the thin client *and* a
+     Spotify Connect speaker *and* a cast device, say), a single hardcoded
+     `entity_id` can't stay correct on its own — nothing tells this device
+     "prefer whichever one is actually making sound." Build a Home Assistant
+     [Universal Media Player](https://www.home-assistant.io/integrations/universal/)
+     for that room that aggregates all of its real `media_player` entities, and
+     point `media_player_entity_id` at the universal one instead of any single
+     real device:
+     ```yaml
+     media_player:
+       - platform: universal
+         name: "Living room media"
+         children:
+           - media_player.living_room_thinclient
+           - media_player.living_room_spotify_connect
+     ```
+     Default child-selection behaviour is "whichever child isn't idle/off" —
+     see the integration's own docs (`active_child_template`) if a room needs
+     more deliberate priority between its sources. That Home Assistant config
+     is not built by this repo, same "nothing here builds the HA side"
+     convention as everywhere else.
+
+Nothing in this firmware can detect or correct a wrongly-configured
+`media_player_entity_id` — an entity from the wrong room is a config mistake this
+device has no way to notice, not a bug it degrades gracefully from. Double-check
+it against Developer Tools → States in Home Assistant before trusting the display.
+
+## Mounting upside down
+
+Two places to change, both commented `# FLIP DISPLAY`-style in
+`voice-display.yaml`: `display.rotation: 180°` and `touchscreen.transform.mirror_x`
+/ `mirror_y: true`. Both must change together or the touch coordinates and the
+image will disagree.
+
+## Nothing here builds the Home Assistant side
+
+Same convention as the rest of this repo (`admin-canvas/README.md`,
+`hosts/thin-client/README.md`'s own note): this device shows up in Home Assistant
+automatically once flashed and adopted, like any ESPHome device — there's no HA
+YAML to write for it. It **is** a new consumer of the existing Phase 3 Assist
+pipeline, so if that pipeline has never been used by a device before, follow the
+Phase 3 setup in `docs/project-plan.md` first.
+
+## Manual verification still outstanding
+
+None of this has been run on real hardware — only validated with `esphome config
+voice-display.yaml` (ESPHome's own schema/config validator), which the file
+currently passes cleanly. In rough order of what to check first on a real unit:
+
+1. **Display bring-up.** The full register init sequence is adapted from a
+   community config for this exact board
+   ([ulsmith/home-assistant-esphome-esp32-s3-touch-lcd-185c](https://github.com/ulsmith/home-assistant-esphome-esp32-s3-touch-lcd-185c))
+   and cross-checked against Waveshare's own V2-specific notes (EXIO2 reset,
+   80MHz, `invert_colors`), but has not been flashed here. If it boots to static
+   or a blank screen, that sequence is the first place to look.
+2. `GPIO46`/`GPIO45` strapping-pin warnings from `esphome config` are **expected**
+   — they're the QSPI display's own data pins, not a mistake in this config; the
+   community reference has the identical warnings.
+3. **Audio / AEC path.** The mic (ES7210) and speaker (ES8311) I2S pins are
+   adapted from the same reference; whether the board's echo-cancellation
+   reference-signal routing actually suppresses the speaker bleeding into the mic
+   during TTS playback hasn't been tested.
+4. **`micro_wake_word`'s "okay_nabu" model.** Wake sensitivity/false-positive rate
+   is whatever ESPHome's shipped model gives you — untuned, unlistened-to.
+5. **Cover-art fetch.** `entity_picture` is assumed to be a path relative to Home
+   Assistant (`ha_base_url` gets prepended unless it already starts with
+   `http`) — true for HA's own local media sources, but some integrations
+   (Spotify's own card, certain streaming integrations) may return an already-
+   absolute URL, or one on a different host entirely. If cover art never
+   appears, check what `entity_picture` actually contains for your specific
+   `media_player` entity (Developer Tools → States in HA) before assuming the
+   display is broken.
+6. Optional: drop a `no_art.png` into Home Assistant's `config/www/` (served at
+   `/local/no_art.png`) for a clean idle image instead of a broken-image glyph
+   before the first track ever plays. Not required — the device works without
+   it, just shows nothing there until either that file exists or a real track
+   with artwork plays.
+7. **Idle-cycle and priority timing.** The 8-second weather/clock cycle interval
+   and the "switch to media_page the instant state becomes playing" transition
+   are both unverified against how promptly Home Assistant's own `media_player`
+   state actually updates in practice (some integrations lag a second or two).
+8. Settings-page mute toggle and the physical restart button are both simple,
+   low-risk carryover from the reference config, not specifically requested —
+   remove them if you'd rather have a leaner build.
+9. **Multi-unit room correctness.** Only ever built/tested against a single
+   `secrets.yaml`. If you deploy more than one of these, double-check each
+   unit's `media_player_entity_id` against Developer Tools → States before
+   trusting it — see [Multiple rooms](#multiple-rooms). This firmware has no
+   way to detect a copy-pasted `secrets.yaml` pointing two units at the same
+   entity.
diff --git a/firmware/esp32-s3-touch-lcd-1.85c/secrets.yaml.example b/firmware/esp32-s3-touch-lcd-1.85c/secrets.yaml.example
new file mode 100644
index 0000000..2def2b4
--- /dev/null
+++ b/firmware/esp32-s3-touch-lcd-1.85c/secrets.yaml.example
@@ -0,0 +1,77 @@
+# voice-display.yaml configuration template.
+#
+# Copy this to firmware/esp32-s3-touch-lcd-1.85c/secrets.yaml (same directory as
+# voice-display.yaml — that's where ESPHome's !secret lookup expects it) and fill
+# in real values. Same never-commit handling as digest-engine.env / admin-canvas.env
+# elsewhere in this repo: the real secrets.yaml is git-ignored, only this .example
+# is tracked.
+#
+# ONE SECRETS.YAML PER PHYSICAL UNIT. This is a per-room device, exactly like the
+# thin client's per-image THINCLIENT_NAME/DIGEST_WEB_URL (hosts/thin-client/
+# scripts/build-thin-client-iso.sh) — flashing two units from the same secrets.yaml
+# means both show the SAME room's media status, which defeats the point. Keep a
+# separate copy (or a separate ESPHome dashboard entry) per unit, with `room` in
+# voice-display.yaml's substitutions and media_player_entity_id below both set to
+# match that specific unit's actual room. See README.md's "Multiple rooms" section.
+
+# ---------------------------------------------------------------------------
+# Wi-Fi
+# ---------------------------------------------------------------------------
+wifi_ssid: "your-wifi-ssid"
+wifi_password: "your-wifi-password"
+# Fallback AP password if this device can't join the network above.
+ap_password: "changeme-fallback-ap-password"
+
+# ---------------------------------------------------------------------------
+# Home Assistant API — generate with the ESPHome dashboard/CLI ("esphome
+# secrets" or the wizard does this automatically), or by hand:
+#   python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"
+# ---------------------------------------------------------------------------
+api_encryption_key: ""
+
+# OTA updates after the first (USB) flash.
+ota_password: "changeme-ota-password"
+
+# ---------------------------------------------------------------------------
+# Home Assistant base URL — used only to resolve the relative entity_picture
+# path HA returns for the media_player entity below into a fetchable URL.
+# LAN address, not the public one; this device never needs to leave the LAN.
+# ---------------------------------------------------------------------------
+ha_base_url: "http://homeassistant.local:8123"
+
+# ---------------------------------------------------------------------------
+# What to mirror on screen — NEITHER of these is guessed, both need a real
+# decision before the device shows anything meaningful. See README.md.
+# ---------------------------------------------------------------------------
+# The media_player entity to mirror (cover art + title/artist + play state) —
+# MUST be the one that actually reflects what's playing in THIS device's own
+# room, not just any media_player in the house.
+#
+# If this room only ever has one real audio source (e.g. just the thin
+# client's own media_player, hosts/thin-client/agent/thinclient_agent/
+# mqtt_discovery.py's register_media_player), point straight at that entity
+# and you're done.
+#
+# If this room can have more than one active source (the thin client AND a
+# Spotify Connect speaker AND a cast device, say), a single hardcoded
+# entity_id can't stay correct on its own — build a Home Assistant "Universal
+# Media Player" for this room (https://www.home-assistant.io/integrations/universal/)
+# that aggregates all of that room's real media_player entities and reports
+# whichever one is actually active, then point this at the universal entity
+# instead of any one real device:
+#
+#   media_player:
+#     - platform: universal
+#       name: "Living room media"
+#       children:
+#         - media_player.living_room_thinclient
+#         - media_player.living_room_spotify_connect
+#
+# (default child-selection behaviour: whichever child isn't idle/off — see
+# the integration docs if you need custom logic instead, e.g. one source
+# should always win over another). That HA-side config is not built by this
+# repo, same "nothing here builds the HA side" convention as everywhere else.
+media_player_entity_id: "media_player.CHANGE_ME"
+
+# The weather entity to show on the idle page.
+weather_entity_id: "weather.CHANGE_ME"
diff --git a/firmware/esp32-s3-touch-lcd-1.85c/voice-display.yaml b/firmware/esp32-s3-touch-lcd-1.85c/voice-display.yaml
new file mode 100644
index 0000000..3a3f855
--- /dev/null
+++ b/firmware/esp32-s3-touch-lcd-1.85c/voice-display.yaml
@@ -0,0 +1,864 @@
+# Waveshare ESP32-S3-Touch-LCD-1.85C-V2 — voice satellite + status display.
+#
+# See firmware/esp32-s3-touch-lcd-1.85c/README.md before flashing this: it needs
+# the V2 hardware revision specifically (V1 has no echo-cancellation circuit and
+# different audio pins), and two entity IDs in secrets.yaml filled in before the
+# display shows anything real.
+#
+# Hardware bring-up (esp32/psram/i2c/pca9554/spi/display/touchscreen/i2s_audio
+# sections) is adapted from the community-verified config at
+# https://github.com/ulsmith/home-assistant-esphome-esp32-s3-touch-lcd-185c —
+# the register-level display init sequence in particular is copied close to
+# verbatim rather than re-derived, since getting it wrong just shows static.
+# Everything voice/UI-behaviour related below that is new, built for this
+# project's specific ask: media-with-cover-art takes priority over an idle
+# weather/time/date cycle, with a voice-state visualizer overlaid on top of
+# whichever of those is showing.
+
+substitutions:
+  # REQUIRED per physical unit — see README.md's "Multiple rooms" section.
+  # This is what makes the device identifiable as belonging to one specific
+  # room in both Home Assistant's device list and this file's own hostname;
+  # it does NOT by itself make media_player_entity_id (a secret, below)
+  # point at the right room's media — that's a separate, equally required
+  # step covered in the same README section.
+  room: "living-room" # lowercase, hyphens only — becomes part of the hostname
+  friendly_name: "Voice display (Living room)"
+  # Substitution (not a plain !secret) specifically so it can be interpolated
+  # inside larger strings and lambda string literals below, e.g.
+  # "${ha_base_url}/local/no_art.png" — !secret alone can't be embedded that way.
+  ha_base_url: !secret ha_base_url
+
+esphome:
+  name: voice-display-${room}
+  friendly_name: ${friendly_name}
+
+# ESP-IDF (not Arduino) is required for octal PSRAM + this QSPI display driver.
+esp32:
+  board: esp32-s3-devkitc-1
+  flash_size: 16MB
+  framework:
+    type: esp-idf
+    sdkconfig_options:
+      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
+      CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
+      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
+      CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"
+      CONFIG_SPIRAM_RODATA: "y"
+
+psram:
+  mode: octal
+  speed: 80MHz
+
+logger:
+  level: INFO
+
+api:
+  encryption:
+    key: !secret api_encryption_key
+
+ota:
+  - platform: esphome
+    password: !secret ota_password
+
+wifi:
+  ssid: !secret wifi_ssid
+  password: !secret wifi_password
+  ap:
+    ssid: "voice-display-${room}"
+    password: !secret ap_password
+
+captive_portal:
+
+# Required by online_image below (cover-art fetch) — LAN-only in practice,
+# since ha_base_url always points at the household's own Home Assistant.
+http_request:
+  useragent: esphome/voice-display-${room}
+  timeout: 10s
+
+# Home Assistant's own clock — this device never needs to be right on its own.
+time:
+  - platform: homeassistant
+    id: ha_time
+    on_time:
+      - seconds: 0
+        then:
+          - lvgl.label.update:
+              id: lbl_time
+              text: !lambda "return id(ha_time).now().strftime(\"%H:%M\");"
+          - lvgl.label.update:
+              id: lbl_date
+              text: !lambda "return id(ha_time).now().strftime(\"%a %d %b\");"
+
+# ---------------------------------------------------------------------------
+# Display + touch (ST77916 360x360 round QSPI, CST816 touch) — Phase 13-style
+# "copied from a verified source, not re-derived" note applies to the whole
+# i2c/pca9554/spi/display block below.
+# ---------------------------------------------------------------------------
+
+i2c:
+  sda: GPIO11
+  scl: GPIO10
+  scan: true
+
+# I/O expander that carries the display's reset line — the V2 variant
+# specifically resets via EXIO2 (pca9554 pin 2), not a plain GPIO.
+pca9554:
+  - id: pca9554_device
+    address: 0x20
+
+output:
+  - platform: ledc
+    pin: GPIO5
+    id: backlight_pwm
+
+light:
+  - platform: monochromatic
+    output: backlight_pwm
+    id: display_backlight
+    name: "Display backlight"
+    restore_mode: ALWAYS_ON
+
+spi:
+  id: display_qspi
+  type: quad
+  clk_pin: GPIO40
+  data_pins: [GPIO46, GPIO45, GPIO42, GPIO41]
+
+display:
+  - platform: qspi_dbi
+    model: CUSTOM
+    id: main_display
+    spi_id: display_qspi
+    cs_pin: GPIO21
+    reset_pin:
+      pca9554: pca9554_device
+      number: 2 # EXIO2 — V2-variant-specific, see README
+    data_rate: 80MHz
+    dimensions:
+      width: 360
+      height: 360
+    color_order: rgb
+    invert_colors: true
+    auto_clear_enabled: false
+    update_interval: never
+    # Two lines to change if the unit needs mounting upside down (see README):
+    # rotation: 180° and touchscreen.transform.mirror_x/mirror_y: true below.
+    rotation: 0°
+    # Official Waveshare V2 init sequence — do not reorder or trim these lines.
+    init_sequence:
+      - [0xF0, 0x28]
+      - [0xF2, 0x28]
+      - [0x73, 0xF0]
+      - [0x7C, 0xD1]
+      - [0x83, 0xE0]
+      - [0x84, 0x61]
+      - [0xF2, 0x82]
+      - [0xF0, 0x00]
+      - [0xF0, 0x01]
+      - [0xF1, 0x01]
+      - [0xB0, 0x56]
+      - [0xB1, 0x4D]
+      - [0xB2, 0x24]
+      - [0xB4, 0x87]
+      - [0xB5, 0x44]
+      - [0xB6, 0x8B]
+      - [0xB7, 0x40]
+      - [0xB8, 0x86]
+      - [0xBA, 0x00]
+      - [0xBB, 0x08]
+      - [0xBC, 0x08]
+      - [0xBD, 0x00]
+      - [0xC0, 0x80]
+      - [0xC1, 0x10]
+      - [0xC2, 0x37]
+      - [0xC3, 0x80]
+      - [0xC4, 0x10]
+      - [0xC5, 0x37]
+      - [0xC6, 0xA9]
+      - [0xC7, 0x41]
+      - [0xC8, 0x01]
+      - [0xC9, 0xA9]
+      - [0xCA, 0x41]
+      - [0xCB, 0x01]
+      - [0xD0, 0x91]
+      - [0xD1, 0x68]
+      - [0xD2, 0x68]
+      - [0xF5, 0x00, 0xA5]
+      - [0xDD, 0x4F]
+      - [0xDE, 0x4F]
+      - [0xF1, 0x10]
+      - [0xF0, 0x00]
+      - [0xF0, 0x02]
+      - [0xE0, 0xF0, 0x0A, 0x10, 0x09, 0x09, 0x36, 0x35, 0x33, 0x4A, 0x29, 0x15, 0x15, 0x2E, 0x34]
+      - [0xE1, 0xF0, 0x0A, 0x0F, 0x08, 0x08, 0x05, 0x34, 0x33, 0x4A, 0x39, 0x15, 0x15, 0x2D, 0x33]
+      - [0xF0, 0x10]
+      - [0xF3, 0x10]
+      - [0xE0, 0x07]
+      - [0xE1, 0x00]
+      - [0xE2, 0x00]
+      - [0xE3, 0x00]
+      - [0xE4, 0xE0]
+      - [0xE5, 0x06]
+      - [0xE6, 0x21]
+      - [0xE7, 0x01]
+      - [0xE8, 0x05]
+      - [0xE9, 0x02]
+      - [0xEA, 0xDA]
+      - [0xEB, 0x00]
+      - [0xEC, 0x00]
+      - [0xED, 0x0F]
+      - [0xEE, 0x00]
+      - [0xEF, 0x00]
+      - [0xF8, 0x00]
+      - [0xF9, 0x00]
+      - [0xFA, 0x00]
+      - [0xFB, 0x00]
+      - [0xFC, 0x00]
+      - [0xFD, 0x00]
+      - [0xFE, 0x00]
+      - [0xFF, 0x00]
+      - [0x60, 0x40]
+      - [0x61, 0x04]
+      - [0x62, 0x00]
+      - [0x63, 0x42]
+      - [0x64, 0xD9]
+      - [0x65, 0x00]
+      - [0x66, 0x00]
+      - [0x67, 0x00]
+      - [0x68, 0x00]
+      - [0x69, 0x00]
+      - [0x6A, 0x00]
+      - [0x6B, 0x00]
+      - [0x70, 0x40]
+      - [0x71, 0x03]
+      - [0x72, 0x00]
+      - [0x73, 0x42]
+      - [0x74, 0xD8]
+      - [0x75, 0x00]
+      - [0x76, 0x00]
+      - [0x77, 0x00]
+      - [0x78, 0x00]
+      - [0x79, 0x00]
+      - [0x7A, 0x00]
+      - [0x7B, 0x00]
+      - [0x80, 0x48]
+      - [0x81, 0x00]
+      - [0x82, 0x06]
+      - [0x83, 0x02]
+      - [0x84, 0xD6]
+      - [0x85, 0x04]
+      - [0x86, 0x00]
+      - [0x87, 0x00]
+      - [0x88, 0x48]
+      - [0x89, 0x00]
+      - [0x8A, 0x08]
+      - [0x8B, 0x02]
+      - [0x8C, 0xD8]
+      - [0x8D, 0x04]
+      - [0x8E, 0x00]
+      - [0x8F, 0x00]
+      - [0x90, 0x48]
+      - [0x91, 0x00]
+      - [0x92, 0x0A]
+      - [0x93, 0x02]
+      - [0x94, 0xDA]
+      - [0x95, 0x04]
+      - [0x96, 0x00]
+      - [0x97, 0x00]
+      - [0x98, 0x48]
+      - [0x99, 0x00]
+      - [0x9A, 0x0C]
+      - [0x9B, 0x02]
+      - [0x9C, 0xDC]
+      - [0x9D, 0x04]
+      - [0x9E, 0x00]
+      - [0x9F, 0x00]
+      - [0xA0, 0x48]
+      - [0xA1, 0x00]
+      - [0xA2, 0x05]
+      - [0xA3, 0x02]
+      - [0xA4, 0xD5]
+      - [0xA5, 0x04]
+      - [0xA6, 0x00]
+      - [0xA7, 0x00]
+      - [0xA8, 0x48]
+      - [0xA9, 0x00]
+      - [0xAA, 0x07]
+      - [0xAB, 0x02]
+      - [0xAC, 0xD7]
+      - [0xAD, 0x04]
+      - [0xAE, 0x00]
+      - [0xAF, 0x00]
+      - [0xB0, 0x48]
+      - [0xB1, 0x00]
+      - [0xB2, 0x09]
+      - [0xB3, 0x02]
+      - [0xB4, 0xD9]
+      - [0xB5, 0x04]
+      - [0xB6, 0x00]
+      - [0xB7, 0x00]
+      - [0xB8, 0x48]
+      - [0xB9, 0x00]
+      - [0xBA, 0x0B]
+      - [0xBB, 0x02]
+      - [0xBC, 0xDB]
+      - [0xBD, 0x04]
+      - [0xBE, 0x00]
+      - [0xBF, 0x00]
+      - [0xC0, 0x10]
+      - [0xC1, 0x47]
+      - [0xC2, 0x56]
+      - [0xC3, 0x65]
+      - [0xC4, 0x74]
+      - [0xC5, 0x88]
+      - [0xC6, 0x99]
+      - [0xC7, 0x01]
+      - [0xC8, 0xBB]
+      - [0xC9, 0xAA]
+      - [0xD0, 0x10]
+      - [0xD1, 0x47]
+      - [0xD2, 0x56]
+      - [0xD3, 0x65]
+      - [0xD4, 0x74]
+      - [0xD5, 0x88]
+      - [0xD6, 0x99]
+      - [0xD7, 0x01]
+      - [0xD8, 0xBB]
+      - [0xD9, 0xAA]
+      - [0xF3, 0x01]
+      - [0xF0, 0x00]
+      - [0x3A, 0x55] # RGB565
+      - [0x36, 0x00] # MADCTL
+      - [0x2A, 0x00, 0x00, 0x01, 0x67] # column address 0-359
+      - [0x2B, 0x00, 0x00, 0x01, 0x67] # row address 0-359
+      - [0x21, 0x00] # display inversion on
+      - [0x11, 0x00] # sleep out
+      - delay 120ms
+      - [0x29, 0x00] # display on
+      - delay 20ms
+
+touchscreen:
+  platform: cst816
+  id: my_touchscreen
+  address: 0x15
+  interrupt_pin: GPIO4
+  transform:
+    mirror_x: false
+    mirror_y: false
+  on_touch:
+    then:
+      - light.turn_on:
+          id: display_backlight
+          brightness: 100%
+      - lvgl.resume:
+
+# Narrow edge strips for swipe-to-navigate between the auto-managed idle/media
+# pages and the two manual pages (settings, device info) — same shape as the
+# reference config's own swipe zones.
+binary_sensor:
+  - platform: touchscreen
+    touchscreen_id: my_touchscreen
+    id: swipe_left
+    internal: true
+    x_min: 0
+    x_max: 60
+    y_min: 100
+    y_max: 260
+    on_press:
+      then:
+        - lvgl.page.previous:
+  - platform: touchscreen
+    touchscreen_id: my_touchscreen
+    id: swipe_right
+    internal: true
+    x_min: 300
+    x_max: 360
+    y_min: 100
+    y_max: 260
+    on_press:
+      then:
+        - lvgl.page.next:
+
+# ---------------------------------------------------------------------------
+# Audio: ES7210 mic ADC (with the board's echo-cancellation reference path)
+# + ES8311 speaker DAC, wired for on-device wake word (micro_wake_word) plus
+# the existing Phase 3 Assist pipeline for everything after the wake word.
+# ---------------------------------------------------------------------------
+
+i2s_audio:
+  - id: i2s_in
+    i2s_lrclk_pin: GPIO2
+    i2s_bclk_pin: GPIO15
+  - id: i2s_out
+    i2s_lrclk_pin: GPIO38
+    i2s_bclk_pin: GPIO48
+
+microphone:
+  - platform: i2s_audio
+    id: i2s_microphone
+    i2s_audio_id: i2s_in
+    i2s_din_pin: GPIO39
+    adc_type: external
+    pdm: false
+    channel: right
+    sample_rate: 16000
+    bits_per_sample: 16bit
+
+speaker:
+  - platform: i2s_audio
+    id: i2s_speaker
+    i2s_audio_id: i2s_out
+    i2s_dout_pin: GPIO47
+    dac_type: external
+    i2s_mode: primary
+
+# On-device wake-word spotting (TFLite, runs on the ESP32-S3 itself) — matches
+# this project's Phase 11.8 principle that wake-word detection happens locally,
+# not by streaming continuously to Home Assistant. "okay_nabu" is the same
+# phrase as the thin client's VOICE_WAKE_WORD, so the household has one
+# consistent wake word regardless of which kind of satellite answers.
+micro_wake_word:
+  microphone:
+    microphone: i2s_microphone
+    channels: 0
+  models:
+    - model: okay_nabu
+      id: okay_nabu_model
+  on_wake_word_detected:
+    - micro_wake_word.stop:
+    - voice_assistant.start:
+        wake_word: !lambda "return wake_word;"
+
+voice_assistant:
+  id: va
+  microphone: i2s_microphone
+  speaker: i2s_speaker
+  use_wake_word: false # micro_wake_word above starts a session explicitly instead
+  noise_suppression_level: 2
+  auto_gain: 31dBFS
+  volume_multiplier: 2.0
+  on_listening:
+    - light.turn_on:
+        id: display_backlight
+        brightness: 100%
+    - lvgl.resume:
+    - script.execute: show_visualizer_listening
+  on_stt_end:
+    - script.execute: show_visualizer_thinking
+  on_tts_start:
+    - script.execute: show_visualizer_replying
+  on_tts_end:
+    - script.execute: hide_visualizer
+  on_end:
+    - script.execute: hide_visualizer
+    - micro_wake_word.start:
+  on_error:
+    - script.execute: show_visualizer_error
+    - delay: 2s
+    - script.execute: hide_visualizer
+    - micro_wake_word.start:
+
+# ---------------------------------------------------------------------------
+# Home Assistant data this device mirrors — both entity IDs are placeholders,
+# see README.md. Neither is guessed at; an unset value just means that part of
+# the idle page/media page stays blank, same "don't build against a guess"
+# rule this repo already applies to Phase 12/13's own unresolved data sources.
+# ---------------------------------------------------------------------------
+
+sensor:
+  - platform: homeassistant
+    id: weather_temperature
+    entity_id: !secret weather_entity_id
+    attribute: temperature
+    on_value:
+      then:
+        - lvgl.label.update:
+            id: lbl_weather_temp
+            text: !lambda "return (str_sprintf(\"%.0f\", x) + \"°\");"
+  - platform: wifi_signal
+    id: wifi_signal_sensor
+    update_interval: 60s
+    on_value:
+      then:
+        - lvgl.label.update:
+            id: lbl_wifi_signal
+            text: !lambda "return (\"WiFi: \" + std::to_string((int) x) + \" dBm\");"
+  - platform: uptime
+    id: uptime_sensor
+    update_interval: 60s
+    on_value:
+      then:
+        - lvgl.label.update:
+            id: lbl_uptime
+            text: !lambda |-
+              int s = (int) x;
+              return "Uptime: " + std::to_string(s / 3600) + "h " + std::to_string((s % 3600) / 60) + "m";
+
+globals:
+  - id: media_is_playing
+    type: bool
+    restore_value: no
+    initial_value: "false"
+  - id: idle_showing_weather
+    type: bool
+    restore_value: no
+    initial_value: "false"
+
+text_sensor:
+  - platform: homeassistant
+    id: weather_condition
+    entity_id: !secret weather_entity_id
+    on_value:
+      then:
+        - lvgl.label.update:
+            id: lbl_weather_cond
+            text: !lambda "return x;"
+
+  - platform: homeassistant
+    id: media_state
+    entity_id: !secret media_player_entity_id
+    on_value:
+      then:
+        - if:
+            condition:
+              lambda: 'return x == "playing";'
+            then:
+              - globals.set:
+                  id: media_is_playing
+                  value: "true"
+              - lvgl.page.show: media_page
+            else:
+              - globals.set:
+                  id: media_is_playing
+                  value: "false"
+              - lvgl.page.show: idle_page
+
+  - platform: homeassistant
+    id: media_title
+    entity_id: !secret media_player_entity_id
+    attribute: media_title
+    on_value:
+      then:
+        - lvgl.label.update:
+            id: lbl_media_title
+            text: !lambda "return x;"
+
+  - platform: homeassistant
+    id: media_artist
+    entity_id: !secret media_player_entity_id
+    attribute: media_artist
+    on_value:
+      then:
+        - lvgl.label.update:
+            id: lbl_media_artist
+            text: !lambda "return x;"
+
+  # entity_picture from Home Assistant is a path relative to the HA instance
+  # (e.g. "/api/media_player_proxy/...") — this device fetches it directly over
+  # HTTP, not through the HA connection, so ha_base_url has to be prepended.
+  - platform: homeassistant
+    id: media_picture
+    entity_id: !secret media_player_entity_id
+    attribute: entity_picture
+    on_value:
+      then:
+        - online_image.set_url:
+            id: media_cover_image
+            url: !lambda |-
+              if (x.rfind("http", 0) == 0) return x;
+              return std::string("${ha_base_url}") + x;
+
+  - platform: wifi_info
+    ip_address:
+      id: wifi_ip
+      on_value:
+        then:
+          - lvgl.label.update:
+              id: lbl_ip
+              text: !lambda "return (\"IP: \" + x);"
+
+# Must be the online_image platform (not a plain local file/mdi image) —
+# that's the only image type online_image.set_url (used above) can retarget
+# at runtime. The initial URL points at Home Assistant's own /local/
+# (config/www/) static folder rather than anywhere external — nothing else
+# this device talks to leaves the LAN, and it stays that way here too. It
+# 404s harmlessly (shows no image, not a broken canvas) until either the
+# first real track plays or you drop a no_art.png into config/www/ yourself
+# — see README.md.
+image:
+  - platform: online_image
+    id: media_cover_image
+    url: "${ha_base_url}/local/no_art.png"
+    format: PNG
+    type: RGB565
+    resize: 220x220
+
+# ---------------------------------------------------------------------------
+# Screen content
+# ---------------------------------------------------------------------------
+
+font:
+  - file: "gfonts://Montserrat"
+    id: font_time
+    size: 54
+  - file: "gfonts://Montserrat"
+    id: font_date
+    size: 20
+  - file: "gfonts://Montserrat"
+    id: font_weather
+    size: 32
+  - file: "gfonts://Montserrat"
+    id: font_weather_cond
+    size: 18
+  - file: "gfonts://Montserrat"
+    id: font_media
+    size: 20
+  - file: "gfonts://Montserrat"
+    id: font_small
+    size: 14
+
+# Idle-page auto-cycle: only ever touches idle_page's own two widget groups,
+# and only while media isn't playing — the priority automation above already
+# forces media_page the instant something starts, so this never fights it.
+interval:
+  - interval: 8s
+    then:
+      - if:
+          condition:
+            lambda: "return !id(media_is_playing);"
+          then:
+            - globals.set:
+                id: idle_showing_weather
+                value: !lambda "return !id(idle_showing_weather);"
+            - if:
+                condition:
+                  lambda: "return id(idle_showing_weather);"
+                then:
+                  - lvgl.widget.hide: idle_clock_group
+                  - lvgl.widget.show: idle_weather_group
+                else:
+                  - lvgl.widget.show: idle_clock_group
+                  - lvgl.widget.hide: idle_weather_group
+
+script:
+  - id: show_visualizer_listening
+    then:
+      - lvgl.spinner.update:
+          id: voice_visualizer
+          arc_color: 0x003300
+          indicator:
+            arc_color: 0x00FF00
+      - lvgl.widget.show: voice_visualizer
+  - id: show_visualizer_thinking
+    then:
+      - lvgl.spinner.update:
+          id: voice_visualizer
+          arc_color: 0x332200
+          indicator:
+            arc_color: 0xFF9900
+      - lvgl.widget.show: voice_visualizer
+  - id: show_visualizer_replying
+    then:
+      - lvgl.spinner.update:
+          id: voice_visualizer
+          arc_color: 0x001a33
+          indicator:
+            arc_color: 0x00AAFF
+      - lvgl.widget.show: voice_visualizer
+  - id: show_visualizer_error
+    then:
+      - lvgl.spinner.update:
+          id: voice_visualizer
+          arc_color: 0x330000
+          indicator:
+            arc_color: 0xFF0000
+      - lvgl.widget.show: voice_visualizer
+  - id: hide_visualizer
+    then:
+      - lvgl.widget.hide: voice_visualizer
+
+lvgl:
+  displays:
+    - main_display
+  touchscreens:
+    - touchscreen_id: my_touchscreen
+  buffer_size: 25%
+  color_depth: 16
+  bg_color: 0x000000
+
+  on_idle:
+    timeout: 30s
+    then:
+      - lvgl.pause:
+      - light.turn_off:
+          id: display_backlight
+          transition_length: 5s
+
+  # Voice-state visualizer: a spinning ring covering the whole screen,
+  # regardless of which page is active underneath — "a visualizer when
+  # speaking" as an overlay concern, not a page of its own. Hidden by
+  # default; the voice_assistant scripts above are the only things that
+  # show/hide or recolour it.
+  top_layer:
+    widgets:
+      - spinner:
+          id: voice_visualizer
+          align: CENTER
+          width: 356
+          height: 356
+          arc_color: 0x003300
+          arc_width: 6
+          spin_time: 1200ms
+          arc_length: 60
+          indicator:
+            arc_color: 0x00FF00
+            arc_width: 6
+          hidden: true
+
+  pages:
+    - id: idle_page
+      widgets:
+        - obj:
+            id: idle_clock_group
+            width: 100%
+            height: 100%
+            widgets:
+              - label:
+                  id: lbl_time
+                  align: CENTER
+                  y: -20
+                  text_font: font_time
+                  text_color: 0xFFFFFF
+                  text: "--:--"
+              - label:
+                  id: lbl_date
+                  align: CENTER
+                  y: 35
+                  text_font: font_date
+                  text_color: 0xAAAAAA
+                  text: "--- -- ---"
+        - obj:
+            id: idle_weather_group
+            width: 100%
+            height: 100%
+            hidden: true
+            widgets:
+              - label:
+                  id: lbl_weather_temp
+                  align: CENTER
+                  y: -20
+                  text_font: font_weather
+                  text_color: 0x00BFFF
+                  text: "--°"
+              - label:
+                  id: lbl_weather_cond
+                  align: CENTER
+                  y: 25
+                  text_font: font_weather_cond
+                  text_color: 0x87CEEB
+                  text: "Weather loading..."
+
+    - id: media_page
+      widgets:
+        - image:
+            id: img_media_cover
+            src: media_cover_image
+            align: CENTER
+            y: -40
+        - label:
+            id: lbl_media_title
+            align: CENTER
+            y: 90
+            text_font: font_media
+            text_color: 0xFFFFFF
+            text: ""
+        - label:
+            id: lbl_media_artist
+            align: CENTER
+            y: 118
+            text_font: font_small
+            text_color: 0xAAAAAA
+            text: ""
+
+    - id: settings_page
+      widgets:
+        - label:
+            align: TOP_MID
+            y: 50
+            text_font: font_weather_cond
+            text_color: 0xFFFFFF
+            text: "Settings"
+        - label:
+            align: CENTER
+            x: -70
+            y: 0
+            text_font: font_small
+            text_color: 0xFFFFFF
+            text: "Mute mic"
+        - switch:
+            id: mute_switch_widget
+            align: CENTER
+            x: 60
+            y: 0
+            # Delegates to the HA-exposed template switch below rather than
+            # touching micro_wake_word directly — that switch's turn_on_action/
+            # turn_off_action already does the real stop/start and reports its
+            # new state back to this widget via lvgl.widget.update, so this is
+            # the only place that pairing needs to be defined.
+            on_click:
+              then:
+                - switch.toggle: mute_switch
+
+    - id: info_page
+      widgets:
+        - label:
+            align: TOP_MID
+            y: 50
+            text_font: font_weather_cond
+            text_color: 0xFFFFFF
+            text: "Device info"
+        - label:
+            id: lbl_ip
+            align: CENTER
+            y: -20
+            text_font: font_small
+            text_color: 0x00FF00
+            text: "IP: connecting..."
+        - label:
+            id: lbl_wifi_signal
+            align: CENTER
+            y: 10
+            text_font: font_small
+            text_color: 0x00FF00
+            text: "WiFi: --"
+        - label:
+            id: lbl_uptime
+            align: CENTER
+            y: 40
+            text_font: font_small
+            text_color: 0x00FFFF
+            text: "Uptime: --"
+
+button:
+  - platform: restart
+    name: "Restart"
+
+switch:
+  - platform: template
+    name: "Mute microphone"
+    id: mute_switch
+    icon: "mdi:microphone-off"
+    optimistic: true
+    turn_on_action:
+      - micro_wake_word.stop:
+      - lvgl.widget.update:
+          id: mute_switch_widget
+          state:
+            checked: true
+    turn_off_action:
+      - micro_wake_word.start:
+      - lvgl.widget.update:
+          id: mute_switch_widget
+          state:
+            checked: false
diff --git a/hosts/container-host/scripts/setup-container-host.sh b/hosts/container-host/scripts/setup-container-host.sh
index 21fad12..e3d5728 100755
--- a/hosts/container-host/scripts/setup-container-host.sh
+++ b/hosts/container-host/scripts/setup-container-host.sh
@@ -25,6 +25,9 @@
 #     host, see DIGEST_ENGINE_SRC below and digest-engine/README.md)
 #   - whatsapp-bridge (optional, off by default, gated separately — real ban
 #     risk, see digest-engine/README.md before enabling)
+#   - admin-canvas + admin-web (Phase 13 sys-admin-llm display surface, optional,
+#     off by default — needs admin-canvas/ from this repo checked out on this
+#     host, see ADMIN_CANVAS_SRC below and admin-canvas/README.md)
 #
 # Run as: sudo ./setup-container-host.sh
 #
@@ -83,6 +86,14 @@ DIGEST_ENGINE_SRC="/opt/smart-home/src/digest-engine"
 DIGEST_WEB_PORT="8091"             # LAN-facing read-only static serving
 DIGEST_SCHEDULE="00,06,12,18"      # systemd OnCalendar hours, 4x/day
 
+# --- On-demand sys-admin-llm display surface (Phase 13) — off by default until
+# --- ADMIN_CANVAS_TOKEN is provisioned. See admin-canvas/README.md.
+ENABLE_ADMIN_CANVAS="false"
+# Where this repo's admin-canvas/ directory lives on THIS host (build context).
+ADMIN_CANVAS_SRC="/opt/smart-home/src/admin-canvas"
+ADMIN_CANVAS_PORT="8092"           # internal only — no `ports:` mapping, HA-reachable only
+ADMIN_WEB_PORT="8094"              # LAN-facing read-only static serving
+
 # ---------------------------------------------------------------------------
 # Sanity checks
 # ---------------------------------------------------------------------------
@@ -113,6 +124,12 @@ if [[ "$ENABLE_DIGEST_ENGINE" == "true" && ! -d "$DIGEST_ENGINE_SRC" ]]; then
   echo "  build context for the digest-engine image — then re-run."
 fi
 
+if [[ "$ENABLE_ADMIN_CANVAS" == "true" && ! -d "$ADMIN_CANVAS_SRC" ]]; then
+  echo "Warning: ENABLE_ADMIN_CANVAS=true but $ADMIN_CANVAS_SRC does not exist."
+  echo "  Copy or clone this repo's admin-canvas/ directory there — it is the"
+  echo "  build context for the admin-canvas image — then re-run."
+fi
+
 if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
   echo "WARNING: WhatsApp ingestion is enabled."
   echo "  There is no officially sanctioned way to read WhatsApp programmatically."
@@ -225,6 +242,16 @@ if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
     echo "  Seeded $BASE_DIR/digest/digest-engine.env from the template — fill in real values."
   fi
 fi
+if [[ "$ENABLE_ADMIN_CANVAS" == "true" ]]; then
+  mkdir -p "$BASE_DIR"/admin-canvas/output/media
+  # Same handling as digest-engine.env above: seed from the committed template on
+  # first run, 600, never committed (repo .gitignore covers *.env).
+  if [[ ! -f "$BASE_DIR/admin-canvas/admin-canvas.env" ]]; then
+    cp "$ADMIN_CANVAS_SRC/admin-canvas.env.example" "$BASE_DIR/admin-canvas/admin-canvas.env"
+    chmod 600 "$BASE_DIR/admin-canvas/admin-canvas.env"
+    echo "  Seeded $BASE_DIR/admin-canvas/admin-canvas.env from the template — fill in a real ADMIN_CANVAS_TOKEN."
+  fi
+fi
 
 # ---------------------------------------------------------------------------
 # 4. Mosquitto config
@@ -556,6 +583,48 @@ if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
 "
 fi
 
+# admin-canvas (Phase 13) — the on-demand sys-admin-llm display surface. Same
+# write/read split as digest-engine/digest-web above, except the write side is a
+# small always-on listener (restart: unless-stopped) rather than a oneshot, since
+# content here arrives whenever HA calls it rather than on a timer. Deliberately
+# NO `ports:` entry on admin-canvas itself — it must only ever be reachable from
+# other containers on this compose network (i.e. homeassistant), the same trust
+# boundary mosquitto<->homeassistant already share. See admin-canvas/README.md.
+ADMIN_CANVAS_BLOCK=""
+ADMIN_WEB_BLOCK=""
+if [[ "$ENABLE_ADMIN_CANVAS" == "true" ]]; then
+  ADMIN_CANVAS_BLOCK="
+  admin-canvas:
+    build: ${ADMIN_CANVAS_SRC}
+    image: smart-home/admin-canvas:local
+    container_name: admin-canvas
+    restart: unless-stopped
+    env_file:
+      - ${BASE_DIR}/admin-canvas/admin-canvas.env
+    volumes:
+      - ${BASE_DIR}/admin-canvas/output:/output
+      - /etc/localtime:/etc/localtime:ro
+    environment:
+      - ADMIN_CANVAS_PORT=${ADMIN_CANVAS_PORT}
+      - TZ=${TIMEZONE}
+"
+
+  ADMIN_WEB_BLOCK="
+  admin-web:
+    image: nginx:alpine
+    container_name: admin-web
+    restart: unless-stopped
+    ports:
+      - \"${ADMIN_WEB_PORT}:80\"
+    volumes:
+      - ${ADMIN_CANVAS_SRC}/render/templates/canvas.html:/usr/share/nginx/html/canvas.html:ro
+      - ${ADMIN_CANVAS_SRC}/render/canvas-sdk:/usr/share/nginx/html/canvas-sdk:ro
+      - ${BASE_DIR}/admin-canvas/output:/usr/share/nginx/html/output:ro
+    environment:
+      - TZ=${TIMEZONE}
+"
+fi
+
 if [[ "$ENABLE_DIGEST_ENGINE" == "true" && "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
   # Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session
   # open and appends to /data/messages.jsonl, which digest-engine drains each
@@ -652,7 +721,7 @@ ${FRIGATE_DEVICES}
       - PUID=1000
       - PGID=1000
       - TZ=${TIMEZONE}
-${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}${GALLERY_SMB_BLOCK}${DIGEST_ENGINE_BLOCK}${DIGEST_WEB_BLOCK}${WHATSAPP_BRIDGE_BLOCK}
+${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}${GALLERY_SMB_BLOCK}${DIGEST_ENGINE_BLOCK}${DIGEST_WEB_BLOCK}${WHATSAPP_BRIDGE_BLOCK}${ADMIN_CANVAS_BLOCK}${ADMIN_WEB_BLOCK}
 EOF
 
 # ---------------------------------------------------------------------------
@@ -808,6 +877,9 @@ if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
   echo "  Digest (full)   : http://${HOST_IP}:${DIGEST_WEB_PORT}/full.html"
   echo "  Digest (compact): http://${HOST_IP}:${DIGEST_WEB_PORT}/compact.html"
 fi
+if [[ "$ENABLE_ADMIN_CANVAS" == "true" ]]; then
+  echo "  Admin canvas    : http://${HOST_IP}:${ADMIN_WEB_PORT}/canvas.html"
+fi
 if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
   echo "  Gallery SMB     : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})"
 fi
@@ -848,6 +920,13 @@ if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
   echo "     you set at the top of this script into /etc/thinclient-agent/gallery-credentials"
   echo "     — see hosts/thin-client/README.md."
 fi
+if [[ "$ENABLE_ADMIN_CANVAS" == "true" ]]; then
+  echo " 14. Fill in $BASE_DIR/admin-canvas/admin-canvas.env before the sys-admin-llm can push"
+  echo "     anything (ADMIN_CANVAS_TOKEN is required — the service rejects every request"
+  echo "     while it is empty). Paste the same token into HA's rest_command: config — see"
+  echo "     admin-canvas/README.md for the worked example and the 'Show admin canvas'"
+  echo "     entity documented in hosts/thin-client/README.md."
+fi
 echo
 echo "Updating later: cd $BASE_DIR && docker compose pull && docker compose up -d"
 echo "Backing up manually: sudo $BASE_DIR/backup.sh (requires ENABLE_BACKUPS=true was run once)"
diff --git a/hosts/thin-client/README.md b/hosts/thin-client/README.md
index cb2d229..6289f17 100644
--- a/hosts/thin-client/README.md
+++ b/hosts/thin-client/README.md
@@ -8,12 +8,13 @@ What ends up on the image:
 
 | | |
 |---|---|
-| Compositor | Sway, no bars, no lock screen, workspaces `1:web` / `2:digest` / `3:media` |
+| Compositor | Sway, no bars, no lock screen, workspaces `1:web` / `2:digest` / `3:media` / `4:admin` / `5:capture` |
 | Autologin | greetd, `default_session` straight into `/usr/local/bin/kiosk-session` |
 | Remote control (human) | wayvnc on `0.0.0.0:5900`, authentication required |
 | Remote control (HA/LLM) | `thinclient-agent`, a systemd service publishing HA MQTT-discovery entities |
 | Browser | Firefox ESR in kiosk mode on the digest workspace, pointed at `digest-web` |
 | Media | mpv + mpv-mpris, playerctl, PipeWire/WirePlumber |
+| Capture-card input ("receiver box") | Any USB/PCIe HDMI capture card plugged into the box, selectable in HA, shown via mpv on `5:capture` — see below |
 | Game streaming | Steam Link (Flathub flatpak) forced onto Xwayland |
 | Voice | wyoming-satellite + openWakeWord — **opt-in, off by default** |
 | Gesture control | Camera hand tracking (MediaPipe) — **opt-in, off by default, see below** |
@@ -40,6 +41,7 @@ Then edit the `# CONFIGURATION` block at the top of
 | `MQTT_BROKER_HOST` | LAN IP of the container host running Mosquitto (Phase 1) |
 | `HA_URL` | Home Assistant URL, e.g. `http://192.168.1.10:8123` |
 | `DIGEST_WEB_URL` | The Phase 12 `digest-web` service. Placeholder until that is deployed — the image builds and boots fine without it, the digest workspace just shows a connection error. |
+| `ADMIN_WEB_URL` | The Phase 13 `admin-web` service (see `../../admin-canvas/README.md`). Same placeholder handling as `DIGEST_WEB_URL` — and unlike the digest workspace, `4:admin` is never auto-launched at session start anyway, so an unset value just means "Show admin canvas" has nothing to open yet. |
 | `KIOSK_USERNAME` | Autologin account name (`kiosk`) |
 | `THINCLIENT_NAME` / `IMAGE_HOSTNAME` | Per-room identity; each thin client needs its own |
 | `ENABLE_STEAM_LINK` | `true`/`false` |
@@ -205,6 +207,42 @@ project has no way to push a secret from one machine to the other, so both sides
 set by hand from the same value. Also fill in `GALLERY_SMB_HOST` in
 `build-thin-client-iso.sh` (the container host's LAN IP) before rebuilding.
 
+## Capture-card / receiver-box viewing
+
+Any USB or PCIe HDMI/AV capture card plugged into this machine can be picked as a
+video source from Home Assistant — the intent is "use this box like a TV/AVR with
+selectable inputs" for a console, cable box, or anything else that only speaks
+HDMI/AV out. Selecting one switches to `5:capture` and shows it full-screen via
+mpv (`mpv av://v4l2:/dev/videoN`); picking "none" clears it. See
+`thinclient_agent/capture_control.py`.
+
+**Detection is dynamic and periodic**, not just at boot: the agent re-scans
+`v4l2-ctl --list-devices` roughly every 20 seconds and republishes the HA select's
+option list when it changes, so a capture card plugged in mid-session shows up on
+its own — no reconnect or restart needed. Each physical device's label includes
+its USB bus path (what `v4l2-ctl` itself reports), which is what keeps two
+identical dongles distinguishable without needing extra disambiguation logic.
+
+**The gesture-control camera is never offered as a capture source.** Enumeration
+reads `gesture-config.json`'s `camera_device` and excludes it — but only while
+`"enabled": true`, the same gate `gesture_pointer.py` itself uses to decide
+whether the camera is ever opened at all. `camera_device` defaults to
+`/dev/video0` on every image whether or not gesture control was even built in, so
+the exclusion is deliberately conditional: excluding it unconditionally would
+silently hide a real capture card that happens to enumerate as `/dev/video0` on
+the (default, common) image where gesture control is off, for no privacy benefit
+— there's nothing to protect while that camera is never opened in the first
+place. See "Camera gesture control" above for the invariant this preserves: no HA
+entity can turn that specific camera on, full stop.
+
+**Audio is best-effort.** Many cheap USB HDMI-capture dongles carry their
+embedded HDMI audio over a *separate* USB Audio Class interface rather than in
+the V4L2 stream itself — `capture_control.find_audio_card()` tries to match a
+capture device to a sibling ALSA card by shared USB device topology (sysfs, not
+vendor/product IDs) and passes it to mpv as `--external-file=alsa://hw:X,0` if
+found. Video-only playback (not a crash) if nothing matches. **Unverified against
+real hardware** — see the checklist below.
+
 ## Home Assistant entities
 
 `thinclient-agent` publishes MQTT-discovery configs on connect. Under the MQTT
@@ -212,7 +250,17 @@ integration you should get one device per thin client with:
 
 - **Show digest canvas** (button) — switches to `2:digest` and reloads Firefox
 - **Digest detail level** (select) — `compact` / `full`
-- **Workspace** (select) — `1:web` / `2:digest` / `3:media`
+- **Show admin canvas** (button) — switches to `4:admin` and reloads Firefox at
+  `admin-web`'s `canvas.html` (Phase 13, see `../../admin-canvas/README.md`). No
+  detail level or any other option — everything it shows is populated out of band by
+  `admin-canvas`'s write API, not by this agent. This is the sys-admin-llm's surface
+  for on-demand stats/graphics/media (e.g. "show me the kitchen outlet's power
+  draw"), as opposed to the digest's scheduled 4x/day synthesis.
+- **Capture source** (select) — "none" plus whatever capture cards are currently
+  plugged in (dynamic, re-scanned periodically — see "Capture-card /
+  receiver-box viewing" above); switches to `5:capture` and shows the picked one
+  full-screen via mpv.
+- **Workspace** (select) — `1:web` / `2:digest` / `3:media` / `4:admin` / `5:capture`
 - **Launch Firefox**, **Launch web browser**, **Launch Steam Link** (buttons)
 - **Playback state** (sensor, with track metadata as attributes), **Volume** (number),
   and play/pause / next / previous / stop (buttons)
@@ -259,6 +307,24 @@ resolution. Home Assistant resolves *who* and *where* (including the "whose dige
 disambiguation when several people are in the room) and sends an already-resolved
 request; this agent only shows what it is told to show.
 
+`thinclient_agent/admin_canvas.py` (Phase 13) follows the identical pattern, taken
+one step further: its MQTT payload isn't even inspected, since there is nothing
+content-specific for this agent to decide — "Show admin canvas" always switches to
+`4:admin` and opens the same fixed, locally-configured `ADMIN_WEB_URL/canvas.html`.
+All of that page's actual content (stats, charts, images, short clips) is populated
+by a completely separate path — the sys-admin-llm, via an HA tool call, calling
+`admin-canvas`'s own token-gated write API on the container host — that never
+touches this agent or this machine's MQTT surface at all. See
+`../../admin-canvas/README.md` for that API and its own security notes.
+
+`thinclient_agent/capture_control.py` follows the same "payload only ever
+selects among already-enumerated values" rule as `audio_control.py`/
+`remote_desktop.py`: the HA select's payload is looked up in the device list
+`list_devices()` already built server-side, and it's *that* lookup's `.path` —
+never the payload — that reaches `capture-view` as an argv element. An unknown
+or since-unplugged selection resolves to "no source," never to acting on
+whatever string HA sent.
+
 ## Manual verification still outstanding
 
 None of this has been run on hardware. In rough order:
@@ -367,3 +433,21 @@ None of this has been run on hardware. In rough order:
     `ACCOUNT_`/`SAMBA_VOLUME_CONFIG_` env-var syntax in
     `setup-container-host.sh` is confirmed against that project's documentation, but
     the container itself was never actually run.
+20. **Capture-card viewing — nothing here has been tried against a real capture
+    device.** In particular:
+    - `mpv av://v4l2:/dev/videoN` itself: latency, whether `--profile=low-latency
+      --untimed --no-cache` is actually the right flag combination for a given
+      card, and whether any card needs an explicit `--demuxer-lavf-format`/pixel
+      format hint `v4l2-ctl` doesn't surface.
+    - The ALSA-audio-matching heuristic in `find_audio_card()` — whether a given
+      dongle's video and audio interfaces actually share a sysfs USB device
+      ancestor the way assumed, and whether `hw:X,0` is always the right
+      subdevice index (some cards expose audio on a non-zero one).
+    - `capture-view`'s `--title=thinclient-capture-view` pkill/pgrep scoping has
+      only been checked to not match unrelated `mpv` invocations by string
+      inspection, not exercised against a real running mpv process tree.
+    - Whether `v4l2-ctl --info`'s "Device Caps" parsing correctly picks the real
+      capture node (and skips a metadata/extra node) for capture cards other than
+      whatever specific chipset a future tester's dongle happens to use — the
+      logic is chipset-agnostic in design but only reasoned about, not run
+      against real `v4l2-ctl` output from more than one card model.
diff --git a/hosts/thin-client/agent/thinclient_agent/admin_canvas.py b/hosts/thin-client/agent/thinclient_agent/admin_canvas.py
new file mode 100644
index 0000000..7d02d7e
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/admin_canvas.py
@@ -0,0 +1,40 @@
+"""Handles "show the admin canvas" requests on this thin client.
+
+Sibling of digest_canvas.py (docs/project-plan.md Phase 13), for the sys-admin-llm's
+on-demand display surface instead of digest-engine's scheduled one. This module is
+deliberately even simpler than DigestCanvas: there is no detail level, no person, no
+presence resolution of any kind for it to get wrong — the MQTT payload that triggers
+`show()` is never even inspected. Every call opens the exact same fixed,
+locally-configured URL; whatever is currently on that page is entirely admin-canvas's
+concern (see admin-canvas/README.md), never this agent's.
+
+SECURITY BOUNDARY, same invariant as digest_canvas.py's: this module never accepts a
+URL, path, or any other value from its own inbound payload. ADMIN_WEB_URL is a local
+config value set at image-build/deploy time, not something MQTT can influence.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from .sway_control import WS_ADMIN, SwayControl
+
+log = logging.getLogger(__name__)
+
+BROWSER_COMMAND = "/usr/local/bin/admin-browser"
+
+
+class AdminCanvas:
+    def __init__(self, sway: SwayControl, admin_web_url: str):
+        self.sway = sway
+        self.admin_web_url = (admin_web_url or "").rstrip("/")
+
+    def show(self) -> None:
+        if not self.admin_web_url:
+            log.error("ADMIN_WEB_URL is not configured; cannot show the admin canvas")
+            return
+
+        url = f"{self.admin_web_url}/canvas.html"
+        log.info("showing admin canvas")
+        self.sway.switch_workspace(WS_ADMIN)
+        self.sway.open_url(url, browser_command=BROWSER_COMMAND)
diff --git a/hosts/thin-client/agent/thinclient_agent/capture_control.py b/hosts/thin-client/agent/thinclient_agent/capture_control.py
new file mode 100644
index 0000000..7dd110c
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/capture_control.py
@@ -0,0 +1,236 @@
+"""Capture-card ("receiver box") video source selection.
+
+USB/PCIe HDMI capture cards plugged into this machine are enumerated and exposed
+as an HA select, mirroring audio_control.AudioControl's shape: dynamic discovery,
+options()/current_option()/select(), a payload from HA only ever used to look up
+an already-enumerated device — see the security note in mqtt_discovery.py. The
+actual /dev/videoN path handed to the capture-view launcher is server-side data
+resolved from that lookup, never the payload itself.
+
+PRIVACY INVARIANT — read before touching this file. hosts/thin-client/README.md
+documents that the gesture-control camera has no HA entity and never will: "nothing
+reachable over MQTT can turn the camera on." A naive "list every capture-capable
+/dev/video*" would break that the moment gesture control is enabled on a unit, so
+list_devices() excludes gesture-control's configured camera_device whenever
+gesture-config.json's "enabled" is true — the same gate gesture_pointer.py itself
+uses to decide whether the camera is ever opened at all. It is deliberately NOT an
+unconditional exclusion: that file's camera_device defaults to /dev/video0 on
+every image regardless of whether gesture control was even built in, and excluding
+it while gesture control is off would just hide a real capture card that happens
+to enumerate there, for no privacy benefit — there is nothing to protect while
+gesture_pointer.py itself never opens the camera.
+"""
+
+from __future__ import annotations
+
+import glob
+import logging
+import os
+import re
+import subprocess
+from dataclasses import dataclass
+
+from .runtime_state import ensure_runtime_copy, load_json
+
+log = logging.getLogger(__name__)
+
+GESTURE_CONFIG_FILENAME = "gesture-config.json"
+NO_SOURCE = "none"
+
+_DEVICE_CAPS_HEADER = re.compile(r"^\s*Device Caps\s*:")
+_CAPTURE_CAPS = {"Video Capture", "Video Capture Multiplanar"}
+
+
+@dataclass(frozen=True)
+class CaptureDevice:
+    path: str
+    label: str
+
+
+def _run(args: list[str]) -> str | None:
+    try:
+        result = subprocess.run(args, capture_output=True, text=True, timeout=10, check=False)
+    except (OSError, subprocess.TimeoutExpired) as exc:
+        log.warning("%s failed: %s", " ".join(args), exc)
+        return None
+    if result.returncode != 0:
+        log.warning("%s: %s", " ".join(args), result.stderr.strip())
+        return None
+    return result.stdout
+
+
+def _parse_list_devices(output: str) -> dict[str, list[str]]:
+    """`v4l2-ctl --list-devices` groups /dev/videoN nodes under a physical-device
+    header line, blank-line separated, e.g.:
+
+        USB Video: USB Video (usb-0000:00:14.0-3):
+        \t/dev/video0
+        \t/dev/video1
+
+    The header already includes a bus-path suffix, which is what keeps two
+    identical capture dongles distinguishable without AudioControl-style "(2)"
+    suffixing.
+    """
+    groups: dict[str, list[str]] = {}
+    label: str | None = None
+    for raw_line in output.splitlines():
+        if not raw_line.strip():
+            label = None
+            continue
+        if not raw_line[0].isspace():
+            label = raw_line.rstrip(":").strip()
+            groups.setdefault(label, [])
+            continue
+        if label is not None:
+            path = raw_line.strip()
+            if path:
+                groups[label].append(path)
+    return groups
+
+
+def _has_capture_capability(path: str) -> bool:
+    output = _run(["v4l2-ctl", "-d", path, "--info"])
+    if output is None:
+        return False
+
+    lines = output.splitlines()
+    in_device_caps = False
+    for line in lines:
+        if _DEVICE_CAPS_HEADER.match(line):
+            in_device_caps = True
+            continue
+        if not in_device_caps:
+            continue
+        stripped = line.strip()
+        if not line[:1].isspace() or not stripped:
+            break
+        if stripped in _CAPTURE_CAPS:
+            return True
+    return False
+
+
+def _usb_device_dir(sysfs_device_symlink: str) -> str | None:
+    """Resolves a component's sysfs `device` symlink up to the nearest ancestor
+    directory that looks like a USB device (has an `idVendor` file) — the shared
+    physical USB device two different interfaces (video, audio) both hang off.
+    """
+    try:
+        current = os.path.realpath(sysfs_device_symlink)
+    except OSError:
+        return None
+    if not os.path.exists(current):
+        return None
+    # USB sysfs trees are shallow; a hard cap avoids any chance of looping on a
+    # pathological symlink structure.
+    for _ in range(6):
+        if os.path.exists(os.path.join(current, "idVendor")):
+            return current
+        parent = os.path.dirname(current)
+        if parent == current:
+            return None
+        current = parent
+    return None
+
+
+def find_audio_card(device_path: str) -> str | None:
+    """Best-effort match of a /dev/videoN to its sibling USB Audio Class ALSA
+    card. Many cheap USB HDMI-capture dongles present a UVC video interface and
+    a *separate* USB Audio interface — the embedded HDMI audio does not ride
+    along in the V4L2 stream, so mpv needs a second, explicit audio source.
+    Matched by shared physical USB device (sysfs), not by vendor/product ID.
+
+    Unverified against real hardware — see hosts/thin-client/README.md. Returns
+    None (video-only playback, not a crash) if nothing matches.
+    """
+    video_name = os.path.basename(device_path.rstrip("/"))
+    video_usb = _usb_device_dir(f"/sys/class/video4linux/{video_name}/device")
+    if video_usb is None:
+        return None
+
+    for card_dir in sorted(glob.glob("/sys/class/sound/card[0-9]*")):
+        card_usb = _usb_device_dir(os.path.join(card_dir, "device"))
+        if card_usb is not None and card_usb == video_usb:
+            index = os.path.basename(card_dir).removeprefix("card")
+            return f"hw:{index},0"
+    return None
+
+
+class CaptureControl:
+    def __init__(self) -> None:
+        self._devices: list[CaptureDevice] = []
+        self._current: str | None = None  # a device path, or None for "no source"
+
+    def _excluded_device_path(self) -> str:
+        # Gated on "enabled", not just present: gesture-config.json's template ships
+        # on every image with camera_device defaulting to /dev/video0 regardless of
+        # whether gesture control was even built into this image (see its own "always
+        # copied in" comment in build-thin-client-iso.sh) — gesture_pointer.py itself
+        # never opens the camera while "enabled" is false, so there is nothing to
+        # protect against on the (default, common) image where it's off, and excluding
+        # /dev/video0 unconditionally would just be silently hiding a real capture card
+        # that happens to enumerate there. The exclusion has to track the same gate
+        # gesture_pointer.py uses to decide whether the camera is ever opened at all.
+        path = ensure_runtime_copy(GESTURE_CONFIG_FILENAME)
+        config = load_json(path)
+        if not config.get("enabled"):
+            return ""
+        return str(config.get("camera_device") or "").strip()
+
+    def list_devices(self) -> list[CaptureDevice]:
+        output = _run(["v4l2-ctl", "--list-devices"])
+        if output is None:
+            self._devices = []
+            return self._devices
+
+        excluded = self._excluded_device_path()
+        devices: list[CaptureDevice] = []
+        for label, paths in _parse_list_devices(output).items():
+            for path in paths:
+                if not path.startswith("/dev/video") or path == excluded:
+                    continue
+                if _has_capture_capability(path):
+                    devices.append(CaptureDevice(path=path, label=label))
+                    break  # one entry per physical device — the first real capture node
+
+        self._devices = devices
+        return devices
+
+    # --- entity surface -----------------------------------------------------
+    def options(self) -> list[str]:
+        return [NO_SOURCE] + [d.label for d in self._devices]
+
+    def current_option(self) -> str:
+        if self._current is None:
+            return NO_SOURCE
+        for device in self._devices:
+            if device.path == self._current:
+                return device.label
+        # Selected, then unplugged since — say so rather than silently reporting
+        # a source that is no longer there.
+        return NO_SOURCE
+
+    def _find(self, label: str) -> CaptureDevice | None:
+        for device in self._devices:
+            if device.label == label:
+                return device
+        return None
+
+    def select(self, payload: str) -> CaptureDevice | None:
+        """Handle the HA select. Returns the resolved device, or None for
+        "no source" (including an unrecognised payload, treated the same as
+        picking "none" rather than acting on anything not currently listed)."""
+        label = payload.strip()
+        self.list_devices()
+
+        if label == NO_SOURCE:
+            self._current = None
+            return None
+
+        device = self._find(label)
+        if device is None:
+            log.warning("ignoring unknown capture source %r", label)
+            self._current = None
+            return None
+
+        self._current = device.path
+        return device
diff --git a/hosts/thin-client/agent/thinclient_agent/main.py b/hosts/thin-client/agent/thinclient_agent/main.py
index d780aa6..1400cff 100644
--- a/hosts/thin-client/agent/thinclient_agent/main.py
+++ b/hosts/thin-client/agent/thinclient_agent/main.py
@@ -14,13 +14,15 @@ from datetime import datetime, timezone
 
 import paho.mqtt.client as mqtt
 
+from .admin_canvas import AdminCanvas
 from .audio_control import AudioControl
+from .capture_control import CaptureControl, find_audio_card
 from .digest_canvas import DETAIL_LEVELS, DigestCanvas
 from .input_control import InputControl
 from .mpris_bridge import MprisBridge
 from .mqtt_discovery import Discovery
 from .remote_desktop import RemoteDesktop
-from .sway_control import WS_DIGEST, WS_MEDIA, WS_WEB, SwayControl
+from .sway_control import WS_ADMIN, WS_CAPTURE, WS_DIGEST, WS_MEDIA, WS_WEB, SwayControl
 
 CONFIG_PATH = os.environ.get("THINCLIENT_AGENT_CONFIG", "/etc/thinclient-agent/config.env")
 
@@ -31,11 +33,17 @@ CONFIG_KEYS = (
     "MQTT_PASSWORD",
     "HA_URL",
     "DIGEST_WEB_URL",
+    "ADMIN_WEB_URL",
     "KIOSK_USERNAME",
     "THINCLIENT_NAME",
 )
 
-WORKSPACES = (WS_WEB, WS_DIGEST, WS_MEDIA)
+WORKSPACES = (WS_WEB, WS_DIGEST, WS_MEDIA, WS_ADMIN, WS_CAPTURE)
+
+# How often the background thread re-scans for capture cards (hot-plugged, not
+# just present at boot) and republishes the HA select's options if they changed.
+# See main()'s poll_capture_devices().
+CAPTURE_POLL_SECONDS = 20
 
 # Fixed, household-wide — not under this device's own thinclient/ namespace.
 # "Was the digest looked at" is a fact about the digest, not about this specific
@@ -149,8 +157,10 @@ def main() -> int:
 
     sway = SwayControl()
     canvas = DigestCanvas(sway, config.get("DIGEST_WEB_URL", ""))
+    admin_canvas = AdminCanvas(sway, config.get("ADMIN_WEB_URL", ""))
     apps = build_apps(config)
     audio = AudioControl(sway.session_env)
+    capture = CaptureControl()
     remote = RemoteDesktop(sway)
     keyboard = InputControl(sway.session_env)
 
@@ -199,9 +209,41 @@ def main() -> int:
         # viewed. See digest-engine/viewed_tracker.py for what reads this.
         publish_digest_viewed(client)
 
+    def on_show_admin_canvas(_payload: str) -> None:
+        # Payload intentionally ignored — see admin_canvas.py's docstring for why
+        # there is nothing in it for this agent to act on. No viewed-tracking here
+        # either; that concept is specific to the scheduled digest.
+        admin_canvas.show()
+        discovery.publish_workspace(WS_ADMIN)
+
     def on_audio_output(payload: str) -> None:
         discovery.publish_audio_output(audio.select(payload))
 
+    def on_capture_select(payload: str) -> None:
+        device = capture.select(payload)
+        discovery.publish_capture_source(capture.current_option())
+        if device is None:
+            # "none", or an unrecognised/unplugged-since source — nothing to show,
+            # same as never having picked one.
+            return
+        audio_card = find_audio_card(device.path) or ""
+        sway.launch_app(
+            ["/usr/local/bin/capture-view", device.path, audio_card], workspace=WS_CAPTURE
+        )
+        discovery.publish_workspace(WS_CAPTURE)
+
+    def poll_capture_devices(stop_event: threading.Event) -> None:
+        last_options: list[str] | None = None
+        while not stop_event.wait(CAPTURE_POLL_SECONDS):
+            capture.list_devices()
+            options = capture.options()
+            if options != last_options:
+                log.info("capture source list changed: %s", options)
+                discovery.register_capture_select(
+                    options, on_capture_select, capture.current_option()
+                )
+                last_options = options
+
     def on_remote_target(payload: str) -> None:
         discovery.publish_remote_target(remote.select(payload))
 
@@ -221,6 +263,7 @@ def main() -> int:
         log.info("connected to MQTT broker %s:%s", broker_host, broker_port)
         discovery.register_media_player(mpris.handle_command, mpris.set_volume)
         discovery.register_digest(on_show_digest, on_detail_level, DETAIL_LEVELS, canvas.detail_level)
+        discovery.register_admin_canvas(on_show_admin_canvas)
         discovery.register_app_launchers(apps, on_launch)
         discovery.register_workspace_select(WORKSPACES, on_workspace, WS_DIGEST)
         # audio.apply_preferred() already ran once at startup (before MQTT was even
@@ -228,6 +271,10 @@ def main() -> int:
         # reflect this exact moment rather than whatever was plugged in at boot.
         audio.list_sinks()
         discovery.register_audio_output(audio.options(), on_audio_output, audio.current_option())
+        capture.list_devices()
+        discovery.register_capture_select(
+            capture.options(), on_capture_select, capture.current_option()
+        )
         discovery.register_remote_desktop(
             remote.options(), on_remote_target, remote.connect, remote.disconnect, remote.current
         )
@@ -254,6 +301,7 @@ def main() -> int:
     signal.signal(signal.SIGTERM, handle_signal)
     signal.signal(signal.SIGINT, handle_signal)
 
+    capture_thread: threading.Thread | None = None
     if not broker_host:
         log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH)
     else:
@@ -262,6 +310,13 @@ def main() -> int:
         # this agent must never be able to stall the session waiting on the broker.
         client.connect_async(broker_host, broker_port, keepalive=60)
         client.loop_start()
+        # Own thread rather than piggybacking on paho's loop thread (which only
+        # pumps MQTT I/O): a newly plugged-in capture card should appear in HA
+        # without waiting for a reconnect, per the plan's "active poll" choice.
+        capture_thread = threading.Thread(
+            target=poll_capture_devices, args=(stop_event,), daemon=True, name="capture-poll"
+        )
+        capture_thread.start()
 
     log.info("thinclient-agent %s started (node_id=%s)", node_id, node_id)
     try:
@@ -272,6 +327,8 @@ def main() -> int:
             discovery.publish_available(False)
             client.loop_stop()
             client.disconnect()
+        if capture_thread is not None:
+            capture_thread.join(timeout=5)
 
     return 0
 
diff --git a/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py b/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
index e06c901..62961b1 100644
--- a/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
+++ b/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
@@ -38,6 +38,7 @@ class Discovery:
         self.media_state_topic = f"{self.base}/media/state"
         self.detail_level_state_topic = f"{self.base}/digest/detail_level/state"
         self.audio_sink_state_topic = f"{self.base}/audio/sink/state"
+        self.capture_source_state_topic = f"{self.base}/capture/source/state"
         self.remote_target_state_topic = f"{self.base}/remote/target/state"
         self.input_text_state_topic = f"{self.base}/input/text/state"
         self._handlers: dict[str, Callable[[str], None]] = {}
@@ -190,6 +191,26 @@ class Discovery:
         )
         self.publish_detail_level(current_level)
 
+    def register_admin_canvas(self, on_show) -> None:
+        """The sys-admin-llm's display surface (docs/project-plan.md Phase 13).
+
+        Deliberately just a button, no select/detail-level — see admin_canvas.py's
+        docstring for why there's nothing else here for MQTT to configure. Content
+        reaches the canvas through a completely separate path (HA -> admin-canvas's
+        write API, never through this agent); this button only ever switches
+        workspace and (re)opens the fixed canvas URL, exactly like "Show digest
+        canvas" below does for the digest.
+        """
+        self._publish_config(
+            "button",
+            "admin_canvas_show",
+            {
+                "name": "Show admin canvas",
+                "command_topic": self._command_topic("admin/show", on_show),
+                "icon": "mdi:monitor-dashboard",
+            },
+        )
+
     def register_app_launchers(self, apps, on_launch) -> None:
         for key, app in apps.items():
             self._publish_config(
@@ -248,6 +269,31 @@ class Discovery:
     def publish_audio_output(self, option: str) -> None:
         self.client.publish(self.audio_sink_state_topic, option, qos=1, retain=True)
 
+    def register_capture_select(self, options, on_select, current) -> None:
+        """Capture-card ("receiver box") source select. See capture_control.py.
+
+        Safe to call again with an updated `options` list at any time, not just at
+        connect — MQTT discovery re-publishing the same config topic (same
+        component/node_id/object_id) just updates Home Assistant's copy of it, which
+        is exactly how main.py's background poll picks up a newly plugged-in capture
+        card without waiting for the next MQTT reconnect.
+        """
+        self._publish_config(
+            "select",
+            "capture_source",
+            {
+                "name": "Capture source",
+                "command_topic": self._command_topic("capture/source/set", on_select),
+                "state_topic": self.capture_source_state_topic,
+                "options": list(options),
+                "icon": "mdi:video-input-hdmi",
+            },
+        )
+        self.publish_capture_source(current)
+
+    def publish_capture_source(self, option: str) -> None:
+        self.client.publish(self.capture_source_state_topic, option, qos=1, retain=True)
+
     def register_remote_desktop(self, targets, on_select, on_connect, on_disconnect, current) -> None:
         self._publish_config(
             "select",
diff --git a/hosts/thin-client/agent/thinclient_agent/sway_control.py b/hosts/thin-client/agent/thinclient_agent/sway_control.py
index 2b14ea5..752cb4f 100644
--- a/hosts/thin-client/agent/thinclient_agent/sway_control.py
+++ b/hosts/thin-client/agent/thinclient_agent/sway_control.py
@@ -13,6 +13,8 @@ log = logging.getLogger(__name__)
 WS_WEB = "1:web"
 WS_DIGEST = "2:digest"
 WS_MEDIA = "3:media"
+WS_ADMIN = "4:admin"
+WS_CAPTURE = "5:capture"
 
 
 def runtime_dir() -> str:
@@ -112,5 +114,9 @@ class SwayControl:
         except OSError as exc:
             log.error("could not launch %s: %s", " ".join(command), exc)
 
-    def open_url(self, url: str) -> None:
-        self.launch_app([self.browser_command, url])
+    def open_url(self, url: str, browser_command: str | None = None) -> None:
+        # browser_command defaults to digest-browser (self.browser_command) for the
+        # digest canvas's existing callers; admin_canvas.py passes admin-browser
+        # explicitly so the two canvases keep separate kiosk Firefox profiles/
+        # processes (see admin-browser's own comment for why that matters).
+        self.launch_app([browser_command or self.browser_command, url])
diff --git a/hosts/thin-client/configs/sway/admin-browser b/hosts/thin-client/configs/sway/admin-browser
new file mode 100755
index 0000000..c47562b
--- /dev/null
+++ b/hosts/thin-client/configs/sway/admin-browser
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Points the kiosk Firefox window at the admin canvas URL. Installed to
+# /usr/local/bin/admin-browser. Called by thinclient-agent (admin_canvas.py) when a
+# "Show admin canvas" command arrives (docs/project-plan.md Phase 13).
+#
+# A near-copy of /usr/local/bin/digest-browser: same navigate-by-restart approach
+# (Firefox's --kiosk window has no tab/address bar, and the page is stateless anyway),
+# same reasoning for a --kiosk (not --new-window) launch. The one thing that matters is
+# its own profile, separate from digest-browser's and web-browser's — a Firefox profile
+# can only be open in one process, so sharing one would mean opening this window closed
+# the digest canvas, and the pkill/pgrep patterns below are scoped to THIS profile only,
+# so relaunching this window can never kill (or race against) digest-browser's — see the
+# comment above digest-browser's own pkill line for the other half of that guarantee.
+set -eu
+
+PROFILE_DIR="${HOME:-/home/$(id -un)}/.mozilla/firefox/admin"
+
+URL="${1:-${ADMIN_WEB_URL:-}}"
+[ -n "$URL" ] || { echo "admin-browser: no URL given and ADMIN_WEB_URL is unset" >&2; exit 1; }
+
+if command -v firefox-esr >/dev/null 2>&1; then
+  FIREFOX=firefox-esr
+else
+  FIREFOX=firefox
+fi
+
+mkdir -p "$PROFILE_DIR/chrome"
+cp /etc/thinclient-firefox/userChrome.css "$PROFILE_DIR/chrome/userChrome.css" 2>/dev/null || true
+cp /etc/thinclient-firefox/user.js        "$PROFILE_DIR/user.js"               2>/dev/null || true
+
+pkill -u "$(id -u)" -f "$FIREFOX .*--profile $PROFILE_DIR" 2>/dev/null || true
+
+# Wait for the old process to release its profile lock before relaunching.
+i=0
+while pgrep -u "$(id -u)" -f "$FIREFOX .*--profile $PROFILE_DIR" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do
+  sleep 0.25
+  i=$((i + 1))
+done
+
+exec "$FIREFOX" --profile "$PROFILE_DIR" --new-instance --kiosk "$URL"
diff --git a/hosts/thin-client/configs/sway/capture-view b/hosts/thin-client/configs/sway/capture-view
new file mode 100755
index 0000000..2196cf7
--- /dev/null
+++ b/hosts/thin-client/configs/sway/capture-view
@@ -0,0 +1,48 @@
+#!/bin/sh
+# Points mpv at a capture-card video device. Installed to /usr/local/bin/capture-view.
+# Called by thinclient-agent (capture_control.py / main.py) when a capture source is
+# selected — the thin client's "receiver box" input switching, see
+# hosts/thin-client/README.md's "Capture-card / receiver-box viewing" section.
+#
+# Kill-and-relaunch, same shape as digest-browser/admin-browser: mpv has no live
+# "change input" command worth scripting around, and there is no state to preserve
+# across a source switch.
+#
+# --title=thinclient-capture-view is what makes this safe to relaunch: mpv is ALSO
+# used for local media playback and the idle-gallery slideshow on this machine, and a
+# bare `pkill -f mpv` here would kill those too. Scoping pkill/pgrep to this exact
+# title — never anything broader — is the same fix already applied to
+# digest-browser's/admin-browser's own pkill patterns (see the comment there); this
+# script starts from that fix rather than needing it retrofitted later.
+set -eu
+
+DEVICE="${1:?capture-view: no device path given}"
+AUDIO_CARD="${2:-}"
+
+pkill -u "$(id -u)" -f "mpv .*--title=thinclient-capture-view" 2>/dev/null || true
+
+# Wait for the old process to release the device node before relaunching.
+i=0
+while pgrep -u "$(id -u)" -f "mpv .*--title=thinclient-capture-view" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do
+  sleep 0.25
+  i=$((i + 1))
+done
+
+# --untimed/--no-cache: this is a live source, not a file — play frames as they
+# arrive rather than pacing/buffering the way mpv would for on-disk media.
+set -- mpv \
+  "av://v4l2:${DEVICE}" \
+  --title=thinclient-capture-view \
+  --profile=low-latency \
+  --untimed \
+  --no-cache
+
+# Best-effort: many USB HDMI-capture dongles carry their embedded HDMI audio over a
+# *separate* USB Audio Class interface rather than in the V4L2 stream itself — see
+# capture_control.find_audio_card()'s docstring for how (and how confidently) this
+# gets matched. Video-only playback (no crash) if none was found.
+if [ -n "$AUDIO_CARD" ]; then
+  set -- "$@" "--external-file=alsa://${AUDIO_CARD}"
+fi
+
+exec "$@"
diff --git a/hosts/thin-client/configs/sway/config b/hosts/thin-client/configs/sway/config
index 9f44c43..42b0f85 100644
--- a/hosts/thin-client/configs/sway/config
+++ b/hosts/thin-client/configs/sway/config
@@ -6,11 +6,14 @@
 # here without this file being templated.
 
 set $mod Mod4
-set $ws_web    1:web
-set $ws_digest 2:digest
-set $ws_media  3:media
+set $ws_web     1:web
+set $ws_digest  2:digest
+set $ws_media   3:media
+set $ws_admin   4:admin
+set $ws_capture 5:capture
 
-# Workspace names are a contract with thinclient_agent/digest_canvas.py and
+# Workspace names are a contract with thinclient_agent/digest_canvas.py,
+# thinclient_agent/admin_canvas.py, thinclient_agent/capture_control.py, and
 # thinclient_agent/sway_control.py — changing one side means changing the other.
 
 # ---------------------------------------------------------------------------
@@ -123,6 +126,8 @@ bindsym $mod+Shift+c reload
 bindsym $mod+1 workspace $ws_web
 bindsym $mod+2 workspace $ws_digest
 bindsym $mod+3 workspace $ws_media
+bindsym $mod+4 workspace $ws_admin
+bindsym $mod+5 workspace $ws_capture
 bindsym $mod+Left focus left
 bindsym $mod+Right focus right
 bindsym $mod+Up focus up
diff --git a/hosts/thin-client/configs/sway/digest-browser b/hosts/thin-client/configs/sway/digest-browser
index c00550a..a3fbdcf 100755
--- a/hosts/thin-client/configs/sway/digest-browser
+++ b/hosts/thin-client/configs/sway/digest-browser
@@ -8,10 +8,10 @@
 # either opens an invisible tab or a second window that accumulates over time. Killing
 # and relaunching is blunt but deterministic, and the page is stateless anyway.
 #
-# Its own profile, separate from /usr/local/bin/web-browser's: a Firefox profile can
-# only be open in one process, so sharing one would mean opening the browsable window
-# closed the digest canvas. Both profiles carry the same user.js, chrome/userChrome.css
-# and enterprise policies.
+# Its own profile, separate from /usr/local/bin/web-browser's (and, since Phase 13,
+# /usr/local/bin/admin-browser's): a Firefox profile can only be open in one process,
+# so sharing one would mean opening the browsable window closed the digest canvas.
+# Both profiles carry the same user.js, chrome/userChrome.css and enterprise policies.
 set -eu
 
 PROFILE_DIR="${HOME:-/home/$(id -un)}/.mozilla/firefox/digest"
@@ -29,11 +29,15 @@ mkdir -p "$PROFILE_DIR/chrome"
 cp /etc/thinclient-firefox/userChrome.css "$PROFILE_DIR/chrome/userChrome.css" 2>/dev/null || true
 cp /etc/thinclient-firefox/user.js        "$PROFILE_DIR/user.js"               2>/dev/null || true
 
-pkill -u "$(id -u)" -f "$FIREFOX .*--kiosk" 2>/dev/null || true
+# Scoped to THIS script's own profile, not just "--kiosk" — admin-browser also runs
+# --kiosk on its own separate profile (Phase 13), and a bare "--kiosk" pattern would
+# kill/relaunch-race against it instead of only ever touching this script's own old
+# instance.
+pkill -u "$(id -u)" -f "$FIREFOX .*--profile $PROFILE_DIR" 2>/dev/null || true
 
 # Wait for the old process to release its profile lock before relaunching.
 i=0
-while pgrep -u "$(id -u)" -f "$FIREFOX .*--kiosk" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do
+while pgrep -u "$(id -u)" -f "$FIREFOX .*--profile $PROFILE_DIR" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do
   sleep 0.25
   i=$((i + 1))
 done
diff --git a/hosts/thin-client/scripts/build-thin-client-iso.sh b/hosts/thin-client/scripts/build-thin-client-iso.sh
index b28b6a3..0fad51f 100755
--- a/hosts/thin-client/scripts/build-thin-client-iso.sh
+++ b/hosts/thin-client/scripts/build-thin-client-iso.sh
@@ -75,6 +75,12 @@ HA_URL="http://192.168.1.10:8123"   # <-- EDIT: Home Assistant URL
 # must still come up with the container host powered off, per Phase 11.10).
 DIGEST_WEB_URL="http://192.168.1.10:8081"   # <-- EDIT once digest-web is deployed
 
+# admin-web (Phase 13) — the sys-admin-llm's on-demand display surface. Same
+# placeholder handling as DIGEST_WEB_URL above: harmless until deployed, the admin
+# workspace just won't have anything to open yet (and unlike the digest workspace it
+# is never auto-launched at session start anyway — see configs/sway/config).
+ADMIN_WEB_URL="http://192.168.1.10:8094"    # <-- EDIT once admin-web is deployed
+
 # The container host's gallery-smb share (ENABLE_GALLERY_SMB in
 # setup-container-host.sh), used by the idle-timeout slideshow. Just the host —
 # idle-gallery.sh always mounts the fixed "gallery" share name.
@@ -140,6 +146,12 @@ if [[ "$DIGEST_WEB_URL" == "http://192.168.1.10:8081" ]]; then
   echo "  and boots fine without it — the digest workspace just won't load anything."
 fi
 
+if [[ "$ADMIN_WEB_URL" == "http://192.168.1.10:8094" ]]; then
+  echo "Warning: ADMIN_WEB_URL is still the placeholder."
+  echo "  Fill it in once Phase 13's admin-web service is deployed. The image builds"
+  echo "  and boots fine without it — 'Show admin canvas' just won't load anything."
+fi
+
 if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
   echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room"
   echo "  (\"${VOICE_SATELLITE_NAME}\"). Do not flash it to a room without a microphone."
@@ -159,6 +171,7 @@ echo "Image hostname   : $IMAGE_HOSTNAME"
 echo "MQTT broker      : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
 echo "Home Assistant   : $HA_URL"
 echo "digest-web       : $DIGEST_WEB_URL"
+echo "admin-web        : $ADMIN_WEB_URL"
 echo "Steam Link       : $ENABLE_STEAM_LINK"
 echo "Voice satellite  : $ENABLE_VOICE_SATELLITE"
 echo "Gesture control  : $ENABLE_GESTURE_CONTROL"
@@ -201,6 +214,8 @@ subst "${AGENT_DIR}/thinclient-agent.service" "$INCLUDES/opt/thinclient-agent/th
 
 install -m 0755 "${CONFIGS_DIR}/greetd/kiosk-session"  "$INCLUDES/usr/local/bin/kiosk-session"
 install -m 0755 "${CONFIGS_DIR}/sway/digest-browser"   "$INCLUDES/usr/local/bin/digest-browser"
+install -m 0755 "${CONFIGS_DIR}/sway/admin-browser"     "$INCLUDES/usr/local/bin/admin-browser"
+install -m 0755 "${CONFIGS_DIR}/sway/capture-view"      "$INCLUDES/usr/local/bin/capture-view"
 install -m 0755 "${CONFIGS_DIR}/wayvnc/start-wayvnc"   "$INCLUDES/usr/local/bin/start-wayvnc"
 install -m 0644 "${CONFIGS_DIR}/mpv/mpv.conf"          "$INCLUDES/home/${KIOSK_USERNAME}/.config/mpv/mpv.conf"
 
@@ -294,6 +309,7 @@ MQTT_PASSWORD=${MQTT_PASSWORD}
 
 HA_URL=${HA_URL}
 DIGEST_WEB_URL=${DIGEST_WEB_URL}
+ADMIN_WEB_URL=${ADMIN_WEB_URL}
 GALLERY_SMB_HOST=${GALLERY_SMB_HOST}
 
 ENABLE_STEAM_LINK=${ENABLE_STEAM_LINK}
@@ -407,8 +423,9 @@ echo "  5. Confirm thinclient-agent connected and registered:"
 echo "       systemctl status thinclient-agent"
 echo "     In Home Assistant, a '${THINCLIENT_NAME}' device should appear under the MQTT"
 echo "     integration with: Show digest canvas (button), Digest detail level (select),"
-echo "     Workspace (select), Launch Firefox / Launch Steam Link (buttons), Volume"
-echo "     (number), Playback state (sensor), and play/pause/next/prev buttons."
+echo "     Show admin canvas (button), Workspace (select), Launch Firefox / Launch Steam"
+echo "     Link (buttons), Volume (number), Playback state (sensor), and"
+echo "     play/pause/next/prev buttons."
 echo "  6. Start something in mpv and confirm Playback state follows it (this goes through"
 echo "     mpv-mpris -> playerctl -> the agent's MPRIS bridge)."
 if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then