From 64270272c4de5e3c2563e2f30ef35e7efaa384ae Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 18 Oct 2026 21:08:57 +0200 Subject: feat(ledstrip): breathe the accent while suspended `udt-ledstrip sleep` plays the accent alone as the strip's breathing scene, desaturated to 60 and at half brightness. Its DP 51 layout, mode 0x03 with flags 0, was read off the strip's status push while the Tuya app set a breathing scene; the selftest checks it against that capture. AGENTS.md also records why poweroff never reached the strip: rc.6 stops networking before it signals Hyprland, so the hyprland.shutdown handler fires with no route. The power menu now switches the strip off before loginctl instead. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 14 ++++++++++++-- bin/udt-ledstrip | 32 +++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7547181..4c3ccae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,10 +227,20 @@ by its UDP 6667 broadcast rather than a stored address. The effect is DP 51, omits: it was read off the strip's status pushes while the app set scenes. LEDs render pastel accents as white, so saturation is floored at full. The strip also follows the session: hypr-theme's autostart runs `udt-ledstrip -on` at login and `off` on Hyprland's `hyprland.shutdown` event, which logout -and poweroff both raise. `off` cannot wait for a broadcast as the session +on` at login and `off` on Hyprland's `hyprland.shutdown` event, which covers +logout. Poweroff and reboot do not reach it in time: rc.6 stops networking +before it signals Hyprland, so the event fires with no route to the strip. +rofi-scripts' `udt-powermenu.sh` therefore runs `off` itself before +`loginctl`. `off` cannot wait for a broadcast as the session dies, so the last address is cached in `~/.cache/udt/ledstrip.json` and discovery is only the fallback. +Suspend does not switch it off: `udt-ledstrip sleep` breathes the accent alone, +desaturated and at half brightness (DP 51 mode `0x03`, read off the strip the +same way), and hypridle's `after_sleep_cmd` runs `on` to bring the flow back. +`sleep` is sent before `loginctl suspend`, by the power menu and by +hypridle's idle timeout, for the same reason as `off`: NetworkManager drops the +network on the same sleep signal hypridle's `before_sleep_cmd` answers. That +`before_sleep_cmd` sends it too, as a best effort for any other suspend path. **Sublime's, GRUB's and the homepage's accents are replaced, not substituted into a placeholder.** GRUB and the homepage are generated by udt-palette with diff --git a/bin/udt-ledstrip b/bin/udt-ledstrip index ca39063..039fe35 100755 --- a/bin/udt-ledstrip +++ b/bin/udt-ledstrip @@ -7,13 +7,16 @@ Usage: udt-ledstrip play the gradient, leaving the strip on or off udt-ledstrip on|off switch it, the gradient with it when on + udt-ledstrip sleep breathe the accent, soft and dim, while asleep udt-ledstrip --selftest The colours are the three udt-accent wrote for the window border, the accent between its two hue neighbours, played as the strip's "flow" scene: a gradient moving along the strip. udt-accent starts this detached on every run, like udt-keyboard. hypr-theme's autostart runs `on` at login and `off` on -Hyprland's shutdown event, so the strip follows the session. +Hyprland's shutdown event, so the strip follows the session. `sleep` is sent +before a suspend and `on` after it, so a sleeping desktop breathes instead of +going dark. The device id and local key come from ~/.config/udt/tuya/devices.json, written once by `python -m tinytuya wizard` against a Tuya IoT cloud project; after @@ -54,6 +57,10 @@ DISCOVERY_TIMEOUT = 20 SATURATION_FLOOR = 100 BRIGHTNESS = 100 SPEED = 50 +# Sleep: the accent alone, desaturated and at half brightness, breathing slowly. +SLEEP_SATURATION = 60 +SLEEP_BRIGHTNESS = 50 +SLEEP_SPEED = 20 SESS_START, SESS_FINISH, CONTROL = 3, 5, 13 @@ -61,17 +68,20 @@ SESS_START, SESS_FINISH, CONTROL = 3, 5, 13 # status pushes; Tuya does not document it): version 1, scene id (0 for a # custom one), mode, speed twice, flags, two zero bytes, brightness, then # per colour a two byte hue and a saturation byte. Mode 0x0a with flags -# 0xe0 is the app's moving gradient. +# 0xe0 is the app's moving gradient; mode 0x03 with flags 0 is its breathing. FLOW, FLOW_FLAGS = 0x0A, 0xE0 +BREATH, BREATH_FLAGS = 0x03, 0x00 -def scene(colours): - """DP 51 bytes playing #rrggbb colours as a flow, base64 for the wire.""" - body = bytearray([1, 0, FLOW, SPEED, SPEED, FLOW_FLAGS, 0, 0, BRIGHTNESS]) +def scene(colours, mode=FLOW, flags=FLOW_FLAGS, speed=SPEED, + brightness=BRIGHTNESS, saturation=None): + """DP 51 bytes playing #rrggbb colours, base64 for the wire. A fixed + saturation replaces each colour's own, floored one.""" + body = bytearray([1, 0, mode, speed, speed, flags, 0, 0, brightness]) for c in colours: h, s, _ = colorsys.rgb_to_hsv(*(int(c[i:i + 2], 16) / 255 for i in (1, 3, 5))) body += (round(h * 360) % 360).to_bytes(2, "big") - body.append(max(round(s * 100), SATURATION_FLOOR)) + body.append(saturation or max(round(s * 100), SATURATION_FLOOR)) return base64.b64encode(body).decode() @@ -149,6 +159,9 @@ def selftest(): raw = base64.b64decode(scene(["#ff0000", "#0000ff"])) assert raw[:9] == bytes([1, 0, FLOW, SPEED, SPEED, FLOW_FLAGS, 0, 0, BRIGHTNESS]) assert raw[9:] == bytes([0, 0, 100, 0, 240, 100]) + # As the app wrote a breathing scene: 01 00 03 32 32 00 00 00 32, hue, sat. + raw = base64.b64decode(scene(["#00ff00"], BREATH, BREATH_FLAGS, 50, 50, 60)) + assert raw == bytes([1, 0, 3, 50, 50, 0, 0, 0, 50, 0, 120, 60]) key = b"0123456789abcdef" assert unpack(key, pack(key, 7, CONTROL, b"payload")) == (CONTROL, b"payload") print("ok") @@ -157,7 +170,7 @@ def selftest(): def main(): if sys.argv[1:] == ["--selftest"]: return selftest() - if sys.argv[1:] not in ([], ["on"], ["off"]): + if sys.argv[1:] not in ([], ["on"], ["off"], ["sleep"]): sys.exit(__doc__.split("\n\n")[1]) try: devices = json.loads(DEVICES.read_text()) @@ -173,6 +186,11 @@ def main(): return if sys.argv[1:] == ["off"]: dps = {"20": False} + elif sys.argv[1:] == ["sleep"] and colours: + # The border's middle colour is the accent itself. + dps = {"20": True, "21": "scene", + "51": scene([colours[len(colours) // 2]], BREATH, BREATH_FLAGS, + SLEEP_SPEED, SLEEP_BRIGHTNESS, SLEEP_SATURATION)} elif colours: dps = {"21": "scene", "51": scene(colours)} if sys.argv[1:] == ["on"]: -- cgit v1.2.3