#!/usr/bin/env python3 """Turn CoreSystemConfig.json's `stream_dock` block into everything the dock needs. Writes into stream-dock/generated/ (gitignored — one of these files holds the HA token): bindings.md what to type into OpenDeck, per dial and per key leds.toml the device plugin's knob-ring colours, as a start led-sync.env environment for the LED sync service stream-dock-led-sync.service a systemd --user unit for it 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 — one file, no typing. This does not do it, because the schema of that file 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. So the generator emits the *contents* — the exact service names, entity lists and service-data JSON — and you paste them into the plugin's own settings, which is a few minutes once and cannot be silently wrong. If the profile schema is ever pinned down against a real installation, this is the file that grows a --profile flag. Usage: stream-dock/generate.py [CoreSystemConfig.json] [--out DIR] """ from __future__ import annotations import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import dock_leds # noqa: E402 REPO = Path(__file__).resolve().parent.parent # The key presets. Ten keys, and the dock has exactly ten. Colours are plain rgb rather # than colour temperatures because the whole surface is built around rgb_color — a # mixed rgb/color_temp control scheme means the channel dials read back a converted # approximation of a temperature, which drifts every time you touch them. KEY_PRESETS = [ ("Toggle", "toggle", None, None), ("Warm white", "color", (255, 167, 87), 60), ("Daylight", "color", (255, 250, 244), 100), ("Red", "color", (255, 0, 0), None), ("Green", "color", (0, 255, 0), None), ("Blue", "color", (0, 80, 255), None), ("Amber", "color", (255, 130, 0), None), ("Purple", "color", (170, 0, 255), None), ("Nightlight", "color", (255, 110, 30), 3), ("Full", "brightness", None, 100), ] def fail(message: str) -> int: print(f"error: {message}", file=sys.stderr) return 2 def json_block(payload: dict, raw: dict[str, str] | None = None) -> str: """JSON for the plugin's Service Data box, with placeholders left unquoted. The plugin substitutes {{ticks}} before parsing, so the string it is given is not valid JSON at the point we write it — json.dumps would quote the placeholder into a string and the script would receive "{{ticks}}" as text. Hence the swap. """ raw = raw or {} for key, placeholder in raw.items(): payload[key] = f"__RAW__{key}__" text = json.dumps(payload, indent=2) for key, placeholder in raw.items(): text = text.replace(f'"__RAW__{key}__"', placeholder) return text def build_bindings(dock: dict, ws_url: str) -> str: lights = list(dock.get("lights") or []) leds_cfg = dock.get("knob_leds", {}) or {} room = dock.get("room") or "(no room set)" rgb_step = dock.get("rgb_step", 8) bright_step = dock.get("brightness_step_pct", 5) bucket = dock.get("tick_bucket_ms", 120) out: list[str] = [] add = out.append add(f"# Stream Dock bindings — {room}") add("") add("Generated by `stream-dock/generate.py` from `CoreSystemConfig.json`. Every") add("value below is a literal: paste it into OpenDeck's Home Assistant plugin as") add("written. Regenerate rather than editing this file.") add("") add("## 0. Plugin connection (once, in the plugin's global settings)") add("") add("| Field | Value |") add("|---|---|") add(f"| Server URL | `{ws_url}` |") add("| Access token | the long-lived token from `secrets.ha_token` — it must belong to an **admin** user, because the plugin uses HA's admin-only `execute-script` command |") add("") add("If the entity list stays empty after saving, the connection failed — check that") add("your desktop can reach Home Assistant at all before touching anything else") add("(`docs/network-integration.md` §3: the trusted LAN reaching the smart-home VLAN") add("on 8123 is the one inter-VLAN rule this needs, and it is a rule that already") add("exists for the HA app).") add("") add("## 1. The four dials") add("") add("Each dial gets a **rotation** action and a **press** action. All of them call a") add("script from `stream-dock/ha-package/stream_dock.yaml`, so the colour arithmetic") add("happens in Home Assistant where the lamp's current colour actually lives.") add("") add(f"Set **Tick bucket size** to `{bucket}` ms on every rotation action: it sums the") add("ticks of a fast spin into one service call instead of firing one call per detent.") add("") dial_names = {"r": "Red", "g": "Green", "b": "Blue"} for index, channel in enumerate(("r", "g", "b"), start=1): add(f"### Dial {index} — {dial_names[channel]}") add("") add("Rotation → service `script.stream_dock_channel_adjust`") add("") add("```json") add(json_block({"entity_id": lights, "channel": channel, "step": rgb_step}, raw={"ticks": "{{ticks}}"})) add("```") add("") add("Press → service `script.stream_dock_channel_extreme`") add("") add("```json") add(json.dumps({"entity_id": lights, "channel": channel}, indent=2)) add("```") add("") add("### Dial 4 — Brightness") add("") add("Rotation → service `script.stream_dock_brightness_adjust`") add("") add("```json") add(json_block({"entity_id": lights, "step_pct": bright_step}, raw={"ticks": "{{ticks}}"})) add("```") add("") add("Press → service `script.stream_dock_toggle`") add("") add("```json") add(json.dumps({"entity_id": lights}, indent=2)) add("```") add("") add("### If `{{ticks}}` does not substitute") add("") add("The plugin documents `ticks` as a rotation variable and `{{rotationPercent}}`") add("as a placeholder; which spelling a given build accepts has not been verified") add("here against a real installation. Test one dial before binding four: turn it and") add("watch Developer Tools → Actions, or the script's trace. If the script receives") add("the literal text instead of a number, switch the three colour dials to the") add("absolute variant, which needs no relative maths at all:") add("") add("Rotation → service `script.stream_dock_channel_set`") add("") add("```json") add(json_block({"entity_id": lights, "channel": "r"}, raw={"percent": "{{rotationPercent}}"})) add("```") add("") add("That variant maps the dial's accumulated position straight onto 0–255, which is") add("arguably the better fit for a colour channel anyway — the dial has an absolute") add("position and so does the channel. Its cost is that the dial's idea of where it") add("is and the lamp's can diverge the moment anything else changes the colour.") add("") add("## 2. The ten keys") add("") add("All keys call `script.stream_dock_set_color` unless noted.") add("") for index, (label, kind, rgb, brightness_pct) in enumerate(KEY_PRESETS, start=1): if kind == "toggle": add(f"**Key {index} — {label}** → `script.stream_dock_toggle`") add("") add("```json") add(json.dumps({"entity_id": lights}, indent=2)) add("```") elif kind == "brightness": add(f"**Key {index} — {label}** → `light.turn_on`") add("") add("```json") add(json.dumps({"entity_id": lights, "brightness_pct": brightness_pct}, indent=2)) add("```") else: payload: dict = {"entity_id": lights, "rgb": list(rgb)} if brightness_pct is not None: payload["brightness_pct"] = brightness_pct add(f"**Key {index} — {label}** → `script.stream_dock_set_color`") add("") add("```json") add(json.dumps(payload, indent=2)) add("```") add("") add("## 3. Knob rings") add("") add("Ring order left to right: **red channel, green channel, blue channel, the") add("colour the room is actually emitting**. That order is fixed in") add("`stream-dock/dock_leds.py` and has to match the dial order above — if you") add("re-order the dials, re-order `RING_ORDER` with them.") add("") add("`generated/leds.toml` is a starting state only. The live version is written by") add("`stream-dock-led-sync`.") add("") strategy = str(leds_cfg.get("apply_strategy", "none") or "none") if leds_cfg.get("apply_command"): add("Ring reload: a command of your own (`knob_leds.apply_command`).") elif strategy == "none": add("**Ring reload: not configured yet.** The file will be kept correct and the") add("rings will not follow it until the plugin next starts. Run") add("`stream-dock/apply-leds.sh --probe` with the dock plugged in — its first") add("test is whether the plugin already watches the file, which would mean") add("nothing more is needed. See `stream-dock/README.md` §5.") else: add(f"Ring reload: `{strategy}`, at most once every " f"{leds_cfg.get('apply_min_interval_seconds', 2.0)}s.") add("") idle = list(leds_cfg.get("idle_colors") or []) if idle: cycle = leds_cfg.get("idle_cycle_seconds", 3.0) add("Off this layer the rings drop to the desktop's own palette — " + ", ".join(f"`#{c}`" for c in idle) + (f", chasing one ring every {cycle}s." if cycle else ", held still.") + " The sync service stops") add("polling Home Assistant entirely while the layer is hidden, so a dock parked") add("on another layer costs nothing and shows nothing about your lamps.") else: add("Off this layer the rings are left exactly as they are: no idle colours are") add("configured, so whatever put them there keeps them.") add("") return "\n".join(out) def main(argv: list[str]) -> int: args = [a for a in argv[1:] if not a.startswith("--")] out_dir = Path(__file__).resolve().parent / "generated" if "--out" in argv: out_dir = Path(argv[argv.index("--out") + 1]) config_path = Path(args[0]) if args else REPO / "CoreSystemConfig.json" if not config_path.exists(): return fail(f"{config_path} not found — copy CoreSystemConfig.json.template first") cfg = json.loads(config_path.read_text()) dock = cfg.get("stream_dock") or {} if not dock.get("enabled"): return fail("stream_dock.enabled is false (or the block is missing) — nothing to " "generate. Set it in " + config_path.name) lights = list(dock.get("lights") or []) if not lights: return fail("stream_dock.lights is empty — a dial has to name entities") prefix = cfg["network"]["subnet_prefix"] container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}" ha_port = cfg["ports"]["home_assistant"] ws_url = f"ws://{container_ip}:{ha_port}/api/websocket" http_url = f"http://{container_ip}:{ha_port}" token = (cfg.get("secrets") or {}).get("ha_token", "") leds = dock.get("knob_leds", {}) or {} led_path = leds.get("config_path") or dock_leds.DEFAULT_LEDS_PATH # An explicit apply_command wins; otherwise the strategy becomes a call to # apply-leds.sh, which is where every "make the plugin re-read the file" mechanism # lives. Strategy "none" produces no command at all rather than a no-op process # spawned on every ring change. apply_command = str(leds.get("apply_command", "") or "").strip() strategy = str(leds.get("apply_strategy", "none") or "none").strip() if not apply_command and strategy != "none": apply_command = f"{Path(__file__).resolve().parent}/apply-leds.sh --strategy {strategy}" out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "bindings.md").write_text(build_bindings(dock, ws_url)) # A neutral warm white as the starting state, so the rings mean something before # the sync service has ever run — and so a dock whose sync service is switched off # still looks deliberate rather than dead. start = dock_leds.ring_colors((255, 167, 87), 255, True, floor=leds.get("min_channel_led", 0)) (out_dir / "leds.toml").write_text(dock_leds.render_leds_toml( start, leds.get("brightness", 100), note=f"starting state for {dock.get('room') or 'unnamed room'}")) env_lines = [ "# Generated by stream-dock/generate.py — CONTAINS THE HOME ASSISTANT TOKEN.", "# Gitignored, mode 0600. Regenerate rather than editing.", f"STREAM_DOCK_HA_URL={http_url}", f"STREAM_DOCK_HA_TOKEN={token}", f"STREAM_DOCK_LIGHTS={','.join(lights)}", f"STREAM_DOCK_LEDS_PATH={led_path}", f"STREAM_DOCK_LED_BRIGHTNESS={leds.get('brightness', 100)}", f"STREAM_DOCK_LED_MIN_CHANNEL={leds.get('min_channel_led', 0)}", f"STREAM_DOCK_LED_POLL_SECONDS={leds.get('poll_seconds', 2.0)}", f"STREAM_DOCK_LED_DEBOUNCE_MS={leds.get('debounce_ms', 400)}", f"STREAM_DOCK_LED_APPLY_COMMAND={apply_command}", f"STREAM_DOCK_LED_APPLY_MIN_INTERVAL={leds.get('apply_min_interval_seconds', 2.0)}", # Read by apply-leds.sh, not by led_sync.py — it is what pgrep matches to find # the device plugin, and the probe prints the candidates on a real machine. f"STREAM_DOCK_PLUGIN_PATTERN={leds.get('plugin_process_pattern', 'akp05')}", f"STREAM_DOCK_LED_GATE_COMMAND={leds.get('layer_gate_command', '')}", f"STREAM_DOCK_LED_IDLE_COLORS={','.join(str(c) for c in leds.get('idle_colors', dock_leds.IDLE_PALETTE) or [])}", f"STREAM_DOCK_LED_IDLE_CYCLE_SECONDS={leds.get('idle_cycle_seconds', 3.0)}", "", ] env_file = out_dir / "led-sync.env" env_file.write_text("\n".join(env_lines)) env_file.chmod(0o600) service = f"""[Unit] Description=Stream Dock knob-ring colours from Home Assistant ({dock.get('room') or 'lights'}) Documentation=file://{REPO}/stream-dock/README.md After=network-online.target [Service] Type=simple EnvironmentFile=%h/.config/stream-dock/led-sync.env ExecStart={REPO}/stream-dock/led_sync.py Restart=always RestartSec=10 [Install] WantedBy=default.target """ (out_dir / "stream-dock-led-sync.service").write_text(service) if not token: print("warn: secrets.ha_token is empty — led-sync.env has no token and the " "plugin will not connect either", file=sys.stderr) print(f"wrote {out_dir}/") for name in ("bindings.md", "leds.toml", "led-sync.env", "stream-dock-led-sync.service"): print(f" {name}") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))