SmartestHome/stream-dock/README.md

13 KiB
Raw Blame History

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 13 show their own channel's current value in their own colour, and ring 4 shows the colour the room is actually emitting — rgb scaled by brightness. Turn brightness down and ring 4 fades in the room's own colour rather than going grey. While a lamp is off, the channel rings keep showing the stored colour (it is what the lamp will come back on with) and ring 4 goes black (nothing is being emitted, and drawing that as anything else would be a lie).

The gap: the akp05 device plugin reads leds.toml when it starts, and nothing documents it watching the file. Nothing else can drive those LEDs either — the plugin holds the USB device open, so a second process cannot write HID reports at it. So "live rings" comes down to one unknown that is a property of your installed build, not of this repo: what makes that process pick the file up again.

Everything on this side is done and tested against a stub Home Assistant: the colours, the debounce, the atomic rewrite, the off-lamp and unreachable-HA cases, and a rate-limited apply that never drops the final state — the file is always current, and only the (expensive) reload is paced by apply_min_interval_seconds.

Finding the reload path — two minutes, with the dock in front of you

stream-dock/apply-leds.sh --probe

It stops the sync service, writes an obvious colour, and asks whether the rings changed — walking four strategies cheapest-first, then printing the config line to paste:

Strategy What it does Cost
none writes the file and stops free — and it is the first thing the probe tests, because if the plugin already watches the file the rings are live with nothing else needed
signal SIGHUP to the plugin process free if the build handles it. Be clear-eyed: the default action for an unhandled SIGHUP is terminate, so a build without it lands you in restart-plugin with extra steps — the probe checks whether the process survived and says so
restart-plugin TERM the plugin, let OpenDeck respawn it re-initialises the device: a visible blink, keys redrawing. Usable only behind apply_min_interval_seconds, never per detent
restart-opendeck restart the whole app takes the dock away for a second or two — last resort

Set the winner as knob_leds.apply_strategy and re-run setup-stream-dock.sh. generate.py turns the strategy into the apply command; apply_command overrides it entirely if you have a better idea.

The real fix is upstream and small: make the device plugin watch the file. upstream-file-watch-request.md in this directory is written and ready to file. If it lands, set apply_strategy back to none and the rings go live with no local mechanism at all.

6. The layer gate

The lighting controls live on their own OpenDeck layer, and the four rings are shared hardware: on any other layer those dials mean something else, and painting a lamp's colour onto them there is worse than not lighting them at all.

knob_leds.layer_gate_command is run before every update — exit 0 for "the lighting layer is showing", exit 1 for "it is not", anything else for "I cannot tell", which is treated exactly like "it is not". A service that has lost track of which layer is up must not keep painting; going idle is the recoverable mistake, hijacking is not. While the layer is hidden, Home Assistant is not polled at all.

Off the layer the rings fall back to the desktop's own palette — COLOR_HIGHLIGHT #E40046, COLOR_DARK #5018DD, COLOR_RED #F50505 from ~/Dotfiles/colors.conf — chasing one ring at a time so three colours on four rings read as movement. The values are copied into the config rather than read from colors.conf at runtime: nothing here reaches into dotfiles while running, and a lighting service that dies because a theme file moved would be a silly way to lose the dials. Re-paste them if the theme changes; it is one line.

layer-active.sh is the gate implementation, and it ships answering "I cannot tell" — which means the rings sit on the idle chase until you point it at something real. OpenDeck keeps its state under ~/.config/opendeck, but the file and field that name the selected profile are not documented anywhere this repo could check, and guessing them would produce the worst failure available: a gate that confidently answers "yes, lighting" on every layer. Finding the real answer takes three minutes:

stream-dock/layer-active.sh --discover     # switch layers a few times; it names the file

then set STREAM_DOCK_LAYER_FILE, STREAM_DOCK_LAYER_JQ and STREAM_DOCK_LAYER. If your OpenDeck build exposes the current profile some other way, throw the script away and put that command in layer_gate_command instead — the gate is a contract about exit codes, nothing more.

7. What is unverified

No hardware was involved in any of this. In rough order of how likely each is to bite:

  1. {{ticks}}. The HA plugin documents ticks as a rotation variable and {{rotationPercent}} as a placeholder; which spelling a given build substitutes has not been checked. Test one dial before binding four — bindings.md §1 carries an absolute-position variant (script.stream_dock_channel_set) that needs no relative maths if the placeholder does not work.
  2. The ring reload (§5). The file format is from the akp05 plugin's documentation; no knob has ever lit up from it. Which strategy your build needs is what apply-leds.sh --probe exists to answer, and it can only be answered with the dock plugged in.
  3. The layer gate (§6). Ships as "cannot tell" by design.
  4. The HA scripts. Written from the template documentation, YAML-validated, reviewed by hand, never run against a real Home Assistant. light.turn_on with rgb_color moves a lamp that was in colour-temperature mode into colour mode — deliberate, since the dials are an rgb surface, but it is a change you will see.
  5. Which akp05 fork claims an N4 Pro. Three forks exist with different device lists; if OpenDeck does not see the dock, that is the first thing to try, and it is a question for those repos rather than for this one.

8. The token

The HA plugin needs a long-lived token belonging to an admin user — it drives HA's admin-only execute-script command. That token ends up in a plugin's settings on a desktop machine, which is a broader exposure than a token living on the container host, and it is worth knowing rather than discovering. It is the same secrets.ha_token the rest of this project uses; the dock is not a good reason to mint one with more rights than the household already has.

9. Files

ha-package/stream_dock.yaml   the six scripts. Room-agnostic; goes into HA's packages/
generate.py                   config -> bindings.md, leds.toml, led-sync.env, the unit
dock_leds.py                  the ring rule and the ONE writer of leds.toml
led_sync.py                   the service: poll HA, gate on the layer, rewrite the file
layer-active.sh               the gate (§6), and its --discover mode
apply-leds.sh                 the ring reload (§5), and its --probe mode
upstream-file-watch-request.md  the real fix, written and ready to file
setup-stream-dock.sh          desk side: generate, install, start
install-ha-package.sh         HA side: copy the package, enable packages:, reload
generated/                    gitignored — led-sync.env holds the token