Repository navigation
Expand file tree
/
Copy pathengine.py
More file actions
1622 lines (1543 loc) · 82.6 KB
/
Copy pathengine.py
File metadata and controls
1622 lines (1543 loc) · 82.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""OpenLamp engine — local control of smart LED lamps (Tuya + WLED), frontend-agnostic.
This is the CORE layer: drivers (persistent connections, one thread per lamp),
the dispatcher (OpenLamp State = WLED-compatible state patch + legacy aliases),
groups, snapshots, animations (cycle/flash/tempo), connect-time sync, the rainbow
welcome sweep, and the local API on 127.0.0.1:8377 (/cmd /status /syntax
+ /json/state WLED-compat).
It knows NOTHING about Stream Deck. Frontends embed it two ways:
- in-process : the Stream Deck plugin subclasses Engine (see plugin.py);
- out-of-process : daemon.py hosts Engine headless, and the CLI (lamp.py) or the
MIDI overlay talk to the local API.
Run ONE host at a time (plugin OR daemon): each Tuya lamp accepts a single local
connection, and both hosts bind port 8377.
The only upward link is the on_change hook — a callable fired ~1.5 s after a
dispatch so a frontend can refresh whatever it displays. No other engine code
touches a frontend.
Config: tuya-lamps.json (shared source of truth with every frontend).
Log: ~/Library/Logs/ElgatoStreamDeck/com.openlamp.lamps.log
"""
import sys, os, json, time, threading, queue, importlib.util, socket
import subprocess
import urllib.request, urllib.parse
import http.server
import tinytuya
if getattr(sys, "frozen", False):
# PyInstaller bundle (store/alpha): binary in <.sdPlugin root>/bin/lampsd,
# lamp.py + tuya-lamps.json are at the ROOT of the .sdPlugin
HERE = os.path.dirname(os.path.dirname(os.path.abspath(sys.executable)))
_FALLBACK = HERE
else:
# dev mode: this file lives in scripts/lamps/sd-plugin, lamp.py one level up
HERE = os.path.dirname(os.path.abspath(__file__))
_FALLBACK = os.path.dirname(HERE)
# Optional external source-of-truth for lamp.py + tuya-lamps.json, e.g. a synced
# folder (Google Drive/Dropbox) shared with the CLI. Set OPENLAMP_LAMPS_DIR to that
# path; otherwise the files bundled next to the plugin/script are used.
_EXT = os.path.expanduser(os.environ.get("OPENLAMP_LAMPS_DIR", ""))
LAMPS_DIR = _EXT if (_EXT and os.path.isdir(_EXT)) else _FALLBACK
# The label written into WLED when a preset is saved without a name. A downstream
# product embeds this engine and puts its OWN name in front of the user: hard-coding
# one here would rebrand someone else's product on their own lamps. Same reasoning as
# OPENLAMP_LAMPS_DIR — what belongs to an installation is read, never assumed.
PRESET_LABEL = os.environ.get("OPENLAMP_PRESET_LABEL", "OpenLamp %d")
CONFIG = os.path.join(LAMPS_DIR, "tuya-lamps.json")
LOGFILE = os.path.expanduser("~/Library/Logs/ElgatoStreamDeck/com.openlamp.lamps.log")
API_PORT = 8377 # local API (127.0.0.1 only) — see LocalApi
_log_lock = threading.Lock()
def log(*a):
with _log_lock:
try:
with open(LOGFILE, "a") as f:
f.write(time.strftime("%H:%M:%S ") + " ".join(str(x) for x in a) + "\n")
except Exception:
pass
# lamp.py provides COLORS, PALIERS, scale, cur_bri_pct, local_subnets, find_ip_any_subnet
spec = importlib.util.spec_from_file_location("lamp", os.path.join(LAMPS_DIR, "lamp.py"))
lamp_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lamp_mod)
COLOR_ORDER = list(lamp_mod.COLORS.keys())
# lamp.py's sweep cache is designed for a CLI run of a few seconds; in a plugin
# that lives for hours, we expire it so we can find again a lamp that changed
# network along the way (observed: lamps flap between box <-> Mango)
_sweep_gate = threading.Lock()
_last_sweep = [0.0]
def allow_resweep():
with _sweep_gate:
if time.time() - _last_sweep[0] > 30:
lamp_mod._swept.clear()
_last_sweep[0] = time.time()
_cfg_lock = threading.Lock()
def load_config():
with _cfg_lock:
return json.load(open(CONFIG))
def save_config(cfg):
with _cfg_lock:
json.dump(cfg, open(CONFIG, "w"), indent=2)
SYNTAX_VERSION = "2.0"
def _maybe_enable_driver_debug(cfg):
"""Opt-in deep instrumentation ("debug": true in the config): tinytuya
protocol dump (packets, retries, 3.5 negotiation) to a dedicated log
— the microscope when the ack timer isn't enough."""
if not (cfg or {}).get("debug"):
return
import logging
dbg = os.path.expanduser(
"~/Library/Logs/ElgatoStreamDeck/com.openlamp.lamps-debug.log")
logging.basicConfig(filename=dbg, level=logging.DEBUG,
format="%(asctime)s %(name)s %(message)s")
tinytuya.set_debug(True)
log("driver debug ACTIVE ->", dbg) # generic syntax = WLED-compatible state patch (cf SYNTAXE-MOTEUR.md)
_rejoin_last = [0.0]
def rejoin_stage_wifi(cfg):
"""Anti-zombie level 1.5 (learned 2026-07-04): when the Mac jumps off the
stage Wi-Fi (home auto-join while the router is absent), it NEVER comes
back on its own — ghost lease + unreachable lamps while the router is
fine. If the router no longer responds AND an SSID is configured
("router": {"ssid": "BEN-MUSIC", ...}), we re-join outright. The
networksetup -3900 error is cosmetic: the association happens anyway.
Throttle 3 min; macOS switches back on its own if the SSID is off the air."""
r = (cfg or {}).get("router") or {}
host, ssid = r.get("host"), r.get("ssid")
if not host or not ssid or sys.platform != "darwin":
return False
if time.time() - _rejoin_last[0] < 180:
return False
try:
if subprocess.run(["ping", "-c", "1", "-t", "2", host],
capture_output=True, timeout=5).returncode == 0:
return False # router reachable: nothing to heal
except Exception:
pass
_rejoin_last[0] = time.time()
try:
subprocess.run(["networksetup", "-setairportnetwork", "en0", ssid],
capture_output=True, timeout=15)
log("rejoin stage Wi-Fi:", ssid)
return True
except Exception as e:
log("rejoin Wi-Fi failed:", e)
return False
_deauth_last = {}
def mango_deauth(mac, cfg):
"""Anti-zombie level 1: disassociates the lamp from Wi-Fi via SSH OpenWrt on
the router (GL.iNet). It re-associates and its IP stack restarts — the
equivalent of a NETWORK unplug/replug, without touching the lamp. Active if
the config contains "router": {"host": "192.168.4.1", "ssh_key": "~/.ssh/id_router"}
(public key to install in the router: LuCI > System > Administration).
Throttle 3 min per lamp: hammering the deauth would make the case worse."""
r = (cfg or {}).get("router") or {}
host = r.get("host")
key = os.path.expanduser(r.get("ssh_key", ""))
if not host or not key or not os.path.exists(key):
return False
if time.time() - _deauth_last.get(mac, 0) < 180:
return False
_deauth_last[mac] = time.time()
# standard OpenWrt = hostapd via ubus; proprietary MediaTek builds
# (GL-MT300N-V2…) = iwpriv DisConnectSta on ra0. We try both.
script = ("for i in $(ubus list 'hostapd.*' 2>/dev/null); do ubus call $i del_client "
"'{\"addr\":\"%s\",\"reason\":5,\"deauth\":true,\"ban_time\":0}'; done; "
"iwpriv ra0 set DisConnectSta=%s 2>/dev/null; true" % (mac, mac))
try:
p = subprocess.run(["ssh", "-i", key, "-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=4", "root@" + host, script],
capture_output=True, timeout=12)
log("deauth", mac, "via", host, "->", "OK" if p.returncode == 0 else
("rc=%d %s" % (p.returncode, p.stderr.decode()[:80])))
return p.returncode == 0
except Exception as e:
log("deauth failed:", e)
return False
class BaseLamp(threading.Thread):
"""Common base: command queue + life loop. Subclasses: _connect/_exec/_heartbeat."""
def __init__(self, conf, state):
super().__init__(daemon=True)
self.c = conf; self.state = state
self.q = queue.Queue()
self.ok = False; self.stop = False
self.bri = 60; self.is_on = True
self.rgb = (0, 100, 200) # last RGB sent — used for tt/blackout/snapshot
self.pal = None # last palette the USER chose (None/0 = Default -> effects
# auto-boost to a vivid palette so they're visible)
self.saved = None # state saved by blackout (restore replays it)
self._next_tt = None # one-shot per-command fade (WLED x100 ms units) set by
# "tt1:N" (a per-button fondu), else None = use config default
def tracked_state(self):
# photo of the engine-side tracked state (snapshots, blackout/restore)
return {"on": self.is_on, "bri": self.bri, "rgb": list(self.rgb)}
def snapshot_state(self):
# state captured by a snapshot; drivers enrich it (cf WledLamp, which
# adds RGBW colour + white temperature + effect + palette).
return self.tracked_state()
def snapshot_patch(self, s):
# recall patch for a snapshot (None = do nothing). Base = on/colour/bri.
if not s.get("on", True):
return {"on": False}
return {"on": True, "col": s.get("rgb"), "bri": round(s.get("bri", 60) * 2.55)}
def _post_exec_verify(self):
pass # TuyaLamp override: colour verify
def _greet(self):
"""Rainbow welcome sweep (Benoit 2026-07-04): a clearly visible ~2.7 s pass
through the whole hue wheel, fired on connect / power-on before the sync
state. BEST-EFFORT and cosmetic: if a step glitches (flaky radio) we abandon
the sweep silently and let sync take over — the greeting must never trigger a
reconnect loop on a fragile lamp. Returns True if the full sweep completed."""
import colorsys
steps = 9
for i in range(steps + 1):
r, g, b = colorsys.hsv_to_rgb((i / steps) % 1.0, 1.0, 1.0)
try:
self._exec({"on": True, "bri": 255,
"col": [round(r * 255), round(g * 255), round(b * 255)]})
except Exception:
return False # glitch -> stop sweeping, no reconnect
time.sleep(2.7 / steps)
return True
@property
def name(self): return self.c.get("name", "?")
def run(self):
backoff = 3
while not self.stop:
if not self.ok:
try:
ok = self._connect()
except Exception as e:
log(self.name, "connection failed:", e)
ok = False
if not ok:
# progressive backoff 3->30 s: hammering a stuck lamp makes
# its state worse (single slot); we give it room to breathe
self._fails = getattr(self, "_fails", 0) + 1
if self._fails % 2 == 0:
# first the Mac's NETWORK (auto-join jump?), then the lamp
try:
rejoin_stage_wifi(load_config())
except Exception:
pass
if self._fails % 4 == 0 and self.c.get("type", "tuya") == "tuya":
# ~4 failures in a row = zombie profile -> deauth via router
try:
mango_deauth(self.c.get("mac", ""), load_config())
except Exception:
pass
time.sleep(backoff)
backoff = min(30, backoff * 2)
continue
self._fails = 0
# 1) "I'm online" blink, 2) sync state (cf sync_patch)
eng = getattr(self, "engine", None)
try:
# anti-loop (2026-07-04 17h): on degraded radio, the rainbow
# burst re-kills the session on EVERY reconnect -> infinite
# greet->death loop. After 3 consecutive greeting/sync failures,
# we skip the greet until a connection that holds.
greet = eng.cfg.get("greet", True) if eng else True
if getattr(self, "_greet_fails", 0) >= 3:
greet = False
if getattr(self, "eph", False) and getattr(self, "_greeted", False):
greet = False # ephemeral: a single greeting per startup
self._greeted = True
st = eng.sync_patch(self) if eng else None
prev = self.tracked_state()
if greet:
done = self._greet()
log(self.name, "rainbow welcome sweep" if done
else "rainbow welcome sweep (partial — flaky radio)")
if st:
self._exec(st)
log(self.name, "synced on connect")
elif greet and prev.get("on", True):
# no sync: give the lamp back its pre-greeting state
self._exec({"on": True, "col": prev["rgb"],
"bri": round(prev["bri"] * 2.55)})
except Exception as e:
# greeting/sync half-applied = mongrel state (e.g. stays full
# white) -> force a clean reconnect rather than leave it
self._greet_fails = getattr(self, "_greet_fails", 0) + 1
self._last_err_ts = time.time()
log(self.name, "connect greeting/sync failed:", e,
"-> reconnect (greet failure #%d)" % self._greet_fails)
self.ok = False
continue
else:
self._greet_fails = 0
backoff = 3
try:
cmd = self.q.get(timeout=9)
except queue.Empty:
try:
self._heartbeat()
except Exception:
log(self.name, "heartbeat lost -> reconnect")
self.ok = False
continue
if cmd is None:
break
# synchronous state request (integration test, panel) — goes through the
# queue so it runs AFTER the commands already pending (order guaranteed)
if isinstance(cmd, tuple) and cmd and cmd[0] == "__status__":
_, holder, ev = cmd
try:
holder[self.name] = self._status()
except Exception as e:
holder[self.name] = {"error": str(e)}
self.ok = False
ev.set()
continue
if isinstance(cmd, tuple) and cmd and cmd[0] == "__snap__":
_, holder, ev = cmd # photo AFTER the queue (FIFO)
holder[self.name] = self.snapshot_state() # driver-specific (WLED = full state)
ev.set()
continue
# per-button fondu: "tt1:N" (ms) arms a ONE-SHOT transition for the NEXT
# command only, without touching the lamp's persistent "transition" config.
# dispatch() queues it right before the button's real command; the driver's
# payload builder (_payload for WLED, _fade_to for Tuya) consumes it once.
if isinstance(cmd, str) and cmd.startswith("tt1:"):
try:
self._next_tt = max(0, round(int(cmd.split(":", 1)[1] or 0) / 100))
except (ValueError, TypeError):
self._next_tt = None
continue
try:
self._exec(cmd)
self._post_exec_verify()
except Exception as e:
self._last_err_ts = time.time()
log(self.name, "error:", e, "-> reconnect + retry")
self.ok = False
try:
if self._connect():
self._exec(cmd)
except Exception as e2:
log(self.name, "retry failed:", e2); self.ok = False
# =====================================================================================
# TUYA PROTOCOL AUTOPSY — why this class is so defensive (2026-07-04)
# =====================================================================================
# A whole day of debugging (bare protocol bench + router tcpdump capture + reading the
# tinytuya issues) laid bare WHY controlling Tuya bulbs locally is a nightmare. Each
# workaround below answers one of these pathologies. Documented here so the pain serves
# a purpose, and to justify the choice to migrate to WLED (cf WledLamp, the exact
# opposite: plaintext HTTP, stateless, sessionless).
#
# 1. SINGLE CONNECTION SLOT. A Tuya lamp accepts only ONE TCP connection at a time
# (port 6668). The Smart Life app on the same LOCAL network grabs it and starves
# us -> "always close the app first". Hence set_socketPersistent + the lamp at the
# top of the config, and the fragility at the slightest competitor.
#
# 2. ENCRYPTED 3.5 SESSION THAT RENEGOTIATES. The 3.5 protocol opens a session with a
# key exchange (GCM). Under load, the lamp renegotiates in a loop (measured: ~20
# renegos in 10 min in the debug log). Each renego costs 2-10 s -> the "horrible
# latency". The ephemeral mode (socket per command) is WORSE: it pays the renego
# on EVERY command (A/B measured 2026-07-04: ephemeral clear loser).
#
# 3. ACK WITHOUT EXECUTION (false acknowledgements). A half-dead session ACKNOWLEDGES
# a send without EXECUTING it: the response buffer desyncs, the ack read belongs to
# the previous command. Consequence: the engine believes "orange applied" while the
# lamp stays green. The ONLY remedy: re-read the real colour (verify-after-write,
# cf _verify_colour) — the ack is never enough.
#
# 4. EMPTY RESPONSE = ZOMBIE SESSION. The TCP socket is alive, but the lamp drops our
# packets without responding (payload b''). Indistinguishable from a success at the
# TCP level. So we treat "empty response" as a failure -> reconnect (cf _dps).
#
# 5. DRIFTING _set_values_check CACHE. tinytuya caches the last DP state; in a burst it
# doesn't re-emit a DP it considers "unchanged" -> after a mode:scene, the return to
# mode:colour was no longer sent and the lamp ignored the colours. Hence the
# EXPLICIT multi-DP send every time (cf _dps), bypassing the cache.
#
# 6. THROUGHPUT CEILING ~4 cmd/s. Beyond that, the firmware drops the session mid-fade.
# Hence the pacing of fades (max 12 steps, >=250 ms) and the rate-capped verify.
#
# 7. FIRMWARE MEMORY CORRUPTION after ~2 h of uptime. Documented by the tinytuya
# maintainers (discussion #443): the lamp "forgets" its local key, code 914, until a
# power-cycle. Network multicast/broadcast speeds up the corruption. NO software
# fix — hence the level 1 deauth, and ultimately the WLED hardware choice.
#
# 8. LOCAL KEY VIA THE CLOUD. To talk LOCALLY to the lamp, you first have to extract its
# "local_key" from the Tuya cloud (account + API). Local that depends on the cloud:
# the opposite of an open protocol.
#
# VERDICT: Tuya optimises for locking the user into its app/cloud, not for local
# control. WLED does the exact opposite (cf WledLamp). These bulbs stay supported
# (hardware already bought) but are NOT recommended for the stage.
# =====================================================================================
class TuyaLamp(BaseLamp):
"""Persistent tinytuya socket + multi-network (one IP per subnet, cf lamp.py).
Loaded with workarounds — see the AUTOPSY above for the why of each one."""
def _probe(self, ip):
try:
s = socket.create_connection((ip, 6668), 1.5); s.close(); return True
except Exception:
return False
def _resolve_ip(self):
ips = self.c.setdefault("ips", {})
last = self.c.get("last")
subs = ([last] if last in ips else []) + \
[s for s in lamp_mod.local_subnets() if s in ips and s != last]
for sn in subs:
if self._probe(ips[sn]):
self.c["last"] = sn
return ips[sn]
allow_resweep()
ip = lamp_mod.find_ip_any_subnet(self.c["mac"])
if ip:
sn = ".".join(ip.split(".")[:3])
ips[sn] = ip; self.c["last"] = sn
return ip
def _connect(self):
self.ok = False; self.dev = None
ip = self._resolve_ip()
if not ip:
return False
d = tinytuya.BulbDevice(self.c["device_id"], ip, self.c["local_key"], version=3.5)
# EPHEMERAL MODE by default (2026-07-04 19h45, Benoit's decision "too slow
# too unstable"): the persistent socket monopolised the lamp's single
# connection slot and manufactured the day's pathologies (zombie sessions,
# false acks, desynced buffer, 20 3.5 renegotiations in 10 min). In
# ephemeral: a fresh connection PER command (~150-300 ms), nothing rots
# between two presses, the slot stays free. The old behaviour:
# "connection": "persistent" in the config.
self.eph = (self.c.get("connection")
or (load_config().get("connection") or "ephemeral")) == "ephemeral"
d.set_socketPersistent(not self.eph)
d.set_socketTimeout(3)
d.set_socketRetryLimit(1)
st = d.status()
dps = st.get("dps", {}) if isinstance(st, dict) else {}
if not dps:
return False
self.bri = lamp_mod.cur_bri_pct(dps)
self.is_on = bool(dps.get("20", True))
# also retrieve the real COLOUR (DP24 = HHHH SSSS VVVV): without it, after a
# reconnect the rgb tracking fell back to the default -> wrong snapshots
cd = dps.get("24", "")
if isinstance(cd, str) and len(cd) >= 12:
try:
import colorsys
h = int(cd[0:4], 16) / 360.0
s = int(cd[4:8], 16) / 1000.0
r, g, b = colorsys.hsv_to_rgb(h % 1.0, min(1, s), 1.0)
self.rgb = (round(r * 255), round(g * 255), round(b * 255))
except Exception:
pass
self.dev = d; self.ok = True
log(self.name, "connected @", ip, "bri:", self.bri, "on:", self.is_on)
return True
def _heartbeat(self):
if getattr(self, "eph", False):
# no session to maintain; light TCP probe (1 call in 4, i.e. ~36 s)
# so the Status key detects a lamp that's off
self._hb_tick = getattr(self, "_hb_tick", 0) + 1
if self._hb_tick % 4 == 0:
ip = (self.c.get("ips") or {}).get(self.c.get("last", ""), "")
if ip and not self._probe(ip):
raise RuntimeError("TCP probe: lamp off or off Wi-Fi")
return
self.dev.heartbeat(nowait=False)
def _post_exec_verify(self):
# v3 (2026-07-04 18h35, blind spot found by Benoit): the "recent error"
# gating was blind to FALSE ACKS — a lamp that acknowledges without
# executing looks perfectly healthy, so it was never verified.
# New contract: RATE-CAPPED verification — at most one re-read per lamp
# every 8 s, at the end of the queue. Cost ~8x under the firmware ceiling,
# and no final state diverges anymore without being seen.
now = time.time()
recent_err = now - getattr(self, "_last_err_ts", 0) < 300
if not recent_err and now - getattr(self, "_last_verify_ts", 0) < 8:
return
self._last_verify_ts = now
if self.q.empty(): # mashing: we only verify the end
self._verify_colour()
def _dps(self, **caps):
# EXPLICIT multi-DP send in a single packet, WITHOUT going through the
# tinytuya cache (_set_values_check): with blind sends the cache drifts,
# and the scene->colour return was no longer sent -> the lamp stayed in
# scene mode and ignored the colours (keys "with no effect", 2026-07-03).
# WITH acknowledgement (nowait=False): on the persistent socket it only
# costs one round-trip (~50-100 ms) and it detects silent losses — in a
# burst, nowait lost commands without any error (2026-07-03).
d = self.dev
out = {}
for k, v in caps.items():
dp = d.dpset.get(k)
if dp:
out[dp] = v
if out:
t0 = time.monotonic()
r = d.set_multiple_values(out, nowait=False)
ms = (time.monotonic() - t0) * 1000
# timing ALWAYS logged (Benoit instrumentation 2026-07-04): makes
# visible the L1/L2 radio asymmetry and the real cost of each ack
log(self.name, "ack", "+".join(sorted(caps.keys())), "in %.0f ms" % ms)
if isinstance(r, dict) and r.get("Error"):
raise RuntimeError("set_multiple_values: %s" % r["Error"])
if not r:
# EMPTY response = undead 3.5 session: the TCP socket is alive but
# the lamp drops our packets without responding (observed on L2 on
# 2026-07-03: zero error, zero effect). Empty = failure -> reconnect.
raise RuntimeError("empty response (dead session?)")
def _colour(self, r, g, b):
d = self.dev
self._dps(switch=True, mode="colour",
colour=d.rgb_to_hexvalue(r, g, b, d.dpset["value_hexformat"]))
self.is_on = True
# DEFERRED verification (v2 of the "ack without execution" fix, 2026-07-04):
# the inline re-read cost up to 6 s per press (draining at full timeout)
# — unacceptable live. We note the target; the life loop verifies as soon
# as the queue is EMPTY, at short timeout (cf _verify_colour). Key mashing
# => only the LAST colour is verified.
self._want_rgb = (r, g, b)
self._verify_tried = False
def _verify_colour(self):
"""Verify (fast) that the last wanted colour is physically on the lamp.
A half-dead session can ACKNOWLEDGE without EXECUTING (desynced response
buffer, observed 2026-07-04): we re-read the hue at short timeout, we
resend ONCE, otherwise we condemn the session."""
want = getattr(self, "_want_rgb", None)
if not want:
return
import colorsys
r, g, b = want
want_h = round(colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)[0] * 360)
d = self.dev
d.set_socketTimeout(0.7) # lightning re-read, not 3 s
try:
dps = {}
empty = 0
for _ in range(5):
st = d.status()
cur = st.get("dps", {}) if isinstance(st, dict) else {}
if not cur:
empty += 1
if dps or empty >= 1:
break
continue
dps.update(cur)
except Exception:
return # re-read impossible: no opinion
finally:
d.set_socketTimeout(3)
cd = dps.get(str(d.dpset.get("colour") or 24), "")
if not (isinstance(cd, str) and len(cd) >= 4):
return
got_h = int(cd[0:4], 16)
diff = abs(got_h - want_h)
if min(diff, 360 - diff) <= 10:
self._want_rgb = None # conform: nothing to do
return
if not getattr(self, "_verify_tried", False):
self._verify_tried = True
log(self.name, "colour not applied (read %d, wanted %d) -> resend" %
(got_h, want_h))
self._dps(switch=True, mode="colour",
colour=d.rgb_to_hexvalue(r, g, b, d.dpset["value_hexformat"]))
else:
self._want_rgb = None
raise RuntimeError("colour not applied after resend (rotten session)")
def _fade_to(self, rgb_target, pct_target, dur_ms):
# tt EMULATED (native in WLED): step-by-step interpolation on the
# persistent socket. MAX ~4 steps/s: at ~10 acked steps/s the lamps'
# firmware chokes and drops the session mid-fade (observed 2026-07-03).
steps = max(2, min(12, int(dur_ms / 250)))
r0, g0, b0 = self.rgb
p0 = self.bri
for i in range(1, steps + 1):
f = i / steps
# tracking updated on EVERY step: a crash mid-fade leaves an exact
# state (before: bri/rgb frozen -> wrong snapshots after an incident)
self.rgb = (round(r0 + (rgb_target[0] - r0) * f),
round(g0 + (rgb_target[1] - g0) * f),
round(b0 + (rgb_target[2] - b0) * f))
self.bri = round(p0 + (pct_target - p0) * f)
r, g, b = lamp_mod.scale(self.rgb, self.bri)
self._dps(switch=True, mode="colour",
colour=self.dev.rgb_to_hexvalue(
r, g, b, self.dev.dpset["value_hexformat"]))
if i < steps:
time.sleep(max(0.25, dur_ms / 1000 / steps))
self.rgb = tuple(rgb_target); self.bri = pct_target; self.is_on = True
def _apply(self, st):
# WLED-compatible state patch (SYNTAXE-MOTEUR.md) translated into Tuya DPs.
# Omitted fields = unchanged, as in WLED.
if "nl" in st: # nightlight = countdown DP26
nl = st["nl"] or {}
self._dps(timer=(int(nl.get("dur", 0)) * 60) if nl.get("on") else 0)
if st.get("music"): # extension
self._dps(switch=True, mode="music"); self.is_on = True; return
if "scene" in st: # extension: named scene
data = (self.c.get("scenes") or {}).get(st["scene"])
if data:
self._dps(switch=True, mode="scene", scene=data); self.is_on = True
return
if "ps" in st: # preset -> memorised scene
self._dps(switch=True, mode="scene"); self.is_on = True; return
if "cct" in st: # white warm<->cool (0-255)
bri = st.get("bri")
pct = round(bri / 2.55) if bri is not None else self.bri
self.bri = max(1, min(100, pct))
self._dps(switch=True, mode="white", brightness=max(10, self.bri * 10),
colourtemp=max(0, min(1000, round(st["cct"] / 255 * 1000))))
self.is_on = True; return
rgb = tuple(st["col"][:3]) if st.get("col") else None
bri = st.get("bri")
pct = max(1, min(100, round(bri / 2.55))) if bri is not None else None
if rgb or pct is not None:
tt = st.get("tt")
tgt_rgb = rgb or self.rgb
tgt_pct = pct if pct is not None else self.bri
if tt:
self._fade_to(tgt_rgb, tgt_pct, int(tt) * 100) # tt in x100 ms (WLED)
else:
self.bri = tgt_pct
r, g, b = lamp_mod.scale(tgt_rgb, tgt_pct)
self._colour(r, g, b)
self.rgb = tuple(tgt_rgb)
if "on" in st:
v = st["on"]
if v == "t":
v = not self.is_on
self._dps(switch=bool(v)); self.is_on = bool(v)
def _status(self):
# persistent socket + nowait sends = the acks pile up in the buffer.
# We drain EVERYTHING (old packets contain STALE states) and keep the
# merge — the last packet read reflects the current state.
dps = {}
empty = 0
for _ in range(8):
st = self.dev.status()
cur = st.get("dps", {}) if isinstance(st, dict) else {}
if not cur:
empty += 1
if dps or empty >= 2: # 2 empty reads = stop
break
continue # 1st empty: the lamp is slow
dps.update(cur)
return dps
def _exec(self, cmd):
d, COLORS, PALIERS = self.dev, lamp_mod.COLORS, lamp_mod.PALIERS
if isinstance(cmd, dict): # v2 syntax: WLED state patch
self._apply(cmd); return
if cmd == "blackout": # black + remembers the prior state
self.saved = self.tracked_state()
self._dps(switch=False); self.is_on = False; return
if cmd == "restore": # replays the pre-blackout state (once)
if not self.saved: # nothing pending -> no-op (was replaying a stale look)
return
s = self.saved; self.saved = None # consume it: a stray 2nd restore does nothing
if s.get("on", True):
self.bri = s.get("bri", self.bri)
self.rgb = tuple(s.get("rgb", self.rgb))
r, g, b = lamp_mod.scale(self.rgb, self.bri)
self._colour(r, g, b)
return
if cmd in ("solide", "solid"): # no WLED effects on Tuya: the engine already
return # stopped its cycle/flash anim before dispatch
if cmd == "off":
self._dps(switch=False); self.is_on = False; return
if cmd == "on":
self._dps(switch=True); self.is_on = True; return
if cmd == "toggle": # toggle (idea borrowed from the WLED plugin)
self._dps(switch=not self.is_on)
self.is_on = not self.is_on; return
if cmd.startswith("wled:"): # WLED-specific: skips its turn
return
if cmd.startswith("set:"): # ADVANCED mode: colour + brightness
_, cname, pct = cmd.split(":")
pct = max(1, min(100, int(pct)))
self.bri = pct
self.rgb = COLORS.get(cname, COLORS["bleu"])
r, g, b = lamp_mod.scale(self.rgb, pct)
self._colour(r, g, b); return
if cmd.startswith("white:"): # white: brightness + temperature
_, bri, temp = cmd.split(":")
self.bri = max(1, min(100, int(bri)))
self._dps(switch=True, mode="white",
brightness=max(10, self.bri * 10),
colourtemp=max(0, min(1000, int(temp) * 10)))
self.is_on = True; return
if cmd.startswith("scene:"): # NAMED scene captured (config)
name = cmd.split(":", 1)[1]
data = (self.c.get("scenes") or {}).get(name)
if data:
self._dps(switch=True, mode="scene", scene=data)
self.is_on = True
else:
log(self.name, "scene unknown:", name, "- capture it first")
return
if cmd.startswith("countdown:"): # power-off timer (minutes)
self._dps(timer=max(0, int(cmd.split(":")[1])) * 60)
return
if cmd.startswith("preset:"): # common: Tuya replays ITS scene
self._dps(switch=True, mode="scene") # (the N is only for WLED)
self.is_on = True; return
if cmd.startswith("mode:"): # raw modes of the Tuya app (DP21)
self._dps(switch=True, mode=cmd.split(":")[1])
self.is_on = True; return
if cmd in COLORS: # colour: keeps the brightness
self.rgb = COLORS[cmd]
tt = self._next_tt # per-button fondu -> smooth Tuya fade, once
if tt:
self._next_tt = None
self._fade_to(COLORS[cmd], self.bri, tt * 100); return
r, g, b = lamp_mod.scale(COLORS[cmd], self.bri)
self._colour(r, g, b); return
pct = PALIERS.get(cmd)
if pct is None and cmd.startswith("bri:"):
pct = max(1, min(100, int(cmd.split(":")[1])))
if pct is not None: # brightness: keeps the colour
self.bri = pct
color = self.state.get("color", "bleu")
self.rgb = COLORS.get(color, COLORS["bleu"])
tt = self._next_tt
if tt:
self._next_tt = None
self._fade_to(self.rgb, pct, tt * 100); return
r, g, b = lamp_mod.scale(self.rgb, pct)
self._colour(r, g, b); return
log(self.name, "command unknown:", cmd)
class WledLamp(BaseLamp):
"""Lamps/strips on WLED firmware (ESP8266/ESP32), local JSON HTTP API.
The EXACT OPPOSITE of TuyaLamp (cf AUTOPSY above this class). Why WLED is simple
and reliable where Tuya is a nightmare:
- No session: each command is a standalone, stateless HTTP POST. Nothing to
"maintain", so nothing can "rot" (vs Tuya pathologies #2, #4, #7).
- No single slot: WLED accepts several simultaneous connections (vs #1).
- No encryption/negotiation: plaintext request, immediate response (vs #2).
- No false ack: the POST /json/state replies the real state with {"v":true} — the
lamp doesn't lie, so no acrobatic verify-after-write needed (vs #3).
- No cloud key: nothing to extract, the local IP is enough (vs #8).
- Trivial debug: you type http://<host>/ in a browser and see everything (vs the
tcpdump capture at 10pm).
A WLED command fits on one line: POST {"seg":[{"col":[[255,0,0]]}]}.
Config: {"name": "L1", "type": "wled", "host": "192.168.8.50"} — host = IP OR mDNS
hostname (e.g. "wled-abc123.local"). Options: "segment": N (strip zone),
"transition": N (fade x100 ms per command)."""
def _url(self, path):
return "http://%s%s" % (self.c["host"], path)
def _post(self, payload, verify=False):
# standalone POST, sessionless. verify=True -> WLED returns the real state
# ({"v":true}) which we re-read to refresh the tracking. An HTTP/network
# failure raises -> the life loop reconnects (but "reconnect" = just re-ping,
# there's no session to reopen: cf _connect).
body = dict(payload)
if verify:
body["v"] = True
req = urllib.request.Request(
self._url("/json/state"), data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"}, method="POST")
last = None
for attempt in (1, 2): # 1 retry: covers an isolated Wi-Fi timeout
try:
raw = urllib.request.urlopen(req, timeout=3).read()
return json.loads(raw) if verify and raw else None
except Exception as e:
last = e
time.sleep(0.15)
raise RuntimeError("WLED POST failed: %s" % last)
def _connect(self):
# WLED "connection" = simple HTTP sanity-check (no session to open).
try:
info = json.loads(urllib.request.urlopen(
self._url("/json/info"), timeout=3).read())
self.ok = True
log(self.name, "(WLED) OK @", self.c["host"],
"ver", info.get("ver", "?"), "leds", (info.get("leds") or {}).get("count", "?"))
# retrieve the real state on connect (for the Status keys)
try:
self._read_into_tracked(self._status())
except Exception:
pass
return True
except Exception:
self.ok = False
return False
def _heartbeat(self):
urllib.request.urlopen(self._url("/json/info"), timeout=3).read()
def _status(self):
return json.loads(urllib.request.urlopen(
self._url("/json/state"), timeout=3).read())
def _read_into_tracked(self, st):
# WLED always tells the truth: we align the engine tracking on its real state.
if not isinstance(st, dict):
return
if "on" in st:
self.is_on = bool(st["on"])
if "bri" in st:
self.bri = max(1, min(100, round(st["bri"] / 2.55)))
segs = st.get("seg") or []
if segs and segs[0].get("col"):
self.rgb = tuple(segs[0]["col"][0][:3])
def snapshot_state(self):
# WLED tells the truth: we photograph the FULL PHYSICAL state (RGBW colour
# with the white channel, white temperature, effect, palette) so the recall
# reproduces the ambiance exactly — not just on/bri/rgb (cf fixed limitation).
try:
st = self._status()
except Exception:
return self.tracked_state() # network blip: fall back on engine tracking
seg = (st.get("seg") or [{}])[0]
col = (seg.get("col") or [[0, 0, 0, 0]])[0]
return {"on": bool(st.get("on", True)),
"bri": max(1, min(100, round(st.get("bri", 128) / 2.55))),
"rgb": list(col[:3]), # compat old readers (Tuya, UI)
"col": list(col[:4]), # full RGBW (dedicated white included)
"cct": seg.get("cct", 0),
"fx": seg.get("fx", 0),
"pal": seg.get("pal", 0)}
def snapshot_patch(self, s):
# FLAT patch (v2 syntax): _apply itself places col/cct/fx/pal into seg.
if not s.get("on", True):
return {"on": False}
return {"on": True, "bri": round(s.get("bri", 60) * 2.55),
"col": list(s.get("col") or s.get("rgb") or [0, 0, 0]),
"cct": s.get("cct", 0), "fx": s.get("fx", 0), "pal": s.get("pal", 0)}
def _seg(self, seg):
# config "segment": N -> this plugin "lamp" only drives one zone of the strip
if "segment" in self.c:
seg["id"] = self.c["segment"]
return seg
def _payload(self, p):
# tt precedence: explicit patch tt > per-button fondu (one-shot) > config default
if self._next_tt is not None:
p.setdefault("tt", self._next_tt); self._next_tt = None
if "transition" in self.c:
# config "transition": N -> fade (x100 ms) applied to each command
p.setdefault("tt", self.c["transition"])
return p
def _apply(self, st):
# v2 syntax = the WLED API: near-passthrough
p = {}; seg = {}
for k in ("on", "bri", "tt", "ps", "nl"):
if k in st: p[k] = st[k]
for k in ("fx", "sx", "ix", "pal", "cct"):
if k in st: seg[k] = st[k]
if st.get("col"):
seg["col"] = [list(st["col"][:4])] # RGBW: keeps the dedicated white W channel if provided
if seg:
p["seg"] = [self._seg(seg)]
if not p:
return
self._post(self._payload(p))
if "on" in st and st["on"] != "t":
self.is_on = bool(st["on"])
elif seg or "bri" in st:
self.is_on = True
if st.get("col"):
self.rgb = tuple(st["col"][:3])
if "bri" in st:
self.bri = max(1, min(100, round(st["bri"] / 2.55)))
def _exec(self, cmd):
COLORS, PALIERS = lamp_mod.COLORS, lamp_mod.PALIERS
if isinstance(cmd, dict): # v2 syntax: WLED state patch
self._apply(cmd); return
if cmd == "blackout":
self.saved = self.tracked_state()
self._post({"on": False}); self.is_on = False; return
if cmd == "restore":
if not self.saved: # nothing pending -> no-op (was replaying a stale look)
return
s = self.saved; self.saved = None # consume it: a stray 2nd restore does nothing
if s.get("on", True):
self.bri = s.get("bri", self.bri)
self.rgb = tuple(s.get("rgb", self.rgb))
self._post(self._payload({"on": True, "bri": round(self.bri * 2.55),
"seg": [self._seg({"col": [list(self.rgb)]})]}))
self.is_on = True
return
if cmd == "off":
self._post(self._payload({"on": False})); self.is_on = False; return
if cmd == "on":
self._post(self._payload({"on": True})); self.is_on = True; return
if cmd == "toggle":
self._post(self._payload({"on": "t"})) # "t" = native WLED toggle
self.is_on = not self.is_on; return
if cmd in ("solide", "solid"): # stop the effect, keep the colour
# fx:0 = solid. No col/bri in the payload, so the current colour stays put —
# this is the "back to a plain fixed colour" button (Benoit 2026-07-14).
self._post(self._payload({"seg": [self._seg({"fx": 0})]}))
self.is_on = True; return
if cmd.startswith("set:"): # advanced: colour + brightness
_, cname, pct = cmd.split(":")
r, g, b = COLORS.get(cname, COLORS["bleu"])
self.bri = max(1, min(100, int(pct)))
self._post(self._payload({"on": True, "bri": round(self.bri * 2.55),
"seg": [self._seg({"col": [[r, g, b]], "fx": 0})]}))
self.rgb = (r, g, b) # track the colour (snapshot/restore/sync depend on it)
self.is_on = True; return
if cmd.startswith("preset:"): # common: WLED has 250 presets
self._post(self._payload({"ps": int(cmd.split(":")[1])}))
self.is_on = True; return
if cmd.startswith("wled:fx:"): # animated effect: fx[:sx[:ix[:pal]]]
parts = cmd.split(":")[2:]
fx = parts[0] if parts[0] in ("~", "~-", "r") else int(parts[0])
seg = self._seg({"fx": fx})
for k, v in zip(("sx", "ix", "pal"), parts[1:]):
seg[k] = int(v)
if "pal" in seg:
self.pal = seg["pal"] # explicit palette -> remember the user's choice
elif fx != 0 and self.pal in (None, 0):
# Visibility fix (Benoit 2026-07-14): on the Default palette (0), most effects
# paint the pattern over a BLACK secondary colour -> looks dim. If the user hasn't
# chosen a palette, launch the effect on "Party" (6) so it bursts with colour. Any
# palette the user picked (dial/keys) is respected; only Default gets boosted.
seg["pal"] = 6
self._post(self._payload({"on": True, "seg": [seg]}))
self.is_on = True; return
if cmd == "wled:psave:new" or cmd.startswith("wled:psave:new:"): # save to the 1st free slot
used = set()
try:
pr = json.loads(urllib.request.urlopen(self._url("/presets.json"), timeout=3).read())
used = {int(k) for k in pr if str(k).isdigit() and int(k) > 0}
except Exception:
pass
slot = next((i for i in range(1, 251) if i not in used), 1)
# optional custom name: "wled:psave:new:My Look" (name may contain ':')
name = cmd.split(":", 3)[3] if cmd.count(":") >= 3 else PRESET_LABEL % slot
self._post({"psave": slot, "n": name})
log(self.name, "preset saved ->", slot, name); return
if cmd.startswith("wled:psave:"): # save the state as preset N (optional :Name)
parts = cmd.split(":", 3) # wled : psave : N [: Name]
payload = {"psave": int(parts[2])}
if len(parts) >= 4 and parts[3]: # name given -> label the WLED preset
payload["n"] = parts[3]
self._post(payload); return
if cmd.startswith("wled:sync:"): # WLED UDP sync (broadcast to the group)
v = cmd.split(":")[2]
if v == "toggle":
try:
v = "off" if (self._status().get("udpn") or {}).get("send") else "on"
except Exception:
v = "on"
on = (v == "on")
self._post({"udpn": {"send": on, "recv": on}}); return
if cmd.startswith("sbri:"): # SEGMENT brightness (vs bri: = global master)
pct = max(1, min(100, int(cmd.split(":")[1])))
self._post(self._payload({"seg": [self._seg({"bri": round(pct * 2.55)})]}))
self.is_on = True; return
if cmd.startswith(("sx:", "ix:")): # EFFECT speed / intensity (0-100, ~ scroll, r random)
key, v = cmd.split(":", 1)
if v == "r":
import random as _rnd
val = _rnd.randint(0, 255) # random (WLED has no native "r" for sx/ix)
else:
val = v if v in ("~", "~-") else round(max(0, min(100, int(v))) * 2.55)
self._post(self._payload({"seg": [self._seg({key: val})]})); return
if cmd.startswith("wled:playlist:"): # minute-by-minute preset sequence (= preset-cycle)
# wled:playlist:1,2,3[@ms] — native WLED: the lamp cycles through the presets
# on its own (no engine thread, survives even if the plugin closes). Any other
# command (colour, preset:N…) interrupts the playlist.
body = cmd.split(":", 2)[2]
ids, _, ms = body.partition("@")
ps = [int(x) for x in ids.split(",") if x.strip()]
dur = max(1, int(ms or 3000) // 100) # WLED counts in 1/10 s
self._post({"on": True, "playlist": {"ps": ps, "dur": [dur] * len(ps),
"transition": self.c.get("transition", 7), "repeat": 0}})
self.is_on = True; return
if cmd.startswith("mode:"):
log(self.name, "(WLED) Tuya modes not applicable — use WLED presets")
return
# ---- RGBCW/WLED capabilities absent from Tuya (2026-07-11) ----
if cmd.startswith("white:"): # adjustable white: dedicated W channel + CCT
# white:<brightness%>[:<temperature%>] (temp 0=warm .. 100=cool).
# Compat with the Tuya alias white:b:t. We push everything onto the 4th
# channel (W) of the RGBCW bulb: clean white, not an approximate R=G=B mix.
parts = cmd.split(":")
pct = max(1, min(100, int(parts[1]))) if len(parts) > 1 and parts[1] else self.bri