Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input

Three additions to the thin-client/container-host stack:

- admin-canvas + admin-web (Phase 13): an on-demand display surface for the
  sys-admin-llm, mirroring digest-engine/digest-web's write/read split but as
  a small always-on token-gated write API instead of a scheduled job. New
  "4:admin" thin-client workspace, MQTT button, and duplicated canvas-sdk with
  stat/image/video/chart window kinds.

- ESPHome firmware for the Waveshare ESP32-S3-Touch-LCD-1.85C-V2 (Phase 14):
  a voice satellite + round-display status screen, evaluated as a DIY
  alternative to Home Assistant Voice PE. Media/cover-art always takes
  priority over an idle weather/time/date cycle, with a voice-state
  visualizer overlaid on top. On-device wake word (micro_wake_word) feeding
  the existing Assist pipeline, per-room "media_player_entity_id" wiring
  documented explicitly to avoid cross-room bleed. Validated with ESPHome's
  own config validator; not yet flashed to real hardware.

- Capture-card ("receiver box") video source selection on the thin client: a
  new "5:capture" workspace and HA select that dynamically enumerates
  USB/PCIe capture cards (re-scanned periodically for hot-plug), launches
  mpv against the chosen one, and unconditionally excludes the gesture-
  control camera from the list to preserve its existing no-HA-entity
  invariant.

Also fixes a pre-existing bug found along the way: digest-browser's pkill
pattern matched any --kiosk mpv/firefox process rather than its own, which
would have let the new admin-browser/capture-view scripts kill each other's
windows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgTwMqiho8DT6ue5S49WBP
digest-per-person-and-agendas
Amir Alexander Abdelbaki 2026-07-29 11:03:11 +02:00
parent 2682e16020
commit 1d8b416ab9
27 changed files with 3105 additions and 22 deletions

11
.gitignore vendored
View File

@ -6,6 +6,10 @@
# digest-engine per-run output (rendered artifacts, cached ingestion context) # digest-engine per-run output (rendered artifacts, cached ingestion context)
digest-engine/output/ 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 # 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.example is tracked, matching the digest-engine.env.example pattern.
IDSconf.json IDSconf.json
@ -54,5 +58,12 @@ hosts/thin-client/**/tls_key.pem
# password above — only gallery-credentials.example is tracked. # password above — only gallery-credentials.example is tracked.
gallery-credentials 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__/ __pycache__/
*.pyc *.pyc

View File

@ -22,10 +22,17 @@ hosts/
firmware/ firmware/
ruview/ RuView ESP32-S3 CSI presence node configs ruview/ RuView ESP32-S3 CSI presence node configs
esphome-ble-proxy/ ESPHome configs for Bermuda BLE proxy nodes 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 identity/ Face<->MAC<->name correlation logic (Node-RED flow
export once stabilized, or a Python service) export once stabilized, or a Python service)
digest-engine/ Quarter-daily LLM digest: mail/message/news/financial digest-engine/ Quarter-daily LLM digest: mail/message/news/financial
ingestion, LLM synthesis, digest-canvas SDK rendering 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 ## Status
@ -42,8 +49,10 @@ digest-engine/ Quarter-daily LLM digest: mail/message/news/financia
- [ ] CalDAV / Nextcloud calendar integration notes - [ ] CalDAV / Nextcloud calendar integration notes
- [ ] Identity correlation flow (Node-RED) - [ ] 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 - [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] 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 ## Quick start

View File

@ -0,0 +1,6 @@
output/
render/
README.md
admin-canvas.env.example
**/__pycache__/
**/*.pyc

17
admin-canvas/Dockerfile Normal file
View File

@ -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"]

153
admin-canvas/README.md Normal file
View File

@ -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/<filename>`, 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 <ADMIN_CANVAS_TOKEN>
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/<filename>`
```json
{ "kind": "image", "title": "CPU — last 24h (Netdata)",
"content": { "src": "media/netdata-cpu.png", "alt": "CPU utilization graph" } }
```
`content.src` **must** match `media/<filename>` — 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://<container-host>: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=<that JSON>)`. 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.

View File

@ -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/<name> takes the raw image/video bytes.
ADMIN_CANVAS_MAX_SHOW_KB=256
ADMIN_CANVAS_MAX_MEDIA_MB=25

View File

@ -0,0 +1,256 @@
/*
* admin-canvas SDK styling the whole SDK's CSS, in one file, no build step.
* Loaded with a plain <link>; 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;
}

View File

@ -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 <pre> dump of the raw text
* - not a {"windows": [...]} -> a <pre> dump of whatever was there instead
* - a window that throws -> a <pre> dump of that window, siblings still render
* - a chart/stat with bad data -> the same per-window <pre> 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/<filename>'");
}
// 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);

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[\s(])\*([^*]+)\*/g, '$1<em>$2</em>');
}
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, '<br>');
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);

