243 lines
13 KiB
Markdown
243 lines
13 KiB
Markdown
# 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
|
||
```
|