OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / e351d08

phase 2 verified on hardware: Blender + web UI share studiolight
tests/e2e_shared_control.py drives the addon's real sender and the deployed web UI (cthuloid-web 0.2.0 on stortron) against studiolight (firmware proto 3): 14/14 -- Blender's slot shows as held in the web UI and is handed back after ~1.5 s quiet; a slot the web user holds refuses Blender's change and the fixture reports who has it; the web user's released colour stays and is stored. READMEs describe the shared-control behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e351d08abb012fc79b986a0b182d2e5a66b7c4fc
by plankatron <fleeky@gmail.com> · 2026-10-02T15:21:20.000Z

Changed files (3) +168 -1

blender-addon/README.md +11 -0
……@@ -150,6 +150,17 @@ that loop:
150150 dead fixture's own thread.
151151- **Reconnects with backoff**, 1 s doubling to 15 s.
152152
153**Shared control (units on firmware proto 3).** The unit referees its four
154slots between controllers (web UI, phones, Blender), first come first served.
155Blender then sends only the slots whose colour changed: it takes the slot, sends
156it, and hands it back after 1.5 s without a change. A slot someone else holds is
157skipped and the fixture row says so (lock icon, "slot 2 held by alice"); when
158they let go, their colour stays until your scene changes that slot again.
159Connect pushes every slot; a silent reconnect after a network drop pushes
160nothing (the lamp keeps what people set meanwhile). Older firmware: unchanged.
161Tests: `python3 tests/test_net_v3.py`; on hardware with the web UI,
162`python3 tests/e2e_shared_control.py <unit-ip> <web-ui-url>`.
163
153164`Blackout` sends `stopfade` + all-zero to every fixture, connected or not. While
154165disconnected it is one-shot: one connection attempt per fixture, deliver, close.
155166
blender-addon/tests/e2e_shared_control.py +137 -0
……@@ -0,0 +1,137 @@
1#!/usr/bin/env python3
2"""Shared control end to end, on REAL hardware: Blender's sender + the web UI
3(cthuloid-web) + a proto-3 unit.
4
5 python3 blender-addon/tests/e2e_shared_control.py <unit-ip> <web-ui-url>
6
7Uses the addon's real net.py/protocol.py (no Blender needed) and the web UI's
8HTTP API. Values stay dim; the unit's colours are restored at the end. Leaves
9the web UI connected (Connect switch on).
10"""
11
12import json
13import os
14import socket
15import sys
16import time
17import urllib.request
18
19HERE = os.path.dirname(os.path.abspath(__file__))
20sys.path.insert(0, os.path.join(HERE, "..", "cthuloid_lights"))
21import net # noqa: E402
22import protocol # noqa: E402
23
24UNIT = sys.argv[1] if len(sys.argv) > 1 else "192.168.50.89"
25WEB = (sys.argv[2] if len(sys.argv) > 2 else "http://stortron.local:4050").rstrip("/")
26failures = []
27
28
29def check(name, ok, detail=""):
30 print(("PASS " if ok else "FAIL ") + name + ("" if ok else " " + str(detail)))
31 if not ok:
32 failures.append(name)
33
34
35def wait(pred, timeout=4.0):
36 end = time.monotonic() + timeout
37 while time.monotonic() < end:
38 if pred():
39 return True
40 time.sleep(0.05)
41 return pred()
42
43
44def api(path, body=None):
45 req = urllib.request.Request(WEB + path, data=None if body is None else json.dumps(body).encode(),
46 headers={"content-type": "application/json", "x-cthuloid": "1"},
47 method="GET" if body is None else "POST")
48 try:
49 with urllib.request.urlopen(req, timeout=5) as r:
50 return r.status, json.loads(r.read() or b"null")
51 except urllib.error.HTTPError as e:
52 return e.code, json.loads(e.read() or b"null")
53
54
55def unit_state():
56 st = api("/api/state")[1]
57 return next(u for u in st["units"] if u["addr"] == UNIT)
58
59
60def lamp_values():
61 s = socket.create_connection((UNIT, 1337), timeout=4)
62 time.sleep(0.3)
63 s.sendall(b"state\n")
64 time.sleep(0.5)
65 data = s.recv(8192).decode()
66 s.close()
67 line = next(l for l in data.splitlines() if l.startswith("state "))
68 return [int(x) for x in line.split()[6:]]
69
70
71# --- the web UI on, with this unit included ---------------------------------------
72u = unit_state()
73UID = u["id"]
74api("/api/units/" + UID, {"enabled": True, "fadeMs": 0})
75api("/api/connect", {"on": True})
76check("web UI connected to the unit", wait(lambda: unit_state()["status"] == "connected", 8))
77
78before = lamp_values()
79print("lamp before:", before)
80
81blender = net.Sender()
82blender.min_interval = 0.0
83blender.start()
84HOLDER = blender.holder
85
86
87def frame(vals):
88 return protocol.Frame(tuple(vals), 0)
89
90
91# Connect (force): Blender takes every slot to set the scene -- here the lamp's
92# own values, so nothing visibly changes -- then lets go when quiet.
93blender.send(UNIT, 1337, frame(before), force=True)
94check("Blender connected with proto 3", wait(lambda: blender.status_of(UNIT, 1337)[0] == "connected"))
95time.sleep(2.0)
96
97# 1. Blender changes slot 1 only.
98scene = list(before)
99scene[0:3] = [1500, 0, 0]
100blender.send(UNIT, 1337, frame(scene))
101check("web UI shows slot 1 held by Blender",
102 wait(lambda: (unit_state()["holds"][0] or {}).get("holder") == HOLDER), unit_state()["holds"])
103check("lamp slot 1 = Blender's value", wait(lambda: lamp_values()[0:3] == [1500, 0, 0]))
104check("Blender lets go after ~1.5 s quiet", wait(lambda: unit_state()["holds"][0] is None, 4))
105
106# 2. The web UI grabs slot 2; Blender's change to slot 2 is refused and reported.
107code, r = api("/api/units/%s/grab" % UID, {"index": 1, "session": "e2e", "name": "webtester"})
108check("web grabs slot 2", code == 200 and r == {"granted": True}, (code, r))
109scene[3:6] = [0, 1500, 0]
110blender.send(UNIT, 1337, frame(scene))
111check("Blender reports slot 2 held by webtester",
112 wait(lambda: "slot 2 held by webtester" in blender.status_of(UNIT, 1337)[1]), blender.status_of(UNIT, 1337))
113check("Blender did not change slot 2", lamp_values()[3:6] == before[3:6])
114
115# 3. The web user sets their colour and lets go; Blender must not stomp on it.
116code, _ = api("/api/units/%s/slot" % UID, {"index": 1, "session": "e2e", "r": 0, "g": 0, "b": 0.02, "level": 1})
117check("web sets slot 2", code == 200, code)
118api("/api/units/%s/release" % UID, {"index": 1, "session": "e2e"})
119check("web's release frees the slot", wait(lambda: unit_state()["holds"][1] is None))
120want = [int(round(0.02 * 65535)), 0, 0]
121check("lamp slot 2 = the web user's colour", wait(lambda: lamp_values()[3:6] == want), lamp_values()[3:6])
122time.sleep(1.0)
123check("Blender left it alone after the web user let go", lamp_values()[3:6] == want)
124check("web UI stored the released value", abs(unit_state()["slots"][1]["level"] - 0.02) < 0.001,
125 unit_state()["slots"][1])
126
127blender.stop()
128
129# Restore the lamp (legacy pwm only touches unheld slots; red clamped to the cap).
130time.sleep(0.5)
131s = socket.create_connection((UNIT, 1337), timeout=4)
132s.sendall(("pwm " + " ".join(str(min(v, 45000) if i % 3 == 2 else v) for i, v in enumerate(before)) + "\n").encode())
133time.sleep(0.5)
134s.close()
135
136print("\n%d failure(s)" % len(failures) if failures else "\nALL PASS")
137sys.exit(1 if failures else 0)
webui/README.md +20 -1
……@@ -25,6 +25,25 @@ Same model as the Blender addon (`../blender-addon`), so the two read alike:
2525 gamma/frequency/red cap. Looks: save all units' colours, recall with a fade.
2626- Blackout works connected or not (disconnected: one-shot, then lets go).
2727
28### Shared control (units on firmware proto 3)
29
30Several people (and Blender) can work one rig at once; the light unit referees
31each of its four slots, first come first served (`docs/DESIGN-collab.md`).
32
33- The lamp is the truth: connecting adopts what it shows and sends nothing;
34 `values` / `free` broadcasts keep the page current (no polling).
35- Touching a slot (picker, brightness) takes it; letting go (or 1 s without a
36 change) hands it back. Someone else's slot is greyed, says **held by X**, and
37 mirrors the lamp live. A refused grab snaps back.
38- All browser sessions of one server share its single socket to a unit, so the
39 server referees its own sessions before asking the unit.
40- When anyone lets go, the colour the lamp ended on becomes the slot's stored look.
41- Recalling a look takes all four slots of each unit, fades, and lets go; a unit
42 someone is holding is skipped and reported.
43- Set **You** in the header: that name is what others see.
44
45Units on older firmware behave as before (no grants).
46
2847LAN-only with no login, by decision. POSTs must carry `x-cthuloid: 1` with a
2948JSON body, which a cross-site page cannot send without a CORS preflight that this
3049server never answers.
……@@ -35,7 +54,7 @@ Settings and looks live in `~/.config/cthuloid-web/config.json`.
3554
3655```bash
3756npm install
38npm test # brittle-bare, 32 tests
57npm test # brittle-bare
3958bare index.js # 127.0.0.1:4050 by default
4059CTHULOID_WEB_HOST=0.0.0.0 bare index.js
4160```