#!/usr/bin/env python3 """The knob-ring colour rule, and the one writer for the device plugin's leds.toml. Two programs need this: `generate.py`, which lays down a starting file at setup time, and `led_sync.py`, which rewrites it every time the lamp changes. They share this module rather than each formatting TOML their own way — the same reason the Pebble app's wire format has one encoder and one decoder tested against each other. A file format with two writers drifts, and a drifted leds.toml does not error, it just lights the wrong ring. THE RULE (what the four rings mean): ring 1 the red channel's current value, in red (0,0,0 when that channel is 0) ring 2 the green channel's current value, in green ring 3 the blue channel's current value, in blue ring 4 what the room is actually emitting — the lamp's rgb scaled by its brightness So the three colour dials answer "how much of this am I dialling in" without you reading a number, and the fourth is a preview of the mix: turn brightness down and it fades in the room's own colour rather than going grey. The channel rings keep showing the stored colour while the lamp is OFF, because that colour is what the lamp will come back on with, and a dial whose ring goes black when you switch the light off tells you nothing about what turning it would do. Ring 4 does go black — nothing is being emitted, and claiming otherwise is the kind of small lie this project keeps out of its displays. Nothing here has driven a real device: the file format is written from the akp05 plugin's documentation, and no knob has ever lit up from it. See stream-dock/README.md. """ from __future__ import annotations RGB = tuple[int, int, int] # Where the akp05 device plugin reads its LED configuration, per its README. Windows # and macOS paths exist too; this project only ever runs on Linux desktops. DEFAULT_LEDS_PATH = "~/.config/opendeck-akp05/leds.toml" # Ring order as the device numbers its knobs, left to right. The dial-to-channel # mapping in generate.py's bindings has to agree with this or the red dial lights the # green ring — they are both derived from this one list. RING_ORDER = ("r", "g", "b", "output") # The desktop's own accent colours, from ~/Dotfiles/colors.conf (the CyberQueer theme): # COLOR_HIGHLIGHT, COLOR_DARK, COLOR_RED. They are the rings' idle scheme — what the # dock wears when its lighting layer is not the one showing, so an unused dock matches # the rest of the desk instead of holding the last lamp colour it happened to see. # # Copied rather than read from colors.conf at runtime: nothing in this repo reaches # into a user's dotfiles while running, and a service that dies because a theme file # moved would be a silly way to lose the lighting controls. Re-paste them if the theme # changes — it is one line in CoreSystemConfig.json. IDLE_PALETTE = ("E40046", "5018DD", "F50505") def hex_to_rgb(value: str) -> list[int]: """'E40046' -> [228, 0, 70]. Tolerates a leading '#' even though the config format (like colors.conf itself) does not use one.""" text = str(value).strip().lstrip("#") if len(text) != 6: raise ValueError(f"not a 6-digit hex colour: {value!r}") return [int(text[i:i + 2], 16) for i in (0, 2, 4)] def idle_ring_colors(palette: list[str] | tuple[str, ...], step: int = 0) -> list[list[int]]: """The idle scheme: the palette chasing across the four rings. Ring i wears palette[(i + step) % len], so advancing `step` walks the colours around the dock rather than flashing all four in unison — three colours on four rings already reads as movement standing still, and the chase makes it deliberate. """ colours = [hex_to_rgb(c) for c in palette] if not colours: return [[0, 0, 0] for _ in RING_ORDER] return [colours[(index + step) % len(colours)] for index in range(len(RING_ORDER))] def _clamp(value: float, low: int = 0, high: int = 255) -> int: return max(low, min(high, int(round(value)))) def _channel_ring(value: int, index: int, floor: int) -> list[int]: """One colour channel's ring: its own value, on its own axis. `floor` lifts a non-zero channel to a minimum so a value of 3/255 is still visibly lit rather than indistinguishable from off. It deliberately does not lift zero: zero means "no red in this colour", and that should read as a dark ring. """ value = _clamp(value) if value > 0: value = max(value, _clamp(floor)) ring = [0, 0, 0] ring[index] = value return ring def ring_colors(rgb: RGB | None, brightness: int | None, is_on: bool, floor: int = 0) -> list[list[int]]: """The four ring colours for a lamp state. Order matches RING_ORDER. `rgb` is the lamp's rgb_color attribute (or the last one seen while it was on — the caller owns that memory), `brightness` its 0-255 brightness attribute. """ red, green, blue = (rgb or (255, 255, 255)) scale = (_clamp(brightness if brightness is not None else 255)) / 255.0 output = [0, 0, 0] if not is_on else [ _clamp(red * scale), _clamp(green * scale), _clamp(blue * scale), ] return [ _channel_ring(red, 0, floor), _channel_ring(green, 1, floor), _channel_ring(blue, 2, floor), output, ] def render_leds_toml(colors: list[list[int]], brightness: int = 100, note: str = "") -> str: """The plugin's leds.toml, as documented by opendeck-akp05. `brightness` here is the LED driver's own global 0-100 output level, NOT the lamp's brightness — the lamp's brightness is encoded in the ring colours themselves, so this stays fixed and only exists to turn the whole ring set down if it is too bright on a desk at night. """ lines = [ "# Generated by stream-dock — do not edit by hand, it is rewritten on every", "# lamp change by stream-dock-led-sync. Change stream_dock.knob_leds in", "# CoreSystemConfig.json instead.", ] if note: lines.append(f"# {note}") lines.append("") lines.append(f"brightness = {_clamp(brightness, 0, 100)}") lines.append("") lines.append("[mode.Static]") rows = ", ".join("[" + ", ".join(str(channel) for channel in ring) + "]" for ring in colors) lines.append(f"colors = [{rows}]") lines.append("") return "\n".join(lines)