aboutsummaryrefslogtreecommitdiffstats
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rwxr-xr-xbin/udt-accent6
-rwxr-xr-xbin/udt-bulb152
2 files changed, 155 insertions, 3 deletions
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()