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 --- bin/udt-ledstrip | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) (limited to 'bin/udt-ledstrip') 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