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