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
|
||
|---|---|---|
| .. | ||
| render | ||
| .dockerignore | ||
| Dockerfile | ||
| README.md | ||
| admin-canvas.env.example | ||
| server.py | ||
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. AcceptsPOST /showandPOST /media/<filename>, both bearer-token gated, writes to a sharedoutput/volume. Has no published port — reachable only from other containers on the compose network (i.e. Home Assistant), the same trust boundarymosquitto/homeassistantalready share.admin-web(nginx, defined insetup-container-host.sh, not here) — servesoutput/,render/canvas-sdk/, andrender/templates/canvas.htmlread-only to the LAN, exactly likedigest-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.