OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / 779e6f3

blender-addon: recover from a dropped fixture without user action
A fixture that went away stayed dark until something in the scene changed, and the change that discovered the drop was itself swallowed. Three separate faults, all in net.py: The payload was lost on failure. _service popped pending, and the except branch closed the socket without putting it back -- so the first send after a silent drop vanished, and only the send after THAT got through. Reconnection was never attempted on its own. A reconnect could only happen while handling a new payload, so an idle fixture never came back by itself. Worse in keyframe fade mode, which deliberately sends nothing between keyframes: the firmware drops a client idle for CLIENT_IDLE_TIMEOUT_MS (300 s, esp324xlight.ino:95), so a static look reliably crossed that threshold. Nothing ever read the socket. Command acks accumulated in the receive buffer, and a full buffer eventually stalls writes. Now: desired state is kept separately from what reached the wire, so a failed write leaves it queued rather than dropping it; reconnection runs on the worker's own schedule with 1s-to-15s backoff; and on reconnect last_sent is cleared so the state is re-asserted, since a fixture that rebooted or was displaced cannot be assumed to still hold it. Drops are detected by reading, not by failing to write. recv() returning b"" is an orderly close, so _drain reports it and the target is closed immediately -- no traffic of our own required. Measured against showlight01 by displacing the session with a second client (the firmware stop()s the previous one, esp324xlight.ino:701): status transitions: [(0.0, 'lost'), (0.3, 'connecting'), (0.4, 'connected')] recovered after 0.6 s, fixture replied ' ready\r\nGOT pwm\r\nOK pwm 12' An initial attempt relied on the keepalive failing to notice the drop, which took up to 120 s -- unusable for a show, and the reason detection now happens on the read side. A 60 s keepalive remains, but only to keep the firmware's idle timer fed. It sends "hostname", whose no-argument branch just prints the current hostname: the one command that produces traffic without touching pin state, so it cannot restart or cancel a pwm2 fade in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
779e6f3c412b252bbd368e37a488a8b861ed0266
by plankatron <fleeky@gmail.com> · 2026-08-30T19:40:57.000Z

Changed files (2) +141 -26

