154 lines
6.6 KiB
Markdown
154 lines
6.6 KiB
Markdown
# 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.
|