| … | … | @@ -115,9 +115,18 @@ channelbag and only the slot bound to that datablock may be read. |
| 115 | 115 | |
| 116 | 116 | ## Sending |
| 117 | 117 | |
| 118 | **Arm `LIVE` and output is automatic.** There is no "send" step in normal use -- | |
| 119 | playback, scrubbing/seeking anywhere in the timeline, and any light colour or | |
| 120 | energy edit all push on their own (both sources toggleable under Output). | |
| 118 | **One switch: `CONNECT`.** Every session (and every file you open) starts | |
| 119 | disconnected: no sockets, no retries, nothing sent. Click `CONNECT` and Blender | |
| 120 | connects to every ticked fixture and pushes the current scene straight away; | |
| 121 | from then on playback, scrubbing/seeking and any light colour or energy edit go | |
| 122 | to the lights on their own (both sources toggleable under Output). The button | |
| 123 | turns red while connected. Click it again to disconnect: every socket closes | |
| 124 | and 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 | ||
| 127 | Each fixture row shows its state in words: `connected`, `connecting...`, | |
| 128 | `unreachable`, `not connected`, or `off` for an unticked fixture. Unticking a | |
| 129 | fixture drops its connection; ticking it while connected brings it up at once. | |
| 121 | 130 | |
| 122 | 131 | The Tools menu (▾ beside Add/Remove) holds the two things that are *not* part of |
| 123 | 132 | that loop: |
| … | … | @@ -125,21 +134,24 @@ that loop: |
| 125 | 134 | - **Convert Name-Encoded Lights** — one-time migration for older .blend files |
| 126 | 135 | that stored wiring in object names. Does nothing in a scene built with |
| 127 | 136 | 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). | |
| 131 | 139 | |
| 132 | 140 | - **Coalescing** — one pending frame per fixture; if the UI outruns the wire, |
| 133 | 141 | intermediate frames are dropped rather than queued, so output never plays back |
| 134 | 142 | late. |
| 135 | 143 | - **Rate limited** — `Max Rate`, default 25 Hz. The firmware is single-threaded; |
| 136 | 144 | 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. | |
| 140 | 151 | - **Reconnects with backoff**, 1 s doubling to 15 s. |
| 141 | 152 | |
| 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 | |
| 154 | disconnected it is one-shot: one connection attempt per fixture, deliver, close. | |
| 143 | 155 | |
| 144 | 156 | ## Differences from quadpanel.py |
| 145 | 157 |
| … | … | @@ -17,6 +17,7 @@ def register(): |
| 17 | 17 | |
| 18 | 18 | def unregister(): |
| 19 | 19 | engine.unregister_handlers() |
| 20 | props.reset_connection() | |
| 20 | 21 | net.SENDER.stop() |
| 21 | 22 | ui.unregister() |
| 22 | 23 | ops.unregister() |
| … | … | @@ -9,6 +9,7 @@ working after running it. |
| 9 | 9 | import bpy |
| 10 | 10 | |
| 11 | 11 | from . import anim, protocol, rig |
| 12 | from . import props as props_mod | |
| 12 | 13 | from .net import SENDER |
| 13 | 14 | |
| 14 | 15 | # Sentinel: this fixture's current animation segment has already been commanded, |
| … | … | @@ -100,16 +101,22 @@ def push_scene(scene, force=False): |
| 100 | 101 | SENDER.min_interval = 1.0 / max(1, props.max_hz) |
| 101 | 102 | sent = 0 |
| 102 | 103 | 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 | |
| 110 | 106 | return sent |
| 111 | 107 | |
| 112 | 108 | |
| 109 | def 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 | ||
| 113 | 120 | def push_settings(scene, fixture): |
| 114 | 121 | """Push the firmware-side settings (gamma, freq) for one fixture.""" |
| 115 | 122 | payload = protocol.gamma_command(fixture.gamma_b, fixture.gamma_g, fixture.gamma_r) |
| … | … | @@ -126,7 +133,10 @@ def blackout(scene): |
| 126 | 133 | for fixture in props.fixtures: |
| 127 | 134 | if not fixture.host: |
| 128 | 135 | 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) | |
| 130 | 140 | count += 1 |
| 131 | 141 | return count |
| 132 | 142 | |
| … | … | @@ -165,6 +175,8 @@ def _on_depsgraph_update(scene, depsgraph=None): |
| 165 | 175 | |
| 166 | 176 | @bpy.app.handlers.persistent |
| 167 | 177 | def _on_load_post(*args): |
| 178 | # A newly opened file starts disconnected, like a fresh session. | |
| 179 | props_mod.reset_connection() | |
| 168 | 180 | reset_state() |
| 169 | 181 | |
| 170 | 182 |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | 1 | """Non-blocking sender for ESP32 light fixtures. |
| 2 | 2 | |
| 3 | One worker thread owns every socket. Blender's main thread only ever drops the | |
| 3 | Each fixture has its own worker thread, which owns that fixture's socket, so an | |
| 4 | unreachable unit only ever delays itself. Blender's main thread only ever drops the | |
| 4 | 5 | latest desired state into a per-host mailbox and returns immediately -- it never |
| 5 | 6 | calls connect() or sendall(). The original quadpanel.py did its sendall() inline |
| 6 | 7 | in the frame_change handler, so a fixture that had gone away stalled the UI for |
| … | … | @@ -43,11 +44,15 @@ KEEPALIVE_PAYLOAD = "hostname\n" |
| 43 | 44 | class _Target: |
| 44 | 45 | __slots__ = ("host", "port", "sock", "desired", "dirty", "force", |
| 45 | 46 | "last_sent", "last_tx_time", "next_retry", "backoff", |
| 46 | "status", "error") | |
| 47 | "status", "error", "addr", "wake", "thread", "closed", "once") | |
| 47 | 48 | |
| 48 | 49 | def __init__(self, host, port): |
| 49 | 50 | self.host = host |
| 50 | 51 | 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 | |
| 51 | 56 | self.sock = None |
| 52 | 57 | self.desired = None # latest state the fixture SHOULD hold |
| 53 | 58 | self.dirty = False # desired differs from what reached the wire |
| … | … | @@ -57,41 +62,61 @@ class _Target: |
| 57 | 62 | self.next_retry = 0.0 |
| 58 | 63 | self.backoff = BACKOFF_START |
| 59 | 64 | self.status = "idle" |
| 60 | self.error = '' | |
| 65 | self.error = "" | |
| 66 | self.addr = None # resolved ip, filled in by the worker | |
| 61 | 67 | |
| 62 | 68 | |
| 63 | 69 | class 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 | ||
| 64 | 80 | def __init__(self): |
| 65 | 81 | self._targets = {} |
| 66 | 82 | self._lock = threading.Lock() |
| 67 | self._wake = threading.Event() | |
| 68 | self._thread = None | |
| 69 | 83 | self._running = False |
| 70 | 84 | self.min_interval = 1.0 / 25.0 |
| 71 | 85 | |
| 72 | 86 | # ---- main-thread API ------------------------------------------------- |
| 73 | 87 | |
| 74 | 88 | 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) | |
| 80 | 95 | |
| 81 | 96 | 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 | |
| 88 | 97 | with self._lock: |
| 89 | for tgt in self._targets.values(): | |
| 90 | self._close(tgt) | |
| 98 | self._running = False | |
| 99 | targets = list(self._targets.values()) | |
| 91 | 100 | 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. | |
| 92 | 114 | |
| 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 | """ | |
| 95 | 120 | if not host or not payload: |
| 96 | 121 | return |
| 97 | 122 | key = (host, port) |
| … | … | @@ -99,20 +124,40 @@ class Sender: |
| 99 | 124 | tgt = self._targets.get(key) |
| 100 | 125 | if tgt is None: |
| 101 | 126 | tgt = _Target(host, port) |
| 127 | tgt.once = once | |
| 102 | 128 | self._targets[key] = tgt |
| 129 | if self._running: | |
| 130 | self._spawn(tgt) | |
| 131 | elif not once: | |
| 132 | tgt.once = False | |
| 103 | 133 | tgt.desired = payload |
| 104 | 134 | if force: |
| 105 | 135 | tgt.force = True |
| 106 | 136 | tgt.dirty = True |
| 107 | 137 | elif payload != tgt.last_sent: |
| 108 | 138 | tgt.dirty = True |
| 109 | self._wake.set() | |
| 139 | tgt.wake.set() | |
| 110 | 140 | |
| 111 | 141 | def forget(self, host, port): |
| 142 | """Stop driving a fixture. Its worker exits and closes the socket.""" | |
| 112 | 143 | with self._lock: |
| 113 | 144 | 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) | |
| 116 | 161 | |
| 117 | 162 | def status_of(self, host, port): |
| 118 | 163 | with self._lock: |
| … | … | @@ -121,21 +166,36 @@ class Sender: |
| 121 | 166 | return ("idle", "") |
| 122 | 167 | return (tgt.status, tgt.error) |
| 123 | 168 | |
| 124 | # ---- worker thread --------------------------------------------------- | |
| 169 | # ---- worker threads -------------------------------------------------- | |
| 125 | 170 | |
| 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) | |
| 139 | 199 | |
| 140 | 200 | def _service(self, tgt, now): |
| 141 | 201 | if tgt.desired is None: |
| … | … | @@ -148,6 +208,8 @@ class Sender: |
| 148 | 208 | if now < tgt.next_retry: |
| 149 | 209 | return |
| 150 | 210 | if not self._connect(tgt): |
| 211 | if tgt.once: | |
| 212 | self._retire(tgt) # one attempt only; never a retry loop | |
| 151 | 213 | return |
| 152 | 214 | # The fixture may have rebooted or been dropped mid-look; it cannot |
| 153 | 215 | # be assumed to still hold last_sent. Re-assert the desired state. |
| … | … | @@ -177,6 +239,10 @@ class Sender: |
| 177 | 239 | tgt.last_sent = payload |
| 178 | 240 | tgt.dirty = False |
| 179 | 241 | tgt.force = False |
| 242 | if tgt.once: | |
| 243 | self._retire(tgt) | |
| 244 | elif tgt.once: | |
| 245 | self._retire(tgt) | |
| 180 | 246 | return |
| 181 | 247 | |
| 182 | 248 | # (4) Keepalive, so the firmware's idle timer never fires on a look we |
| … | … | @@ -226,8 +292,45 @@ class Sender: |
| 226 | 292 | pass |
| 227 | 293 | return alive |
| 228 | 294 | |
| 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 | ||
| 229 | 319 | def _connect(self, tgt): |
| 230 | 320 | 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 | |
| 231 | 334 | try: |
| 232 | 335 | sock = socket.create_connection((tgt.host, tgt.port), timeout=CONNECT_TIMEOUT) |
| 233 | 336 | sock.settimeout(SEND_TIMEOUT) |
| … | … | @@ -238,6 +341,12 @@ class Sender: |
| 238 | 341 | tgt.status = "connected" |
| 239 | 342 | tgt.error = "" |
| 240 | 343 | 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 | |
| 241 | 350 | # The firmware greets with "<hostname> ready"; drain it so it does |
| 242 | 351 | # not sit in the buffer. Non-fatal if nothing arrives. |
| 243 | 352 | try: |
| … | … | @@ -162,10 +162,13 @@ class CTHULOID_OT_send_now(Operator): |
| 162 | 162 | bl_idname = "cthuloid.send_now" |
| 163 | 163 | bl_label = "Resend / Force Refresh" |
| 164 | 164 | 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 | |
| 169 | 172 | |
| 170 | 173 | def execute(self, context): |
| 171 | 174 | n = engine.push_scene(context.scene, force=True) |
| … | … | @@ -176,7 +179,11 @@ class CTHULOID_OT_send_now(Operator): |
| 176 | 179 | class CTHULOID_OT_push_settings(Operator): |
| 177 | 180 | bl_idname = "cthuloid.push_settings" |
| 178 | 181 | 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 | |
| 180 | 187 | |
| 181 | 188 | def execute(self, context): |
| 182 | 189 | fixture = _active_fixture(context) |
| … | … | @@ -14,6 +14,7 @@ from bpy.props import (BoolProperty, CollectionProperty, EnumProperty, |
| 14 | 14 | from bpy.types import Object, PropertyGroup, Scene |
| 15 | 15 | |
| 16 | 16 | from . import protocol |
| 17 | from .net import SENDER | |
| 17 | 18 | |
| 18 | 19 | |
| 19 | 20 | class CthuloidLightProps(PropertyGroup): |
| … | … | @@ -32,6 +33,56 @@ class CthuloidLightProps(PropertyGroup): |
| 32 | 33 | description="Which of the fixture's four channel triples this light drives") |
| 33 | 34 | |
| 34 | 35 | |
| 36 | def _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 | ||
| 61 | def _get_live(self): | |
| 62 | return _connected | |
| 63 | ||
| 64 | ||
| 65 | def _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 | ||
| 79 | def 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 | ||
| 35 | 86 | class CthuloidFixture(PropertyGroup): |
| 36 | 87 | """One ESP32 running esp324xlight: 4 slots x 3 channels.""" |
| 37 | 88 | |
| … | … | @@ -47,7 +98,8 @@ class CthuloidFixture(PropertyGroup): |
| 47 | 98 | |
| 48 | 99 | enabled: BoolProperty( |
| 49 | 100 | 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) | |
| 51 | 103 | |
| 52 | 104 | # --- output shaping --------------------------------------------------- |
| 53 | 105 | |
| … | … | @@ -130,8 +182,12 @@ class CthuloidSceneProps(PropertyGroup): |
| 130 | 182 | active_index: IntProperty(name="Active Fixture", default=0) |
| 131 | 183 | |
| 132 | 184 | 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) | |
| 135 | 191 | |
| 136 | 192 | send_on_frame: BoolProperty( |
| 137 | 193 | name="On Frame Change", default=True, |
| … | … | @@ -12,22 +12,33 @@ class CTHULOID_UL_fixtures(UIList): |
| 12 | 12 | row = layout.row(align=True) |
| 13 | 13 | row.prop(item, "enabled", text="") |
| 14 | 14 | 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 | ||
| 21 | def 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')) | |
| 26 | 37 | |
| 27 | 38 | |
| 28 | 39 | class CTHULOID_MT_tools(bpy.types.Menu): |
| 29 | 40 | """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.""" | |
| 31 | 42 | bl_label = "Tools" |
| 32 | 43 | bl_idname = "CTHULOID_MT_tools" |
| 33 | 44 | |
| … | … | @@ -51,9 +62,15 @@ class CTHULOID_PT_main(CthuloidPanelBase, Panel): |
| 51 | 62 | layout = self.layout |
| 52 | 63 | props = context.scene.cthuloid |
| 53 | 64 | |
| 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. | |
| 54 | 68 | 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') | |
| 57 | 74 | row.operator("cthuloid.blackout", text="", icon='CANCEL') |
| 58 | 75 | |
| 59 | 76 | row = layout.row() |
| … | … | @@ -66,7 +83,9 @@ class CTHULOID_PT_main(CthuloidPanelBase, Panel): |
| 66 | 83 | col.menu("CTHULOID_MT_tools", text="", icon='DOWNARROW_HLT') |
| 67 | 84 | |
| 68 | 85 | 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") | |
| 70 | 89 | |
| 71 | 90 | |
| 72 | 91 | class CTHULOID_PT_fixture(CthuloidPanelBase, Panel): |
| … | … | @@ -88,11 +107,13 @@ class CTHULOID_PT_fixture(CthuloidPanelBase, Panel): |
| 88 | 107 | col.prop(fixture, "host") |
| 89 | 108 | col.prop(fixture, "port") |
| 90 | 109 | |
| 91 | status, err = SENDER.status_of(fixture.host, fixture.port) | |
| 110 | text, icon = fixture_state(context.scene, fixture) | |
| 92 | 111 | 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) | |
| 96 | 117 | |
| 97 | 118 | layout.separator() |
| 98 | 119 | col = layout.column(align=True) |
| … | … | @@ -229,11 +250,29 @@ CLASSES = ( |
| 229 | 250 | ) |
| 230 | 251 | |
| 231 | 252 | |
| 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 | ||
| 258 | def _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 | ||
| 232 | 268 | def register(): |
| 233 | 269 | for cls in CLASSES: |
| 234 | 270 | bpy.utils.register_class(cls) |
| 271 | bpy.app.timers.register(_redraw_tick, first_interval=_REDRAW_INTERVAL, persistent=True) | |
| 235 | 272 | |
| 236 | 273 | |
| 237 | 274 | def unregister(): |
| 275 | if bpy.app.timers.is_registered(_redraw_tick): | |
| 276 | bpy.app.timers.unregister(_redraw_tick) | |
| 238 | 277 | for cls in reversed(CLASSES): |
| 239 | 278 | bpy.utils.unregister_class(cls) |
| … | … | @@ -0,0 +1,134 @@ |
| 1 | """The Connect switch is the ONE state that decides whether Blender talks to lights. | |
| 2 | ||
| 3 | Run: <blender> -b --factory-startup --python blender-addon/tests/test_connect_switch.py | |
| 4 | ||
| 5 | Uses the real sender and worker threads. The rig's fixtures are repointed at | |
| 6 | local fake fixtures, so no real lamp is touched; one is left at a .local name | |
| 7 | that never resolves, as an unplugged unit would be. | |
| 8 | """ | |
| 9 | ||
| 10 | import socket | |
| 11 | import sys | |
| 12 | import threading | |
| 13 | import time | |
| 14 | ||
| 15 | import addon_utils | |
| 16 | import bpy | |
| 17 | ||
| 18 | MOD = "bl_ext.user_default.cthuloid_lights" | |
| 19 | BLEND = "/home/fleeky/cthuloid/LightArm/blender/light-test.blend" | |
| 20 | DEAD = "cthuloid-test-nonexistent.local" | |
| 21 | ||
| 22 | addon_utils.enable(MOD, default_set=False) | |
| 23 | net = sys.modules[MOD + ".net"] | |
| 24 | S = net.SENDER | |
| 25 | ||
| 26 | ||
| 27 | class 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 | ||
| 61 | def 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 | ||
| 70 | failures = [] | |
| 71 | ||
| 72 | ||
| 73 | def check(name, ok): | |
| 74 | print(("PASS " if ok else "FAIL ") + name) | |
| 75 | if not ok: | |
| 76 | failures.append(name) | |
| 77 | ||
| 78 | ||
| 79 | bpy.ops.wm.open_mainfile(filepath=BLEND) | |
| 80 | scene = bpy.context.scene | |
| 81 | props = scene.cthuloid | |
| 82 | ||
| 83 | # 1. The file was saved with Live on; opening it must NOT connect. | |
| 84 | check("opens disconnected", props.live is False) | |
| 85 | check("no connections after load", wait(lambda: not S._targets, 1.0)) | |
| 86 | ||
| 87 | fakes = [] | |
| 88 | fx = list(props.fixtures) | |
| 89 | for f in fx[:-1]: | |
| 90 | fk = Fake() | |
| 91 | fakes.append(fk) | |
| 92 | f.host, f.port = "127.0.0.1", fk.port | |
| 93 | fx[-1].host = DEAD # an unplugged unit | |
| 94 | fx[1].enabled = False # an unticked unit | |
| 95 | time.sleep(0.3) | |
| 96 | check("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. | |
| 99 | props.live = True | |
| 100 | check("connect pushes to ticked fixtures", | |
| 101 | wait(lambda: fakes[0].lines and fakes[2].lines)) | |
| 102 | check("unticked fixture untouched", not fakes[1].lines) | |
| 103 | ||
| 104 | # 6. Ticking a fixture while connected brings it up right away. | |
| 105 | fx[1].enabled = True | |
| 106 | check("ticking while connected pushes to it", wait(lambda: fakes[1].lines)) | |
| 107 | ||
| 108 | # 5. Resend / push settings are available only while connected. | |
| 109 | check("send_now available while connected", bpy.ops.cthuloid.send_now.poll()) | |
| 110 | ||
| 111 | # 3. Disconnect closes every connection and stops every retry. | |
| 112 | props.live = False | |
| 113 | check("disconnect drops every target", wait(lambda: not S._targets)) | |
| 114 | check("disconnect closes sockets", wait(lambda: all(fk.open_conns == 0 for fk in fakes))) | |
| 115 | check("send_now unavailable while disconnected", not bpy.ops.cthuloid.send_now.poll()) | |
| 116 | check("push_settings unavailable while disconnected", not bpy.ops.cthuloid.push_settings.poll()) | |
| 117 | ||
| 118 | # Edits while disconnected send nothing. | |
| 119 | before = [len(fk.lines) for fk in fakes] | |
| 120 | bpy.data.objects["bgr-1-studiolight.local"].data.color = (0.2, 0.4, 0.6) | |
| 121 | bpy.context.view_layer.update() | |
| 122 | time.sleep(0.4) | |
| 123 | check("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. | |
| 126 | bpy.ops.cthuloid.blackout() | |
| 127 | check("blackout reaches fixtures while disconnected", | |
| 128 | wait(lambda: any(l == "stopfade" for l in fakes[0].lines))) | |
| 129 | check("blackout releases its connections", wait(lambda: not any( | |
| 130 | k[0] == "127.0.0.1" for k in S._targets), 3.0)) | |
| 131 | ||
| 132 | addon_utils.disable(MOD, default_set=False) | |
| 133 | print("ALL PASS" if not failures else "FAILED: %s" % failures) | |
| 134 | sys.exit(1 if failures else 0) |
| … | … | @@ -0,0 +1,49 @@ |
| 1 | """Unticking a fixture's Enabled must stop the sender driving it. | |
| 2 | ||
| 3 | Run: <blender> -b --factory-startup --python blender-addon/tests/test_disable_forgets.py | |
| 4 | ||
| 5 | Before this, Enabled only stopped NEW payloads being produced (engine.push_scene | |
| 6 | skips disabled fixtures); the sender kept the target and its reconnect loop | |
| 7 | forever, so an unplugged unit kept costing a ~5 s mDNS lookup every backoff | |
| 8 | window until Blender was restarted. | |
| 9 | """ | |
| 10 | ||
| 11 | import sys | |
| 12 | import time | |
| 13 | ||
| 14 | import addon_utils | |
| 15 | import bpy | |
| 16 | ||
| 17 | MOD = "bl_ext.user_default.cthuloid_lights" | |
| 18 | addon_utils.enable(MOD, default_set=False) | |
| 19 | net = sys.modules[MOD + ".net"] | |
| 20 | ||
| 21 | HOST = "cthuloid-test-nonexistent.local" # never resolves: no real fixture touched | |
| 22 | PORT = 1337 | |
| 23 | ||
| 24 | scene = bpy.context.scene | |
| 25 | fixture = scene.cthuloid.fixtures.add() | |
| 26 | fixture.id = "test" | |
| 27 | fixture.host = HOST | |
| 28 | fixture.port = PORT | |
| 29 | ||
| 30 | net.SENDER.send(HOST, PORT, "pwm 0\n") | |
| 31 | tgt = net.SENDER._targets.get((HOST, PORT)) | |
| 32 | assert tgt is not None, "precondition: send() should create the target" | |
| 33 | ||
| 34 | fixture.enabled = False | |
| 35 | ||
| 36 | ok = True | |
| 37 | if (HOST, PORT) in net.SENDER._targets: | |
| 38 | print("FAIL: target still registered after Enabled was unticked") | |
| 39 | ok = False | |
| 40 | else: | |
| 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 | ||
| 47 | addon_utils.disable(MOD, default_set=False) | |
| 48 | print("PASS: unticking Enabled released the target" if ok else "FAILED") | |
| 49 | sys.exit(0 if ok else 1) |
| … | … | @@ -0,0 +1,116 @@ |
| 1 | """A dead fixture must not delay a live one. | |
| 2 | ||
| 3 | Run: python3 blender-addon/tests/test_net_isolation.py | |
| 4 | ||
| 5 | net.py imports nothing from Blender, so it is exercised directly. A local TCP | |
| 6 | server stands in for a live fixture; a .local name that does not exist stands | |
| 7 | in 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 | ||
| 11 | import importlib.util | |
| 12 | import os | |
| 13 | import socket | |
| 14 | import sys | |
| 15 | import threading | |
| 16 | import time | |
| 17 | ||
| 18 | HERE = os.path.dirname(os.path.abspath(__file__)) | |
| 19 | NET_PY = os.path.join(HERE, "..", "cthuloid_lights", "net.py") | |
| 20 | ||
| 21 | spec = importlib.util.spec_from_file_location("cthuloid_net", NET_PY) | |
| 22 | net = importlib.util.module_from_spec(spec) | |
| 23 | spec.loader.exec_module(net) | |
| 24 | ||
| 25 | DEAD_HOST = "cthuloid-test-nonexistent.local" | |
| 26 | BUDGET_S = 0.5 # a live fixture must see each update within this | |
| 27 | ||
| 28 | ||
| 29 | class 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 | ||
| 77 | def 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 | ||
| 115 | if __name__ == "__main__": | |
| 116 | sys.exit(main()) |