Stream Dock lighting controls, and Discord on the Loggia panel

Two changes that share the config file, the exporter and the validator, so
they land together.

--- stream-dock/ ---

Four rotary encoders on a desk-side MiraBox N4 Pro (the xVSDinside-branded
one; Ajazz AKP05 family) as R / G / B / brightness for one room's lamps,
through OpenDeck.

No new plugin, deliberately. cgiesche/streamdeck-homeassistant already does
Home Assistant over websocket with encoder actions, and opendeck-akp05 already
teaches OpenDeck this non-Elgato hardware. What was missing was the
configuration between them, and one thing neither can do: relative colour. An
encoder emits "three ticks clockwise" and HA has brightness_step_pct but no
equivalent for a colour channel, so the dock sends only which channel and how
many ticks, and ha-package/stream_dock.yaml does the read-clamp-write against
the lamp's current rgb_color. Room-agnostic — the scripts take entity_id as a
field, so one copy serves every room and a second dock needs no new HA config.

Bindings are generated as a document to paste, not as an OpenDeck profile
file. That schema is not documented anywhere this could be checked against,
and a profile written to a guessed schema fails in the least useful way
available: OpenDeck starts, the profile looks present, the dials do nothing.

Rings 1-3 show their own channel's value in their own colour; ring 4 shows
what the room is actually emitting (rgb scaled by brightness). Off the dock's
lighting layer they fall back to the desktop's own palette from
~/Dotfiles/colors.conf, chasing one ring at a time, and Home Assistant is not
polled at all — the rings are shared hardware, and on another layer those
dials mean something else. A gate that cannot tell which layer is showing
reports "cannot tell", which is treated as "not ours": going idle is the
recoverable mistake, hijacking is not.

The one gap is the reload. The akp05 plugin reads leds.toml at startup and
holds the USB device open, so nothing else can drive those LEDs and no local
change makes it re-read. apply-leds.sh carries four strategies and a --probe
that walks them cheapest-first with the dock in front of you — its FIRST test
is whether the plugin already watches the file, in which case the rings are
live for free. The apply is rate-limited separately from the file write, so a
spun dial cannot re-initialise the device per detent and the last state is
never dropped. The real fix is upstream and small;
upstream-file-watch-request.md is written and ready to file.

Nothing here has touched hardware. Verified against a stub Home Assistant:
ring colours on/off, unreachable HA, the layer gate in all three states, the
idle chase, and the rate limiter (12 colour changes in 6s -> 3 applies, final
state on disk). Unverified, in bite-order: whether {{ticks}} substitutes (an
absolute-position variant is in the generated bindings if it does not), the
ring reload, the gate's discovery of OpenDeck's profile state, and the HA
scripts themselves.

--- Discord on the touch panel ---

An optional fourth app, kiosks[].enable_discord, validator-restricted to the
touch-panel type: no other image installs the Flatpak or has a workspace for
it, so elsewhere the flag would be silently ignored on a panel that boots
looking fine.

It is for the Loggia — the point is a voice call that survives stepping
outside for a cigarette, which is a switch-device action, not a read-messages
one. So it starts logged in at session boot and every control path (dock
button, HA "Show Discord", the Screen select) focuses the running app rather
than launching it. The Screen select's options are now derived from the app
table instead of being fixed, so a dropdown never offers a workspace this
image has no app for.

Three things follow from it being XWayland rather than native Wayland: sway
matches class, not app_id (an app_id rule would silently never fire); touch is
the emulated-pointer tier; and it deliberately does not inhibit idle, because
a call must not hold the panel's screen on in an empty room.

The Loggia panel itself is a new kiosk entry in the template. The real
CoreSystemConfig.json is gitignored, so its copy of that entry is local only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
main
Amir Alexander Abdelbaki 2026-08-31 13:04:17 +02:00
parent 2f42b88c19
commit 86fe981d7c
24 changed files with 2457 additions and 22 deletions

4
.gitignore vendored
View File

@ -121,3 +121,7 @@ hosts/audio-endpoint/rpi-image-gen/config/generated-*.yaml
tokens.txt tokens.txt
# Caddy's exported root CA (public, but not something to publish casually). # Caddy's exported root CA (public, but not something to publish casually).
proxy/ca/ proxy/ca/
# stream-dock/generate.py's output. led-sync.env holds the Home Assistant token, and
# bindings.md names every light entity in the room — regenerate it, never commit it.
stream-dock/generated/

View File

