aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-18 13:50:12 +0200
committerDanilo M. <danix@danix.xyz>2026-10-18 13:50:12 +0200
commite00d8be5e8aff7a6588bd3f932a6706574a07ee6 (patch)
treecaa398a62e5de047e889e4956af98964eee91be8
parent59ea46306cb11fe27e10ba827501ec21e52f7de3 (diff)
downloadunified-desktop-theme-e00d8be5e8aff7a6588bd3f932a6706574a07ee6.tar.gz
unified-desktop-theme-e00d8be5e8aff7a6588bd3f932a6706574a07ee6.zip
feat(ledstrip): switch the strip on and off with the session
`udt-ledstrip on|off` switches the strip, replaying the gradient when it comes on; with no argument it still only replays the gradient, so a wallpaper change leaves a strip the user switched off alone. The last address is cached in ~/.cache/udt/ledstrip.json, because `off` runs as the session ends and cannot wait up to 20 s for a broadcast. From the cache a switch takes under half a second; discovery is the fallback when the cached address fails. hypr-theme calls it at login, on Hyprland's shutdown event, and around suspend from hypridle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--AGENTS.md5
-rwxr-xr-xbin/udt-ledstrip45
2 files changed, 42 insertions, 8 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 52c4754..7547181 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -226,6 +226,11 @@ by its UDP 6667 broadcast rather than a stored address. The effect is DP 51,
`dreamlight_scene_mode`, whose layout Tuya does not document and the cloud spec
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
+dies, so the last address is cached in `~/.cache/udt/ledstrip.json` and
+discovery is only the fallback.
**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 0318bd8..ca39063 100755
--- a/bin/udt-ledstrip
+++ b/bin/udt-ledstrip
@@ -5,19 +5,22 @@
# Licensed under the GNU General Public License v2 only.
"""Run the Hyprland border's gradient along every Tuya addressable strip.
-Usage: udt-ledstrip
+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 --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.
+udt-keyboard. hypr-theme's autostart runs `on` at login and `off` on
+Hyprland's shutdown event, so the strip follows the session.
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
that everything is local. Re-pairing a light in the app changes its key, so
-the wizard has to run again. The address is not stored: the light broadcasts
-it on UDP 6667 every few seconds, so a DHCP change costs nothing.
+the wizard has to run again. The address is cached in ~/.cache/udt, because
+`off` runs as the session ends and cannot wait for discovery; when the cached
+one fails, the light's own broadcast on UDP 6667 finds it again within seconds.
Speaks Tuya protocol 3.5 itself (AES-GCM, session key negotiated per
connection) with the `cryptography` module, rather than depending on tinytuya.
@@ -38,6 +41,7 @@ from pathlib import Path
DEVICES = Path.home() / ".config/udt/tuya/devices.json"
BORDER = Path.home() / ".cache/udt/udt-border.lua"
+ADDRESSES = Path.home() / ".cache/udt/ledstrip.json"
# Every Tuya device encrypts its discovery broadcast with this published key.
UDP_KEY = hashlib.md5(b"yGAdlopoPVldABfn").digest()
@@ -153,7 +157,7 @@ def selftest():
def main():
if sys.argv[1:] == ["--selftest"]:
return selftest()
- if sys.argv[1:]:
+ if sys.argv[1:] not in ([], ["on"], ["off"]):
sys.exit(__doc__.split("\n\n")[1])
try:
devices = json.loads(DEVICES.read_text())
@@ -165,15 +169,40 @@ def main():
if d.get("mapping", {}).get("24", {}).get("code") == "colour_data"}
border = BORDER.read_text() if BORDER.exists() else ""
colours = ["#" + h for h in re.findall(r"rgb\(([0-9a-fA-F]{6})\)", border)]
- if not colours or not lights:
+ if not lights:
+ return
+ if sys.argv[1:] == ["off"]:
+ dps = {"20": False}
+ elif colours:
+ dps = {"21": "scene", "51": scene(colours)}
+ if sys.argv[1:] == ["on"]:
+ dps["20"] = True
+ elif sys.argv[1:] == ["on"]:
+ dps = {"20": True}
+ else:
return
- dps = {"21": "scene", "51": scene(colours)}
- for dev_id, ip in discover(set(lights)).items():
+ try:
+ known = json.loads(ADDRESSES.read_text())
+ except (OSError, ValueError):
+ known = {}
+ pending = set(lights)
+ for dev_id in pending & set(known):
+ try:
+ set_dps(known[dev_id], lights[dev_id], dps)
+ pending.discard(dev_id)
+ except Exception:
+ pass # moved, or unplugged; discovery below settles which
+ if not pending:
+ return
+ for dev_id, ip in discover(pending).items():
try:
set_dps(ip, lights[dev_id], dps)
+ known[dev_id] = ip
except Exception as e:
print(f"udt-ledstrip: {dev_id} at {ip}: {e}", file=sys.stderr)
+ ADDRESSES.parent.mkdir(parents=True, exist_ok=True)
+ ADDRESSES.write_text(json.dumps(known))
if __name__ == "__main__":