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