@ -214,6 +214,38 @@
"wake_word": "ok_nabu" "wake_word": "ok_nabu"
}, },
"stream_dock": {
"_comment": "A desk-side Stream Dock (MiraBox N4 Pro / Ajazz AKP05 family: 10 LCD keys, 4 RGB-lit rotary encoders) driving ONE room's colour lamps through OpenDeck. This block generates the dock's key/dial bindings, its knob-LED colours and the LED sync service's environment. It does not build an image and nothing here runs on the container host — the dock hangs off a desktop machine. See stream-dock/README.md.",
"_room": "An HA area_id, the same vocabulary as every kiosk (docs/rooms-and-endpoints.md). It labels the generated bindings and names the systemd unit; `lights` is what actually gets controlled, because a dial has to name entities, not an area.",
"enabled": false,
"room": "living_room",
"lights": ["light.living_room_lamp"],
"_steps": "How far one encoder detent moves things. rgb_step is in 0-255 channel units (8 = 32 detents end to end); brightness_step_pct is percentage points. tick_bucket_ms is the plugin's own aggregation window — ticks inside it are summed into one service call, so spinning the dial fast does not queue fifty calls at Home Assistant.",
"rgb_step": 8,
"brightness_step_pct": 5,
"tick_bucket_ms": 120,
"_knob_leds": "The four encoder rings. r/g/b each glow their own channel's current value in their own colour; the fourth glows in the colour the room is actually emitting (rgb scaled by brightness). THIS NEEDS THE DEVICE PLUGIN TO RE-READ leds.toml AT RUNTIME, WHICH IT DOES NOT DO TODAY — the sync service writes the file correctly and then runs apply_command, which is the hook where that gap gets closed. Read stream-dock/README.md section 5 before relying on it.",
"knob_leds": {
"enabled": true,
"brightness": 100,
"min_channel_led": 0,
"poll_seconds": 2.0,
"debounce_ms": 400,
"config_path": "",
"_apply": "How a fresh leds.toml gets in front of the device plugin, which reads that file at startup and (as far as anyone has checked) not again. apply_strategy is what stream-dock/apply-leds.sh does after each write: 'none' writes the file and stops there; 'signal' sends SIGHUP to the plugin process; 'restart-plugin' kills it and lets OpenDeck respawn it; 'restart-opendeck' restarts the whole app. RUN `stream-dock/apply-leds.sh --probe` WITH THE DOCK PLUGGED IN AND SET WHAT IT TELLS YOU — its first test is whether the plugin already picks the file up on its own, in which case 'none' is the right answer and the rings are live for free. apply_command overrides all of it with a command of your own; leave it empty to use the strategy.",
"apply_strategy": "none",
"apply_command": "",
"_apply_pacing": "apply_min_interval_seconds rate-limits the strategy: a spun dial changes the colour on every service call, and 'restart-plugin' at that rate would re-initialise the device continuously. The last state is never dropped — it is applied once the interval elapses. plugin_process_pattern is what pgrep matches to find the device plugin; the probe prints the candidates on your machine.",
"apply_min_interval_seconds": 2.0,
"plugin_process_pattern": "akp05",
"_layer_gate": "The dock's lighting controls live on their own OpenDeck layer, and the rings belong to whatever layer is showing. layer_gate_command is run before every LED update: exit 0 means the lighting layer is up and the rings are ours to drive, exit 1 means it is not, and any other outcome (or a command that cannot run) is treated as 'not ours' — a service that cannot tell which layer is showing must not paint over another one's rings. Empty means no gate: drive the rings always. stream-dock/layer-active.sh is a starting implementation, and it needs verifying against a real OpenDeck install — see stream-dock/README.md section 6.",
"layer_gate_command": "",
"_idle": "What the rings do when the lighting layer is NOT showing. Defaults to the CyberQueer palette from ~/Dotfiles/colors.conf — COLOR_HIGHLIGHT, COLOR_DARK, COLOR_RED — chasing across the four rings, so an idle dock matches the rest of the desktop instead of sitting on the last lamp colour it happened to see. Bare 6-digit hex, same format as colors.conf. An empty list leaves the rings untouched instead. idle_cycle_seconds is how long each step holds; 0 holds the first colour forever, which is what you want when apply_command is expensive, because every step of the chase costs one apply.",
"idle_colors": ["E40046", "5018DD", "F50505"],
"idle_cycle_seconds": 3.0
}
},
"kiosks": [ "kiosks": [
{ {
"_comment": "type must be one of: thin-client, touch-panel, door-panel, kitchen-display, steam-tv-box. hostname must be unique and a valid DNS label — it is what the HA device shows up as.", "_comment": "type must be one of: thin-client, touch-panel, door-panel, kitchen-display, steam-tv-box. hostname must be unique and a valid DNS label — it is what the HA device shows up as.",
@ -254,6 +286,18 @@
"voice_satellite": false, "voice_satellite": false,
"enable_installer": false "enable_installer": false
}, },
{
"_comment": "The Loggia's Lenovo all-in-one (docs/components.md #Have). Same touch-panel image as the kitchen's, plus Discord: this is the balcony panel, and the point of the fourth app is that a voice call survives stepping outside — you switch the call onto this machine on the way out and back onto the desktop on the way in.",
"type": "touch-panel",
"hostname": "touch-panel-loggia",
"room": "loggia",
"friendly_name": "Loggia touch panel",
"kiosk_username": "kiosk",
"voice_satellite": false,
"enable_installer": false,
"_comment_enable_discord": "touch-panel only. Installs the Discord Flatpak, adds a 4:discord workspace, a Discord dock button and an HA app-launch button, and starts it logged-in at session boot. Off everywhere else — a kitchen panel does not want a chat client.",
"enable_discord": true
},
{ {
"_comment": "The living-room gaming box. Games run ON this machine (real GPU/CPU) — it is not the thin client's Steam Link streaming. Boots straight into Steam Big Picture; the media apps (Firefox+uBlock Origin, Spotify, mpv) are launched lazily the first time somebody leaves Big Picture. Prism Launcher is installed and registered as a non-Steam game so it runs through Steam with Steam Input active.", "_comment": "The living-room gaming box. Games run ON this machine (real GPU/CPU) — it is not the thin client's Steam Link streaming. Boots straight into Steam Big Picture; the media apps (Firefox+uBlock Origin, Spotify, mpv) are launched lazily the first time somebody leaves Big Picture. Prism Launcher is installed and registered as a non-Steam game so it runs through Steam with Steam Input active.",
"type": "steam-tv-box", "type": "steam-tv-box",

View File

@ -83,11 +83,17 @@ chores/ Presence/calendar-driven household chore nudging +
passive fairness tally + camera-verified trash-bin/ passive fairness tally + camera-verified trash-bin/
dishes/litter checks (systemd-timed oneshot, no dishes/litter checks (systemd-timed oneshot, no
long-lived service, no LLM-picked assignment) long-lived service, no LLM-picked assignment)
stream-dock/ Desk-side MiraBox N4 Pro (10 keys, 4 encoders) driving
one room's colour lamps through OpenDeck: an HA script
package for the relative-colour maths, generated dial/key
bindings, and a knob-ring colour service gated on the
dock's lighting layer
``` ```
## Status ## Status
- [x] Project plan drafted - [x] Project plan drafted
- [ ] **Stream Dock lighting controls** (`stream-dock/`) — four rotary encoders on a desk-side **MiraBox N4 Pro** (the xVSDinside-branded one; Ajazz AKP05 family) as R / G / B / brightness for one room, through **OpenDeck**. **No new plugin was written, deliberately**: [streamdeck-homeassistant](https://github.com/cgiesche/streamdeck-homeassistant) already does HA-over-websocket with encoder actions, and [opendeck-akp05](https://github.com/aroaxinping/opendeck-akp05) already teaches OpenDeck this non-Elgato hardware — what was missing was the configuration between them, which is what this directory is. The one thing neither plugin can do is **relative colour**: an encoder emits "three ticks clockwise" and HA has `brightness_step_pct` but no equivalent for a colour channel, so the dock sends only *which channel, how many ticks* and `ha-package/stream_dock.yaml` does the read-clamp-write against the lamp's current `rgb_color`. Rings 13 glow their own channel's value in their own colour and ring 4 glows in **what the room is actually emitting** (rgb scaled by brightness); off the dock's lighting layer they fall back to the desktop's own `#E40046`/`#5018DD`/`#F50505` chase and Home Assistant stops being polled at all. **Nothing has touched hardware.** The ring colours are computed, debounced and written correctly — verified against a stub HA — but the akp05 plugin reads `leds.toml` only at startup and holds the USB device open, so the last hop is an `apply_command` hook that ships empty; the real fix is a file-watch upstream. The layer gate ships answering *"I cannot tell"* rather than guessing OpenDeck's undocumented profile-state schema, and `{{ticks}}` is the first thing to test before binding four dials — see `stream-dock/README.md` §57
- [x] Container host setup script v1 (HA, Mosquitto, Zigbee2MQTT USB, Frigate, Grocy) - [x] Container host setup script v1 (HA, Mosquitto, Zigbee2MQTT USB, Frigate, Grocy)
- [x] Node-RED + monitoring (Netdata) + dashboard (Homepage) + ntfy + Portainer added to compose stack - [x] Node-RED + monitoring (Netdata) + dashboard (Homepage) + ntfy + Portainer added to compose stack
- [x] Backup (restic) setup — scripted, off by default until a backup target is picked (`ENABLE_BACKUPS`) - [x] Backup (restic) setup — scripted, off by default until a backup target is picked (`ENABLE_BACKUPS`)
@ -103,7 +109,7 @@ chores/ Presence/calendar-driven household chore nudging +
- [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 - [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 - [ ] 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
- [ ] Headless audio endpoint (`hosts/audio-endpoint/`) — per-room independent Spotify Connect appliance for rooms without a thin client, arm64 (Raspberry Pi + HiFiBerry Amp2, rpi-image-gen) and amd64 (mini PC + USB DAC/amp, live-build) build pipelines written, **neither built/flashed/booted on real hardware** — rpi-image-gen's exact config schema in particular is unverified, see `hosts/audio-endpoint/README.md` - [ ] Headless audio endpoint (`hosts/audio-endpoint/`) — per-room independent Spotify Connect appliance for rooms without a thin client, arm64 (Raspberry Pi + HiFiBerry Amp2, rpi-image-gen) and amd64 (mini PC + USB DAC/amp, live-build) build pipelines written, **neither built/flashed/booted on real hardware** — rpi-image-gen's exact config schema in particular is unverified, see `hosts/audio-endpoint/README.md`
- [ ] Sway touch panel (`hosts/touch-panel/`) — touch-driven Sway image: full Spotify GUI (Flathub), a dedicated Home Assistant Chromium kiosk window, a general web browser, an always-on touch dock for app switching, an on-screen keyboard (toggled manually, no auto-show), and `touchpanel-agent` (HA MQTT control, same LLM-mediated-through-HA security model as the thin client) — built, **no touch-panel hardware chosen and nothing booted on real metal**, see `hosts/touch-panel/README.md` - [ ] Sway touch panel (`hosts/touch-panel/`) — touch-driven Sway image: full Spotify GUI (Flathub), a dedicated Home Assistant Chromium kiosk window, a general web browser, an always-on touch dock for app switching, an on-screen keyboard (toggled manually, no auto-show), and `touchpanel-agent` (HA MQTT control, same LLM-mediated-through-HA security model as the thin client) — built, **no touch-panel hardware chosen and nothing booted on real metal**, see `hosts/touch-panel/README.md`. **Discord is now an optional fourth app** (`kiosks[].enable_discord`, validator-restricted to this image type), which is what the Loggia panel — the Lenovo all-in-one already owned — is for: the point is a voice call that survives stepping outside for a cigarette, so it starts logged-in at session boot and every control path (dock button, HA **Show Discord**, the **Screen** select, whose options are now derived from the app table rather than fixed) focuses the running app instead of launching it. It is an XWayland window, so sway matches `class` rather than `app_id`, touch is the emulated-pointer tier, and it deliberately does not inhibit idle — a call must not hold the panel's screen on in an empty room
- [ ] Steam TV box (`hosts/steam-tv-box/`) — living-room machine that boots straight into **Steam Big Picture** and plays games **locally** (native `steam-installer` + i386 multiarch + full Mesa/Vulkan stack, `steam-devices`, gamemode, gamescope when the release has it) — the opposite end of `hosts/thin-client/`'s Steam Link streaming, and the only host here pinned to **trixie** (per-kiosk `debian_release` override; bookworm's Mesa 22.3 is too old for a machine that renders). **Prism Launcher** is installed from Flathub and registered as a *non-Steam game* so Minecraft runs through Steam with Steam Input and the Steam Controller API live. Leaving Big Picture drops into the same media set as the other monitor clients — Firefox with uBlock Origin/SponsorBlock, Spotify, mpv — **launched lazily**, on two independent triggers (the Steam process exiting, and a sway workspace-focus IPC watcher), both idempotent, and never torn down when you go back into a game. `steamtv-agent` adds a session-mode sensor, mode/screen/audio-output selects, app-launch buttons and a CEC display switch over the same MQTT-only control surface as the other kiosks. Written and validated end-to-end through `validate-config.py`/`config-export.py`, **never built, never flashed, never booted, no hardware chosen** — the `shortcuts.vdf` binary format, the Flathub app IDs, gamescope's availability and the NVIDIA package names are all reasoned rather than verified, see `hosts/steam-tv-box/README.md` - [ ] Steam TV box (`hosts/steam-tv-box/`) — living-room machine that boots straight into **Steam Big Picture** and plays games **locally** (native `steam-installer` + i386 multiarch + full Mesa/Vulkan stack, `steam-devices`, gamemode, gamescope when the release has it) — the opposite end of `hosts/thin-client/`'s Steam Link streaming, and the only host here pinned to **trixie** (per-kiosk `debian_release` override; bookworm's Mesa 22.3 is too old for a machine that renders). **Prism Launcher** is installed from Flathub and registered as a *non-Steam game* so Minecraft runs through Steam with Steam Input and the Steam Controller API live. Leaving Big Picture drops into the same media set as the other monitor clients — Firefox with uBlock Origin/SponsorBlock, Spotify, mpv — **launched lazily**, on two independent triggers (the Steam process exiting, and a sway workspace-focus IPC watcher), both idempotent, and never torn down when you go back into a game. `steamtv-agent` adds a session-mode sensor, mode/screen/audio-output selects, app-launch buttons and a CEC display switch over the same MQTT-only control surface as the other kiosks. Written and validated end-to-end through `validate-config.py`/`config-export.py`, **never built, never flashed, never booted, no hardware chosen** — the `shortcuts.vdf` binary format, the Flathub app IDs, gamescope's availability and the NVIDIA package names are all reasoned rather than verified, see `hosts/steam-tv-box/README.md`
- [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes. All four stock movements now run off the camera — **unload** (a scan loop, one confirm per item, with pack size and where to put it away), **consume** (asks which brand and how many), **list expired** (cleared by scanning what you're binning, booked out as spoiled), and **edit inventory** (the deliberately camera-free correction screen) — with two invariants: stock counts *individual units* (a twelve-pack of eggs is twelve) and folds *brand-free* via Grocy product groups (12 of brand X + 10 of brand Y = 22 eggs, expandable per brand). Built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md` - [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes. All four stock movements now run off the camera — **unload** (a scan loop, one confirm per item, with pack size and where to put it away), **consume** (asks which brand and how many), **list expired** (cleared by scanning what you're binning, booked out as spoiled), and **edit inventory** (the deliberately camera-free correction screen) — with two invariants: stock counts *individual units* (a twelve-pack of eggs is twelve) and folds *brand-free* via Grocy product groups (12 of brand X + 10 of brand Y = 22 eggs, expandable per brand). Built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md`
- [x] Where-is-it-actually, for multiple fridges — `docs/fridge-item-location.md`: separates "which appliance" (a software-only change: Grocy locations + a transfer action) from "which shelf" (a *hint* at best) and "exact position" (occlusion makes it unbuildable), and rules out interior cameras on power/condensation/18 °C grounds — which is exactly the compartment the question starts from. **Built**: appliances as Grocy locations, `POST /transfer` with a "Move to…" picker on the edit screen, and the door-sensor→camera→hint path (`pantry-vision/doorway.py`, `POST /doorway-event` — an HA automation on a Zigbee contact sensor, answered 202 with the camera burst running off-thread; what it recognises is stored in its own SQLite file with a timestamp and a confidence and is **never** written to stock). **Not bought, not tested**: no door sensor, no doorway camera, and the assumption the camera half rests on — that a local vision model can identify an item in a moving hand at ~1.5 m — has never been checked (open decision #40). An appliance can be configured with a sensor and no camera, which is still the recommended way to start - [x] Where-is-it-actually, for multiple fridges — `docs/fridge-item-location.md`: separates "which appliance" (a software-only change: Grocy locations + a transfer action) from "which shelf" (a *hint* at best) and "exact position" (occlusion makes it unbuildable), and rules out interior cameras on power/condensation/18 °C grounds — which is exactly the compartment the question starts from. **Built**: appliances as Grocy locations, `POST /transfer` with a "Move to…" picker on the edit screen, and the door-sensor→camera→hint path (`pantry-vision/doorway.py`, `POST /doorway-event` — an HA automation on a Zigbee contact sensor, answered 202 with the camera burst running off-thread; what it recognises is stored in its own SQLite file with a timestamp and a confidence and is **never** written to stock). **Not bought, not tested**: no door sensor, no doorway camera, and the assumption the camera half rests on — that a local vision model can identify an item in a moving hand at ~1.5 m — has never been checked (open decision #40). An appliance can be configured with a sensor and no camera, which is still the recommended way to start

View File

@ -153,6 +153,7 @@ near infinite ATX Pcs
1xHA Voice PE 1xHA Voice PE
1xZigbee adapter 1xZigbee adapter
1xLenovo all-in-one PC, large built-in touchscreen (Loggia's Miniscreen System — free, already have it) 1xLenovo all-in-one PC, large built-in touchscreen (Loggia's Miniscreen System — free, already have it)
1xMiraBox N4 Pro stream dock, xVSDinside-branded (10 LCD keys, 4 RGB-lit rotary encoders) — sits on a desk, not in a room's build; drives one room's colour lamps via OpenDeck, see stream-dock/
##Need ##Need

View File

@ -2,8 +2,9 @@
Phase 16 of `docs/project-plan.md`. Builds a Debian 12 live ISO for a touch-driven Phase 16 of `docs/project-plan.md`. Builds a Debian 12 live ISO for a touch-driven
wall/counter panel: autologin into Sway, three fixed apps (Spotify, Home Assistant, wall/counter panel: autologin into Sway, three fixed apps (Spotify, Home Assistant,
a general web browser) switched with a finger via an always-on dock, and also a general web browser) — four where the panel asks for **Discord** — switched with a
controllable by Home Assistant/the local LLM over MQTT. finger via an always-on dock, and also controllable by Home Assistant/the local LLM
over MQTT.
**A different device from `../thin-client/`**, not a variant of it. The thin client is **A different device from `../thin-client/`**, not a variant of it. The thin client is
a couch-distance media station whose primary control surface is HA/MQTT and wayvnc, a couch-distance media station whose primary control surface is HA/MQTT and wayvnc,
@ -17,10 +18,11 @@ What ends up on the image:
| | | | | |
|---|---| |---|---|
| Compositor | Sway, workspaces `1:spotify` / `2:home` / `3:web`, always-on touch dock | | Compositor | Sway, workspaces `1:spotify` / `2:home` / `3:web` (+ `4:discord` where enabled), always-on touch dock |
| Autologin | greetd, `default_session` straight into `/usr/local/bin/kiosk-session` | | Autologin | greetd, `default_session` straight into `/usr/local/bin/kiosk-session` |
| Touch input | Native Wayland `wl_touch` preferred; degrades to a usable single-touch pointer if the hardware only exposes an emulated-mouse HID interface — see below | | Touch input | Native Wayland `wl_touch` preferred; degrades to a usable single-touch pointer if the hardware only exposes an emulated-mouse HID interface — see below |
| Music | Full Spotify GUI client (Flathub `com.spotify.Client`) — not a headless Connect receiver | | Music | Full Spotify GUI client (Flathub `com.spotify.Client`) — not a headless Connect receiver |
| Chat / voice | Discord (Flathub `com.discordapp.Discord`), **only where `enable_discord` is set** — see below |
| Home dashboard | Chromium in kiosk mode, `--app=$HA_URL`, auto-restart if it crashes | | Home dashboard | Chromium in kiosk mode, `--app=$HA_URL`, auto-restart if it crashes |
| Web browser | Firefox, minimal chrome (back/forward/reload/address bar), general browsing | | Web browser | Firefox, minimal chrome (back/forward/reload/address bar), general browsing |
| On-screen keyboard | wvkbd, toggled from the dock — see the caveat below, no auto-show | | On-screen keyboard | wvkbd, toggled from the dock — see the caveat below, no auto-show |
@ -139,8 +141,9 @@ panel and adjust `PLAYER_PRIORITY` in `mpris_bridge.py`.
## Touch dock ## Touch dock
An always-visible bar reserved at the bottom of the screen (`configs/eww/`, layer-shell An always-visible bar reserved at the bottom of the screen (`configs/eww/`, layer-shell
`:exclusive true` so nothing ever tiles under or draws over it) with four buttons: `:exclusive true` so nothing ever tiles under or draws over it) with four buttons —
**Spotify**, **Home**, **Web**, and **Keyboard**. The first three call `swaymsg five on a panel with Discord: **Spotify**, **Home**, **Web**, (**Discord**), and
**Keyboard**. The workspace ones call `swaymsg
workspace` directly — fixed constants in `eww.yuck`, nothing from MQTT/HA is ever workspace` directly — fixed constants in `eww.yuck`, nothing from MQTT/HA is ever
interpolated into them, same rule as the thin client's now-playing widget. This is the interpolated into them, same rule as the thin client's now-playing widget. This is the
touch-first equivalent of the thin client's HA **Screen** select entity — both exist, touch-first equivalent of the thin client's HA **Screen** select entity — both exist,
@ -157,6 +160,37 @@ there is no on-screen app switcher at all. The panel still boots and shows Home;
LLM-driven app switching via `touchpanel-agent`'s **Screen** select and **Show LLM-driven app switching via `touchpanel-agent`'s **Screen** select and **Show
\<app\>** buttons still works either way, since that's a separate control path. \<app\>** buttons still works either way, since that's a separate control path.
## Discord — a per-panel fourth app
Off by default; turned on per panel with `enable_discord` on that kiosk in
`CoreSystemConfig.json`. `tools/validate-config.py` rejects it on any other image
type — no other build installs the Flatpak or has a workspace for it, so the flag
would otherwise be silently ignored on a panel that boots looking fine.
**What it is for.** The Loggia panel is the balcony one, and the point is that a voice
call survives stepping outside for a cigarette: you move the call onto this machine on
the way out and back onto the desktop on the way in. That is a *switch device* action,
which is why Discord starts at session boot and stays logged in rather than launching
on demand — an app that needs fifteen seconds and a login is not one you use on the
way past. It is also why the HA **Show Discord** button and the dock button both
resolve to focus-the-running-app, exactly like Home and Spotify.
Three things follow from it being an Electron/XWayland app rather than a native
Wayland one:
- Sway matches it on `class="discord"`, **not** `app_id` — an `app_id` rule would
silently never fire.
- Touch arrives as emulated pointer events, so it lands in the same tier-2 behaviour
described under *Touch input* below: tap and drag work, two-finger scrolling in the
message list may not. Fine for join / mute / leave, which is the job.
- It deliberately does **not** get `inhibit_idle`. A call would otherwise hold the
panel's display on for an hour in an empty room; the screen blanks and the call
keeps running, because sway's idle timeout powers off the output, it does not
suspend the machine or the audio stream.
Which audio device the call actually uses is Discord's own setting, on-device, once —
this image does not manage it, and `touchpanel-agent` has no entity for it.
## On-screen keyboard — no auto-show, by design ## On-screen keyboard — no auto-show, by design
`configs/keyboard/toggle-keyboard` toggles `wvkbd` on and off. There is deliberately `configs/keyboard/toggle-keyboard` toggles `wvkbd` on and off. There is deliberately
@ -177,11 +211,14 @@ placeholder shape as `eww` and the thin client's `spotifyd`/`librespot` install.
`touchpanel-agent` publishes MQTT-discovery configs on connect. Under the MQTT `touchpanel-agent` publishes MQTT-discovery configs on connect. Under the MQTT
integration you should get one device per touch panel with: integration you should get one device per touch panel with:
- **Show Spotify**, **Show Home**, **Show web browser** (buttons) — switch workspace - **Show Spotify**, **Show Home**, **Show web browser**, and on a Discord panel
**Show Discord** (buttons) — switch workspace
and, for Spotify/Home, focus the already-running app rather than relaunching it and, for Spotify/Home, focus the already-running app rather than relaunching it
(both are meant to stay open and stateful, unlike the thin client's stateless (both are meant to stay open and stateful, unlike the thin client's stateless
digest/admin kiosk pages) digest/admin kiosk pages)
- **Screen** (select) — `1:spotify` / `2:home` / `3:web` - **Screen** (select) — `1:spotify` / `2:home` / `3:web`, plus `4:discord` on a panel
that has it. The options are derived from the app table rather than fixed, so a
dropdown never offers a workspace this image has no app for
- **Playback state** (sensor, with track metadata as attributes), **Volume** - **Playback state** (sensor, with track metadata as attributes), **Volume**
(number), and play/pause / next / previous / stop (buttons), bridged from (number), and play/pause / next / previous / stop (buttons), bridged from
Spotify's own MPRIS interface Spotify's own MPRIS interface
@ -217,6 +254,13 @@ None of this has been run on hardware. In rough order:
4. `touchpanel-agent` connects to Mosquitto and the device appears in HA. 4. `touchpanel-agent` connects to Mosquitto and the device appears in HA.
5. **Flathub app ID `com.spotify.Client` is correct** — flagged for verification in 5. **Flathub app ID `com.spotify.Client` is correct** — flagged for verification in
`0300-flatpak-spotify.hook.chroot`, same as the thin client's Steam Link ID. `0300-flatpak-spotify.hook.chroot`, same as the thin client's Steam Link ID.
5b. **Flathub app ID `com.discordapp.Discord` is correct**, on a Discord panel —
flagged in `0350-flatpak-discord.hook.chroot` for the same reason.
5c. **Discord's real X11 `class`** — assumed `discord`, which is what the sway
fullscreen rule and the agent's focus criteria both match on. `swaymsg -t
get_tree` on the booted panel is the way to check; if it is capitalised or
suffixed, the app opens in a window that never goes fullscreen and **Show
Discord** switches workspace without focusing anything.
6. Spotify login persists in `~/.var/app/com.spotify.Client` across a reboot. 6. Spotify login persists in `~/.var/app/com.spotify.Client` across a reboot.
7. Spotify's real MPRIS bus name — assumed `spotify`, not confirmed (see the Spotify 7. Spotify's real MPRIS bus name — assumed `spotify`, not confirmed (see the Spotify
section above); `playerctl -l` on the booted panel is the way to check. section above); `playerctl -l` on the booted panel is the way to check.

View File

@ -14,7 +14,7 @@ import paho.mqtt.client as mqtt
from .mpris_bridge import MprisBridge from .mpris_bridge import MprisBridge
from .mqtt_discovery import Discovery from .mqtt_discovery import Discovery
from .sway_control import WS_HOME, WS_SPOTIFY, WS_WEB, SwayControl from .sway_control import WS_DISCORD, WS_HOME, WS_SPOTIFY, WS_WEB, SwayControl
CONFIG_PATH = os.environ.get("TOUCHPANEL_AGENT_CONFIG", "/etc/touchpanel-agent/config.env") CONFIG_PATH = os.environ.get("TOUCHPANEL_AGENT_CONFIG", "/etc/touchpanel-agent/config.env")
@ -26,9 +26,14 @@ CONFIG_KEYS = (
"HA_URL", "HA_URL",
"KIOSK_USERNAME", "KIOSK_USERNAME",
"TOUCHPANEL_NAME", "TOUCHPANEL_NAME",
"ENABLE_DISCORD",
) )
WORKSPACES = (WS_SPOTIFY, WS_HOME, WS_WEB) # The workspaces the panel offers over MQTT. Derived from the app table rather than
# fixed, because Discord is per-panel (kiosks[].enable_discord): a select whose
# options include a workspace this image has no app for is a dropdown entry that
# switches to a black screen.
BASE_WORKSPACES = (WS_SPOTIFY, WS_HOME, WS_WEB)
log = logging.getLogger("touchpanel-agent") log = logging.getLogger("touchpanel-agent")
@ -63,8 +68,8 @@ def load_config(path: str = CONFIG_PATH) -> dict[str, str]:
return values return values
def build_apps(_config: dict[str, str]) -> dict[str, App]: def build_apps(config: dict[str, str]) -> dict[str, App]:
return { apps = {
"spotify": App( "spotify": App(
name="Spotify", name="Spotify",
command=["/usr/local/bin/spotify-launch"], command=["/usr/local/bin/spotify-launch"],
@ -95,6 +100,23 @@ def build_apps(_config: dict[str, str]) -> dict[str, App]:
), ),
} }
# Discord, on the panels that asked for it. Launched at session start by the sway
# config, so this entry — like "home" — is normally a focus-and-switch rather than
# a cold start: launch_app() sees the process already running and just brings the
# workspace up, which is what "step outside without dropping the call" needs.
# Matched on X11 class, not app_id: it is an XWayland window (see discord-launch).
if str(config.get("ENABLE_DISCORD", "")).strip().lower() == "true":
apps["discord"] = App(
name="Discord",
command=["/usr/local/bin/discord-launch"],
process_pattern="com.discordapp.Discord",
workspace=WS_DISCORD,
focus_criteria='class="discord"',
icon="mdi:discord",
)
return apps
def make_client(client_id: str) -> mqtt.Client: def make_client(client_id: str) -> mqtt.Client:
# paho-mqtt 2.x requires an explicit callback API version; bookworm's # paho-mqtt 2.x requires an explicit callback API version; bookworm's
@ -126,6 +148,8 @@ def main() -> int:
sway = SwayControl() sway = SwayControl()
apps = build_apps(config) apps = build_apps(config)
workspaces = tuple(dict.fromkeys(
list(BASE_WORKSPACES) + [app.workspace for app in apps.values() if app.workspace]))
client = make_client(f"touchpanel-agent-{node_id}") client = make_client(f"touchpanel-agent-{node_id}")
if config.get("MQTT_USERNAME"): if config.get("MQTT_USERNAME"):
@ -148,7 +172,7 @@ def main() -> int:
def on_workspace(payload: str) -> None: def on_workspace(payload: str) -> None:
name = payload.strip() name = payload.strip()
# Enumerated, never passed through: see the security note in mqtt_discovery.py. # Enumerated, never passed through: see the security note in mqtt_discovery.py.
if name not in WORKSPACES: if name not in workspaces:
log.warning("ignoring unknown workspace %r", name) log.warning("ignoring unknown workspace %r", name)
return return
sway.switch_workspace(name) sway.switch_workspace(name)
@ -161,7 +185,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_app_launchers(apps, on_launch) discovery.register_app_launchers(apps, on_launch)
discovery.register_workspace_select(WORKSPACES, on_workspace, WS_HOME) discovery.register_workspace_select(workspaces, on_workspace, WS_HOME)
discovery.subscribe_all() discovery.subscribe_all()
discovery.publish_available(True) discovery.publish_available(True)

View File

@ -18,6 +18,10 @@ log = logging.getLogger(__name__)
WS_SPOTIFY = "1:spotify" WS_SPOTIFY = "1:spotify"
WS_HOME = "2:home" WS_HOME = "2:home"
WS_WEB = "3:web" WS_WEB = "3:web"
# Only present on panels built with enable_discord — see build_apps() in main.py. The
# workspace exists in the sway config either way (an unused workspace name costs
# nothing); what is gated is the app, the dock button and the HA select's options.
WS_DISCORD = "4:discord"
def runtime_dir() -> str: def runtime_dir() -> str:

View File

@ -0,0 +1,25 @@
#!/bin/sh
# Discord, on the touch panel. Installed to /usr/local/bin/discord-launch.
#
# WHY IT IS HERE: this panel is in the Loggia, and the point is walking out for a
# cigarette without dropping out of a voice call — you join from the panel and leave
# the call on the desktop, or the other way round. That is a "switch device" action,
# not a "read messages" one, so the app stays logged in and running from session
# start rather than being launched on demand: an app that takes fifteen seconds to
# start and then wants a login is not something you use on the way past.
#
# Flathub, for the same reason as Spotify: Discord's own .deb exists but updates
# itself by nagging you to download a new one, which on a kiosk nobody logs into
# means it eventually refuses to connect. The Flatpak updates like everything else.
#
# Login is interactive, on-device, on first launch. The Flatpak's persistent data dir
# (~/.var/app/com.discordapp.Discord) keeps that session across reboots — nothing here
# reseeds it, unlike the kiosk Firefox profiles.
set -eu
# Discord runs under XWayland here (its Electron build does not default to Wayland),
# so touch arrives as emulated pointer events rather than wl_touch. Tap and drag
# work; two-finger scrolling in the message list may not. See README.md's touch
# section — this is the same tier-2 behaviour the pointer-fallback block in
# configs/sway/config exists for, and it is good enough for "join voice, mute, leave".
exec flatpak run com.discordapp.Discord "$@"

View File

@ -25,7 +25,7 @@
(dock-btn :ws "1:spotify" :icon "🎵" :label "Spotify") (dock-btn :ws "1:spotify" :icon "🎵" :label "Spotify")
(dock-btn :ws "2:home" :icon "🏠" :label "Home") (dock-btn :ws "2:home" :icon "🏠" :label "Home")
(dock-btn :ws "3:web" :icon "🌐" :label "Web") (dock-btn :ws "3:web" :icon "🌐" :label "Web")
(box :class "dock-sep") @DISCORD_DOCK_BTN@ (box :class "dock-sep")
(button :class "dock-btn" :onclick "toggle-keyboard" (button :class "dock-btn" :onclick "toggle-keyboard"
(box :orientation "vertical" :space-evenly false :spacing 2 (box :orientation "vertical" :space-evenly false :spacing 2
(label :class "dock-icon" :text "⌨") (label :class "dock-icon" :text "⌨")

View File

@ -14,6 +14,7 @@ set $mod Mod4
set $ws_spotify 1:spotify set $ws_spotify 1:spotify
set $ws_home 2:home set $ws_home 2:home
set $ws_web 3:web set $ws_web 3:web
set $ws_discord 4:discord
# Workspace names are a contract with touchpanel_agent/sway_control.py — changing one # Workspace names are a contract with touchpanel_agent/sway_control.py — changing one
# side means changing the other. # side means changing the other.
@ -107,13 +108,23 @@ exec_always eww open touch-dock
# separately-matched `assign` rule can't race it. # separately-matched `assign` rule can't race it.
for_window [app_id="chromium.*"] fullscreen enable for_window [app_id="chromium.*"] fullscreen enable
for_window [app_id="spotify"] fullscreen enable for_window [app_id="spotify"] fullscreen enable
# Discord runs under XWayland, so it lands as an X11 window with a `class`, not a
# native `app_id` — matching on app_id alone would silently never fire.
for_window [class="discord"] fullscreen enable
# HA kiosk window and Spotify both auto-launch at session start — this is a fixed # HA kiosk, Spotify and (where enabled) Discord all auto-launch at session start —
# 3-app panel, not an on-demand surface like the thin client's digest/admin # this is a fixed panel of three or four apps, not an on-demand surface like the thin
# workspaces, so there is no "nothing to show yet" state to guard against beyond an # client's digest/admin workspaces, so there is no "nothing to show yet" state to
# unset HA_URL. # guard against beyond an unset HA_URL.
exec sh -c '[ -n "$HA_URL" ] && { swaymsg workspace $ws_home; /usr/local/bin/ha-kiosk; }' exec sh -c '[ -n "$HA_URL" ] && { swaymsg workspace $ws_home; /usr/local/bin/ha-kiosk; }'
exec sh -c 'swaymsg workspace $ws_spotify; /usr/local/bin/spotify-launch' exec sh -c 'swaymsg workspace $ws_spotify; /usr/local/bin/spotify-launch'
# Discord, on the panels that asked for it (kiosks[].enable_discord). Started at
# session boot rather than on demand for the reason in discord-launch's own header:
# the point is stepping out mid-call, and an app that needs fifteen seconds and a
# login is not something you use on the way past. $ENABLE_DISCORD is not a sway
# variable, so it passes through to sh, which reads it from the environment
# kiosk-session sourced out of /etc/touchpanel-agent/config.env.
exec sh -c '[ "$ENABLE_DISCORD" = "true" ] && { swaymsg workspace $ws_discord; /usr/local/bin/discord-launch; }'
exec swaymsg workspace $ws_home exec swaymsg workspace $ws_home
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -128,6 +139,10 @@ exec swayidle -w \
for_window [app_id="chromium.*"] inhibit_idle fullscreen for_window [app_id="chromium.*"] inhibit_idle fullscreen
for_window [app_id="spotify"] inhibit_idle fullscreen for_window [app_id="spotify"] inhibit_idle fullscreen
# Deliberately NOT inhibiting idle for Discord: a voice call would otherwise hold the
# panel's screen on for an hour in an empty room. The call keeps running with the
# output powered off — sway's idle timeout blanks the display, it does not suspend
# the machine or the audio stream.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Local override keys — a fallback for standing in front of the machine (or a # Local override keys — a fallback for standing in front of the machine (or a
@ -143,6 +158,7 @@ bindsym $mod+Shift+c reload
bindsym $mod+1 workspace $ws_spotify bindsym $mod+1 workspace $ws_spotify
bindsym $mod+2 workspace $ws_home bindsym $mod+2 workspace $ws_home
bindsym $mod+3 workspace $ws_web bindsym $mod+3 workspace $ws_web
bindsym $mod+4 workspace $ws_discord
bindsym XF86AudioPlay exec playerctl -p spotify play-pause bindsym XF86AudioPlay exec playerctl -p spotify play-pause
bindsym XF86AudioNext exec playerctl -p spotify next bindsym XF86AudioNext exec playerctl -p spotify next

View File

@ -0,0 +1,37 @@
#!/bin/sh
# Discord from Flathub, for panels that ask for it (kiosks[].enable_discord).
#
# Gated rather than unconditional: a kitchen panel does not want a chat client, and
# the Loggia one is the whole reason this exists (see configs/discord/discord-launch).
# ENABLE_DISCORD comes from /etc/touchpanel-agent/config.env, which live-build has
# already copied in before hooks run — the same mechanism 0300-flatpak-spotify uses
# for nothing at all today, and the reason that file is written first.
set -eu
ENABLE_DISCORD=false
if [ -r /etc/touchpanel-agent/config.env ]; then
# shellcheck disable=SC1091
. /etc/touchpanel-agent/config.env
fi
if [ "${ENABLE_DISCORD:-false}" != "true" ]; then
echo "0350-flatpak-discord: ENABLE_DISCORD is not true — skipping."
exit 0
fi
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
# VERIFY BEFORE THE FIRST REAL BUILD: confirm this application ID against the live
# Flathub listing (`flatpak search Discord`). Believed correct (Discord's own
# published app) but not checked from this environment — same honesty as the Spotify
# hook next door.
DISCORD_APP_ID="com.discordapp.Discord"
if flatpak install -y --noninteractive flathub "$DISCORD_APP_ID"; then
echo "0350-flatpak-discord: installed ${DISCORD_APP_ID}."
else
echo "0350-flatpak-discord: WARNING — could not install ${DISCORD_APP_ID} during the"
echo " build (no network in the chroot, or the app ID is wrong). Run this on the"
echo " booted image instead: flatpak install -y flathub ${DISCORD_APP_ID}"
echo " The Discord workspace will be empty until this is done."
fi

242
stream-dock/README.md Normal file
View File

@ -0,0 +1,242 @@
# stream-dock — four dials for one room's colour lamps
A desk-side **MiraBox N4 Pro** (sold under several names, including xVSDinside; the
same hardware family as the Ajazz AKP05 — 10 LCD keys, 4 rotary encoders with RGB-lit
rings) driving one room's lamps through **OpenDeck**:
```
dial 1 red channel press: that channel to 0 / to full
dial 2 green channel press: same
dial 3 blue channel press: same
dial 4 brightness press: toggle the room
10 keys colour presets, warm white, daylight, nightlight, full
rings red / green / blue channel values, then the colour the room is emitting
```
**Nothing here is a new OpenDeck plugin, on purpose.** Two plugins that already exist
do the work, and the job left over was configuration — which is what this directory
is. See §2 for what they are and §7 for what has and has not been checked.
## 1. What actually happens when you turn a dial
An encoder produces a *relative* movement, and Home Assistant has no relative-colour
service: there is `brightness_step_pct`, but nothing equivalent for one colour
channel. Turning the red dial up means reading the lamp's current `rgb_color`, adding
to one element and writing all three back — and that read has to happen where the
state lives.
So the dock sends only "which channel, how many ticks", and the arithmetic sits in
Home Assistant, in `ha-package/stream_dock.yaml`:
```
dial ──ticks──> streamdeck-homeassistant ──ws──> script.stream_dock_channel_adjust
reads rgb_color, clamps, writes back
leds.toml <── stream-dock-led-sync ──polls──────┘
(only while the lighting layer is showing)
```
The scripts take `entity_id` as a field rather than baking a room in, so one copy of
the package serves every room and a second dock needs no new HA config. The per-room
part is the dock's own bindings, generated from `CoreSystemConfig.json`.
## 2. The parts that are not ours
| Part | What it is | Why it is needed |
|---|---|---|
| [OpenDeck](https://github.com/nekename/OpenDeck) | the Stream Deck application for Linux | runs Elgato-SDK plugins |
| [opendeck-akp05](https://github.com/aroaxinping/opendeck-akp05) | unofficial **device** plugin for the Ajazz AKP05 / Mirabox N4 family | an N4 Pro is not an Elgato device; without this OpenDeck does not see the dock at all. Several forks exist ([ambiso](https://github.com/ambiso/opendeck-akp05), [truelecter](https://github.com/truelecter/opendeck-mirabox-n4)) — they differ in which devices they claim |
| [streamdeck-homeassistant](https://github.com/cgiesche/streamdeck-homeassistant) | Home Assistant plugin, keypad **and encoder** actions | this is the "already existing solution". It connects over `ws://host:8123/api/websocket` with a long-lived token, calls any service, and passes a rotation's `ticks` into the service data |
`setup-stream-dock.sh` prints these links and installs none of them: they are
third-party release downloads, and a setup script that silently pulls executables onto
a desktop is not a trade this project makes.
## 3. Setup, in order
Everything below is driven by the `stream_dock` block in `CoreSystemConfig.json`.
Fill it in first — the room, and the light entities the dials address:
```jsonc
"stream_dock": {
"enabled": true,
"room": "living_room",
"lights": ["light.living_room_lamp"],
...
}
```
`tools/validate-config.py` checks it: the room against the one area_id vocabulary
(`docs/rooms-and-endpoints.md`), the entities for being `light.` entities at all — a
switch in that list is a service call that fails at the moment somebody turns a knob,
which is the worst time to find out.
**Home Assistant side** (from anywhere that can ssh to the container host):
```
stream-dock/install-ha-package.sh
```
Copies the package in, adds the `packages:` include to `configuration.yaml` if it is
missing (HA's default config does **not** enable packages — without this the file sits
there being ignored, the most likely way for all of this to look broken for no visible
reason), and reloads YAML over the REST API. Then, before binding anything: Developer
Tools → Actions → *Stream Dock: nudge one RGB channel*, run it by hand against a real
lamp with `channel: r, ticks: 3, step: 8`, and watch the lamp go redder.
**Desk side**, on the machine the dock is plugged into:
```
stream-dock/setup-stream-dock.sh
```
Generates `generated/bindings.md` (every value to paste into OpenDeck), installs the
starting `leds.toml`, and installs + starts the ring-colour service as a systemd
`--user` unit. Then you paste the bindings in by hand, once.
### Why bindings are a document and not a profile file
OpenDeck stores its layout as JSON under `~/.config/opendeck/`, and generating that
directly would be the obvious move. This does not do it, because that schema is not
documented anywhere this project could check, and a profile written against a guessed
schema fails in the least useful way possible: OpenDeck starts, the profile looks
present, and the dials do nothing. Pasting six values takes five minutes and cannot be
silently wrong. If the schema is ever pinned down against a real installation,
`generate.py` is where a `--profile` flag goes.
## 4. Networking
The dock's desktop talks to Home Assistant on **8123**, the same port the HA app on
your phone uses, over the trusted-LAN → smart-home-VLAN rule that already exists for
exactly that (`docs/network-integration.md` §3). No new rule, no new port, nothing
forwarded, no MQTT reach-through. The LED service uses the same host and the same
token — one address, derived in `tools/config-export.py` from the container host's
octet, never typed into a settings box twice.
## 5. The knob rings, and the one gap
The ring rule (`dock_leds.py`) is: rings 13 show their own channel's current value in
their own colour, and ring 4 shows the colour the room is *actually emitting* — rgb
scaled by brightness. Turn brightness down and ring 4 fades in the room's own colour
rather than going grey. While a lamp is off, the channel rings keep showing the stored
colour (it is what the lamp will come back on with) and ring 4 goes black (nothing is
being emitted, and drawing that as anything else would be a lie).
**The gap: the akp05 device plugin reads `leds.toml` when it starts, and nothing
documents it watching the file.** Nothing else can drive those LEDs either — the plugin
holds the USB device open, so a second process cannot write HID reports at it. So
"live rings" comes down to one unknown that is a property of your installed build, not
of this repo: what makes that process pick the file up again.
Everything on this side is done and tested against a stub Home Assistant: the colours,
the debounce, the atomic rewrite, the off-lamp and unreachable-HA cases, and a
**rate-limited apply** that never drops the final state — the file is always current,
and only the (expensive) reload is paced by `apply_min_interval_seconds`.
### Finding the reload path — two minutes, with the dock in front of you
```
stream-dock/apply-leds.sh --probe
```
It stops the sync service, writes an obvious colour, and asks whether the rings
changed — walking four strategies cheapest-first, then printing the config line to
paste:
| Strategy | What it does | Cost |
|---|---|---|
| `none` | writes the file and stops | free — **and it is the first thing the probe tests**, because if the plugin already watches the file the rings are live with nothing else needed |
| `signal` | `SIGHUP` to the plugin process | free if the build handles it. Be clear-eyed: the default action for an unhandled SIGHUP is *terminate*, so a build without it lands you in `restart-plugin` with extra steps — the probe checks whether the process survived and says so |
| `restart-plugin` | `TERM` the plugin, let OpenDeck respawn it | re-initialises the device: a visible blink, keys redrawing. Usable only behind `apply_min_interval_seconds`, never per detent |
| `restart-opendeck` | restart the whole app | takes the dock away for a second or two — last resort |
Set the winner as `knob_leds.apply_strategy` and re-run `setup-stream-dock.sh`.
`generate.py` turns the strategy into the apply command; `apply_command` overrides it
entirely if you have a better idea.
**The real fix is upstream and small**: make the device plugin watch the file.
`upstream-file-watch-request.md` in this directory is written and ready to file. If it
lands, set `apply_strategy` back to `none` and the rings go live with no local
mechanism at all.
## 6. The layer gate
The lighting controls live on their own OpenDeck layer, and the four rings are shared
hardware: on any other layer those dials mean something else, and painting a lamp's
colour onto them there is worse than not lighting them at all.
`knob_leds.layer_gate_command` is run before every update — exit 0 for "the lighting
layer is showing", exit 1 for "it is not", **anything else for "I cannot tell", which
is treated exactly like "it is not"**. A service that has lost track of which layer is
up must not keep painting; going idle is the recoverable mistake, hijacking is not.
While the layer is hidden, Home Assistant is not polled at all.
Off the layer the rings fall back to the desktop's own palette — `COLOR_HIGHLIGHT`
`#E40046`, `COLOR_DARK` `#5018DD`, `COLOR_RED` `#F50505` from
`~/Dotfiles/colors.conf` — chasing one ring at a time so three colours on four rings
read as movement. The values are copied into the config rather than read from
`colors.conf` at runtime: nothing here reaches into dotfiles while running, and a
lighting service that dies because a theme file moved would be a silly way to lose the
dials. Re-paste them if the theme changes; it is one line.
`layer-active.sh` is the gate implementation, and **it ships answering "I cannot
tell"** — which means the rings sit on the idle chase until you point it at something
real. OpenDeck keeps its state under `~/.config/opendeck`, but the file and field that
name the selected profile are not documented anywhere this repo could check, and
guessing them would produce the worst failure available: a gate that confidently
answers "yes, lighting" on every layer. Finding the real answer takes three minutes:
```
stream-dock/layer-active.sh --discover # switch layers a few times; it names the file
```
then set `STREAM_DOCK_LAYER_FILE`, `STREAM_DOCK_LAYER_JQ` and `STREAM_DOCK_LAYER`. If
your OpenDeck build exposes the current profile some other way, throw the script away
and put that command in `layer_gate_command` instead — the gate is a contract about
exit codes, nothing more.
## 7. What is unverified
No hardware was involved in any of this. In rough order of how likely each is to bite:
1. **`{{ticks}}`.** The HA plugin documents `ticks` as a rotation variable and
`{{rotationPercent}}` as a placeholder; which spelling a given build substitutes
has not been checked. Test one dial before binding four — `bindings.md` §1 carries
an absolute-position variant (`script.stream_dock_channel_set`) that needs no
relative maths if the placeholder does not work.
2. **The ring reload** (§5). The file format is from the akp05 plugin's
documentation; no knob has ever lit up from it. Which strategy your build needs is
what `apply-leds.sh --probe` exists to answer, and it can only be answered with the
dock plugged in.
3. **The layer gate** (§6). Ships as "cannot tell" by design.
4. **The HA scripts.** Written from the template documentation, YAML-validated,
reviewed by hand, never run against a real Home Assistant. `light.turn_on` with
`rgb_color` moves a lamp that was in colour-temperature mode into colour mode —
deliberate, since the dials are an rgb surface, but it is a change you will see.
5. **Which akp05 fork claims an N4 Pro.** Three forks exist with different device
lists; if OpenDeck does not see the dock, that is the first thing to try, and it is
a question for those repos rather than for this one.
## 8. The token
The HA plugin needs a long-lived token belonging to an **admin** user — it drives HA's
admin-only `execute-script` command. That token ends up in a plugin's settings on a
desktop machine, which is a broader exposure than a token living on the container
host, and it is worth knowing rather than discovering. It is the same
`secrets.ha_token` the rest of this project uses; the dock is not a good reason to
mint one with more rights than the household already has.
## 9. Files
```
ha-package/stream_dock.yaml the six scripts. Room-agnostic; goes into HA's packages/
generate.py config -> bindings.md, leds.toml, led-sync.env, the unit
dock_leds.py the ring rule and the ONE writer of leds.toml
led_sync.py the service: poll HA, gate on the layer, rewrite the file
layer-active.sh the gate (§6), and its --discover mode
apply-leds.sh the ring reload (§5), and its --probe mode
upstream-file-watch-request.md the real fix, written and ready to file
setup-stream-dock.sh desk side: generate, install, start
install-ha-package.sh HA side: copy the package, enable packages:, reload
generated/ gitignored — led-sync.env holds the token
```

304
stream-dock/apply-leds.sh Executable file
View File

@ -0,0 +1,304 @@
#!/usr/bin/env bash
#
# Make the device plugin notice a freshly-written leds.toml.
#
# apply-leds.sh --strategy <none|signal|restart-plugin|restart-opendeck>
# apply-leds.sh --probe # find out which one your machine needs
#
# WHY THIS IS A SCRIPT AND NOT A LINE OF PYTHON. The akp05 device plugin reads
# leds.toml at startup, and nothing documents it re-reading. Nothing else can drive
# those LEDs either: the plugin holds the USB device open. So "the rings update live"
# comes down to one unknown — what makes that process pick the file up again — and the
# answer is a property of your installed build, not of this repo. The probe finds it
# in about two minutes with the dock in front of you; everything else here is already
# written and tested.
#
# The strategies, cheapest first:
#
# none write the file and stop. CORRECT IF the plugin already watches
# the file — which is the FIRST thing the probe tests, because if
# it does, the rings are live for free and every strategy below is
# a worse answer.
# signal SIGHUP the plugin process. Reload-on-SIGHUP is a common daemon
# convention, so it is worth one test — but be clear-eyed: the
# DEFAULT action for an unhandled SIGHUP is to terminate the
# process, so a build that does not implement it lands you in
# restart-plugin territory with extra steps. The probe checks
# whether the process survived and says so.
# restart-plugin kill the plugin and let OpenDeck respawn it. Re-initialises the
# device: expect a visible blink and the keys redrawing. Only
# tolerable behind apply_min_interval_seconds, never per detent.
# restart-opendeck restart the whole application. The last resort — it takes the
# whole dock away for a second or two.
#
# The real fix is upstream and small: make the device plugin watch leds.toml. See
# upstream-file-watch-request.md in this directory, which is written and ready to file.
set -uo pipefail
STRATEGY="${STREAM_DOCK_LED_APPLY_STRATEGY:-none}"
PLUGIN_PATTERN="${STREAM_DOCK_PLUGIN_PATTERN:-akp05}"
PROBE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--strategy) STRATEGY="${2:-none}"; shift 2 ;;
--probe) PROBE=true; shift ;;
*) echo "usage: apply-leds.sh [--strategy <name>] [--probe]" >&2; exit 2 ;;
esac
done
# The pattern is config-supplied, so it is never interpolated into a shell string:
# pgrep takes it as a separate argument, and -x is deliberately NOT used because the
# plugin's real process name is one of the things the probe is for.
#
# `pgrep -f` matches whole command lines, which includes THIS script's own and its
# parent shell's — a shell invoked as `STREAM_DOCK_PLUGIN_PATTERN=akp05 apply-leds.sh`
# carries the pattern in its command line and matches itself. Sending TERM to that is
# how a probe kills the terminal it is being run from, so every caller filters out its
# own process and its parent. Found the hard way, on the first test run.
# Every process between this script and init, so a match on an ancestor's command line
# can never be signalled. The parent alone is not enough: run the probe from a shell
# that was itself launched with the pattern on its command line and the match lands on
# the GRANDparent, which is exactly how the first test run took down the terminal.
ancestry() {
local pid=$$ ppid
while [[ -n "$pid" && "$pid" != "0" && "$pid" != "1" ]]; do
echo "$pid"
ppid="$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null)" || break
[[ -n "$ppid" ]] || break
pid="$ppid"
done
}
exclude_self() { grep -v -x -F -f <(ancestry) || true; }
plugin_pids() { pgrep -f -- "$PLUGIN_PATTERN" 2>/dev/null | exclude_self; }
opendeck_pids() { pgrep -f -- "opendeck" 2>/dev/null | exclude_self; }
do_none() { return 0; }
do_signal() {
local pids
pids="$(plugin_pids || true)"
[[ -n "$pids" ]] || { echo "apply-leds: no process matching '$PLUGIN_PATTERN'" >&2; return 1; }
# shellcheck disable=SC2086
kill -HUP $pids 2>/dev/null || return 1
return 0
}
do_restart_plugin() {
local pids
pids="$(plugin_pids || true)"
[[ -n "$pids" ]] || { echo "apply-leds: no process matching '$PLUGIN_PATTERN'" >&2; return 1; }
# TERM, never KILL: the plugin owns a USB device, and giving it the chance to close
# the handle is the difference between OpenDeck respawning cleanly and the next
# instance finding the device busy.
# shellcheck disable=SC2086
kill -TERM $pids 2>/dev/null || return 1
return 0
}
do_restart_opendeck() {
# Flatpak first, since that is how OpenDeck is normally installed on Linux, then a
# plain process. Deliberately no `systemctl --user restart` guess: OpenDeck ships no
# user unit, and inventing a name here would fail silently every time.
if command -v flatpak >/dev/null && flatpak ps --columns=application 2>/dev/null | grep -q opendeck; then
flatpak kill me.amankhanna.opendeck >/dev/null 2>&1
sleep 1
setsid flatpak run me.amankhanna.opendeck >/dev/null 2>&1 &
return 0
fi
local pids
pids="$(opendeck_pids || true)"
[[ -n "$pids" ]] || { echo "apply-leds: OpenDeck does not appear to be running" >&2; return 1; }
# shellcheck disable=SC2086
kill -TERM $pids 2>/dev/null || return 1
echo "apply-leds: OpenDeck was asked to exit — it is NOT restarted automatically" >&2
echo "apply-leds: outside a Flatpak install; start it again yourself" >&2
return 1
}
run_strategy() {
case "$1" in
none) do_none ;;
signal) do_signal ;;
restart-plugin) do_restart_plugin ;;
restart-opendeck) do_restart_opendeck ;;
*) echo "apply-leds: unknown strategy '$1'" >&2; return 2 ;;
esac
}
# ---------------------------------------------------------------------------
# Probe
# ---------------------------------------------------------------------------
if ! $PROBE; then
run_strategy "$STRATEGY"
exit $?
fi
ENV_FILE="${STREAM_DOCK_ENV_FILE:-$HOME/.config/stream-dock/led-sync.env}"
LEDS_PATH="${STREAM_DOCK_LEDS_PATH:-}"
if [[ -z "$LEDS_PATH" && -r "$ENV_FILE" ]]; then
LEDS_PATH="$(sed -n 's/^STREAM_DOCK_LEDS_PATH=//p' "$ENV_FILE" | head -1)"
fi
LEDS_PATH="${LEDS_PATH:-$HOME/.config/opendeck-akp05/leds.toml}"
LEDS_PATH="${LEDS_PATH/#\~/$HOME}"
cat <<EOF
Stream Dock ring-reload probe
=============================
LED file : ${LEDS_PATH}
Plugin : processes matching '${PLUGIN_PATTERN}'
Have the dock plugged in and OpenDeck running, and be able to see the four knob
rings. Each test writes an obvious colour and asks whether the rings changed.
EOF
if [[ ! -e "$LEDS_PATH" ]]; then
echo "warn: ${LEDS_PATH} does not exist yet — run stream-dock/setup-stream-dock.sh first." >&2
fi
echo "Processes that look like they could be the device plugin:"
ps -eo pid,comm,args 2>/dev/null | grep -iE "opendeck|akp05|streamdock|stream-dock" | grep -v grep \
| sed 's/^/ /' || true
echo " (if none of these match '${PLUGIN_PATTERN}', re-run with"
echo " STREAM_DOCK_PLUGIN_PATTERN=<something from the list above>)"
echo
if systemctl --user is-active --quiet stream-dock-led-sync 2>/dev/null; then
echo "Stopping stream-dock-led-sync for the duration of the probe, so it does not"
echo "overwrite the test colours."
systemctl --user stop stream-dock-led-sync
RESTART_SYNC=true
else
RESTART_SYNC=false
fi
restore() {
if [[ "${RESTART_SYNC:-false}" == "true" ]]; then
echo "Restarting stream-dock-led-sync."
systemctl --user start stream-dock-led-sync
fi
}
trap restore EXIT
write_test_file() {
mkdir -p "$(dirname "$LEDS_PATH")"
cat > "${LEDS_PATH}.tmp" <<EOF
# Written by apply-leds.sh --probe
brightness = 100
[mode.Static]
colors = [[$1], [$1], [$1], [$1]]
EOF
mv "${LEDS_PATH}.tmp" "$LEDS_PATH"
}
ask() {
local answer
read -r -p "$1 [y/N] " answer
[[ "$answer" == "y" || "$answer" == "Y" ]]
}
WINNER=""
echo
echo "--- Test 1 of 4: does the plugin already watch the file? (strategy: none) ---"
write_test_file "0, 255, 0"
echo "Wrote all four rings GREEN. Waiting 3 seconds."
sleep 3
if ask "Did the rings turn green?"; then
WINNER="none"
else
echo
echo "--- Test 2 of 4: SIGHUP the plugin (strategy: signal) ---"
BEFORE="$(plugin_pids | tr '\n' ' ')"
write_test_file "0, 0, 255"
if do_signal; then
sleep 3
AFTER="$(plugin_pids | tr '\n' ' ')"
if [[ -z "$AFTER" ]]; then
echo "note: the plugin process is GONE — this build does not handle SIGHUP, and the"
echo " default action terminated it. If the rings changed anyway, OpenDeck"
echo " respawned it, which is the restart-plugin strategy in disguise."
elif [[ "$BEFORE" != "$AFTER" ]]; then
echo "note: the process id changed — it was restarted, not reloaded."
fi
if ask "Did the rings turn blue?"; then
[[ -n "$AFTER" && "$BEFORE" == "$AFTER" ]] && WINNER="signal" || WINNER="restart-plugin"
fi
else
echo "signal could not be sent — skipping."
fi
fi
if [[ -z "$WINNER" ]]; then
echo
echo "--- Test 3 of 4: restart the plugin (strategy: restart-plugin) ---"
write_test_file "255, 0, 0"
if do_restart_plugin; then
echo "Asked the plugin to exit. Waiting 5 seconds for OpenDeck to respawn it."
sleep 5
if [[ -z "$(plugin_pids)" ]]; then
echo "note: nothing came back — OpenDeck does not respawn this plugin on its own,"
echo " so this strategy is not usable. The dock may need OpenDeck restarted."
fi
ask "Did the rings turn red?" && WINNER="restart-plugin"
else
echo "could not signal the plugin — skipping."
fi
fi
if [[ -z "$WINNER" ]]; then
echo
echo "--- Test 4 of 4: restart OpenDeck (strategy: restart-opendeck) ---"
echo "This takes the whole dock away for a moment."
if ask "Try it?"; then
write_test_file "255, 0, 255"
if do_restart_opendeck; then
echo "Waiting 10 seconds for OpenDeck to come back."
sleep 10
ask "Did the rings turn magenta?" && WINNER="restart-opendeck"
fi
fi
fi
echo
if [[ -n "$WINNER" ]]; then
cat <<EOF
=== Result: '${WINNER}' works ===
Set it in CoreSystemConfig.json:
"stream_dock": { "knob_leds": { "apply_strategy": "${WINNER}" } }
EOF
if [[ "$WINNER" == "none" ]]; then
cat <<'EOF'
That is the good outcome: the plugin picks the file up on its own, so the rings are
live with no reload hack at all. You can also set apply_min_interval_seconds to 0
there is nothing expensive to pace.
EOF
else
cat <<EOF
Keep apply_min_interval_seconds at 2 or more: '${WINNER}' re-initialises the device,
and doing that on every detent of a spun dial is how a dock ends up blinking instead
of lighting.
EOF
fi
echo
echo "Then re-run: stream-dock/setup-stream-dock.sh"
else
cat <<'EOF'
=== Result: nothing tested here makes the plugin re-read the file ===
That is a real answer, not a failure of the probe. The rings will keep showing
whatever they had at plugin start, and the fix is upstream: see
stream-dock/upstream-file-watch-request.md, which is written and ready to file
against the device plugin. Everything on this side is already done — the moment the
plugin watches the file, the rings go live with no change here.
EOF
fi

142
stream-dock/dock_leds.py Executable file
View File

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""The knob-ring colour rule, and the one writer for the device plugin's leds.toml.
Two programs need this: `generate.py`, which lays down a starting file at setup time,
and `led_sync.py`, which rewrites it every time the lamp changes. They share this
module rather than each formatting TOML their own way the same reason the Pebble
app's wire format has one encoder and one decoder tested against each other. A file
format with two writers drifts, and a drifted leds.toml does not error, it just lights
the wrong ring.
THE RULE (what the four rings mean):
ring 1 the red channel's current value, in red (0,0,0 when that channel is 0)
ring 2 the green channel's current value, in green
ring 3 the blue channel's current value, in blue
ring 4 what the room is actually emitting the lamp's rgb scaled by its brightness
So the three colour dials answer "how much of this am I dialling in" without you
reading a number, and the fourth is a preview of the mix: turn brightness down and it
fades in the room's own colour rather than going grey.
The channel rings keep showing the stored colour while the lamp is OFF, because that
colour is what the lamp will come back on with, and a dial whose ring goes black when
you switch the light off tells you nothing about what turning it would do. Ring 4 does
go black nothing is being emitted, and claiming otherwise is the kind of small lie
this project keeps out of its displays.
Nothing here has driven a real device: the file format is written from the akp05
plugin's documentation, and no knob has ever lit up from it. See stream-dock/README.md.
"""
from __future__ import annotations
RGB = tuple[int, int, int]
# Where the akp05 device plugin reads its LED configuration, per its README. Windows
# and macOS paths exist too; this project only ever runs on Linux desktops.
DEFAULT_LEDS_PATH = "~/.config/opendeck-akp05/leds.toml"
# Ring order as the device numbers its knobs, left to right. The dial-to-channel
# mapping in generate.py's bindings has to agree with this or the red dial lights the
# green ring — they are both derived from this one list.
RING_ORDER = ("r", "g", "b", "output")
# The desktop's own accent colours, from ~/Dotfiles/colors.conf (the CyberQueer theme):
# COLOR_HIGHLIGHT, COLOR_DARK, COLOR_RED. They are the rings' idle scheme — what the
# dock wears when its lighting layer is not the one showing, so an unused dock matches
# the rest of the desk instead of holding the last lamp colour it happened to see.
#
# Copied rather than read from colors.conf at runtime: nothing in this repo reaches
# into a user's dotfiles while running, and a service that dies because a theme file
# moved would be a silly way to lose the lighting controls. Re-paste them if the theme
# changes — it is one line in CoreSystemConfig.json.
IDLE_PALETTE = ("E40046", "5018DD", "F50505")
def hex_to_rgb(value: str) -> list[int]:
"""'E40046' -> [228, 0, 70]. Tolerates a leading '#' even though the config format
(like colors.conf itself) does not use one."""
text = str(value).strip().lstrip("#")
if len(text) != 6:
raise ValueError(f"not a 6-digit hex colour: {value!r}")
return [int(text[i:i + 2], 16) for i in (0, 2, 4)]
def idle_ring_colors(palette: list[str] | tuple[str, ...], step: int = 0) -> list[list[int]]:
"""The idle scheme: the palette chasing across the four rings.
Ring i wears palette[(i + step) % len], so advancing `step` walks the colours
around the dock rather than flashing all four in unison three colours on four
rings already reads as movement standing still, and the chase makes it deliberate.
"""
colours = [hex_to_rgb(c) for c in palette]
if not colours:
return [[0, 0, 0] for _ in RING_ORDER]
return [colours[(index + step) % len(colours)] for index in range(len(RING_ORDER))]
def _clamp(value: float, low: int = 0, high: int = 255) -> int:
return max(low, min(high, int(round(value))))
def _channel_ring(value: int, index: int, floor: int) -> list[int]:
"""One colour channel's ring: its own value, on its own axis.
`floor` lifts a non-zero channel to a minimum so a value of 3/255 is still visibly
lit rather than indistinguishable from off. It deliberately does not lift zero:
zero means "no red in this colour", and that should read as a dark ring.
"""
value = _clamp(value)
if value > 0:
value = max(value, _clamp(floor))
ring = [0, 0, 0]
ring[index] = value
return ring
def ring_colors(rgb: RGB | None, brightness: int | None, is_on: bool,
floor: int = 0) -> list[list[int]]:
"""The four ring colours for a lamp state. Order matches RING_ORDER.
`rgb` is the lamp's rgb_color attribute (or the last one seen while it was on —
the caller owns that memory), `brightness` its 0-255 brightness attribute.
"""
red, green, blue = (rgb or (255, 255, 255))
scale = (_clamp(brightness if brightness is not None else 255)) / 255.0
output = [0, 0, 0] if not is_on else [
_clamp(red * scale), _clamp(green * scale), _clamp(blue * scale),
]
return [
_channel_ring(red, 0, floor),
_channel_ring(green, 1, floor),
_channel_ring(blue, 2, floor),
output,
]
def render_leds_toml(colors: list[list[int]], brightness: int = 100,
note: str = "") -> str:
"""The plugin's leds.toml, as documented by opendeck-akp05.
`brightness` here is the LED driver's own global 0-100 output level, NOT the lamp's
brightness the lamp's brightness is encoded in the ring colours themselves, so
this stays fixed and only exists to turn the whole ring set down if it is too
bright on a desk at night.
"""
lines = [
"# Generated by stream-dock — do not edit by hand, it is rewritten on every",
"# lamp change by stream-dock-led-sync. Change stream_dock.knob_leds in",
"# CoreSystemConfig.json instead.",
]
if note:
lines.append(f"# {note}")
lines.append("")
lines.append(f"brightness = {_clamp(brightness, 0, 100)}")
lines.append("")
lines.append("[mode.Static]")
rows = ", ".join("[" + ", ".join(str(channel) for channel in ring) + "]" for ring in colors)
lines.append(f"colors = [{rows}]")
lines.append("")
return "\n".join(lines)

343
stream-dock/generate.py Executable file
View File

@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Turn CoreSystemConfig.json's `stream_dock` block into everything the dock needs.
Writes into stream-dock/generated/ (gitignored one of these files holds the HA
token):
bindings.md what to type into OpenDeck, per dial and per key
leds.toml the device plugin's knob-ring colours, as a start
led-sync.env environment for the LED sync service
stream-dock-led-sync.service a systemd --user unit for it
WHY BINDINGS ARE A DOCUMENT AND NOT A PROFILE FILE. OpenDeck stores its layout as
JSON under ~/.config/opendeck/, and generating that directly would be the obvious
move one file, no typing. This does not do it, because the schema of that file is
not documented anywhere this project could check, and a profile written against a
guessed schema fails in the least useful way possible: OpenDeck starts, the profile
looks present, and the dials do nothing. So the generator emits the *contents* the
exact service names, entity lists and service-data JSON and you paste them into the
plugin's own settings, which is a few minutes once and cannot be silently wrong. If
the profile schema is ever pinned down against a real installation, this is the file
that grows a --profile flag.
Usage:
stream-dock/generate.py [CoreSystemConfig.json] [--out DIR]
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import dock_leds # noqa: E402
REPO = Path(__file__).resolve().parent.parent
# The key presets. Ten keys, and the dock has exactly ten. Colours are plain rgb rather
# than colour temperatures because the whole surface is built around rgb_color — a
# mixed rgb/color_temp control scheme means the channel dials read back a converted
# approximation of a temperature, which drifts every time you touch them.
KEY_PRESETS = [
("Toggle", "toggle", None, None),
("Warm white", "color", (255, 167, 87), 60),
("Daylight", "color", (255, 250, 244), 100),
("Red", "color", (255, 0, 0), None),
("Green", "color", (0, 255, 0), None),
("Blue", "color", (0, 80, 255), None),
("Amber", "color", (255, 130, 0), None),
("Purple", "color", (170, 0, 255), None),
("Nightlight", "color", (255, 110, 30), 3),
("Full", "brightness", None, 100),
]
def fail(message: str) -> int:
print(f"error: {message}", file=sys.stderr)
return 2
def json_block(payload: dict, raw: dict[str, str] | None = None) -> str:
"""JSON for the plugin's Service Data box, with placeholders left unquoted.
The plugin substitutes {{ticks}} before parsing, so the string it is given is not
valid JSON at the point we write it json.dumps would quote the placeholder into
a string and the script would receive "{{ticks}}" as text. Hence the swap.
"""
raw = raw or {}
for key, placeholder in raw.items():
payload[key] = f"__RAW__{key}__"
text = json.dumps(payload, indent=2)
for key, placeholder in raw.items():
text = text.replace(f'"__RAW__{key}__"', placeholder)
return text
def build_bindings(dock: dict, ws_url: str) -> str:
lights = list(dock.get("lights") or [])
leds_cfg = dock.get("knob_leds", {}) or {}
room = dock.get("room") or "(no room set)"
rgb_step = dock.get("rgb_step", 8)
bright_step = dock.get("brightness_step_pct", 5)
bucket = dock.get("tick_bucket_ms", 120)
out: list[str] = []
add = out.append
add(f"# Stream Dock bindings — {room}")
add("")
add("Generated by `stream-dock/generate.py` from `CoreSystemConfig.json`. Every")
add("value below is a literal: paste it into OpenDeck's Home Assistant plugin as")
add("written. Regenerate rather than editing this file.")
add("")
add("## 0. Plugin connection (once, in the plugin's global settings)")
add("")
add("| Field | Value |")
add("|---|---|")
add(f"| Server URL | `{ws_url}` |")
add("| Access token | the long-lived token from `secrets.ha_token` — it must belong to an **admin** user, because the plugin uses HA's admin-only `execute-script` command |")
add("")
add("If the entity list stays empty after saving, the connection failed — check that")
add("your desktop can reach Home Assistant at all before touching anything else")
add("(`docs/network-integration.md` §3: the trusted LAN reaching the smart-home VLAN")
add("on 8123 is the one inter-VLAN rule this needs, and it is a rule that already")
add("exists for the HA app).")
add("")
add("## 1. The four dials")
add("")
add("Each dial gets a **rotation** action and a **press** action. All of them call a")
add("script from `stream-dock/ha-package/stream_dock.yaml`, so the colour arithmetic")
add("happens in Home Assistant where the lamp's current colour actually lives.")
add("")
add(f"Set **Tick bucket size** to `{bucket}` ms on every rotation action: it sums the")
add("ticks of a fast spin into one service call instead of firing one call per detent.")
add("")
dial_names = {"r": "Red", "g": "Green", "b": "Blue"}
for index, channel in enumerate(("r", "g", "b"), start=1):
add(f"### Dial {index}{dial_names[channel]}")
add("")
add("Rotation → service `script.stream_dock_channel_adjust`")
add("")
add("```json")
add(json_block({"entity_id": lights, "channel": channel, "step": rgb_step},
raw={"ticks": "{{ticks}}"}))
add("```")
add("")
add("Press → service `script.stream_dock_channel_extreme`")
add("")
add("```json")
add(json.dumps({"entity_id": lights, "channel": channel}, indent=2))
add("```")
add("")
add("### Dial 4 — Brightness")
add("")
add("Rotation → service `script.stream_dock_brightness_adjust`")
add("")
add("```json")
add(json_block({"entity_id": lights, "step_pct": bright_step},
raw={"ticks": "{{ticks}}"}))
add("```")
add("")
add("Press → service `script.stream_dock_toggle`")
add("")
add("```json")
add(json.dumps({"entity_id": lights}, indent=2))
add("```")
add("")
add("### If `{{ticks}}` does not substitute")
add("")
add("The plugin documents `ticks` as a rotation variable and `{{rotationPercent}}`")
add("as a placeholder; which spelling a given build accepts has not been verified")
add("here against a real installation. Test one dial before binding four: turn it and")
add("watch Developer Tools → Actions, or the script's trace. If the script receives")
add("the literal text instead of a number, switch the three colour dials to the")
add("absolute variant, which needs no relative maths at all:")
add("")
add("Rotation → service `script.stream_dock_channel_set`")
add("")
add("```json")
add(json_block({"entity_id": lights, "channel": "r"},
raw={"percent": "{{rotationPercent}}"}))
add("```")
add("")
add("That variant maps the dial's accumulated position straight onto 0255, which is")
add("arguably the better fit for a colour channel anyway — the dial has an absolute")
add("position and so does the channel. Its cost is that the dial's idea of where it")
add("is and the lamp's can diverge the moment anything else changes the colour.")
add("")
add("## 2. The ten keys")
add("")
add("All keys call `script.stream_dock_set_color` unless noted.")
add("")
for index, (label, kind, rgb, brightness_pct) in enumerate(KEY_PRESETS, start=1):
if kind == "toggle":
add(f"**Key {index}{label}** → `script.stream_dock_toggle`")
add("")
add("```json")
add(json.dumps({"entity_id": lights}, indent=2))
add("```")
elif kind == "brightness":
add(f"**Key {index}{label}** → `light.turn_on`")
add("")
add("```json")
add(json.dumps({"entity_id": lights, "brightness_pct": brightness_pct}, indent=2))
add("```")
else:
payload: dict = {"entity_id": lights, "rgb": list(rgb)}
if brightness_pct is not None:
payload["brightness_pct"] = brightness_pct
add(f"**Key {index}{label}** → `script.stream_dock_set_color`")
add("")
add("```json")
add(json.dumps(payload, indent=2))
add("```")
add("")
add("## 3. Knob rings")
add("")
add("Ring order left to right: **red channel, green channel, blue channel, the")
add("colour the room is actually emitting**. That order is fixed in")
add("`stream-dock/dock_leds.py` and has to match the dial order above — if you")
add("re-order the dials, re-order `RING_ORDER` with them.")
add("")
add("`generated/leds.toml` is a starting state only. The live version is written by")
add("`stream-dock-led-sync`.")
add("")
strategy = str(leds_cfg.get("apply_strategy", "none") or "none")
if leds_cfg.get("apply_command"):
add("Ring reload: a command of your own (`knob_leds.apply_command`).")
elif strategy == "none":
add("**Ring reload: not configured yet.** The file will be kept correct and the")
add("rings will not follow it until the plugin next starts. Run")
add("`stream-dock/apply-leds.sh --probe` with the dock plugged in — its first")
add("test is whether the plugin already watches the file, which would mean")
add("nothing more is needed. See `stream-dock/README.md` §5.")
else:
add(f"Ring reload: `{strategy}`, at most once every "
f"{leds_cfg.get('apply_min_interval_seconds', 2.0)}s.")
add("")
idle = list(leds_cfg.get("idle_colors") or [])
if idle:
cycle = leds_cfg.get("idle_cycle_seconds", 3.0)
add("Off this layer the rings drop to the desktop's own palette — "
+ ", ".join(f"`#{c}`" for c in idle)
+ (f", chasing one ring every {cycle}s." if cycle else ", held still.")
+ " The sync service stops")
add("polling Home Assistant entirely while the layer is hidden, so a dock parked")
add("on another layer costs nothing and shows nothing about your lamps.")
else:
add("Off this layer the rings are left exactly as they are: no idle colours are")
add("configured, so whatever put them there keeps them.")
add("")
return "\n".join(out)
def main(argv: list[str]) -> int:
args = [a for a in argv[1:] if not a.startswith("--")]
out_dir = Path(__file__).resolve().parent / "generated"
if "--out" in argv:
out_dir = Path(argv[argv.index("--out") + 1])
config_path = Path(args[0]) if args else REPO / "CoreSystemConfig.json"
if not config_path.exists():
return fail(f"{config_path} not found — copy CoreSystemConfig.json.template first")
cfg = json.loads(config_path.read_text())
dock = cfg.get("stream_dock") or {}
if not dock.get("enabled"):
return fail("stream_dock.enabled is false (or the block is missing) — nothing to "
"generate. Set it in " + config_path.name)
lights = list(dock.get("lights") or [])
if not lights:
return fail("stream_dock.lights is empty — a dial has to name entities")
prefix = cfg["network"]["subnet_prefix"]
container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}"
ha_port = cfg["ports"]["home_assistant"]
ws_url = f"ws://{container_ip}:{ha_port}/api/websocket"
http_url = f"http://{container_ip}:{ha_port}"
token = (cfg.get("secrets") or {}).get("ha_token", "")
leds = dock.get("knob_leds", {}) or {}
led_path = leds.get("config_path") or dock_leds.DEFAULT_LEDS_PATH
# An explicit apply_command wins; otherwise the strategy becomes a call to
# apply-leds.sh, which is where every "make the plugin re-read the file" mechanism
# lives. Strategy "none" produces no command at all rather than a no-op process
# spawned on every ring change.
apply_command = str(leds.get("apply_command", "") or "").strip()
strategy = str(leds.get("apply_strategy", "none") or "none").strip()
if not apply_command and strategy != "none":
apply_command = f"{Path(__file__).resolve().parent}/apply-leds.sh --strategy {strategy}"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "bindings.md").write_text(build_bindings(dock, ws_url))
# A neutral warm white as the starting state, so the rings mean something before
# the sync service has ever run — and so a dock whose sync service is switched off
# still looks deliberate rather than dead.
start = dock_leds.ring_colors((255, 167, 87), 255, True,
floor=leds.get("min_channel_led", 0))
(out_dir / "leds.toml").write_text(dock_leds.render_leds_toml(
start, leds.get("brightness", 100),
note=f"starting state for {dock.get('room') or 'unnamed room'}"))
env_lines = [
"# Generated by stream-dock/generate.py — CONTAINS THE HOME ASSISTANT TOKEN.",
"# Gitignored, mode 0600. Regenerate rather than editing.",
f"STREAM_DOCK_HA_URL={http_url}",
f"STREAM_DOCK_HA_TOKEN={token}",
f"STREAM_DOCK_LIGHTS={','.join(lights)}",
f"STREAM_DOCK_LEDS_PATH={led_path}",
f"STREAM_DOCK_LED_BRIGHTNESS={leds.get('brightness', 100)}",
f"STREAM_DOCK_LED_MIN_CHANNEL={leds.get('min_channel_led', 0)}",
f"STREAM_DOCK_LED_POLL_SECONDS={leds.get('poll_seconds', 2.0)}",
f"STREAM_DOCK_LED_DEBOUNCE_MS={leds.get('debounce_ms', 400)}",
f"STREAM_DOCK_LED_APPLY_COMMAND={apply_command}",
f"STREAM_DOCK_LED_APPLY_MIN_INTERVAL={leds.get('apply_min_interval_seconds', 2.0)}",
# Read by apply-leds.sh, not by led_sync.py — it is what pgrep matches to find
# the device plugin, and the probe prints the candidates on a real machine.
f"STREAM_DOCK_PLUGIN_PATTERN={leds.get('plugin_process_pattern', 'akp05')}",
f"STREAM_DOCK_LED_GATE_COMMAND={leds.get('layer_gate_command', '')}",
f"STREAM_DOCK_LED_IDLE_COLORS={','.join(str(c) for c in leds.get('idle_colors', dock_leds.IDLE_PALETTE) or [])}",
f"STREAM_DOCK_LED_IDLE_CYCLE_SECONDS={leds.get('idle_cycle_seconds', 3.0)}",
"",
]
env_file = out_dir / "led-sync.env"
env_file.write_text("\n".join(env_lines))
env_file.chmod(0o600)
service = f"""[Unit]
Description=Stream Dock knob-ring colours from Home Assistant ({dock.get('room') or 'lights'})
Documentation=file://{REPO}/stream-dock/README.md
After=network-online.target
[Service]
Type=simple
EnvironmentFile=%h/.config/stream-dock/led-sync.env
ExecStart={REPO}/stream-dock/led_sync.py
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
"""
(out_dir / "stream-dock-led-sync.service").write_text(service)
if not token:
print("warn: secrets.ha_token is empty — led-sync.env has no token and the "
"plugin will not connect either", file=sys.stderr)
print(f"wrote {out_dir}/")
for name in ("bindings.md", "leds.toml", "led-sync.env", "stream-dock-led-sync.service"):
print(f" {name}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -0,0 +1,289 @@
# Stream Dock N4 Pro — the Home Assistant half of the four-dial lighting control.
#
# Drop this file in HA's config as `packages/stream_dock.yaml` and make sure
# configuration.yaml has:
#
# homeassistant:
# packages: !include_dir_named packages
#
# `stream-dock/install-ha-package.sh` does both of those for you.
#
# WHY THIS EXISTS AT ALL, instead of the dock calling light.turn_on directly:
# a rotary encoder produces a *relative* movement ("three ticks clockwise"), and
# Home Assistant has no relative-colour service. There is `brightness_step_pct`,
# but nothing equivalent for a colour channel — turning the red dial up means
# reading the lamp's current rgb_color, adding to one element, and writing all
# three back. That read has to happen where the state lives, which is here. The
# Stream Deck plugin only ever sends "which channel, how many ticks".
#
# Every script takes `entity_id` explicitly rather than baking a room in, so one
# copy of this package serves every room and a second dock needs no new HA config.
# The per-room part lives in the dock's own button bindings — see
# `stream-dock/generated/bindings.md`, which is generated from CoreSystemConfig.json.
#
# NOTHING HERE HAS RUN AGAINST A REAL HOME ASSISTANT. It is written from the
# template documentation and reviewed by hand; the first thing to do once HA is up
# is run each script from Developer Tools -> Actions with a real light before
# binding a single dial. See stream-dock/README.md §"What is unverified".
script:
stream_dock_channel_adjust:
alias: "Stream Dock: nudge one RGB channel"
description: >-
Relative colour change from an encoder. Reads the target's current rgb_color,
moves one channel by ticks*step, clamps to 0-255 and writes all three back.
mode: queued
max: 30
fields:
entity_id:
name: Lights
description: The light(s) this dock controls. Required — never defaulted.
required: true
selector:
entity:
multiple: true
filter:
domain: light
channel:
name: Channel
required: true
selector:
select:
options: [r, g, b]
ticks:
name: Ticks
description: Encoder movement. Negative is counter-clockwise.
required: true
selector:
number: { min: -64, max: 64, mode: box }
step:
name: Step
description: 0-255 units per tick.
default: 8
selector:
number: { min: 1, max: 64, mode: box }
variables:
# entity_id arrives as a list from the dock, but a hand-run from Developer
# Tools is just as likely to pass a bare string. Normalise both.
targets: >-
{{ entity_id if entity_id is not string else [entity_id] }}
# Which lamp's colour counts as "the current colour" when the dial drives
# several. A lit one, if there is one — an off lamp reports rgb_color: none
# in most integrations, and starting from white every time you touch the dial
# while one lamp of three is off is the bug this line exists to avoid.
reference: >-
{{ (expand(targets) | selectattr('state', 'eq', 'on') | map(attribute='entity_id') | list
+ (targets | list)) | first }}
base: >-
{{ (state_attr(reference, 'rgb_color') or [255, 255, 255]) | list }}
index: "{{ {'r': 0, 'g': 1, 'b': 2}.get(channel, '0') | int }}"
delta: "{{ ((ticks | float(0)) * (step | float(8))) | round | int }}"
value: "{{ [[(base[index] | int) + delta, 255] | min, 0] | max }}"
target_rgb: "{{ base[:index] + [value] + base[index + 1:] }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ targets }}"
data:
rgb_color: "{{ target_rgb }}"
stream_dock_channel_set:
alias: "Stream Dock: set one RGB channel absolutely"
description: >-
The absolute-position variant, for a dial bound to {{ rotationPercent }}
instead of {{ ticks }}. Same colour maths, but the dial's accumulated
position IS the channel value rather than a nudge to it.
mode: queued
max: 30
fields:
entity_id:
name: Lights
required: true
selector:
entity:
multiple: true
filter:
domain: light
channel:
name: Channel
required: true
selector:
select:
options: [r, g, b]
percent:
name: Percent
description: 0-100, mapped onto 0-255.
required: true
selector:
number: { min: 0, max: 100, mode: box }
variables:
targets: >-
{{ entity_id if entity_id is not string else [entity_id] }}
reference: >-
{{ (expand(targets) | selectattr('state', 'eq', 'on') | map(attribute='entity_id') | list
+ (targets | list)) | first }}
base: >-
{{ (state_attr(reference, 'rgb_color') or [255, 255, 255]) | list }}
index: "{{ {'r': 0, 'g': 1, 'b': 2}.get(channel, '0') | int }}"
value: "{{ [[((percent | float(0)) * 2.55) | round | int, 255] | min, 0] | max }}"
target_rgb: "{{ base[:index] + [value] + base[index + 1:] }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ targets }}"
data:
rgb_color: "{{ target_rgb }}"
stream_dock_channel_extreme:
alias: "Stream Dock: slam one RGB channel to an end stop"
description: >-
The dial's push action. A channel that is anywhere above zero goes to zero;
a channel already at zero goes to full. Two presses always get you back to
where you were, which is what makes it safe to press by accident.
mode: queued
max: 10
fields:
entity_id:
name: Lights
required: true
selector:
entity:
multiple: true
filter:
domain: light
channel:
name: Channel
required: true
selector:
select:
options: [r, g, b]
variables:
targets: >-
{{ entity_id if entity_id is not string else [entity_id] }}
reference: >-
{{ (expand(targets) | selectattr('state', 'eq', 'on') | map(attribute='entity_id') | list
+ (targets | list)) | first }}
base: >-
{{ (state_attr(reference, 'rgb_color') or [255, 255, 255]) | list }}
index: "{{ {'r': 0, 'g': 1, 'b': 2}.get(channel, '0') | int }}"
value: "{{ 0 if (base[index] | int) > 0 else 255 }}"
target_rgb: "{{ base[:index] + [value] + base[index + 1:] }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ targets }}"
data:
rgb_color: "{{ target_rgb }}"
stream_dock_brightness_adjust:
alias: "Stream Dock: nudge brightness"
description: >-
The fourth dial. Unlike colour, HA has a native relative service parameter
for this (brightness_step_pct), so there is no read-modify-write here — and
it is the integration, not this script, that decides what 0% means (most
turn the lamp off).
mode: queued
max: 30
fields:
entity_id:
name: Lights
required: true
selector:
entity:
multiple: true
filter:
domain: light
ticks:
name: Ticks
required: true
selector:
number: { min: -64, max: 64, mode: box }
step_pct:
name: Step
description: Percentage points per tick.
default: 5
selector:
number: { min: 1, max: 50, mode: box }
variables:
targets: >-
{{ entity_id if entity_id is not string else [entity_id] }}
delta: "{{ ((ticks | float(0)) * (step_pct | float(5))) | round | int }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ targets }}"
data:
brightness_step_pct: "{{ delta }}"
stream_dock_toggle:
alias: "Stream Dock: toggle the room"
mode: single
fields:
entity_id:
name: Lights
required: true
selector:
entity:
multiple: true
filter:
domain: light
sequence:
- action: light.toggle
target:
entity_id: >-
{{ entity_id if entity_id is not string else [entity_id] }}
stream_dock_set_color:
alias: "Stream Dock: set a preset colour"
description: >-
The key presets. brightness_pct is optional: left out, the lamp keeps
whatever brightness it already had, which is what you want from a key that
only means "make it green".
mode: queued
max: 10
fields:
entity_id:
name: Lights
required: true
selector:
entity:
multiple: true
filter:
domain: light
rgb:
name: RGB
description: "[r, g, b], each 0-255."
required: true
selector:
object:
brightness_pct:
name: Brightness
description: Omit to leave brightness alone.
selector:
number: { min: 1, max: 100, mode: box }
variables:
targets: >-
{{ entity_id if entity_id is not string else [entity_id] }}
sequence:
# Two branches rather than one templated `data:` block. A template that returns
# a whole service-data mapping is a trick that may or may not be supported
# depending on the HA version, and this script is the one thing on the dock that
# eight keys depend on — so it uses only the boring construct.
- choose:
- conditions:
- condition: template
value_template: "{{ brightness_pct is defined and brightness_pct not in [none, ''] }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ targets }}"
data:
rgb_color: "{{ rgb }}"
brightness_pct: "{{ brightness_pct | int }}"
default:
- action: light.turn_on
target:
entity_id: "{{ targets }}"
data:
rgb_color: "{{ rgb }}"

112
stream-dock/install-ha-package.sh Executable file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env bash
#
# Put the Stream Dock's script package into Home Assistant, and make HA load it.
#
# Two halves, both idempotent:
# 1. copy ha-package/stream_dock.yaml into <HA config>/packages/
# 2. make sure configuration.yaml actually includes that directory — HA's default
# configuration.yaml does NOT enable packages, so without this step the file
# sits there being ignored, which is the single most likely way for this whole
# thing to look broken for no visible reason.
#
# Run it from the repo on any machine that can ssh to the container host:
# stream-dock/install-ha-package.sh
# or on the container host itself:
# stream-dock/install-ha-package.sh --local
#
# It does NOT restart Home Assistant. It reloads scripts and the YAML config over the
# REST API, which is enough for a package of scripts and costs nobody their dashboard.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=/dev/null
source "${REPO_ROOT}/tools/lib/coreconfig.sh"
LOCAL=false
[[ "${1:-}" == "--local" ]] && LOCAL=true
core_load
HA_CONFIG_DIR="${HA_CONFIG_DIR:-/opt/smart-home/homeassistant}"
PACKAGE_SRC="${REPO_ROOT}/stream-dock/ha-package/stream_dock.yaml"
[[ -f "$PACKAGE_SRC" ]] || core_die "missing $PACKAGE_SRC"
# The include line, exactly as it has to appear. Written as a heredoc into a temp file
# and appended only when `packages:` is not already configured — appending a second
# packages: key to a YAML file that has one is a config error, not a merge.
read -r -d '' INCLUDE_SNIPPET <<'YAML' || true
# Added by stream-dock/install-ha-package.sh — lets HA load config/packages/*.yaml.
# If you already have a `homeassistant:` block, move `packages:` under it and delete
# this one; two top-level `homeassistant:` keys is a YAML error.
homeassistant:
packages: !include_dir_named packages
YAML
remote() {
if $LOCAL; then
bash -c "$1"
else
ssh "${CORE_CONTAINER_HOST_USER}@${CORE_CONTAINER_HOST_IP}" "$1"
fi
}
core_log "Installing stream_dock.yaml into ${HA_CONFIG_DIR}/packages/"
if $LOCAL; then
sudo mkdir -p "${HA_CONFIG_DIR}/packages"
sudo cp "$PACKAGE_SRC" "${HA_CONFIG_DIR}/packages/stream_dock.yaml"
else
remote "sudo mkdir -p '${HA_CONFIG_DIR}/packages'"
scp "$PACKAGE_SRC" "${CORE_CONTAINER_HOST_USER}@${CORE_CONTAINER_HOST_IP}:/tmp/stream_dock.yaml"
remote "sudo mv /tmp/stream_dock.yaml '${HA_CONFIG_DIR}/packages/stream_dock.yaml'"
fi
core_log "Checking configuration.yaml for a packages include"
if remote "sudo grep -qE '^[[:space:]]*packages:' '${HA_CONFIG_DIR}/configuration.yaml'"; then
echo " already configured — leaving configuration.yaml alone"
else
printf '%s\n' "$INCLUDE_SNIPPET" > /tmp/stream-dock-include.yaml
if $LOCAL; then
sudo cp "${HA_CONFIG_DIR}/configuration.yaml" "${HA_CONFIG_DIR}/configuration.yaml.bak-streamdock"
sudo tee -a "${HA_CONFIG_DIR}/configuration.yaml" < /tmp/stream-dock-include.yaml >/dev/null
else
scp /tmp/stream-dock-include.yaml "${CORE_CONTAINER_HOST_USER}@${CORE_CONTAINER_HOST_IP}:/tmp/stream-dock-include.yaml"
remote "sudo cp '${HA_CONFIG_DIR}/configuration.yaml' '${HA_CONFIG_DIR}/configuration.yaml.bak-streamdock' && sudo tee -a '${HA_CONFIG_DIR}/configuration.yaml' < /tmp/stream-dock-include.yaml >/dev/null && rm -f /tmp/stream-dock-include.yaml"
fi
rm -f /tmp/stream-dock-include.yaml
echo " appended (previous file kept as configuration.yaml.bak-streamdock)"
core_warn "adding a packages include changes how HA loads config — if HA refuses to"
core_warn "start, restore the .bak-streamdock file and add packages: under your own"
core_warn "existing homeassistant: block by hand"
fi
if [[ -z "${CORE_HA_TOKEN:-}" ]]; then
core_warn "secrets.ha_token is empty — skipping the reload. Restart Home Assistant, or"
core_warn "run Developer Tools -> YAML -> 'Reload all YAML configuration' yourself."
exit 0
fi
core_log "Reloading Home Assistant's YAML configuration"
# reload_all covers scripts and the package include in one call. A non-2xx here is not
# fatal to the install — the files are in place either way — so it reports rather than
# dies, and says what to do by hand.
if curl -fsS -X POST \
-H "Authorization: Bearer ${CORE_HA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{}' \
"${CORE_HA_URL}/api/services/homeassistant/reload_all" >/dev/null; then
echo " reloaded"
else
core_warn "reload call failed — do it by hand in Developer Tools -> YAML, or restart HA"
fi
cat <<EOF
Now check the scripts exist before you bind a single dial:
Developer Tools -> Actions -> search 'Stream Dock'
Run 'Stream Dock: nudge one RGB channel' by hand against a real lamp with
channel: r, ticks: 3, step: 8 and watch the lamp go redder. If that works, the
Home Assistant half is done and everything left is on the desk.
EOF

75
stream-dock/layer-active.sh Executable file
View File

@ -0,0 +1,75 @@
#!/usr/bin/env bash
#
# "Is the dock's lighting layer the one currently showing?"
#
# exit 0 yes — the rings belong to the lamps
# exit 1 no — the rings belong to whatever else is on screen
# exit 2 cannot tell — treated by led_sync.py exactly like "no"
#
# THIS SHIPS UNVERIFIED, AND EXITS 2 UNTIL YOU POINT IT AT SOMETHING REAL. OpenDeck
# keeps its state under ~/.config/opendeck, but the file and field that name the
# selected profile are not documented anywhere this repo could check, and guessing
# them would produce the worst possible failure: a gate that confidently answers
# "yes, lighting" on every layer. So the default answer is "I do not know", which
# leaves the rings on their idle chase, and finding the real answer takes three
# minutes with --discover.
#
# stream-dock/layer-active.sh --discover
#
# Then set these in the environment (led-sync.env, or the config's
# stream_dock.knob_leds.layer_gate_command if you would rather pass them as flags):
#
# STREAM_DOCK_LAYER_FILE the JSON file that changes when you switch layers
# STREAM_DOCK_LAYER_JQ a jq expression selecting the active layer's name
# STREAM_DOCK_LAYER the name of your lighting layer
#
# If your OpenDeck build turns out to expose the current profile some other way — a
# CLI, a socket — throw this script away and put that command in
# layer_gate_command instead. The gate is a contract about exit codes, nothing more.
set -uo pipefail
OPENDECK_CONFIG_DIR="${OPENDECK_CONFIG_DIR:-$HOME/.config/opendeck}"
LAYER="${STREAM_DOCK_LAYER:-lighting}"
LAYER_FILE="${STREAM_DOCK_LAYER_FILE:-}"
LAYER_JQ="${STREAM_DOCK_LAYER_JQ:-}"
if [[ "${1:-}" == "--discover" ]]; then
if [[ ! -d "$OPENDECK_CONFIG_DIR" ]]; then
echo "No OpenDeck config directory at $OPENDECK_CONFIG_DIR." >&2
echo "Set OPENDECK_CONFIG_DIR if yours lives elsewhere (a Flatpak install puts it" >&2
echo "under ~/.var/app/me.amankhanna.opendeck/config/opendeck)." >&2
exit 2
fi
echo "Watching $OPENDECK_CONFIG_DIR."
echo
echo " 1. Leave this running."
echo " 2. On the dock, switch AWAY from your lighting layer and back to it."
echo " 3. Whatever is listed below is where OpenDeck records the selection."
echo
snapshot="$(mktemp)"
find "$OPENDECK_CONFIG_DIR" -type f -printf '%T@ %p\n' | sort > "$snapshot"
echo "Switch layers now — press Ctrl-C when you have switched a couple of times."
trap 'echo; echo "Changed while you were switching:";
find "$OPENDECK_CONFIG_DIR" -type f -printf "%T@ %p\n" | sort | comm -13 "$snapshot" - | cut -d" " -f2- ;
rm -f "$snapshot"; exit 0' INT
while true; do sleep 1; done
fi
if [[ -z "$LAYER_FILE" || -z "$LAYER_JQ" ]]; then
echo "layer-active.sh: STREAM_DOCK_LAYER_FILE / STREAM_DOCK_LAYER_JQ are not set," >&2
echo "so this cannot tell which layer is showing. Run --discover." >&2
exit 2
fi
command -v jq >/dev/null || { echo "layer-active.sh: jq is not installed" >&2; exit 2; }
[[ -r "$LAYER_FILE" ]] || { echo "layer-active.sh: cannot read $LAYER_FILE" >&2; exit 2; }
current="$(jq -r "$LAYER_JQ" "$LAYER_FILE" 2>/dev/null)" || exit 2
# An empty or null result means the expression did not match the file's shape — which
# is a broken gate, not an answer of "no". Say so rather than silently reporting the
# lighting layer as hidden forever.
[[ -n "$current" && "$current" != "null" ]] || {
echo "layer-active.sh: '$LAYER_JQ' matched nothing in $LAYER_FILE" >&2
exit 2
}
[[ "$current" == "$LAYER" ]]

314
stream-dock/led_sync.py Executable file
View File

@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""Keep the dock's four knob rings showing what the room's lamps are doing.
Polls Home Assistant's REST API for the configured lights, works out the four ring
colours (`dock_leds.ring_colors`), and rewrites the akp05 device plugin's leds.toml
when and only when they change. Then runs STREAM_DOCK_LED_APPLY_COMMAND, which is
the hook that makes the plugin notice.
THE HONEST LIMIT, and the reason that hook exists: the akp05 device plugin reads
leds.toml when it starts and does not watch it. Nothing else can drive those LEDs
either the plugin holds the USB device open, so a second process cannot write HID
reports to it. So this service is correct and complete on its own side, and the last
few centimetres are somebody's decision about how to make the plugin re-read: a small
upstream patch that watches the file (the right fix), or a command that restarts the
plugin (available today, but it re-initialises the device, so it is only tolerable at
the debounce intervals this service is designed around, not per detent). With the hook
empty this still runs, still keeps the file right, and simply does not light anything
new until the plugin next starts. See stream-dock/README.md §5.
Why polling and not the websocket API: this is stdlib-only, like the rest of this
repo's services, and the thing it feeds cannot react faster than its debounce anyway.
A websocket subscription is a strict improvement the day the apply hook becomes cheap.
Configuration is environment only (generated/led-sync.env, written by generate.py).
"""
from __future__ import annotations
import json
import logging
import os
import shlex
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import dock_leds # noqa: E402
LOG = logging.getLogger("stream-dock-led-sync")
HA_URL = os.environ.get("STREAM_DOCK_HA_URL", "").rstrip("/")
HA_TOKEN = os.environ.get("STREAM_DOCK_HA_TOKEN", "")
LIGHTS = [e.strip() for e in os.environ.get("STREAM_DOCK_LIGHTS", "").split(",") if e.strip()]
LEDS_PATH = Path(os.path.expanduser(
os.environ.get("STREAM_DOCK_LEDS_PATH", dock_leds.DEFAULT_LEDS_PATH)))
LED_BRIGHTNESS = int(os.environ.get("STREAM_DOCK_LED_BRIGHTNESS", "100"))
MIN_CHANNEL = int(os.environ.get("STREAM_DOCK_LED_MIN_CHANNEL", "0"))
POLL_SECONDS = float(os.environ.get("STREAM_DOCK_LED_POLL_SECONDS", "2"))
DEBOUNCE_MS = int(os.environ.get("STREAM_DOCK_LED_DEBOUNCE_MS", "400"))
APPLY_COMMAND = os.environ.get("STREAM_DOCK_LED_APPLY_COMMAND", "").strip()
# Rate limit for the apply command only — never for the file write, which is cheap and
# always reflects the current state. Every strategy except "none" re-initialises the
# device to some degree, so running one per detent of a spun dial would make the dock
# blink rather than light. The last state is never dropped: a deferred apply runs as
# soon as the interval is up.
APPLY_MIN_INTERVAL = float(os.environ.get("STREAM_DOCK_LED_APPLY_MIN_INTERVAL", "0"))
# The layer gate. The dock's lighting controls live on their own OpenDeck layer, and
# the four rings are shared hardware: on any other layer those dials mean something
# else, and painting a lamp's colour onto them there is worse than not lighting them
# at all. Empty means no gate — drive the rings always.
GATE_COMMAND = os.environ.get("STREAM_DOCK_LED_GATE_COMMAND", "").strip()
IDLE_COLORS = [c.strip() for c in os.environ.get("STREAM_DOCK_LED_IDLE_COLORS", "").split(",")
if c.strip()]
IDLE_CYCLE_SECONDS = float(os.environ.get("STREAM_DOCK_LED_IDLE_CYCLE_SECONDS", "3"))
# How long a failing Home Assistant is allowed to be quiet about it. Below this the
# service just retries; above it, it says so once and then stays quiet again, because
# a desktop service that logs a line every two seconds while HA reboots is a service
# nobody keeps enabled.
COMPLAIN_AFTER_SECONDS = 60
class Lights:
"""The lamps, and the last colour anybody saw them wearing.
The remembered colour is the point of this class. Most integrations drop
rgb_color to None when a lamp is off, and a ring set that forgets the colour every
time the light is switched off would tell you nothing about what the colour dials
are currently holding see dock_leds' module docstring.
"""
def __init__(self, entity_ids: list[str]) -> None:
self.entity_ids = entity_ids
self.last_rgb: tuple[int, int, int] = (255, 167, 87)
self.last_brightness = 255
def read(self) -> tuple[bool, bool, tuple[int, int, int], int]:
"""(HA answered at all, any lamp on, colour, brightness).
The first flag is separate from the second on purpose: "every lamp is off" and
"I could not ask" are different facts, and only one of them means the rings are
lying. Same three-state honesty as the infra poller's ok/problem/unreachable.
"""
reachable = False
any_on = False
chosen = None
for entity_id in self.entity_ids:
state = fetch_state(entity_id)
if state is None:
continue
reachable = True
on = state.get("state") == "on"
any_on = any_on or on
if on and chosen is None:
attrs = state.get("attributes") or {}
rgb = attrs.get("rgb_color")
if rgb and len(rgb) == 3:
chosen = (tuple(int(c) for c in rgb),
int(attrs.get("brightness") or 255))
if chosen is not None:
self.last_rgb, self.last_brightness = chosen
return reachable, any_on, self.last_rgb, self.last_brightness
def fetch_state(entity_id: str) -> dict | None:
request = urllib.request.Request(
f"{HA_URL}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {HA_TOKEN}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
# 404 is a configuration mistake, not a transient failure, and it deserves to
# be loud every time: it means the entity in CoreSystemConfig.json does not
# exist in this Home Assistant, which is exactly the failure the dials will
# hit too.
if exc.code == 404:
LOG.error("no such entity in Home Assistant: %s", entity_id)
else:
LOG.debug("HTTP %s fetching %s", exc.code, entity_id)
return None
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
LOG.debug("fetching %s: %s", entity_id, exc)
return None
def write_leds(colors: list[list[int]]) -> None:
"""Atomic rewrite: a half-written leds.toml read by a starting plugin is a device
that comes up with no LEDs and no explanation."""
LEDS_PATH.parent.mkdir(parents=True, exist_ok=True)
temp = LEDS_PATH.with_suffix(".toml.tmp")
temp.write_text(dock_leds.render_leds_toml(colors, LED_BRIGHTNESS))
os.replace(temp, LEDS_PATH)
def layer_active() -> bool | None:
"""Is the dock currently showing the lighting layer? None means "cannot tell".
Exit 0 yes, exit 1 no, anything else including a command that will not run
is None, and the caller treats that as "not ours". A service that has lost track
of which layer is up must not keep painting lamp colours onto rings that may now
belong to something else; going idle is the recoverable mistake, hijacking is not.
"""
if not GATE_COMMAND:
return True
try:
result = subprocess.run(shlex.split(GATE_COMMAND), capture_output=True,
text=True, timeout=5)
except (OSError, subprocess.SubprocessError) as exc:
LOG.debug("layer gate could not run: %s", exc)
return None
if result.returncode == 0:
return True
if result.returncode == 1:
return False
LOG.debug("layer gate exited %s: %s", result.returncode, (result.stderr or "").strip()[:200])
return None
def run_apply() -> None:
if not APPLY_COMMAND:
return
try:
result = subprocess.run(shlex.split(APPLY_COMMAND), capture_output=True,
text=True, timeout=30)
if result.returncode != 0:
LOG.warning("apply command failed (%s): %s", result.returncode,
(result.stderr or "").strip()[:200])
except (OSError, subprocess.SubprocessError) as exc:
LOG.warning("apply command could not run: %s", exc)
def main() -> int:
logging.basicConfig(level=os.environ.get("STREAM_DOCK_LOG_LEVEL", "INFO"),
format="%(levelname)s %(name)s: %(message)s")
if not HA_URL or not HA_TOKEN:
LOG.error("STREAM_DOCK_HA_URL and STREAM_DOCK_HA_TOKEN must be set "
"(generate them with stream-dock/generate.py)")
return 2
if not LIGHTS:
LOG.error("STREAM_DOCK_LIGHTS is empty — nothing to follow")
return 2
running = True
def stop(signum, frame): # noqa: ARG001
nonlocal running
running = False
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
lights = Lights(LIGHTS)
LOG.info("following %s, writing %s", ", ".join(LIGHTS), LEDS_PATH)
if GATE_COMMAND:
LOG.info("gated on: %s", GATE_COMMAND)
else:
LOG.info("no layer gate configured — driving the rings on every layer")
if APPLY_COMMAND:
LOG.info("apply: %s (at most once every %ss)", APPLY_COMMAND, APPLY_MIN_INTERVAL)
else:
LOG.info("no apply command configured — leds.toml will be kept correct, but the "
"device plugin only reads it at startup (README section 5)")
written: list[list[int]] | None = None
pending: list[list[int]] | None = None
pending_since = 0.0
last_apply = 0.0
apply_due = False
unreachable_since = 0.0
complained = False
was_active: bool | None = None
gate_complained = False
while running:
now = time.monotonic()
active = layer_active()
if active is None and not gate_complained:
LOG.warning("the layer gate is not answering — treating the lighting layer as "
"hidden and leaving the rings to their idle scheme (%s)", GATE_COMMAND)
gate_complained = True
elif active is not None:
gate_complained = False
if active is not True and was_active is True:
LOG.debug("lighting layer hidden — rings released")
if active is True and was_active is not True:
LOG.debug("lighting layer showing — rings following %s", ", ".join(LIGHTS))
was_active = active
if active is True:
# Home Assistant is only polled while the layer is actually up. A dock
# sitting on some other layer all day should not be asking about a lamp
# nobody can see the colour of.
reachable, any_on, rgb, brightness = lights.read()
if not reachable:
if unreachable_since == 0.0:
unreachable_since = now
elif not complained and now - unreachable_since > COMPLAIN_AFTER_SECONDS:
LOG.warning("Home Assistant has been unreachable for %ds — the rings are "
"showing the last state anybody saw, not the current one",
int(now - unreachable_since))
complained = True
else:
if complained:
LOG.info("Home Assistant is back")
unreachable_since, complained = 0.0, False
colors = dock_leds.ring_colors(rgb, brightness, any_on, floor=MIN_CHANNEL)
elif IDLE_COLORS:
unreachable_since, complained = 0.0, False
step = int(now / IDLE_CYCLE_SECONDS) if IDLE_CYCLE_SECONDS > 0 else 0
colors = dock_leds.idle_ring_colors(IDLE_COLORS, step)
else:
# No idle scheme configured: hands entirely off. Whatever is on the rings
# stays there, and the next time the layer comes up the colours are
# rewritten from scratch — hence dropping `written`.
written, pending = None, None
time.sleep(POLL_SECONDS)
continue
# Debounce: a dial being spun changes the colour on every service call, and
# each write is followed by whatever the apply command costs — which, if it is
# a plugin restart, is far too expensive to do per detent. Collapse a burst
# into one write once it settles.
if colors != written and colors != pending:
pending, pending_since = colors, now
if pending is not None and (now - pending_since) * 1000 >= DEBOUNCE_MS:
write_leds(pending)
LOG.debug("rings -> %s", pending)
written, pending = pending, None
apply_due = True
# The file is already current; this is only about when the (expensive) reload
# runs. A deferred apply is never dropped — it fires on a later pass with the
# newest state already on disk, which is exactly what should reach the device.
if apply_due and APPLY_COMMAND and now - last_apply >= APPLY_MIN_INTERVAL:
run_apply()
last_apply, apply_due = now, False
elif apply_due and not APPLY_COMMAND:
apply_due = False
interval = POLL_SECONDS
if apply_due and APPLY_MIN_INTERVAL > 0:
# Come back when the deferred reload is allowed to run, not a poll later.
interval = min(interval, max(0.2, APPLY_MIN_INTERVAL - (now - last_apply)))
if active is not True and IDLE_CYCLE_SECONDS > 0:
# Keep the chase on time without polling faster than it steps.
interval = min(interval, max(0.2, IDLE_CYCLE_SECONDS / 4))
if pending is not None:
interval = min(interval, 0.2)
time.sleep(interval)
LOG.info("stopping")
return 0
if __name__ == "__main__":
sys.exit(main())

135
stream-dock/setup-stream-dock.sh Executable file
View File

@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# The desk-side half: generate this dock's files and put them where OpenDeck and
# systemd will find them. Run it ON THE DESKTOP THE DOCK IS PLUGGED INTO, from a
# checkout of this repo.
#
# stream-dock/setup-stream-dock.sh
#
# What it does:
# 1. runs generate.py against CoreSystemConfig.json
# 2. installs the starting leds.toml for the akp05 device plugin (backing up yours)
# 3. installs led-sync.env (mode 0600 — it holds the HA token) and the systemd
# --user unit for the ring-colour service
# 4. tells you what it could not do for you, which is every step that needs a
# human in front of OpenDeck's own UI
#
# It installs no plugins. Both of them come from third parties as release downloads,
# and a setup script that silently pulls executables off the internet onto a desktop
# is not a trade this project makes — the two links are printed instead. See
# stream-dock/README.md §2.
#
# Options:
# --no-enable install the unit but do not start it
# --config PATH use a different CoreSystemConfig.json
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DOCK_DIR="${REPO_ROOT}/stream-dock"
# shellcheck source=/dev/null
source "${REPO_ROOT}/tools/lib/coreconfig.sh"
ENABLE=true
while [[ $# -gt 0 ]]; do
case "$1" in
--no-enable) ENABLE=false; shift ;;
--config) CORE_CONFIG_PATH="$2"; shift 2 ;;
*) core_die "unknown option: $1" ;;
esac
done
core_load
[[ "$CORE_STREAM_DOCK_ENABLED" == "true" ]] || core_die \
"stream_dock.enabled is false in $(basename "$CORE_CONFIG_PATH") — nothing to set up"
core_log "Generating this dock's files"
"${DOCK_DIR}/generate.py" "$CORE_CONFIG_PATH"
GEN="${DOCK_DIR}/generated"
# --- the device plugin's LED file --------------------------------------------------
LEDS_PATH="${CORE_STREAM_DOCK_LED_CONFIG_PATH:-}"
LEDS_PATH="${LEDS_PATH:-$HOME/.config/opendeck-akp05/leds.toml}"
LEDS_PATH="${LEDS_PATH/#\~/$HOME}"
core_log "Installing starting knob colours -> ${LEDS_PATH}"
mkdir -p "$(dirname "$LEDS_PATH")"
if [[ -f "$LEDS_PATH" ]] && ! grep -q "Generated by stream-dock" "$LEDS_PATH"; then
cp "$LEDS_PATH" "${LEDS_PATH}.bak-streamdock"
core_warn "kept your existing file as ${LEDS_PATH}.bak-streamdock"
fi
cp "${GEN}/leds.toml" "$LEDS_PATH"
# --- the sync service ---------------------------------------------------------------
if [[ "$CORE_STREAM_DOCK_LED_ENABLED" == "true" ]]; then
core_log "Installing the ring-colour service"
mkdir -p "$HOME/.config/stream-dock" "$HOME/.config/systemd/user"
install -m 600 "${GEN}/led-sync.env" "$HOME/.config/stream-dock/led-sync.env"
install -m 644 "${GEN}/stream-dock-led-sync.service" \
"$HOME/.config/systemd/user/stream-dock-led-sync.service"
# Not fatal if systemd is not answering (a session without a user manager, a
# container, an ssh login without lingering): the files are installed either way and
# the unit can be started by hand later. Aborting here would leave the dock set up
# and the script looking like it failed.
if ! systemctl --user daemon-reload 2>/dev/null; then
core_warn "systemctl --user is not available here — the unit is installed but not loaded"
elif $ENABLE; then
if systemctl --user enable --now stream-dock-led-sync.service; then
echo " started — follow it with: journalctl --user -fu stream-dock-led-sync"
else
core_warn "could not start the unit — try: systemctl --user status stream-dock-led-sync"
fi
else
echo " installed, not started (--no-enable)"
fi
# A user unit dies at logout unless lingering is on. Worth saying, not worth doing
# unasked: enabling lingering is a system-wide change made with sudo, and this is a
# service for a device on a desk somebody is sitting at.
if ! loginctl show-user "$USER" --property=Linger 2>/dev/null | grep -q "Linger=yes"; then
echo " note: this stops at logout. 'sudo loginctl enable-linger $USER' if you"
echo " want it running while logged out."
fi
else
core_warn "stream_dock.knob_leds.enabled is false — skipping the ring-colour service"
fi
# --- what a script cannot do for you -------------------------------------------------
core_log "What is left, and only you can do it"
have_opendeck=false
command -v opendeck >/dev/null 2>&1 && have_opendeck=true
flatpak list 2>/dev/null | grep -qi opendeck && have_opendeck=true
$have_opendeck || cat <<'EOF'
[ ] Install OpenDeck itself:
flatpak install flathub me.amankhanna.opendeck
EOF
cat <<EOF
[ ] Install the device plugin, or the dock will not appear in OpenDeck at all —
an N4 Pro is not an Elgato device and OpenDeck does not speak to it natively:
https://github.com/aroaxinping/opendeck-akp05 (Ajazz AKP05 / Mirabox N4 family)
[ ] Install the Home Assistant plugin (a .streamDeckPlugin release, installed
through OpenDeck's plugin installer):
https://github.com/cgiesche/streamdeck-homeassistant
[ ] Put the lighting controls on their own OpenDeck layer, then teach the gate
how to see it:
${DOCK_DIR}/layer-active.sh --discover
[ ] Find out what makes the knob rings pick up a colour change — two minutes with
the dock plugged in, and its first test is whether they already do:
${DOCK_DIR}/apply-leds.sh --probe
[ ] Bind the dials and keys — every value to paste is in:
${GEN}/bindings.md
[ ] On the Home Assistant side (from any machine that can ssh to the container host):
${DOCK_DIR}/install-ha-package.sh
EOF
if ! lsusb 2>/dev/null | grep -qiE "mirabox|ajazz|stream ?dock"; then
core_warn "no obviously-Mirabox/Ajazz device in lsusb — if OpenDeck cannot see the dock,"
core_warn "that is a udev rule question for the device plugin, not for this repo"
fi

View File

@ -0,0 +1,50 @@
# Ready-to-file request: watch `leds.toml` instead of reading it once
Written to be pasted into an issue on whichever `opendeck-akp05` fork you installed
([aroaxinping](https://github.com/aroaxinping/opendeck-akp05),
[ambiso](https://github.com/ambiso/opendeck-akp05),
[truelecter](https://github.com/truelecter/opendeck-mirabox-n4)). It is the real fix
for the one gap in this directory: everything else is already built and tested.
---
**Title:** Re-read `leds.toml` when it changes, instead of only at plugin start
**Body:**
The knob LEDs are currently configured from `~/.config/opendeck-akp05/leds.toml`,
which the plugin reads at startup. That makes the ring colours a static decoration.
They could be a display. The rings are the only pixels on this device that sit next to
the encoders, and plenty of things an encoder controls have a colour: a light, a
channel strip, a status. In my case the four dials are red / green / blue / brightness
for a room's lamps, and the natural feedback is each ring showing its own channel's
current value, with the fourth showing the colour the room is actually emitting.
Everything needed for that already exists in the plugin — it can set ring colours.
What is missing is a way to change them after startup:
- The plugin holds the USB device open, so no other process can drive the LEDs.
- Nothing documents a reload mechanism, and the file is not watched.
- The workarounds are all bad: restarting the plugin re-initialises the device (a
visible blink and the keys redrawing) for what should be a two-byte change, and it
cannot be done at the rate a turning dial produces changes.
**The ask:** watch `leds.toml` for modification and re-apply on change — the
`notify` crate, or a poll of the file's mtime once a second, would both be enough. A
debounce on the watcher side would be welcome but is not required; a writer that
respects the device can rate-limit itself (mine does).
**Why a file watch rather than an API:** it needs no new IPC surface, no protocol
decision, and no change to how the file is documented today. Anything that can write a
config file can drive the rings.
Happy to test a branch against a MiraBox N4 Pro.
---
## If the answer is no
Nothing in `stream-dock/` has to change. `apply-leds.sh --strategy restart-plugin`
already works as a fallback, paced by `apply_min_interval_seconds`, and the ring
colours are computed and written correctly either way — see `README.md` §5.

View File

@ -68,6 +68,10 @@ TOUCHPANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
# CoreSystemConfig.json. The agent publishes it as suggested_area so HA files # CoreSystemConfig.json. The agent publishes it as suggested_area so HA files
# the device in the right room by itself — see docs/rooms-and-endpoints.md. # the device in the right room by itself — see docs/rooms-and-endpoints.md.
TOUCHPANEL_ROOM="${CORE_KIOSK_ROOM:-}" TOUCHPANEL_ROOM="${CORE_KIOSK_ROOM:-}"
# Discord as a fourth app on this panel (kiosks[].enable_discord). Per-panel, not
# per-image-type: the Loggia panel wants it so a voice call survives stepping outside
# for a cigarette; a kitchen panel does not want a chat client at all.
ENABLE_DISCORD="${CORE_KIOSK_ENABLE_DISCORD:-false}"
HA_URL="$CORE_HA_URL" HA_URL="$CORE_HA_URL"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -158,6 +162,7 @@ subst "${AGENT_DIR}/touchpanel-agent.service" "$INCLUDES/opt/touchpanel-agent/to
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/ha-kiosk" "$INCLUDES/usr/local/bin/ha-kiosk" install -m 0755 "${CONFIGS_DIR}/sway/ha-kiosk" "$INCLUDES/usr/local/bin/ha-kiosk"
install -m 0755 "${CONFIGS_DIR}/spotify/spotify-launch" "$INCLUDES/usr/local/bin/spotify-launch" install -m 0755 "${CONFIGS_DIR}/spotify/spotify-launch" "$INCLUDES/usr/local/bin/spotify-launch"
install -m 0755 "${CONFIGS_DIR}/discord/discord-launch" "$INCLUDES/usr/local/bin/discord-launch"
install -m 0755 "${CONFIGS_DIR}/keyboard/toggle-keyboard" "$INCLUDES/usr/local/bin/toggle-keyboard" install -m 0755 "${CONFIGS_DIR}/keyboard/toggle-keyboard" "$INCLUDES/usr/local/bin/toggle-keyboard"
# Touchscreen misclassification override (inert 0000:0000 template until the real # Touchscreen misclassification override (inert 0000:0000 template until the real
@ -178,7 +183,23 @@ BACKSPACE="guess"
EOF EOF
# Touch dock (eww) — session-scoped files under the kiosk user's own config. # Touch dock (eww) — session-scoped files under the kiosk user's own config.
install -m 0644 "${CONFIGS_DIR}/eww/eww.yuck" "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww/eww.yuck" # The dock's one templated token is the Discord button: a button for an app this image
# did not install would switch to an empty workspace, which reads as a broken panel
# rather than a disabled feature. The replacement is a fixed string chosen here, never
# anything derived from config values — same rule as eww.yuck's own header.
if [[ "$ENABLE_DISCORD" == "true" ]]; then
DISCORD_DOCK_BTN=' (dock-btn :ws "4:discord" :icon "💬" :label "Discord")'
else
DISCORD_DOCK_BTN=""
fi
awk -v btn="$DISCORD_DOCK_BTN" '
/^@DISCORD_DOCK_BTN@/ {
if (btn != "") print btn
sub(/^@DISCORD_DOCK_BTN@/, "")
}
{ print }
' "${CONFIGS_DIR}/eww/eww.yuck" > "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww/eww.yuck"
chmod 0644 "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww/eww.yuck"
install -m 0644 "${CONFIGS_DIR}/eww/eww.scss" "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww/eww.scss" install -m 0644 "${CONFIGS_DIR}/eww/eww.scss" "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww/eww.scss"
# Firefox: the general-browsing window launcher, plus the shared chrome/prefs template # Firefox: the general-browsing window launcher, plus the shared chrome/prefs template
@ -218,6 +239,10 @@ MQTT_USERNAME=${MQTT_USERNAME}
MQTT_PASSWORD=${MQTT_PASSWORD} MQTT_PASSWORD=${MQTT_PASSWORD}
HA_URL=${HA_URL} HA_URL=${HA_URL}
# Read by 0350-flatpak-discord.hook.chroot at build time, by the sway config's
# autostart line at session start, and by touchpanel-agent's build_apps().
ENABLE_DISCORD=${ENABLE_DISCORD}
EOF EOF
chmod 0644 "$INCLUDES/etc/touchpanel-agent/config.env" chmod 0644 "$INCLUDES/etc/touchpanel-agent/config.env"
@ -271,12 +296,23 @@ echo " ${ISO_PATH}"
echo echo
echo "What's in it:" echo "What's in it:"
echo " greetd : autologin as '${KIOSK_USERNAME}' straight into Sway on vt1" echo " greetd : autologin as '${KIOSK_USERNAME}' straight into Sway on vt1"
echo " Sway : workspaces 1:spotify / 2:home / 3:web, always-on touch dock" if [[ "$ENABLE_DISCORD" == "true" ]]; then
echo " Sway : workspaces 1:spotify / 2:home / 3:web / 4:discord, always-on touch dock"
else
echo " Sway : workspaces 1:spotify / 2:home / 3:web, always-on touch dock"
fi
echo " touchpanel-agent : system service, MQTT ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}" echo " touchpanel-agent : system service, MQTT ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
echo " Spotify : full GUI client (Flathub com.spotify.Client), workspace 1" echo " Spotify : full GUI client (Flathub com.spotify.Client), workspace 1"
echo " Home Assistant : Chromium kiosk, ${HA_URL}, workspace 2, auto-restart-on-crash" echo " Home Assistant : Chromium kiosk, ${HA_URL}, workspace 2, auto-restart-on-crash"
echo " Web browser : Firefox, minimal chrome, uBlock Origin + SponsorBlock, workspace 3" echo " Web browser : Firefox, minimal chrome, uBlock Origin + SponsorBlock, workspace 3"
echo " Touch dock : bottom bar (eww) — Spotify / Home / Web / Keyboard buttons" if [[ "$ENABLE_DISCORD" == "true" ]]; then
echo " Discord : full GUI client (Flathub com.discordapp.Discord), workspace 4,"
echo " started at session boot and left running — the point is"
echo " stepping out mid-call, not reading messages"
echo " Touch dock : bottom bar (eww) — Spotify / Home / Web / Discord / Keyboard"
else
echo " Touch dock : bottom bar (eww) — Spotify / Home / Web / Keyboard buttons"
fi
echo " On-screen keyboard: wvkbd, toggled from the dock's Keyboard button" echo " On-screen keyboard: wvkbd, toggled from the dock's Keyboard button"
echo " Touchscreen input : native wl_touch preferred; degrades to a usable" echo " Touchscreen input : native wl_touch preferred; degrades to a usable"
echo " single-touch/click-drag pointer if the hardware reports" echo " single-touch/click-drag pointer if the hardware reports"

View File

@ -198,6 +198,40 @@ def main(argv: list[str]) -> int:
emit("CORE_PROXY_BASE_URL", "") emit("CORE_PROXY_BASE_URL", "")
emit("CORE_VOICE_WAKE_WORD", cfg.get("voice", {}).get("wake_word", "ok_nabu")) emit("CORE_VOICE_WAKE_WORD", cfg.get("voice", {}).get("wake_word", "ok_nabu"))
# --- Stream Dock (desk-side OpenDeck surface; see stream-dock/README.md) ---------
# Emitted like everything else so the dock's generator and its LED service read the
# same file the rest of the household is built from — the HA URL in particular is
# derived from the container host's octet here, never typed into a plugin's
# settings box twice.
dock = cfg.get("stream_dock", {}) or {}
dock_leds = dock.get("knob_leds", {}) or {}
emit("CORE_STREAM_DOCK_ENABLED", dock.get("enabled", False))
emit("CORE_STREAM_DOCK_ROOM", dock.get("room", ""))
emit("CORE_STREAM_DOCK_LIGHTS", " ".join(str(e) for e in dock.get("lights", []) or []))
emit("CORE_STREAM_DOCK_RGB_STEP", dock.get("rgb_step", 8))
emit("CORE_STREAM_DOCK_BRIGHTNESS_STEP_PCT", dock.get("brightness_step_pct", 5))
emit("CORE_STREAM_DOCK_TICK_BUCKET_MS", dock.get("tick_bucket_ms", 120))
# The websocket URL the Home Assistant plugin wants, and the plain HTTP base the LED
# sync service polls. Same host, same port, one place.
emit("CORE_STREAM_DOCK_HA_WS_URL", f"ws://{container_ip}:{ports['home_assistant']}/api/websocket")
emit("CORE_STREAM_DOCK_LED_ENABLED", dock_leds.get("enabled", False))
emit("CORE_STREAM_DOCK_LED_BRIGHTNESS", dock_leds.get("brightness", 100))
emit("CORE_STREAM_DOCK_LED_MIN_CHANNEL", dock_leds.get("min_channel_led", 0))
emit("CORE_STREAM_DOCK_LED_POLL_SECONDS", dock_leds.get("poll_seconds", 2.0))
emit("CORE_STREAM_DOCK_LED_DEBOUNCE_MS", dock_leds.get("debounce_ms", 400))
emit("CORE_STREAM_DOCK_LED_CONFIG_PATH", dock_leds.get("config_path", ""))
emit("CORE_STREAM_DOCK_LED_APPLY_COMMAND", dock_leds.get("apply_command", ""))
emit("CORE_STREAM_DOCK_LED_APPLY_STRATEGY", dock_leds.get("apply_strategy", "none"))
emit("CORE_STREAM_DOCK_LED_APPLY_MIN_INTERVAL",
dock_leds.get("apply_min_interval_seconds", 2.0))
emit("CORE_STREAM_DOCK_LED_PLUGIN_PATTERN", dock_leds.get("plugin_process_pattern", "akp05"))
# The layer gate. The dock's lighting controls sit on their own OpenDeck layer, so
# the rings are only the LED service's to drive while that layer is the one showing.
emit("CORE_STREAM_DOCK_LED_GATE_COMMAND", dock_leds.get("layer_gate_command", ""))
emit("CORE_STREAM_DOCK_LED_IDLE_COLORS",
",".join(str(c) for c in dock_leds.get("idle_colors", []) or []))
emit("CORE_STREAM_DOCK_LED_IDLE_CYCLE_SECONDS", dock_leds.get("idle_cycle_seconds", 3.0))
emit("CORE_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "iso-out")) emit("CORE_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "iso-out"))
emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True)) emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True))
@ -225,6 +259,10 @@ def main(argv: list[str]) -> int:
emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False)) emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False))
emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False)) emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False))
emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False)) emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False))
# touch-panel only: Discord as a fourth app. Emitted for every kiosk type (like
# the flags above) so a builder reads one variable without knowing whether the
# panel in question asked for it.
emit("CORE_KIOSK_ENABLE_DISCORD", kiosk.get("enable_discord", False))
# A per-kiosk Debian release, falling back to the household one. Only the # A per-kiosk Debian release, falling back to the household one. Only the
# steam-tv-box currently sets it: that machine renders games locally, and # steam-tv-box currently sets it: that machine renders games locally, and
# bookworm's Mesa is too old for that, while nothing else here cares what Mesa # bookworm's Mesa is too old for that, while nothing else here cares what Mesa

