OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / 84b2b8e

blender-addon: one Connect switch, and a dead fixture never stalls a live one
The "immense lag" on studiolight was the addon, not the ESP32: three powered-off fixtures in the scene each cost a ~5 s .local lookup on the ONE shared network thread, starving the live fixture (0 pwm sent in 30 s while the ESP32 acked in 4 ms). Each fixture now has its own worker thread. Live is replaced by a single Connect switch that is session state, never saved: every session opens disconnected; Connect opens the ticked fixtures and pushes the scene at once; Disconnect closes every socket and stops every retry. Unticking a fixture drops it; Blackout works disconnected as a one-shot. The panel shows each fixture's state in words, redrawn live. Tests: tests/test_net_isolation.py (python3), test_disable_forgets.py and test_connect_switch.py (blender -b). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84b2b8ed90a363fbddd0b2bf9fab73f6c8701a63
by plankatron <fleeky@gmail.com> · 2026-10-02T13:42:56.000Z

Changed files (10) +617 -82

blender-addon/README.md +22 -10
……@@ -115,9 +115,18 @@ channelbag and only the slot bound to that datablock may be read.
115115
116116## Sending
117117
118**Arm `LIVE` and output is automatic.** There is no "send" step in normal use --
119playback, scrubbing/seeking anywhere in the timeline, and any light colour or
120energy edit all push on their own (both sources toggleable under Output).
118**One switch: `CONNECT`.** Every session (and every file you open) starts
119disconnected: no sockets, no retries, nothing sent. Click `CONNECT` and Blender
120connects to every ticked fixture and pushes the current scene straight away;
121from then on playback, scrubbing/seeking and any light colour or energy edit go
122to the lights on their own (both sources toggleable under Output). The button
123turns red while connected. Click it again to disconnect: every socket closes
124and every retry stops. The switch is session state and is never saved in the
125.blend, so a file saved while connected does not come back connected.
126
127Each fixture row shows its state in words: `connected`, `connecting...`,
128`unreachable`, `not connected`, or `off` for an unticked fixture. Unticking a
129fixture drops its connection; ticking it while connected brings it up at once.
121130
122131The Tools menu (▾ beside Add/Remove) holds the two things that are *not* part of
123132that loop:
……@@ -125,21 +134,24 @@ that loop:
125134- **Convert Name-Encoded Lights** — one-time migration for older .blend files
126135 that stored wiring in object names. Does nothing in a scene built with
127136 Add Fixture.
128- **Resend / Force Refresh** — recovery only: push once with `LIVE` off, or
129 re-send after a fixture reconnects (an identical payload is otherwise
130 suppressed, so a returning fixture can sit dark until the next real change).
137- **Resend / Force Refresh** — recovery only, while connected: re-send the
138 current scene (an identical payload is otherwise suppressed).
131139
132140- **Coalescing** — one pending frame per fixture; if the UI outruns the wire,
133141 intermediate frames are dropped rather than queued, so output never plays back
134142 late.
135143- **Rate limited** — `Max Rate`, default 25 Hz. The firmware is single-threaded;
136144 a full 30 fps stream overwhelms it (the original script carried the same note).
137- **Never blocks the UI** — one worker thread owns every socket. quadpanel.py
138 called `sendall()` inline in the frame handler, so an absent fixture stalled
139 Blender for the socket timeout on every frame.
145- **Never blocks the UI** — each fixture has its own worker thread owning its
146 socket. quadpanel.py called `sendall()` inline in the frame handler, so an
147 absent fixture stalled Blender for the socket timeout on every frame.
148- **One dead fixture never delays another.** Connecting to a powered-off
149 `.local` host blocks ~5 s in the mDNS lookup; that block is confined to the
150 dead fixture's own thread.
140151- **Reconnects with backoff**, 1 s doubling to 15 s.
141152
142`Blackout` sends `stopfade` + all-zero to every fixture regardless of arm state.
153`Blackout` sends `stopfade` + all-zero to every fixture, connected or not. While
154disconnected it is one-shot: one connection attempt per fixture, deliver, close.
143155
144156## Differences from quadpanel.py
145157
blender-addon/cthuloid_lights/__init__.py +1 -0
……@@ -17,6 +17,7 @@ def register():
1717
1818def unregister():
1919 engine.unregister_handlers()
20 props.reset_connection()
2021 net.SENDER.stop()
2122 ui.unregister()
2223 ops.unregister()
blender-addon/cthuloid_lights/engine.py +20 -8
……@@ -9,6 +9,7 @@ working after running it.
99import bpy
1010
1111from . import anim, protocol, rig
12from . import props as props_mod
1213from .net import SENDER
1314
1415# Sentinel: this fixture's current animation segment has already been commanded,
……@@ -100,16 +101,22 @@ def push_scene(scene, force=False):
100101 SENDER.min_interval = 1.0 / max(1, props.max_hz)
101102 sent = 0
102103 for fixture in props.fixtures:
103 if not fixture.enabled or not fixture.host:
104 continue
105 payload = build_command(scene, fixture)
106 if payload is SKIP:
107 continue
108 SENDER.send(fixture.host, fixture.port, payload, force=force)
109 sent += 1
104 if push_fixture(scene, fixture, force):
105 sent += 1
110106 return sent
111107
112108
109def push_fixture(scene, fixture, force=False):
110 """Send one fixture its current state. False if disabled or nothing to send."""
111 if not fixture.enabled or not fixture.host:
112 return False
113 payload = build_command(scene, fixture)
114 if payload is SKIP:
115 return False
116 SENDER.send(fixture.host, fixture.port, payload, force=force)
117 return True
118
119
113120def push_settings(scene, fixture):
114121 """Push the firmware-side settings (gamma, freq) for one fixture."""
115122 payload = protocol.gamma_command(fixture.gamma_b, fixture.gamma_g, fixture.gamma_r)
……@@ -126,7 +133,10 @@ def blackout(scene):
126133 for fixture in props.fixtures:
127134 if not fixture.host:
128135 continue
129 SENDER.send(fixture.host, fixture.port, protocol.blackout_command(), force=True)
136 # Blackout works connected or not. Disconnected, it is a one-shot:
137 # connect, deliver, let go -- it must not leave connections behind.
138 SENDER.send(fixture.host, fixture.port, protocol.blackout_command(),
139 force=True, once=not props_mod._connected)
130140 count += 1
131141 return count
132142
……@@ -165,6 +175,8 @@ def _on_depsgraph_update(scene, depsgraph=None):
165175
166176@bpy.app.handlers.persistent
167177def _on_load_post(*args):
178 # A newly opened file starts disconnected, like a fresh session.
179 props_mod.reset_connection()
168180 reset_state()
169181
170182
blender-addon/cthuloid_lights/net.py +146 -37
……@@ -1,6 +1,7 @@
11"""Non-blocking sender for ESP32 light fixtures.
22
3One worker thread owns every socket. Blender's main thread only ever drops the
3Each fixture has its own worker thread, which owns that fixture's socket, so an
4unreachable unit only ever delays itself. Blender's main thread only ever drops the
45latest desired state into a per-host mailbox and returns immediately -- it never
56calls connect() or sendall(). The original quadpanel.py did its sendall() inline
67in the frame_change handler, so a fixture that had gone away stalled the UI for
……@@ -43,11 +44,15 @@ KEEPALIVE_PAYLOAD = "hostname\n"
4344class _Target:
4445 __slots__ = ("host", "port", "sock", "desired", "dirty", "force",
4546 "last_sent", "last_tx_time", "next_retry", "backoff",
46 "status", "error")
47 "status", "error", "addr", "wake", "thread", "closed", "once")
4748
4849 def __init__(self, host, port):
4950 self.host = host
5051 self.port = port
52 self.wake = threading.Event()
53 self.thread = None
54 self.closed = False # set by forget()/stop(); the worker then exits
55 self.once = False # deliver the pending payload, then let go
5156 self.sock = None
5257 self.desired = None # latest state the fixture SHOULD hold
5358 self.dirty = False # desired differs from what reached the wire
……@@ -57,41 +62,61 @@ class _Target:
5762 self.next_retry = 0.0
5863 self.backoff = BACKOFF_START
5964 self.status = "idle"
60 self.error = ''
65 self.error = ""
66 self.addr = None # resolved ip, filled in by the worker
6167
6268
6369class Sender:
70 """One worker thread PER fixture.
71
72 A single shared worker used to service every fixture in turn, so one
73 powered-off unit stalled all the others: create_connection() resolves the
74 host first, and an unanswered .local lookup blocks getaddrinfo for ~5 s
75 (nss-mdns timeout). With three units unplugged the shared worker spent
76 nearly all its time blocked, and the one live fixture saw multi-second lag.
77 A thread per target confines that block to the fixture that caused it.
78 """
79
6480 def __init__(self):
6581 self._targets = {}
6682 self._lock = threading.Lock()
67 self._wake = threading.Event()
68 self._thread = None
6983 self._running = False
7084 self.min_interval = 1.0 / 25.0
7185
7286 # ---- main-thread API -------------------------------------------------
7387
7488 def start(self):
75 if self._running:
76 return
77 self._running = True
78 self._thread = threading.Thread(target=self._run, name="cthuloid-net", daemon=True)
79 self._thread.start()
89 with self._lock:
90 if self._running:
91 return
92 self._running = True
93 for tgt in self._targets.values():
94 self._spawn(tgt)
8095
8196 def stop(self):
82 self._running = False
83 self._wake.set()
84 t = self._thread
85 if t is not None and t.is_alive():
86 t.join(timeout=2.0)
87 self._thread = None
8897 with self._lock:
89 for tgt in self._targets.values():
90 self._close(tgt)
98 self._running = False
99 targets = list(self._targets.values())
91100 self._targets.clear()
101 for tgt in targets:
102 tgt.closed = True
103 tgt.wake.set()
104 for tgt in targets:
105 t = tgt.thread
106 # A worker blocked in a lookup cannot be interrupted; it exits (and
107 # closes its socket) when the lookup returns. Daemon threads, so a
108 # timed-out join never holds Blender open.
109 if t is not None and t.is_alive():
110 t.join(timeout=2.0)
111
112 def send(self, host, port, payload, force=False, once=False):
113 """Record the latest desired state for a host. Never blocks.
92114
93 def send(self, host, port, payload, force=False):
94 """Record the latest desired state for a host. Never blocks."""
115 once=True is for a one-shot command sent while disconnected (Blackout):
116 the target makes one connection attempt, delivers, and is dropped, so
117 it does not linger as a connection nobody asked to keep. A once-send to
118 a fixture that is already connected leaves that connection alone.
119 """
95120 if not host or not payload:
96121 return
97122 key = (host, port)
……@@ -99,20 +124,40 @@ class Sender:
99124 tgt = self._targets.get(key)
100125 if tgt is None:
101126 tgt = _Target(host, port)
127 tgt.once = once
102128 self._targets[key] = tgt
129 if self._running:
130 self._spawn(tgt)
131 elif not once:
132 tgt.once = False
103133 tgt.desired = payload
104134 if force:
105135 tgt.force = True
106136 tgt.dirty = True
107137 elif payload != tgt.last_sent:
108138 tgt.dirty = True
109 self._wake.set()
139 tgt.wake.set()
110140
111141 def forget(self, host, port):
142 """Stop driving a fixture. Its worker exits and closes the socket."""
112143 with self._lock:
113144 tgt = self._targets.pop((host, port), None)
114 if tgt is not None:
115 self._close(tgt)
145 if tgt is not None:
146 tgt.closed = True
147 tgt.wake.set()
148
149 def forget_all(self):
150 """Disconnect everything: every worker exits and closes its socket."""
151 with self._lock:
152 targets = list(self._targets.values())
153 self._targets.clear()
154 for tgt in targets:
155 tgt.closed = True
156 tgt.wake.set()
157
158 def has_targets(self):
159 with self._lock:
160 return bool(self._targets)
116161
117162 def status_of(self, host, port):
118163 with self._lock:
……@@ -121,21 +166,36 @@ class Sender:
121166 return ("idle", "")
122167 return (tgt.status, tgt.error)
123168
124 # ---- worker thread ---------------------------------------------------
169 # ---- worker threads --------------------------------------------------
125170
126 def _run(self):
127 while self._running:
128 self._wake.wait(timeout=0.25)
129 self._wake.clear()
130 now = time.monotonic()
131 with self._lock:
132 targets = list(self._targets.values())
133 for tgt in targets:
134 try:
135 self._service(tgt, now)
136 except Exception as exc: # never let the worker die
137 tgt.status = "error"
138 tgt.error = repr(exc)
171 def _retire(self, tgt):
172 """Drop a target from inside its own worker (one-shot finished)."""
173 with self._lock:
174 if self._targets.get((tgt.host, tgt.port)) is tgt:
175 del self._targets[(tgt.host, tgt.port)]
176 tgt.closed = True
177
178 def _spawn(self, tgt):
179 # Caller holds self._lock.
180 tgt.thread = threading.Thread(
181 target=self._run, args=(tgt,),
182 name="cthuloid-net-%s" % tgt.host, daemon=True)
183 tgt.thread.start()
184
185 def _run(self, tgt):
186 while self._running and not tgt.closed:
187 tgt.wake.wait(timeout=0.25)
188 tgt.wake.clear()
189 if tgt.closed:
190 break
191 try:
192 self._service(tgt, time.monotonic())
193 except Exception as exc: # never let the worker die
194 tgt.status = "error"
195 tgt.error = repr(exc)
196 # The worker owns the socket; closing it here rather than from the
197 # main thread means a close can never race a send in progress.
198 self._close(tgt)
139199
140200 def _service(self, tgt, now):
141201 if tgt.desired is None:
……@@ -148,6 +208,8 @@ class Sender:
148208 if now < tgt.next_retry:
149209 return
150210 if not self._connect(tgt):
211 if tgt.once:
212 self._retire(tgt) # one attempt only; never a retry loop
151213 return
152214 # The fixture may have rebooted or been dropped mid-look; it cannot
153215 # be assumed to still hold last_sent. Re-assert the desired state.
……@@ -177,6 +239,10 @@ class Sender:
177239 tgt.last_sent = payload
178240 tgt.dirty = False
179241 tgt.force = False
242 if tgt.once:
243 self._retire(tgt)
244 elif tgt.once:
245 self._retire(tgt)
180246 return
181247
182248 # (4) Keepalive, so the firmware's idle timer never fires on a look we
……@@ -226,8 +292,45 @@ class Sender:
226292 pass
227293 return alive
228294
295 def _duplicate_of(self, tgt):
296 """Another target already holding a live socket to the same device.
297
298 Two fixtures pointing at the same unit would both drive it, each with
299 its own lights, so the unit flickers between two different looks. (On
300 firmware older than e84fa44, which served ONE client, they also evicted
301 each other in a tight reconnect loop.) The second one is held off and
302 reported instead.
303
304 Targets are keyed by the host string as typed, so "showlight02.local"
305 and "192.168.50.233" are two entries for one device. Comparing resolved
306 addresses is what actually catches that.
307 """
308 if tgt.addr is None:
309 return None
310 with self._lock:
311 others = list(self._targets.values())
312 for other in others:
313 if other is tgt or other.sock is None:
314 continue
315 if other.addr == tgt.addr and other.port == tgt.port:
316 return other
317 return None
318
229319 def _connect(self, tgt):
230320 tgt.status = "connecting"
321 # Duplicate check uses the address cached from a previous connect. We
322 # deliberately do NOT resolve here: gethostbyname blocks, and one
323 # slow-resolving .local name would stall the single worker for every
324 # other fixture. create_connection resolves anyway, so the address is
325 # taken from getpeername() below and reused from then on.
326 dup = self._duplicate_of(tgt)
327 if dup is not None:
328 tgt.status = "duplicate"
329 tgt.error = ("same device as '%s' (%s) -- two fixtures would drive "
330 "one unit with different looks" % (dup.host, tgt.addr))
331 tgt.sock = None
332 tgt.next_retry = time.monotonic() + BACKOFF_MAX
333 return False
231334 try:
232335 sock = socket.create_connection((tgt.host, tgt.port), timeout=CONNECT_TIMEOUT)
233336 sock.settimeout(SEND_TIMEOUT)
……@@ -238,6 +341,12 @@ class Sender:
238341 tgt.status = "connected"
239342 tgt.error = ""
240343 tgt.backoff = BACKOFF_START
344 try:
345 # Cache the peer address so the duplicate check above can run
346 # without a blocking lookup on later reconnects.
347 tgt.addr = sock.getpeername()[0]
348 except OSError:
349 pass
241350 # The firmware greets with "<hostname> ready"; drain it so it does
242351 # not sit in the buffer. Non-fatal if nothing arrives.
243352 try:
blender-addon/cthuloid_lights/ops.py +12 -5
……@@ -162,10 +162,13 @@ class CTHULOID_OT_send_now(Operator):
162162 bl_idname = "cthuloid.send_now"
163163 bl_label = "Resend / Force Refresh"
164164 bl_description = (
165 "Recovery only -- output is normally automatic while LIVE is armed. "
166 "Use this to push once with LIVE off, or to re-send after a fixture "
167 "reconnects, since an identical payload is otherwise suppressed and a "
168 "returning fixture can sit dark until the next real change")
165 "Recovery only -- output is automatic while connected. Re-sends the "
166 "current scene to every ticked fixture, since an identical payload is "
167 "otherwise suppressed")
168
169 @classmethod
170 def poll(cls, context):
171 return context.scene.cthuloid.live
169172
170173 def execute(self, context):
171174 n = engine.push_scene(context.scene, force=True)
……@@ -176,7 +179,11 @@ class CTHULOID_OT_send_now(Operator):
176179class CTHULOID_OT_push_settings(Operator):
177180 bl_idname = "cthuloid.push_settings"
178181 bl_label = "Push Gamma & Frequency"
179 bl_description = "Send gamma and PWM frequency to the active fixture"
182 bl_description = "Send gamma and PWM frequency to the active fixture (connect first)"
183
184 @classmethod
185 def poll(cls, context):
186 return context.scene.cthuloid.live
180187
181188 def execute(self, context):
182189 fixture = _active_fixture(context)
blender-addon/cthuloid_lights/props.py +59 -3
……@@ -14,6 +14,7 @@ from bpy.props import (BoolProperty, CollectionProperty, EnumProperty,
1414from bpy.types import Object, PropertyGroup, Scene
1515
1616from . import protocol
17from .net import SENDER
1718
1819
1920class CthuloidLightProps(PropertyGroup):
……@@ -32,6 +33,56 @@ class CthuloidLightProps(PropertyGroup):
3233 description="Which of the fixture's four channel triples this light drives")
3334
3435
36def _on_enabled_changed(fixture, context):
37 # push_scene() already skips a disabled fixture, but that only stops NEW
38 # payloads. The sender would keep the target and its reconnect loop, so an
39 # unplugged unit kept retrying -- and paying a ~5 s .local lookup -- until
40 # Blender restarted. Release it here. Ticking it while connected brings it
41 # up straight away rather than at the next scene change.
42 if not fixture.enabled:
43 SENDER.forget(fixture.host, fixture.port)
44 elif _connected:
45 from . import engine
46 engine.push_fixture(fixture.id_data, fixture, force=True)
47
48
49# --- the Connect switch -----------------------------------------------------
50#
51# Session state, deliberately NOT stored in the .blend. It used to be a saved
52# scene property, so a file saved while live came back live on open, and the
53# button (PLAY/PAUSE icons) gave no clear sign either way. Now every session
54# opens disconnected, and this one flag decides whether Blender talks to lights
55# at all: connecting opens the ticked fixtures and pushes the current scene,
56# disconnecting closes every socket and stops every retry.
57
58_connected = False
59
60
61def _get_live(self):
62 return _connected
63
64
65def _set_live(self, value):
66 global _connected
67 value = bool(value)
68 if value == _connected:
69 return
70 _connected = value
71 from . import engine
72 engine.reset_state()
73 if value:
74 engine.push_scene(self.id_data, force=True)
75 else:
76 SENDER.forget_all()
77
78
79def reset_connection():
80 """Disconnect without a scene at hand (file load, addon unregister)."""
81 global _connected
82 _connected = False
83 SENDER.forget_all()
84
85
3586class CthuloidFixture(PropertyGroup):
3687 """One ESP32 running esp324xlight: 4 slots x 3 channels."""
3788
……@@ -47,7 +98,8 @@ class CthuloidFixture(PropertyGroup):
4798
4899 enabled: BoolProperty(
49100 name="Enabled", default=True,
50 description="Send to this fixture while Live is armed")
101 description="Send to this fixture while Live is armed",
102 update=_on_enabled_changed)
51103
52104 # --- output shaping ---------------------------------------------------
53105
……@@ -130,8 +182,12 @@ class CthuloidSceneProps(PropertyGroup):
130182 active_index: IntProperty(name="Active Fixture", default=0)
131183
132184 live: BoolProperty(
133 name="Live", default=False,
134 description="Send lighting state to hardware as the scene changes")
185 name="Connected",
186 description=(
187 "Connect to the ticked fixtures and drive them live from the scene. "
188 "Off: no connections, nothing sent. Not saved -- every session "
189 "opens disconnected"),
190 get=_get_live, set=_set_live)
135191
136192 send_on_frame: BoolProperty(
137193 name="On Frame Change", default=True,
blender-addon/cthuloid_lights/ui.py +58 -19
……@@ -12,22 +12,33 @@ class CTHULOID_UL_fixtures(UIList):
1212 row = layout.row(align=True)
1313 row.prop(item, "enabled", text="")
1414 row.label(text=item.host or "(no host)", icon='LIGHT_DATA')
15
16 status, _err = SENDER.status_of(item.host, item.port)
17 icons = {
18 "connected": 'CHECKMARK',
19 "connecting": 'SORTTIME',
20 "unreachable": 'ERROR',
21 "lost": 'ERROR',
22 "idle": 'RADIOBUT_OFF',
23 "error": 'ERROR',
24 }
25 row.label(text="", icon=icons.get(status, 'RADIOBUT_OFF'))
15 text, icon = fixture_state(context.scene, item)
16 sub = row.row()
17 sub.alert = icon == 'ERROR'
18 sub.label(text=text, icon=icon)
19
20
21def fixture_state(scene, fixture):
22 """(words, icon) for one fixture, as the user should read it."""
23 if not fixture.enabled:
24 return "off", 'BLANK1'
25 if not scene.cthuloid.live:
26 return "not connected", 'RADIOBUT_OFF'
27 status, _err = SENDER.status_of(fixture.host, fixture.port)
28 return {
29 "connected": ("connected", 'RADIOBUT_ON'),
30 "connecting": ("connecting...", 'SORTTIME'),
31 "idle": ("connecting...", 'SORTTIME'),
32 "unreachable": ("unreachable", 'ERROR'),
33 "lost": ("unreachable", 'ERROR'),
34 "duplicate": ("same device twice", 'ERROR'),
35 "error": ("error", 'ERROR'),
36 }.get(status, (status, 'QUESTION'))
2637
2738
2839class CTHULOID_MT_tools(bpy.types.Menu):
2940 """Occasional operations. Everything here is either a one-time migration or
30 a recovery action -- normal output is automatic while LIVE is armed."""
41 a recovery action -- normal output is automatic while connected."""
3142 bl_label = "Tools"
3243 bl_idname = "CTHULOID_MT_tools"
3344
……@@ -51,9 +62,15 @@ class CTHULOID_PT_main(CthuloidPanelBase, Panel):
5162 layout = self.layout
5263 props = context.scene.cthuloid
5364
65 # One switch decides everything. Red when on, like auto-keying, so a
66 # hot rig is unmistakable; words rather than PLAY/PAUSE icons, which
67 # collided with the timeline's own transport controls.
5468 row = layout.row(align=True)
55 row.scale_y = 1.4
56 row.prop(props, "live", text="LIVE", icon='PLAY' if props.live else 'PAUSE', toggle=True)
69 row.scale_y = 1.6
70 row.alert = props.live
71 row.prop(props, "live", toggle=True,
72 text="CONNECTED - live (click to disconnect)" if props.live else "CONNECT",
73 icon='REC' if props.live else 'RADIOBUT_OFF')
5774 row.operator("cthuloid.blackout", text="", icon='CANCEL')
5875
5976 row = layout.row()
……@@ -66,7 +83,9 @@ class CTHULOID_PT_main(CthuloidPanelBase, Panel):
6683 col.menu("CTHULOID_MT_tools", text="", icon='DOWNARROW_HLT')
6784
6885 if props.live:
69 layout.label(text="Sending on playback, seek and edits", icon='CHECKMARK')
86 layout.label(text="Edits and playback go straight to the lights")
87 else:
88 layout.label(text="Not connected: nothing is sent to the lights")
7089
7190
7291class CTHULOID_PT_fixture(CthuloidPanelBase, Panel):
……@@ -88,11 +107,13 @@ class CTHULOID_PT_fixture(CthuloidPanelBase, Panel):
88107 col.prop(fixture, "host")
89108 col.prop(fixture, "port")
90109
91 status, err = SENDER.status_of(fixture.host, fixture.port)
110 text, icon = fixture_state(context.scene, fixture)
92111 box = layout.box()
93 box.label(text="Status: %s" % status)
94 if err:
95 box.label(text=err, icon='ERROR')
112 box.alert = icon == 'ERROR'
113 box.label(text=text, icon=icon)
114 _status, err = SENDER.status_of(fixture.host, fixture.port)
115 if err and props.live and fixture.enabled:
116 box.label(text=err)
96117
97118 layout.separator()
98119 col = layout.column(align=True)
……@@ -229,11 +250,29 @@ CLASSES = (
229250)
230251
231252
253# Fixture status changes on the worker threads, which cannot redraw a panel,
254# so without this the words above only update when the mouse moves over them.
255_REDRAW_INTERVAL = 0.5
256
257
258def _redraw_tick():
259 wm = bpy.context.window_manager
260 if wm is not None and SENDER.has_targets():
261 for window in wm.windows:
262 for area in window.screen.areas:
263 if area.type == 'VIEW_3D':
264 area.tag_redraw()
265 return _REDRAW_INTERVAL
266
267
232268def register():
233269 for cls in CLASSES:
234270 bpy.utils.register_class(cls)
271 bpy.app.timers.register(_redraw_tick, first_interval=_REDRAW_INTERVAL, persistent=True)
235272
236273
237274def unregister():
275 if bpy.app.timers.is_registered(_redraw_tick):
276 bpy.app.timers.unregister(_redraw_tick)
238277 for cls in reversed(CLASSES):
239278 bpy.utils.unregister_class(cls)
blender-addon/tests/test_connect_switch.py +134 -0
……@@ -0,0 +1,134 @@
1"""The Connect switch is the ONE state that decides whether Blender talks to lights.
2
3Run: <blender> -b --factory-startup --python blender-addon/tests/test_connect_switch.py
4
5Uses the real sender and worker threads. The rig's fixtures are repointed at
6local fake fixtures, so no real lamp is touched; one is left at a .local name
7that never resolves, as an unplugged unit would be.
8"""
9
10import socket
11import sys
12import threading
13import time
14
15import addon_utils
16import bpy
17
18MOD = "bl_ext.user_default.cthuloid_lights"
19BLEND = "/home/fleeky/cthuloid/LightArm/blender/light-test.blend"
20DEAD = "cthuloid-test-nonexistent.local"
21
22addon_utils.enable(MOD, default_set=False)
23net = sys.modules[MOD + ".net"]
24S = net.SENDER
25
26
27class Fake:
28 def __init__(self):
29 self.srv = socket.socket()
30 self.srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
31 self.srv.bind(("127.0.0.1", 0))
32 self.srv.listen(4)
33 self.port = self.srv.getsockname()[1]
34 self.lines = []
35 self.open_conns = 0
36 threading.Thread(target=self._accept, daemon=True).start()
37
38 def _accept(self):
39 while True:
40 c, _ = self.srv.accept()
41 self.open_conns += 1
42 c.sendall(b"fake ready\r\n")
43 threading.Thread(target=self._read, args=(c,), daemon=True).start()
44
45 def _read(self, c):
46 buf = b""
47 while True:
48 try:
49 d = c.recv(4096)
50 except OSError:
51 d = b""
52 if not d:
53 self.open_conns -= 1
54 return
55 buf += d
56 while b"\n" in buf:
57 line, buf = buf.split(b"\n", 1)
58 self.lines.append(line.decode())
59
60
61def wait(pred, timeout=3.0):
62 end = time.monotonic() + timeout
63 while time.monotonic() < end:
64 if pred():
65 return True
66 time.sleep(0.02)
67 return pred()
68
69
70failures = []
71
72
73def check(name, ok):
74 print(("PASS " if ok else "FAIL ") + name)
75 if not ok:
76 failures.append(name)
77
78
79bpy.ops.wm.open_mainfile(filepath=BLEND)
80scene = bpy.context.scene
81props = scene.cthuloid
82
83# 1. The file was saved with Live on; opening it must NOT connect.
84check("opens disconnected", props.live is False)
85check("no connections after load", wait(lambda: not S._targets, 1.0))
86
87fakes = []
88fx = list(props.fixtures)
89for f in fx[:-1]:
90 fk = Fake()
91 fakes.append(fk)
92 f.host, f.port = "127.0.0.1", fk.port
93fx[-1].host = DEAD # an unplugged unit
94fx[1].enabled = False # an unticked unit
95time.sleep(0.3)
96check("still nothing sent while disconnected", all(not fk.lines for fk in fakes))
97
98# 2. Connect: ticked fixtures connect and get the current scene immediately.
99props.live = True
100check("connect pushes to ticked fixtures",
101 wait(lambda: fakes[0].lines and fakes[2].lines))
102check("unticked fixture untouched", not fakes[1].lines)
103
104# 6. Ticking a fixture while connected brings it up right away.
105fx[1].enabled = True
106check("ticking while connected pushes to it", wait(lambda: fakes[1].lines))
107
108# 5. Resend / push settings are available only while connected.
109check("send_now available while connected", bpy.ops.cthuloid.send_now.poll())
110
111# 3. Disconnect closes every connection and stops every retry.
112props.live = False
113check("disconnect drops every target", wait(lambda: not S._targets))
114check("disconnect closes sockets", wait(lambda: all(fk.open_conns == 0 for fk in fakes)))
115check("send_now unavailable while disconnected", not bpy.ops.cthuloid.send_now.poll())
116check("push_settings unavailable while disconnected", not bpy.ops.cthuloid.push_settings.poll())
117
118# Edits while disconnected send nothing.
119before = [len(fk.lines) for fk in fakes]
120bpy.data.objects["bgr-1-studiolight.local"].data.color = (0.2, 0.4, 0.6)
121bpy.context.view_layer.update()
122time.sleep(0.4)
123check("edits while disconnected send nothing", [len(fk.lines) for fk in fakes] == before)
124
125# 4. Blackout works while disconnected, then lets go of the connection.
126bpy.ops.cthuloid.blackout()
127check("blackout reaches fixtures while disconnected",
128 wait(lambda: any(l == "stopfade" for l in fakes[0].lines)))
129check("blackout releases its connections", wait(lambda: not any(
130 k[0] == "127.0.0.1" for k in S._targets), 3.0))
131
132addon_utils.disable(MOD, default_set=False)
133print("ALL PASS" if not failures else "FAILED: %s" % failures)
134sys.exit(1 if failures else 0)
blender-addon/tests/test_disable_forgets.py +49 -0
……@@ -0,0 +1,49 @@
1"""Unticking a fixture's Enabled must stop the sender driving it.
2
3Run: <blender> -b --factory-startup --python blender-addon/tests/test_disable_forgets.py
4
5Before this, Enabled only stopped NEW payloads being produced (engine.push_scene
6skips disabled fixtures); the sender kept the target and its reconnect loop
7forever, so an unplugged unit kept costing a ~5 s mDNS lookup every backoff
8window until Blender was restarted.
9"""
10
11import sys
12import time
13
14import addon_utils
15import bpy
16
17MOD = "bl_ext.user_default.cthuloid_lights"
18addon_utils.enable(MOD, default_set=False)
19net = sys.modules[MOD + ".net"]
20
21HOST = "cthuloid-test-nonexistent.local" # never resolves: no real fixture touched
22PORT = 1337
23
24scene = bpy.context.scene
25fixture = scene.cthuloid.fixtures.add()
26fixture.id = "test"
27fixture.host = HOST
28fixture.port = PORT
29
30net.SENDER.send(HOST, PORT, "pwm 0\n")
31tgt = net.SENDER._targets.get((HOST, PORT))
32assert tgt is not None, "precondition: send() should create the target"
33
34fixture.enabled = False
35
36ok = True
37if (HOST, PORT) in net.SENDER._targets:
38 print("FAIL: target still registered after Enabled was unticked")
39 ok = False
40else:
41 # Its worker may be inside a ~5 s lookup; it must exit once that returns.
42 tgt.thread.join(timeout=8.0)
43 if tgt.thread.is_alive():
44 print("FAIL: worker thread still running after Enabled was unticked")
45 ok = False
46
47addon_utils.disable(MOD, default_set=False)
48print("PASS: unticking Enabled released the target" if ok else "FAILED")
49sys.exit(0 if ok else 1)
blender-addon/tests/test_net_isolation.py +116 -0
……@@ -0,0 +1,116 @@
1"""A dead fixture must not delay a live one.
2
3Run: python3 blender-addon/tests/test_net_isolation.py
4
5net.py imports nothing from Blender, so it is exercised directly. A local TCP
6server stands in for a live fixture; a .local name that does not exist stands
7in for a powered-off one. Resolving such a name blocks getaddrinfo for ~5 s
8(nss-mdns timeout), which is exactly what a real rig with unplugged units does.
9"""
10
11import importlib.util
12import os
13import socket
14import sys
15import threading
16import time
17
18HERE = os.path.dirname(os.path.abspath(__file__))
19NET_PY = os.path.join(HERE, "..", "cthuloid_lights", "net.py")
20
21spec = importlib.util.spec_from_file_location("cthuloid_net", NET_PY)
22net = importlib.util.module_from_spec(spec)
23spec.loader.exec_module(net)
24
25DEAD_HOST = "cthuloid-test-nonexistent.local"
26BUDGET_S = 0.5 # a live fixture must see each update within this
27
28
29class FakeFixture:
30 """Accepts connections, greets like the firmware, records each line's arrival."""
31
32 def __init__(self):
33 self.srv = socket.socket()
34 self.srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
35 self.srv.bind(("127.0.0.1", 0))
36 self.srv.listen(4)
37 self.port = self.srv.getsockname()[1]
38 self.lines = [] # (monotonic arrival, line)
39 self.lock = threading.Lock()
40 threading.Thread(target=self._accept, daemon=True).start()
41
42 def _accept(self):
43 while True:
44 try:
45 c, _ = self.srv.accept()
46 except OSError:
47 return
48 c.sendall(b"fake ready\r\n")
49 threading.Thread(target=self._read, args=(c,), daemon=True).start()
50
51 def _read(self, c):
52 buf = b""
53 while True:
54 try:
55 d = c.recv(4096)
56 except OSError:
57 return
58 if not d:
59 return
60 buf += d
61 while b"\n" in buf:
62 line, buf = buf.split(b"\n", 1)
63 with self.lock:
64 self.lines.append((time.monotonic(), line.decode()))
65
66 def wait_for(self, text, timeout):
67 end = time.monotonic() + timeout
68 while time.monotonic() < end:
69 with self.lock:
70 for t, line in self.lines:
71 if line == text:
72 return t
73 time.sleep(0.01)
74 return None
75
76
77def main():
78 fx = FakeFixture()
79 s = net.Sender()
80 s.min_interval = 0.0
81 s.start()
82 failures = []
83 try:
84 # Bring the live fixture up first so its connect is not what we time.
85 s.send("127.0.0.1", fx.port, "pwm 0\n")
86 if fx.wait_for("pwm 0", 3.0) is None:
87 print("FAIL: live fixture never received its first frame")
88 return 1
89
90 # Now a dead fixture joins, as an unplugged unit in the scene does.
91 s.send(DEAD_HOST, 1337, "pwm 1\n")
92 time.sleep(0.3) # let the worker start resolving it
93
94 for i in range(2, 7):
95 payload = "pwm %d" % i
96 t0 = time.monotonic()
97 s.send("127.0.0.1", fx.port, payload + "\n")
98 t1 = fx.wait_for(payload, 15.0)
99 lat = None if t1 is None else t1 - t0
100 print("update %d -> live fixture: %s" % (
101 i, "never" if lat is None else "%.3f s" % lat))
102 if lat is None or lat > BUDGET_S:
103 failures.append(i)
104 time.sleep(0.5)
105 finally:
106 s.stop()
107
108 if failures:
109 print("FAIL: %d/5 updates exceeded %.1f s" % (len(failures), BUDGET_S))
110 return 1
111 print("PASS: every update reached the live fixture within %.1f s" % BUDGET_S)
112 return 0
113
114
115if __name__ == "__main__":
116 sys.exit(main())