SmartestHome/stream-dock/dock_leds.py

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)