View File

@ -520,6 +520,19 @@ def validate_kiosks(cfg: dict, rep: Report) -> None:
rep.error(f"{where}.debian_release", rep.error(f"{where}.debian_release",
f"must be a Debian suite name like 'bookworm' or 'trixie', got {release!r}") f"must be a Debian suite name like 'bookworm' or 'trixie', got {release!r}")
# Discord as a fourth app on a touch panel. Only that image knows what to do
# with it: on any other type the flag would be silently ignored, which is worse
# than being told, because the panel boots looking fine and simply lacks the
# app somebody configured.
discord = kiosk.get("enable_discord")
if discord is not None:
if not isinstance(discord, bool):
rep.error(f"{where}.enable_discord", f"must be true or false, got {discord!r}")
elif discord and ktype != "touch-panel":
rep.error(f"{where}.enable_discord",
f"only a touch-panel can run Discord, and this is a {ktype!r}"
"no other image installs the Flatpak or has a workspace for it")
if ktype == "steam-tv-box": if ktype == "steam-tv-box":
vendor = kiosk.get("gpu_vendor") vendor = kiosk.get("gpu_vendor")
if vendor not in GPU_VENDORS: if vendor not in GPU_VENDORS:
@ -579,6 +592,142 @@ def validate_kiosks(cfg: dict, rep: Report) -> None:
f"a {kiosk.get('type')} needs {label}, but container_host.enable.{flag} is false") f"a {kiosk.get('type')} needs {label}, but container_host.enable.{flag} is false")
# A light entity_id. Deliberately narrower than a generic entity_id check: the dock's
# dials call light.turn_on, and a switch or a scene in this list is a service call that
# fails at the moment somebody turns a knob, which is the worst time to find out.
LIGHT_ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
# Bare 6-digit hex, no leading '#' — the format ~/Dotfiles/colors.conf uses, so the
# theme's colours can be pasted between the two files without reformatting.
HEX_COLOR_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
# What stream-dock/apply-leds.sh knows how to do after rewriting leds.toml. Kept in
# step with that script's own case statement.
APPLY_STRATEGIES = {"none", "signal", "restart-plugin", "restart-opendeck"}
def validate_stream_dock(cfg: dict, rep: Report) -> None:
"""The desk-side Stream Dock. Off by default; only checked when enabled.
Nothing here builds an image, so an invalid block cannot break a build but it
can generate bindings that fail silently on the dock, hours after the mistake,
with no error anywhere except a lamp that does not change colour. That is what
this function is for.
"""
dock = cfg.get("stream_dock")
if dock is None:
return
if not isinstance(dock, dict):
rep.error("stream_dock", "must be an object")
return
if not dock.get("enabled"):
return
_check_room(dock.get("room"), "stream_dock.room", rep)
lights = dock.get("lights")
if not isinstance(lights, list) or not lights:
rep.error("stream_dock.lights",
"an enabled dock must name at least one light entity — a dial cannot "
"target an area, only entities")
else:
seen: set[str] = set()
for index, entity in enumerate(lights):
where = f"stream_dock.lights[{index}]"
if not isinstance(entity, str) or not LIGHT_ENTITY_RE.match(entity or ""):
rep.error(where, f"{entity!r} is not a light entity_id, e.g. "
"'light.living_room_lamp'")
elif entity in seen:
rep.warn(where, f"'{entity}' is listed twice — harmless, but every dial "
"turn will address it twice")
else:
seen.add(entity)
if isinstance(entity, str) and _looks_like_placeholder(entity):
rep.warn(where, f"'{entity}' looks like the template's example entity — "
"check it against Developer Tools -> States")
for key, low, high, default in (("rgb_step", 1, 64, 8),
("brightness_step_pct", 1, 50, 5),
("tick_bucket_ms", 0, 2000, 120)):
value = dock.get(key, default)
if not isinstance(value, (int, float)) or isinstance(value, bool) or not low <= value <= high:
rep.error(f"stream_dock.{key}", f"must be a number between {low} and {high}, got {value!r}")
# The dock authenticates to HA with the same long-lived token as everything else,
# and the plugin needs an ADMIN one — it drives HA's execute-script command, which
# is admin-only. Worth saying out loud: this token ends up in the plugin's settings
# on a desktop machine, which is a different exposure than a token living on the
# container host, and it is why the dock is not a good reason to create a token
# with more rights than the household already has.
if not (cfg.get("secrets", {}) or {}).get("ha_token"):
rep.warn("secrets.ha_token",
"empty, and stream_dock is enabled — the Home Assistant plugin needs a "
"long-lived token with ADMIN rights before a single dial does anything")
leds = dock.get("knob_leds", {}) or {}
if not isinstance(leds, dict):
rep.error("stream_dock.knob_leds", "must be an object")
return
if not leds.get("enabled"):
return
brightness = leds.get("brightness", 100)
if not isinstance(brightness, int) or isinstance(brightness, bool) or not 0 <= brightness <= 100:
rep.error("stream_dock.knob_leds.brightness", f"must be 0-100, got {brightness!r}")
floor = leds.get("min_channel_led", 0)
if not isinstance(floor, int) or isinstance(floor, bool) or not 0 <= floor <= 255:
rep.error("stream_dock.knob_leds.min_channel_led", f"must be 0-255, got {floor!r}")
poll = leds.get("poll_seconds", 2.0)
if not isinstance(poll, (int, float)) or isinstance(poll, bool) or not 0.2 <= poll <= 60:
rep.error("stream_dock.knob_leds.poll_seconds", f"must be 0.2-60, got {poll!r}")
idle = leds.get("idle_colors", []) or []
if not isinstance(idle, list):
rep.error("stream_dock.knob_leds.idle_colors",
"must be a list of bare 6-digit hex colours, or [] to leave the rings alone")
else:
for index, colour in enumerate(idle):
if not isinstance(colour, str) or not HEX_COLOR_RE.match(colour or ""):
rep.error(f"stream_dock.knob_leds.idle_colors[{index}]",
f"must be a bare 6-digit hex colour with no '#', the same format as "
f"~/Dotfiles/colors.conf, got {colour!r}")
cycle = leds.get("idle_cycle_seconds", 3.0)
if not isinstance(cycle, (int, float)) or isinstance(cycle, bool) or not 0 <= cycle <= 3600:
rep.error("stream_dock.knob_leds.idle_cycle_seconds", f"must be 0-3600, got {cycle!r}")
elif cycle and len(idle) > 1 and leds.get("apply_command"):
rep.warn("stream_dock.knob_leds.idle_cycle_seconds",
f"a chase step every {cycle}s means running apply_command that often, all "
"the time, whether or not anybody is at the desk — set it to 0 to hold one "
"colour if that command is a plugin restart rather than a cheap poke")
if not leds.get("layer_gate_command"):
rep.warn("stream_dock.knob_leds.layer_gate_command",
"empty — the rings will follow the lamps on every layer, including the "
"ones where the dials do something else entirely")
strategy = leds.get("apply_strategy", "none")
if strategy not in APPLY_STRATEGIES:
rep.error("stream_dock.knob_leds.apply_strategy",
f"must be one of {sorted(APPLY_STRATEGIES)}, got {strategy!r}")
interval = leds.get("apply_min_interval_seconds", 2.0)
if not isinstance(interval, (int, float)) or isinstance(interval, bool) or not 0 <= interval <= 60:
rep.error("stream_dock.knob_leds.apply_min_interval_seconds",
f"must be 0-60, got {interval!r}")
elif strategy in ("restart-plugin", "restart-opendeck") and interval < 1:
rep.warn("stream_dock.knob_leds.apply_min_interval_seconds",
f"{interval}s with the {strategy!r} strategy means re-initialising the "
"device that often while a dial is being turned — 2s or more is the "
"point of this setting")
if not isinstance(leds.get("plugin_process_pattern", "akp05"), str):
rep.error("stream_dock.knob_leds.plugin_process_pattern", "must be a string")
if strategy == "none" and not leds.get("apply_command"):
rep.warn("stream_dock.knob_leds.apply_strategy",
"'none' with no apply_command — the sync service will keep leds.toml "
"correct, but the device plugin only reads that file when it starts, so "
"the rings will not follow the lamp until something makes it re-read. "
"Run stream-dock/apply-leds.sh --probe with the dock plugged in; it "
"checks first whether 'none' is already enough (stream-dock/README.md §5)")
def validate(cfg: dict) -> Report: def validate(cfg: dict) -> Report:
rep = Report() rep = Report()
validate_network(cfg, rep) validate_network(cfg, rep)
@ -588,6 +737,7 @@ def validate(cfg: dict) -> Report:
validate_secrets(cfg, rep) validate_secrets(cfg, rep)
validate_kiosks(cfg, rep) validate_kiosks(cfg, rep)
validate_proxy(cfg, rep) validate_proxy(cfg, rep)
validate_stream_dock(cfg, rep)
return rep return rep