344 lines
15 KiB
Python
Executable File
344 lines
15 KiB
Python
Executable File
#!/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))
|