SmartestHome/admin-canvas
Amir Alexander Abdelbaki 1d8b416ab9 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
2026-07-29 11:03:11 +02:00
..
render Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00
.dockerignore Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00
Dockerfile Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00
README.md Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00
admin-canvas.env.example Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00
server.py Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00

README.md

admin-canvas

The sys-admin-llm's display surface, from Phase 13 of the project plan.

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

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

{ "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)

{ "kind": "chart", "title": "Kitchen outlets — right now",
  "content": { "type": "bar", "unit": "W",
    "points": [ { "label": "Fridge", "value": 42 }, { "label": "Kettle", "value": 1800 } ] } }
{ "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>

{ "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:

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:

# 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

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.