View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin canvas</title>
<link rel="stylesheet" href="canvas-sdk/glow.css">
<style>
/*
* The thin client's admin workspace (docs/project-plan.md Phase 13, workspace
* 4:admin). Sibling of digest-engine/render/templates/full.html's #canvas grid —
* no detail-level toggle here, and no globe double-width special case.
*/
html, body {
margin: 0;
padding: 0;
background: var(--admin-bg);
}
#canvas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
grid-auto-rows: minmax(10rem, auto);
gap: 1.1rem;
padding: 1.4rem;
min-height: 100vh;
font-size: 1.05rem;
}
#canvas .admin-canvas-raw {
grid-column: 1 / -1;
margin: 0;
}
</style>
</head>
<body>
<div id="canvas" class="admin-canvas"></div>
<script src="canvas-sdk/window-chrome.js"></script>
<script src="canvas-sdk/render.js"></script>
<script>
// admin-web serves the shared output volume under /output/; admin-canvas's
// server.py rewrites output/latest.json on every POST /show, so this URL is
// always current — no coordination with the caller is needed beyond that write.
var ADMIN_URL = 'output/latest.json';
// Shorter than the digest's 5-minute poll: this canvas is meant to feel closer to
// live (a stat pushed moments ago should show up moments later), and its content
// is far smaller (one screen's worth of windows, no globe/section payload).
var REFRESH_MS = 15 * 1000;
var canvas = document.getElementById('canvas');
function load() {
AdminRender.load(canvas, ADMIN_URL);
}
load();
setInterval(load, REFRESH_MS);
</script>
</body>
</html>

224
admin-canvas/server.py Normal file
View File

@ -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/<name> raw bytes, written to output/media/<name>
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/<filename>' path "
"already uploaded via POST /media/<filename>"
)
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())

View File

@ -83,7 +83,12 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato
| Keyboard + mouse or remote | €2040 | Local fallback input — primary control is HA/MQTT + wayvnc, not this | | Keyboard + mouse or remote | €2040 | Local fallback input — primary control is HA/MQTT + wayvnc, not this |
| USB mic + speaker (mic-enabled rooms only) | €2550/room | Only for the specific rooms chosen for voice interactivity — see §3 Phase 11 open decisions | | USB mic + speaker (mic-enabled rooms only) | €2550/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** | €3545 | 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 — 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 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 | | 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/<f>`, 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/<run-timestamp>/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. 7. **Live follow-up voice Q&A**: persist each run's actually-used ingested-context bundle (not full raw content) as `digest-engine/output/<run-timestamp>/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. 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/<filename>` (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 `<pre>` 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" ### Testing checklist before calling any phase "done"
- Does the reactive path (presence → light on) work with the LLM host powered off? (It must.) - 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.) - 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 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.) - 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.) - 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/<filename>` 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 1112) ## 4. Open decisions (Phases 1114)
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. 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. 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. 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`). 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.

View File

@ -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-<room>`), 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.

View File

@ -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"

View File

@ -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

View File

@ -25,6 +25,9 @@
# host, see DIGEST_ENGINE_SRC below and digest-engine/README.md) # host, see DIGEST_ENGINE_SRC below and digest-engine/README.md)
# - whatsapp-bridge (optional, off by default, gated separately — real ban # - whatsapp-bridge (optional, off by default, gated separately — real ban
# risk, see digest-engine/README.md before enabling) # 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 # 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_WEB_PORT="8091" # LAN-facing read-only static serving
DIGEST_SCHEDULE="00,06,12,18" # systemd OnCalendar hours, 4x/day 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 # 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." echo " build context for the digest-engine image — then re-run."
fi 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 if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
echo "WARNING: WhatsApp ingestion is enabled." echo "WARNING: WhatsApp ingestion is enabled."
echo " There is no officially sanctioned way to read WhatsApp programmatically." 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." echo " Seeded $BASE_DIR/digest/digest-engine.env from the template — fill in real values."
fi fi
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 # 4. Mosquitto config
@ -556,6 +583,48 @@ if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
" "
fi 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 if [[ "$ENABLE_DIGEST_ENGINE" == "true" && "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
# Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session # Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session
# open and appends to /data/messages.jsonl, which digest-engine drains each # open and appends to /data/messages.jsonl, which digest-engine drains each
@ -652,7 +721,7 @@ ${FRIGATE_DEVICES}
- PUID=1000 - PUID=1000
- PGID=1000 - PGID=1000
- TZ=${TIMEZONE} - 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 EOF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -808,6 +877,9 @@ if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
echo " Digest (full) : http://${HOST_IP}:${DIGEST_WEB_PORT}/full.html" echo " Digest (full) : http://${HOST_IP}:${DIGEST_WEB_PORT}/full.html"
echo " Digest (compact): http://${HOST_IP}:${DIGEST_WEB_PORT}/compact.html" echo " Digest (compact): http://${HOST_IP}:${DIGEST_WEB_PORT}/compact.html"
fi 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 if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})" echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})"
fi 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 " you set at the top of this script into /etc/thinclient-agent/gallery-credentials"
echo " — see hosts/thin-client/README.md." echo " — see hosts/thin-client/README.md."
fi 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
echo "Updating later: cd $BASE_DIR && docker compose pull && docker compose up -d" 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)" echo "Backing up manually: sudo $BASE_DIR/backup.sh (requires ENABLE_BACKUPS=true was run once)"

View File

@ -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` | | 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 (human) | wayvnc on `0.0.0.0:5900`, authentication required |
| Remote control (HA/LLM) | `thinclient-agent`, a systemd service publishing HA MQTT-discovery entities | | 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` | | Browser | Firefox ESR in kiosk mode on the digest workspace, pointed at `digest-web` |
| Media | mpv + mpv-mpris, playerctl, PipeWire/WirePlumber | | 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 | | Game streaming | Steam Link (Flathub flatpak) forced onto Xwayland |
| Voice | wyoming-satellite + openWakeWord — **opt-in, off by default** | | Voice | wyoming-satellite + openWakeWord — **opt-in, off by default** |
| Gesture control | Camera hand tracking (MediaPipe) — **opt-in, off by default, see below** | | 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) | | `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` | | `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. | | `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`) | | `KIOSK_USERNAME` | Autologin account name (`kiosk`) |
| `THINCLIENT_NAME` / `IMAGE_HOSTNAME` | Per-room identity; each thin client needs its own | | `THINCLIENT_NAME` / `IMAGE_HOSTNAME` | Per-room identity; each thin client needs its own |
| `ENABLE_STEAM_LINK` | `true`/`false` | | `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 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. `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 ## Home Assistant entities
`thinclient-agent` publishes MQTT-discovery configs on connect. Under the MQTT `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 - **Show digest canvas** (button) — switches to `2:digest` and reloads Firefox
- **Digest detail level** (select) — `compact` / `full` - **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) - **Launch Firefox**, **Launch web browser**, **Launch Steam Link** (buttons)
- **Playback state** (sensor, with track metadata as attributes), **Volume** (number), - **Playback state** (sensor, with track metadata as attributes), **Volume** (number),
and play/pause / next / previous / stop (buttons) 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 disambiguation when several people are in the room) and sends an already-resolved
request; this agent only shows what it is told to show. 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 ## Manual verification still outstanding
None of this has been run on hardware. In rough order: 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_<user>`/`SAMBA_VOLUME_CONFIG_<name>` env-var syntax in `ACCOUNT_<user>`/`SAMBA_VOLUME_CONFIG_<name>` env-var syntax in
`setup-container-host.sh` is confirmed against that project's documentation, but `setup-container-host.sh` is confirmed against that project's documentation, but
the container itself was never actually run. 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.

View File

@ -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)

View File

@ -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

View File

@ -14,13 +14,15 @@ from datetime import datetime, timezone
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from .admin_canvas import AdminCanvas
from .audio_control import AudioControl from .audio_control import AudioControl
from .capture_control import CaptureControl, find_audio_card
from .digest_canvas import DETAIL_LEVELS, DigestCanvas from .digest_canvas import DETAIL_LEVELS, DigestCanvas
from .input_control import InputControl from .input_control import InputControl
from .mpris_bridge import MprisBridge from .mpris_bridge import MprisBridge
from .mqtt_discovery import Discovery from .mqtt_discovery import Discovery
from .remote_desktop import RemoteDesktop 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") CONFIG_PATH = os.environ.get("THINCLIENT_AGENT_CONFIG", "/etc/thinclient-agent/config.env")
@ -31,11 +33,17 @@ CONFIG_KEYS = (
"MQTT_PASSWORD", "MQTT_PASSWORD",
"HA_URL", "HA_URL",
"DIGEST_WEB_URL", "DIGEST_WEB_URL",
"ADMIN_WEB_URL",
"KIOSK_USERNAME", "KIOSK_USERNAME",
"THINCLIENT_NAME", "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/<node_id> namespace. # Fixed, household-wide — not under this device's own thinclient/<node_id> namespace.
# "Was the digest looked at" is a fact about the digest, not about this specific # "Was the digest looked at" is a fact about the digest, not about this specific
@ -149,8 +157,10 @@ def main() -> int:
sway = SwayControl() sway = SwayControl()
canvas = DigestCanvas(sway, config.get("DIGEST_WEB_URL", "")) canvas = DigestCanvas(sway, config.get("DIGEST_WEB_URL", ""))
admin_canvas = AdminCanvas(sway, config.get("ADMIN_WEB_URL", ""))
apps = build_apps(config) apps = build_apps(config)
audio = AudioControl(sway.session_env) audio = AudioControl(sway.session_env)
capture = CaptureControl()
remote = RemoteDesktop(sway) remote = RemoteDesktop(sway)
keyboard = InputControl(sway.session_env) keyboard = InputControl(sway.session_env)
@ -199,9 +209,41 @@ def main() -> int:
# viewed. See digest-engine/viewed_tracker.py for what reads this. # viewed. See digest-engine/viewed_tracker.py for what reads this.
publish_digest_viewed(client) 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: def on_audio_output(payload: str) -> None:
discovery.publish_audio_output(audio.select(payload)) 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: def on_remote_target(payload: str) -> None:
discovery.publish_remote_target(remote.select(payload)) 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) log.info("connected to MQTT broker %s:%s", broker_host, broker_port)
discovery.register_media_player(mpris.handle_command, mpris.set_volume) 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_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_app_launchers(apps, on_launch)
discovery.register_workspace_select(WORKSPACES, on_workspace, WS_DIGEST) discovery.register_workspace_select(WORKSPACES, on_workspace, WS_DIGEST)
# audio.apply_preferred() already ran once at startup (before MQTT was even # 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. # reflect this exact moment rather than whatever was plugged in at boot.
audio.list_sinks() audio.list_sinks()
discovery.register_audio_output(audio.options(), on_audio_output, audio.current_option()) 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( discovery.register_remote_desktop(
remote.options(), on_remote_target, remote.connect, remote.disconnect, remote.current 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.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGINT, handle_signal)
capture_thread: threading.Thread | None = None
if not broker_host: if not broker_host:
log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH) log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH)
else: else:
@ -262,6 +310,13 @@ def main() -> int:
# this agent must never be able to stall the session waiting on the broker. # this agent must never be able to stall the session waiting on the broker.
client.connect_async(broker_host, broker_port, keepalive=60) client.connect_async(broker_host, broker_port, keepalive=60)
client.loop_start() 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) log.info("thinclient-agent %s started (node_id=%s)", node_id, node_id)
try: try:
@ -272,6 +327,8 @@ def main() -> int:
discovery.publish_available(False) discovery.publish_available(False)
client.loop_stop() client.loop_stop()
client.disconnect() client.disconnect()
if capture_thread is not None:
capture_thread.join(timeout=5)
return 0 return 0

View File

@ -38,6 +38,7 @@ class Discovery:
self.media_state_topic = f"{self.base}/media/state" self.media_state_topic = f"{self.base}/media/state"
self.detail_level_state_topic = f"{self.base}/digest/detail_level/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.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.remote_target_state_topic = f"{self.base}/remote/target/state"
self.input_text_state_topic = f"{self.base}/input/text/state" self.input_text_state_topic = f"{self.base}/input/text/state"
self._handlers: dict[str, Callable[[str], None]] = {} self._handlers: dict[str, Callable[[str], None]] = {}
@ -190,6 +191,26 @@ class Discovery:
) )
self.publish_detail_level(current_level) 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: def register_app_launchers(self, apps, on_launch) -> None:
for key, app in apps.items(): for key, app in apps.items():
self._publish_config( self._publish_config(
@ -248,6 +269,31 @@ class Discovery:
def publish_audio_output(self, option: str) -> None: def publish_audio_output(self, option: str) -> None:
self.client.publish(self.audio_sink_state_topic, option, qos=1, retain=True) 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: def register_remote_desktop(self, targets, on_select, on_connect, on_disconnect, current) -> None:
self._publish_config( self._publish_config(
"select", "select",

View File

@ -13,6 +13,8 @@ log = logging.getLogger(__name__)
WS_WEB = "1:web" WS_WEB = "1:web"
WS_DIGEST = "2:digest" WS_DIGEST = "2:digest"
WS_MEDIA = "3:media" WS_MEDIA = "3:media"
WS_ADMIN = "4:admin"
WS_CAPTURE = "5:capture"
def runtime_dir() -> str: def runtime_dir() -> str:
@ -112,5 +114,9 @@ class SwayControl:
except OSError as exc: except OSError as exc:
log.error("could not launch %s: %s", " ".join(command), exc) log.error("could not launch %s: %s", " ".join(command), exc)
def open_url(self, url: str) -> None: def open_url(self, url: str, browser_command: str | None = None) -> None:
self.launch_app([self.browser_command, url]) # 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])

View File

@ -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"

View File

@ -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 "$@"

View File

@ -6,11 +6,14 @@
# here without this file being templated. # here without this file being templated.
set $mod Mod4 set $mod Mod4
set $ws_web 1:web set $ws_web 1:web
set $ws_digest 2:digest set $ws_digest 2:digest
set $ws_media 3:media 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. # 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+1 workspace $ws_web
bindsym $mod+2 workspace $ws_digest bindsym $mod+2 workspace $ws_digest
bindsym $mod+3 workspace $ws_media 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+Left focus left
bindsym $mod+Right focus right bindsym $mod+Right focus right
bindsym $mod+Up focus up bindsym $mod+Up focus up

View File

@ -8,10 +8,10 @@
# either opens an invisible tab or a second window that accumulates over time. Killing # 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. # 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 # Its own profile, separate from /usr/local/bin/web-browser's (and, since Phase 13,
# only be open in one process, so sharing one would mean opening the browsable window # /usr/local/bin/admin-browser's): a Firefox profile can only be open in one process,
# closed the digest canvas. Both profiles carry the same user.js, chrome/userChrome.css # so sharing one would mean opening the browsable window closed the digest canvas.
# and enterprise policies. # Both profiles carry the same user.js, chrome/userChrome.css and enterprise policies.
set -eu set -eu
PROFILE_DIR="${HOME:-/home/$(id -un)}/.mozilla/firefox/digest" 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/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 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. # Wait for the old process to release its profile lock before relaunching.
i=0 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 sleep 0.25
i=$((i + 1)) i=$((i + 1))
done done

View File

@ -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). # 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 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 # The container host's gallery-smb share (ENABLE_GALLERY_SMB in
# setup-container-host.sh), used by the idle-timeout slideshow. Just the host — # setup-container-host.sh), used by the idle-timeout slideshow. Just the host —
# idle-gallery.sh always mounts the fixed "gallery" share name. # 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." echo " and boots fine without it — the digest workspace just won't load anything."
fi 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 if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room" 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." 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 "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
echo "Home Assistant : $HA_URL" echo "Home Assistant : $HA_URL"
echo "digest-web : $DIGEST_WEB_URL" echo "digest-web : $DIGEST_WEB_URL"
echo "admin-web : $ADMIN_WEB_URL"
echo "Steam Link : $ENABLE_STEAM_LINK" echo "Steam Link : $ENABLE_STEAM_LINK"
echo "Voice satellite : $ENABLE_VOICE_SATELLITE" echo "Voice satellite : $ENABLE_VOICE_SATELLITE"
echo "Gesture control : $ENABLE_GESTURE_CONTROL" 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}/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/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 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" 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} HA_URL=${HA_URL}
DIGEST_WEB_URL=${DIGEST_WEB_URL} DIGEST_WEB_URL=${DIGEST_WEB_URL}
ADMIN_WEB_URL=${ADMIN_WEB_URL}
GALLERY_SMB_HOST=${GALLERY_SMB_HOST} GALLERY_SMB_HOST=${GALLERY_SMB_HOST}
ENABLE_STEAM_LINK=${ENABLE_STEAM_LINK} ENABLE_STEAM_LINK=${ENABLE_STEAM_LINK}
@ -407,8 +423,9 @@ echo " 5. Confirm thinclient-agent connected and registered:"
echo " systemctl status thinclient-agent" echo " systemctl status thinclient-agent"
echo " In Home Assistant, a '${THINCLIENT_NAME}' device should appear under the MQTT" 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 " integration with: Show digest canvas (button), Digest detail level (select),"
echo " Workspace (select), Launch Firefox / Launch Steam Link (buttons), Volume" echo " Show admin canvas (button), Workspace (select), Launch Firefox / Launch Steam"
echo " (number), Playback state (sensor), and play/pause/next/prev buttons." 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 " 6. Start something in mpv and confirm Playback state follows it (this goes through"
echo " mpv-mpris -> playerctl -> the agent's MPRIS bridge)." echo " mpv-mpris -> playerctl -> the agent's MPRIS bridge)."
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then