aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-18 21:08:57 +0200
committerDanilo M. <danix@danix.xyz>2026-10-18 21:08:57 +0200
commit64270272c4de5e3c2563e2f30ef35e7efaa384ae (patch)
tree67108581dca90fde1202f1df12a650dafd0e4491
parente00d8be5e8aff7a6588bd3f932a6706574a07ee6 (diff)
downloadunified-desktop-theme-64270272c4de5e3c2563e2f30ef35e7efaa384ae.tar.gz
unified-desktop-theme-64270272c4de5e3c2563e2f30ef35e7efaa384ae.zip
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 <noreply@anthropic.com>
-rw-r--r--AGENTS.md14
-rwxr-xr-xbin/udt-ledstrip32
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"]: