13 KiB
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 | the Stream Deck application for Linux | runs Elgato-SDK plugins |
| 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, truelecter) — they differ in which devices they claim |
| 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:
"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:
{{ticks}}. The HA plugin documentsticksas 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.- 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 --probeexists to answer, and it can only be answered with the dock plugged in. - The layer gate (§6). Ships as "cannot tell" by design.
- The HA scripts. Written from the template documentation, YAML-validated,
reviewed by hand, never run against a real Home Assistant.
light.turn_onwithrgb_colormoves 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. - 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