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
parent
2f42b88c19
commit
86fe981d7c
|
|
@ -121,3 +121,7 @@ hosts/audio-endpoint/rpi-image-gen/config/generated-*.yaml
|
|||
tokens.txt
|
||||
# Caddy's exported root CA (public, but not something to publish casually).
|
||||
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/
|
||||
|
|
|
|||
|
|
@ -214,6 +214,38 @@
|
|||
"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": [
|
||||
{
|
||||
"_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,
|
||||
"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.",
|
||||
"type": "steam-tv-box",
|
||||
|
|
|
|||
|
|
@ -83,11 +83,17 @@ chores/ Presence/calendar-driven household chore nudging +
|
|||
passive fairness tally + camera-verified trash-bin/
|
||||
dishes/litter checks (systemd-timed oneshot, no
|
||||
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
|
||||
|
||||
- [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 1–3 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` §5–7
|
||||
- [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] 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
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] 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
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ near infinite ATX Pcs
|
|||
1xHA Voice PE
|
||||
1xZigbee adapter
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
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,
|
||||
a general web browser) switched with a finger via an always-on dock, and also
|
||||
controllable by Home Assistant/the local LLM over MQTT.
|
||||
a general web browser) — four where the panel asks for **Discord** — switched with a
|
||||
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 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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
@ -139,8 +141,9 @@ panel and adjust `PLAYER_PRIORITY` in `mpris_bridge.py`.
|
|||
## Touch dock
|
||||
|
||||
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:
|
||||
**Spotify**, **Home**, **Web**, and **Keyboard**. The first three call `swaymsg
|
||||
`:exclusive true` so nothing ever tiles under or draws over it) with four buttons —
|
||||
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
|
||||
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,
|
||||
|
|
@ -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
|
||||
\<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
|
||||
|
||||
`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
|
||||
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
|
||||
(both are meant to stay open and stateful, unlike the thin client's stateless
|
||||
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**
|
||||
(number), and play/pause / next / previous / stop (buttons), bridged from
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import paho.mqtt.client as mqtt
|
|||
|
||||
from .mpris_bridge import MprisBridge
|
||||
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")
|
||||
|
||||
|
|
@ -26,9 +26,14 @@ CONFIG_KEYS = (
|
|||
"HA_URL",
|
||||
"KIOSK_USERNAME",
|
||||
"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")
|
||||
|
||||
|
|
@ -63,8 +68,8 @@ def load_config(path: str = CONFIG_PATH) -> dict[str, str]:
|
|||
return values
|
||||
|
||||
|
||||
def build_apps(_config: dict[str, str]) -> dict[str, App]:
|
||||
return {
|
||||
def build_apps(config: dict[str, str]) -> dict[str, App]:
|
||||
apps = {
|
||||
"spotify": App(
|
||||
name="Spotify",
|
||||
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:
|
||||
# paho-mqtt 2.x requires an explicit callback API version; bookworm's
|
||||
|
|
@ -126,6 +148,8 @@ def main() -> int:
|
|||
|
||||
sway = SwayControl()
|
||||
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}")
|
||||
if config.get("MQTT_USERNAME"):
|
||||
|
|
@ -148,7 +172,7 @@ def main() -> int:
|
|||
def on_workspace(payload: str) -> None:
|
||||
name = payload.strip()
|
||||
# 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)
|
||||
return
|
||||
sway.switch_workspace(name)
|
||||
|
|
@ -161,7 +185,7 @@ def main() -> int:
|
|||
log.info("connected to MQTT broker %s:%s", broker_host, broker_port)
|
||||
discovery.register_media_player(mpris.handle_command, mpris.set_volume)
|
||||
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.publish_available(True)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ log = logging.getLogger(__name__)
|
|||
WS_SPOTIFY = "1:spotify"
|
||||
WS_HOME = "2:home"
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
(dock-btn :ws "1:spotify" :icon "🎵" :label "Spotify")
|
||||
(dock-btn :ws "2:home" :icon "🏠" :label "Home")
|
||||
(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"
|
||||
(box :orientation "vertical" :space-evenly false :spacing 2
|
||||
(label :class "dock-icon" :text "⌨")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ set $mod Mod4
|
|||
set $ws_spotify 1:spotify
|
||||
set $ws_home 2:home
|
||||
set $ws_web 3:web
|
||||
set $ws_discord 4:discord
|
||||
|
||||
# Workspace names are a contract with touchpanel_agent/sway_control.py — changing one
|
||||
# side means changing the other.
|
||||
|
|
@ -107,13 +108,23 @@ exec_always eww open touch-dock
|
|||
# separately-matched `assign` rule can't race it.
|
||||
for_window [app_id="chromium.*"] 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
|
||||
# 3-app panel, not an on-demand surface like the thin client's digest/admin
|
||||
# workspaces, so there is no "nothing to show yet" state to guard against beyond an
|
||||
# unset HA_URL.
|
||||
# HA kiosk, Spotify and (where enabled) Discord all auto-launch at session start —
|
||||
# this is a fixed panel of three or four apps, not an on-demand surface like the thin
|
||||
# client's digest/admin workspaces, so there is no "nothing to show yet" state to
|
||||
# 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 '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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -128,6 +139,10 @@ exec swayidle -w \
|
|||
|
||||
for_window [app_id="chromium.*"] 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
|
||||
|
|
@ -143,6 +158,7 @@ bindsym $mod+Shift+c reload
|
|||
bindsym $mod+1 workspace $ws_spotify
|
||||
bindsym $mod+2 workspace $ws_home
|
||||
bindsym $mod+3 workspace $ws_web
|
||||
bindsym $mod+4 workspace $ws_discord
|
||||
|
||||
bindsym XF86AudioPlay exec playerctl -p spotify play-pause
|
||||
bindsym XF86AudioNext exec playerctl -p spotify next
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 1–3 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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 0–255, 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))
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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" ]]
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -68,6 +68,10 @@ TOUCHPANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
|||
# 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.
|
||||
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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -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}/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}/discord/discord-launch" "$INCLUDES/usr/local/bin/discord-launch"
|
||||
install -m 0755 "${CONFIGS_DIR}/keyboard/toggle-keyboard" "$INCLUDES/usr/local/bin/toggle-keyboard"
|
||||
|
||||
# Touchscreen misclassification override (inert 0000:0000 template until the real
|
||||
|
|
@ -178,7 +183,23 @@ BACKSPACE="guess"
|
|||
EOF
|
||||
|
||||
# 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"
|
||||
|
||||
# Firefox: the general-browsing window launcher, plus the shared chrome/prefs template
|
||||
|
|
@ -218,6 +239,10 @@ MQTT_USERNAME=${MQTT_USERNAME}
|
|||
MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||
|
||||
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
|
||||
chmod 0644 "$INCLUDES/etc/touchpanel-agent/config.env"
|
||||
|
||||
|
|
@ -271,12 +296,23 @@ echo " ${ISO_PATH}"
|
|||
echo
|
||||
echo "What's in it:"
|
||||
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 " Spotify : full GUI client (Flathub com.spotify.Client), workspace 1"
|
||||
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 " 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 " Touchscreen input : native wl_touch preferred; degrades to a usable"
|
||||
echo " single-touch/click-drag pointer if the hardware reports"
|
||||
|
|
|
|||
|
|
@ -198,6 +198,40 @@ def main(argv: list[str]) -> int:
|
|||
emit("CORE_PROXY_BASE_URL", "")
|
||||
|
||||
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_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_STEAM_LINK", kiosk.get("enable_steam_link", 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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -520,6 +520,19 @@ def validate_kiosks(cfg: dict, rep: Report) -> None:
|
|||
rep.error(f"{where}.debian_release",
|
||||
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":
|
||||
vendor = kiosk.get("gpu_vendor")
|
||||
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")
|
||||
|
||||
|
||||
# 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:
|
||||
rep = Report()
|
||||
validate_network(cfg, rep)
|
||||
|
|
@ -588,6 +737,7 @@ def validate(cfg: dict) -> Report:
|
|||
validate_secrets(cfg, rep)
|
||||
validate_kiosks(cfg, rep)
|
||||
validate_proxy(cfg, rep)
|
||||
validate_stream_dock(cfg, rep)
|
||||
return rep
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue