# 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 ```