aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-18 21:38:06 +0200
committerDanilo M. <danix@danix.xyz>2026-10-18 21:38:06 +0200
commit3fae77d2eaad99bb39c8296e201f9845837dbe02 (patch)
tree60053db98d905877be1f5192e9be95e7d5f9876a
parent64270272c4de5e3c2563e2f30ef35e7efaa384ae (diff)
downloadunified-desktop-theme-3fae77d2eaad99bb39c8296e201f9845837dbe02.tar.gz
unified-desktop-theme-3fae77d2eaad99bb39c8296e201f9845837dbe02.zip
feat(accent): light IKEA TRADFRI colour bulbs in the accent
udt-bulb fades every paired colour bulb to the wallpaper accent through the TRADFRI gateway's local CoAP API, over DTLS-PSK via libcoap's coap-client. --pair trades the gateway's security code for an identity of its own and records the colour bulbs in ~/.config/udt/tradfri.json. Takes udt-ledstrip's on and off. A bare run, as udt-accent starts it, skips bulbs that are off, since the gateway switches a bulb on for any colour change. Colour and brightness go in separate requests, colour first: combined, or with a colour sent during a brightness fade, the gateway drops part of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--AGENTS.md16
-rwxr-xr-xbin/udt-accent6
-rwxr-xr-xbin/udt-bulb152
-rwxr-xr-xinstall.sh2
4 files changed, 171 insertions, 5 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 4c3ccae..5a31dee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -242,6 +242,20 @@ 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.
+`udt-bulb` does the same for IKEA TRADFRI colour bulbs, called beside every
+`udt-ledstrip`, except that suspend switches the bulbs off: the breathing
+strip alone says the desktop is asleep. A bulb shows one colour, so it gets
+the accent alone. The bulbs are Zigbee, reached through the
+TRADFRI gateway's local CoAP API over DTLS-PSK, which Python cannot speak, so
+it calls libcoap's `coap-client` (SBo `libcoap`). `udt-bulb --pair HOST CODE`
+trades the code on the gateway's label for an identity of its own and writes
+`~/.config/udt/tradfri.json` with every colour bulb listed; trimming that list
+by hand picks which bulbs follow. Two gateway quirks shape the requests,
+both found on the real bulb: any colour change switches a bulb on, so a bare
+run reads the state first and skips an off bulb; and a request setting colour
+and brightness together, or a colour sent while a brightness fade runs, is
+partly dropped, so colour goes first and brightness after, separately.
+
**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
the scheme's accent, then rewritten in place, before `udt-grub` and
@@ -305,7 +319,7 @@ profile, and the new tab stays blank.
Not everything tracks the wallpaper. rofi, quickshell, Hyprland borders,
hyprlock's border (through `~/.cache/udt/hyprlock-border.conf`), Sublime's
-colour scheme, Firefox, GRUB, the homepage, the AK820 Pro keyboard and the LED strip do; conky, Qt, GTK, waybar, hyprlock's panel palette
+colour scheme, Firefox, GRUB, the homepage, the AK820 Pro keyboard, the LED strip and the TRADFRI bulbs do; conky, Qt, GTK, waybar, hyprlock's panel palette
(from `templates/hyprlock/hyprlock-palette.conf`) and Sublime's UI chrome sit
on the scheme's accent, lavender by default, which is also the fallback
when a wallpaper is too grey to snap. Qt and GTK apps only reread a theme on restart,
diff --git a/bin/udt-accent b/bin/udt-accent
index 2f9440d..70557d8 100755
--- a/bin/udt-accent
+++ b/bin/udt-accent
@@ -625,9 +625,9 @@ def main(image):
# GRUB's background is a wallpaper snapshot. Detached for the same reason;
# it only rebuilds and notifies, since the copy into /boot needs root.
# The keyboard takes seconds per colour change, and is often not there.
- # The LED strip waits up to 20 s for its address broadcast. Both read
- # files written above.
- for helper in ("udt-homepage", "udt-grub", "udt-keyboard", "udt-ledstrip"):
+ # The LED strip waits up to 20 s for its address broadcast, the bulbs
+ # up to 5 s per bulb for the gateway. All three read files written above.
+ for helper in ("udt-homepage", "udt-grub", "udt-keyboard", "udt-ledstrip", "udt-bulb"):
subprocess.Popen([Path(__file__).resolve().parent / helper],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
diff --git a/bin/udt-bulb b/bin/udt-bulb
new file mode 100755
index 0000000..b2eddf5
--- /dev/null
+++ b/bin/udt-bulb
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only
+# udt-bulb: light IKEA TRADFRI colour bulbs in the wallpaper accent.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+"""Fade every colour bulb on a TRADFRI gateway to the wallpaper accent.
+
+Usage: udt-bulb show the accent, leaving the bulbs on or off
+ udt-bulb on|off switch them, the accent with them when on
+ udt-bulb --pair HOST CODE pair with the gateway, list its bulbs
+ udt-bulb --selftest
+
+The verbs are udt-ledstrip's, and its callers call this beside it, except on
+suspend: the strip breathes to say the desktop is asleep, and the bulb is
+simply switched off. A bulb shows one colour, so it gets the accent alone: the
+middle of the three udt-accent wrote for the window border. A bare run touches only bulbs that are
+already on, because the gateway switches a bulb on for any colour change.
+
+The bulbs are Zigbee and have no address; the gateway takes commands for them
+over CoAP with DTLS-PSK on UDP 5684, which Python cannot speak, so this calls
+libcoap's coap-client. `--pair` trades the security code on the gateway's
+label for an identity and key of its own, and records the gateway and its
+colour bulbs in ~/.config/udt/tradfri.json; pair again after adding a bulb.
+Without that file, or without coap-client, this does nothing.
+"""
+import colorsys
+import json
+import re
+import secrets
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+CONFIG = Path.home() / ".config/udt/tradfri.json"
+BORDER = Path.home() / ".cache/udt/udt-border.lua"
+
+# Calibration. Hue and saturation run 0..65279 on the gateway, brightness
+# 0..254, transition in tenths of a second. Bulbs wash the schemes' pastel
+# accents out to white, as the LED strip did, so saturation is floored.
+SATURATION_FLOOR = 1.0
+BRIGHTNESS = 254
+FADE = 20
+TIMEOUT = 5
+
+# IPSO object ids the gateway uses: 15001 devices, 3311 light control,
+# 5850 on/off, 5851 brightness, 5707 hue, 5708 saturation, 5712 transition.
+DEVICES, LIGHT = "15001", "3311"
+# The gateway drops part of a request that sets brightness and colour at
+# once (both stalled mid-fade), and a colour sent while a brightness fade
+# runs, so colour goes first and brightness after, in requests of their own.
+HUE_MAX = 65279
+
+
+def coap(method, url, ident, key, payload=None):
+ """The response body of one coaps request, or None on any failure."""
+ cmd = ["coap-client", "-m", method, "-u", ident, "-k", key, "-B", str(TIMEOUT)]
+ if payload is not None:
+ cmd += ["-e", json.dumps(payload, separators=(",", ":"))]
+ try:
+ out = subprocess.run(cmd + [url], capture_output=True, text=True,
+ timeout=TIMEOUT + 2, check=True).stdout
+ except (subprocess.SubprocessError, OSError):
+ return None
+ return out.strip()
+
+
+def light_states(colour):
+ """3311 states for #rrggbb, sent in order."""
+ h, s, _ = colorsys.rgb_to_hsv(*(int(colour[i:i + 2], 16) / 255 for i in (1, 3, 5)))
+ s = max(s, SATURATION_FLOOR)
+ return [{"5707": round(h * HUE_MAX), "5708": round(s * HUE_MAX), "5712": FADE},
+ {"5850": 1, "5851": BRIGHTNESS, "5712": FADE}]
+
+
+def pair(host, code):
+ ident = "udt-" + secrets.token_hex(4)
+ base = f"coaps://{host}:5684"
+ reply = coap("post", f"{base}/15011/9063", "Client_identity", code, {"9090": ident})
+ try:
+ key = json.loads(reply)["9091"]
+ except (TypeError, ValueError, KeyError):
+ sys.exit(f"udt-bulb: pairing with {host} failed: {reply or 'no reply'}")
+ bulbs = []
+ for dev in json.loads(coap("get", f"{base}/{DEVICES}", ident, key) or "[]"):
+ info = json.loads(coap("get", f"{base}/{DEVICES}/{dev}", ident, key) or "{}")
+ # Colour bulbs report a hue; white-spectrum ones only a colour temperature.
+ if "5707" in (info.get(LIGHT) or [{}])[0]:
+ bulbs.append(dev)
+ print(f"{dev}: {info.get('9001', '?')}")
+ CONFIG.parent.mkdir(parents=True, exist_ok=True)
+ CONFIG.write_text(json.dumps({"host": host, "identity": ident, "key": key,
+ "bulbs": bulbs}))
+ CONFIG.chmod(0o600)
+ print(f"{len(bulbs)} colour bulb(s) recorded in {CONFIG}")
+
+
+def selftest():
+ assert light_states("#ff0000") == [
+ {"5707": 0, "5708": HUE_MAX, "5712": FADE},
+ {"5850": 1, "5851": BRIGHTNESS, "5712": FADE}]
+ # A pastel keeps its hue and loses its paleness to the floor.
+ colour = light_states("#8080ff")[0]
+ assert (colour["5707"], colour["5708"]) == (round(HUE_MAX * 2 / 3), HUE_MAX)
+ print("ok")
+
+
+def main():
+ args = sys.argv[1:]
+ if args == ["--selftest"]:
+ return selftest()
+ if len(args) == 3 and args[0] == "--pair":
+ return pair(*args[1:])
+ if args not in ([], ["on"], ["off"]):
+ sys.exit(__doc__.split("\n\n")[1])
+ try:
+ cfg = json.loads(CONFIG.read_text())
+ except (OSError, ValueError):
+ return
+ if not cfg.get("bulbs") or not shutil.which("coap-client"):
+ return
+ border = BORDER.read_text() if BORDER.exists() else ""
+ colours = ["#" + h for h in re.findall(r"rgb\(([0-9a-fA-F]{6})\)", border)]
+ accent = colours[len(colours) // 2] if colours else None
+
+ if args == ["off"]:
+ states = [{"5850": 0}]
+ elif accent:
+ states = light_states(accent)
+ elif args == ["on"]:
+ states = [{"5850": 1}]
+ else:
+ return
+ auth = cfg["identity"], cfg["key"]
+ for dev in cfg["bulbs"]:
+ url = f"coaps://{cfg['host']}:5684/{DEVICES}/{dev}"
+ if not args:
+ # A bare run follows the wallpaper and leaves an off bulb off.
+ try:
+ if json.loads(coap("get", url, *auth))[LIGHT][0]["5850"] != 1:
+ continue
+ except (TypeError, ValueError, KeyError, IndexError):
+ print(f"udt-bulb: bulb {dev} did not answer", file=sys.stderr)
+ continue
+ for state in states:
+ if coap("put", url, *auth, {LIGHT: [state]}) is None:
+ print(f"udt-bulb: bulb {dev} did not answer", file=sys.stderr)
+ break
+
+
+if __name__ == "__main__":
+ main()
diff --git a/install.sh b/install.sh
index 440c54e..13f634e 100755
--- a/install.sh
+++ b/install.sh
@@ -126,7 +126,7 @@ ln -sfn "$HOME/.cache/udt/colors.json" "$HOME/.cache/wal/colors.json"
# The rofi menus' scripts live in the sibling rofi-scripts repo, which links
# its own.
-for f in udt-accent udt-grub udt-keyboard udt-ledstrip udt-steps udt-palette udt-machine-name; do
+for f in udt-accent udt-grub udt-keyboard udt-ledstrip udt-bulb udt-steps udt-palette udt-machine-name; do
ln -sfn "$repo/bin/$f" "$HOME/bin/$f"
done