blender-addon/cthuloid_lights/net.py +100 -26
……@@ -27,22 +27,37 @@ SEND_TIMEOUT = 1.0
2727BACKOFF_START = 1.0
2828BACKOFF_MAX = 15.0
2929
30# The firmware drops a client that has sent nothing for CLIENT_IDLE_TIMEOUT_MS
31# (300 s, esp324xlight.ino:95). Keyframe fade mode deliberately stays silent
32# between keyframes, so a static look WILL cross that threshold and be dropped.
33# Poking the connection well inside the window prevents it.
34KEEPALIVE_INTERVAL = 60.0
35
36# "hostname" with no argument only prints the current hostname (cmdHostname's
37# else branch) -- it is the one command that produces traffic without touching
38# pin state, so it cannot disturb a pwm2 fade in flight. A pwm/pwm2 keepalive
39# would restart or cancel the fade.
40KEEPALIVE_PAYLOAD = "hostname\n"
41
3042
3143class _Target:
32 __slots__ = ("host", "port", "sock", "pending", "last_sent", "last_send_time",
33 "next_retry", "backoff", "status", "error")
44 __slots__ = ("host", "port", "sock", "desired", "dirty", "force",
45 "last_sent", "last_tx_time", "next_retry", "backoff",
46 "status", "error")
3447
3548 def __init__(self, host, port):
3649 self.host = host
3750 self.port = port
3851 self.sock = None
39 self.pending = None
40 self.last_sent = None
41 self.last_send_time = 0.0
52 self.desired = None # latest state the fixture SHOULD hold
53 self.dirty = False # desired differs from what reached the wire
54 self.force = False # bypass dedup and rate limit once
55 self.last_sent = None # what actually reached the wire
56 self.last_tx_time = 0.0
4257 self.next_retry = 0.0
4358 self.backoff = BACKOFF_START
4459 self.status = "idle"
45 self.error = ""
60 self.error = ''
4661
4762
4863class Sender:
……@@ -76,7 +91,7 @@ class Sender:
7691 self._targets.clear()
7792
7893 def send(self, host, port, payload, force=False):
79 """Queue the latest payload for a host. Never blocks."""
94 """Record the latest desired state for a host. Never blocks."""
8095 if not host or not payload:
8196 return
8297 key = (host, port)
……@@ -85,7 +100,12 @@ class Sender:
85100 if tgt is None:
86101 tgt = _Target(host, port)
87102 self._targets[key] = tgt
88 tgt.pending = (payload, force)
103 tgt.desired = payload
104 if force:
105 tgt.force = True
106 tgt.dirty = True
107 elif payload != tgt.last_sent:
108 tgt.dirty = True
89109 self._wake.set()
90110
91111 def forget(self, host, port):
……@@ -118,41 +138,93 @@ class Sender:
118138 tgt.error = repr(exc)
119139
120140 def _service(self, tgt, now):
121 with self._lock:
122 item = tgt.pending
123 tgt.pending = None
124 if item is None:
125 return
126 payload, force = item
127
128 if not force:
129 if payload == tgt.last_sent:
130 return
131 if now - tgt.last_send_time < self.min_interval:
132 # Put it back; we'll pick it up on a later tick.
133 with self._lock:
134 if tgt.pending is None:
135 tgt.pending = item
136 return
141 if tgt.desired is None:
142 return # nothing has ever been asked of this fixture
137143
144 # (1) Reconnect on our own schedule. Previously a reconnect was only
145 # attempted when a new payload happened to arrive, so a fixture dropped
146 # while idle stayed dark until the next scene change.
138147 if tgt.sock is None:
139148 if now < tgt.next_retry:
140149 return
141150 if not self._connect(tgt):
142151 return
152 # The fixture may have rebooted or been dropped mid-look; it cannot
153 # be assumed to still hold last_sent. Re-assert the desired state.
154 tgt.last_sent = None
155 tgt.dirty = True
156
157 # (2) Read whatever the firmware sent back. This serves two purposes:
158 # it stops command acks accumulating until a full receive buffer stalls
159 # writes, and -- more importantly -- recv() returning b"" is the peer
160 # having closed. That detects a drop with no traffic of our own, on the
161 # next 0.25 s tick, instead of waiting for the keepalive to fail.
162 if not self._drain(tgt):
163 tgt.status = "lost"
164 tgt.error = "peer closed"
165 self._close(tgt)
166 tgt.dirty = True # re-assert state once reconnected
167 tgt.next_retry = now + tgt.backoff
168 tgt.backoff = min(tgt.backoff * 2.0, BACKOFF_MAX)
169 return
143170
171 # (3) Push state.
172 if tgt.dirty:
173 if not tgt.force and now - tgt.last_tx_time < self.min_interval:
174 return # rate limited; desired is retained, retry next tick
175 payload = tgt.desired
176 if self._write(tgt, payload, now):
177 tgt.last_sent = payload
178 tgt.dirty = False
179 tgt.force = False
180 return
181
182 # (4) Keepalive, so the firmware's idle timer never fires on a look we
183 # are deliberately not resending.
184 if now - tgt.last_tx_time >= KEEPALIVE_INTERVAL:
185 self._write(tgt, KEEPALIVE_PAYLOAD, now) # never becomes last_sent
186
187 def _write(self, tgt, payload, now):
188 """Write bytes; on failure keep the state queued for the retry."""
144189 try:
145190 tgt.sock.sendall(payload.encode("utf-8"))
146 tgt.last_sent = payload
147 tgt.last_send_time = now
191 tgt.last_tx_time = now
148192 tgt.status = "connected"
149193 tgt.error = ""
194 tgt.backoff = BACKOFF_START
195 return True
150196 except (socket.timeout, OSError) as exc:
151197 tgt.error = str(exc)
152198 tgt.status = "lost"
153199 self._close(tgt)
200 # Do NOT drop the payload -- the previous version discarded it here,
201 # so the first change after a silent drop was swallowed entirely.
202 tgt.dirty = True
154203 tgt.next_retry = now + tgt.backoff
155204 tgt.backoff = min(tgt.backoff * 2.0, BACKOFF_MAX)
205 return False
206
207 @staticmethod
208 def _drain(tgt):
209 """Consume pending acks. False means the peer has closed the socket."""
210 alive = True
211 try:
212 tgt.sock.setblocking(False)
213 while True:
214 if not tgt.sock.recv(4096):
215 alive = False # orderly close from the firmware
216 break
217 except (BlockingIOError, socket.timeout):
218 pass # nothing more to read; still connected
219 except OSError:
220 alive = False
221 finally:
222 try:
223 tgt.sock.setblocking(True)
224 tgt.sock.settimeout(SEND_TIMEOUT)
225 except OSError:
226 pass
227 return alive
156228
157229 def _connect(self, tgt):
158230 tgt.status = "connecting"
……@@ -195,6 +267,8 @@ class Sender:
195267 except OSError:
196268 pass
197269 tgt.sock = None
270 # Forget what the wire holds, but keep `desired` -- that is what gets
271 # re-asserted once the fixture comes back.
198272 tgt.last_sent = None
199273
200274
blender-addon/diagnose.py +41 -0
……@@ -0,0 +1,41 @@
1# Paste into Blender's Python Console (Scripting workspace), or run from Text Editor.
2import bpy, sys
3MOD = "bl_ext.user_default.cthuloid_lights"
4sc = bpy.context.scene
5
6print("=" * 60)
7print("addon loaded :", MOD in bpy.context.preferences.addons.keys())
8m = sys.modules.get(MOD)
9print("has anim module :", hasattr(m, "anim") if m else False, "(False = STALE, restart Blender)")
10
11p = getattr(sc, "cthuloid", None)
12if p is None:
13 print("scene.cthuloid : MISSING")
14else:
15 print("LIVE armed :", p.live, " <-- must be True")
16 print("send_on_frame :", p.send_on_frame)
17 print("send_on_edit :", p.send_on_edit)
18 print("fixtures :", len(p.fixtures))
19
20 from bl_ext.user_default.cthuloid_lights import rig, engine
21 from bl_ext.user_default.cthuloid_lights.net import SENDER
22 for f in p.fixtures:
23 lights = rig.fixture_lights(sc, f)
24 print("-" * 60)
25 print(" host :", repr(f.host), "port", f.port)
26 print(" enabled :", f.enabled)
27 print(" fade_mode :", f.fade_mode)
28 print(" lights mapped :", sorted(lights.keys()), "<-- empty means nothing to send")
29 print(" net status :", SENDER.status_of(f.host, f.port))
30 try:
31 print(" would send :", str(engine.build_command(sc, f)).strip()[:70])
32 except Exception as e:
33 print(" build ERROR :", type(e).__name__, e)
34
35import bl_ext.user_default.cthuloid_lights.engine as eng
36print("-" * 60)
37print("frame handler :", eng._on_frame_change in bpy.app.handlers.frame_change_post)
38print("depsgraph handler :", eng._on_depsgraph_update in bpy.app.handlers.depsgraph_update_post)
39print("untagged lights :", [o.name for o in sc.objects
40 if o.type == 'LIGHT' and not o.cthuloid_light.is_fixture_light])
41print("=" * 60)