OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / cf36d65

docs: collaborative control design (approved)
Light unit is the referee: per-slot first-come grants with 2 s idle / 30 s max / disconnect release, enforced in firmware. One autobee per rig, replicated in the controller apps (never firmware); a slot's stored value is the highest (epoch, grant), signed by the unit (ed25519, measured 9.4 ms per sign on studiolight). Live layer over Hyperswarm/protomux-rpc with LAN relay for off-LAN phones; intents for set-and-forget; explicit load guards on the lights; reusable presets; Android via the favela-surface-android template. Decisions D1-D10, phases in section 15. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cf36d6556b851a36e1eee6f297a138b0a4c646a7
by plankatron <fleeky@gmail.com> · 2026-10-02T14:49:56.000Z

Changed files (1) +355 -0

docs/DESIGN-collab.md +355 -0
……@@ -0,0 +1,355 @@
1# Cthuloid collaborative control — design
2
3Status: **APPROVED** (2026-10-02). Decisions D1–D10. Build follows §15.
4
5## 1. Goal
6
7Several people control the same lights at once, from the web UI, an Android app
8(same UI), and Blender, with:
9
10- a **shared durable state** everyone sees: each unit's slot colours and settings,
11 and reusable **colour presets**;
12- a **live layer** for dragging, where a slot being dragged is held by exactly one
13 person (first drag, first served) and everyone else sees it move and greyed out;
14- no single required server: any controller on the LAN can drive the lights with
15 the others down, and a phone off the LAN reaches the lights through any LAN peer.
16
17## 2. Decisions already made (with the user)
18
19| # | Decision |
20|---|---|
21| D1 | Durable state lives in an **autobee**; live drags never touch it. |
22| D2 | Per-**slot** exclusive grants, first drag first served. |
23| D3 | The **light unit is the referee** (see §3). |
24| D4 | Grants end on release, ~2 s without movement, a hard 30 s maximum, or disconnect — timers in the **firmware**. |
25| D5 | The UI refuses to drag a slot someone else holds (greyed, "held by X"); the unit also enforces it. |
26| D6 | Grant numbers order durable commits: the stored value is the one with the highest grant, not the latest in the log. |
27| D7 | Looks become **reusable colour presets** (apply to any unit or all ticked units). |
28| D8 | **Units sign** what becomes durable (ed25519, §5.7); apps verify in `apply`. |
29| D9 | **Open LAN for now**: no per-rig secret on the firmware (threat T1 accepted). |
30| D10 | Blender's changes become durable only while some bee controller is connected to that unit — accepted. |
31
32## 3. Why the referee is the light unit (audit summary)
33
34Four placements were audited against source and our own history:
35
36| | Unit firmware | Elected peer | No referee | Fixed server (stortron) |
37|---|---|---|---|---|
38| Enforces (not just advises) | **yes** — every write passes through it | no — any socket can send raw `pwm` | no | no |
39| Works with any controller down | **yes** | re-election gap | yes | **no** |
40| Split-brain | **impossible** (one owner per slot) | yes on partition — our own `atproto-room/lib/signer-presence.js:10-12` admits it | by design | no |
41| Blender can take part | **yes** (text lines) | no (can't join Hyperswarm presence) | yes | yes |
42| Cost | firmware + OTA | Bare code | lowest | low |
43
44The `atproto-room` history points the same way: its lease leaked ~20 MB/day by
45putting liveness into the log, then its presence election forked on partition and
46froze when an elected peer was behind; the final fix **removed the election** and
47let the outcome come from the one thing that already decides it. For lights that
48thing exists: the lamp. Hyperswarm/protomux have no presence primitive to lean on
49(only transport keepalive), and keet's call heartbeat (5 min) is far too coarse.
50
51## 4. Architecture
52
53```
54 Android app ──┐ (WebView → loopback HTTP → Bare worklet)
55 web UI page ──┼─► controller (Bare): autobee + live relay + unit links ─TCP 1337─► ESP32 units
56 Blender ──────┘ ▲ (referee, grants, fades)
57 │ (direct TCP to units, grant protocol)│ hyperswarm (protomux-rpc live channel,
58 └──────────────────────────────────────┘ autobee replication) between controllers
59```
60
61- **Controller** = the Bare process. Same code on stortron (as `cthuloid-web`,
62 pgh) and inside the Android app (worklet). It holds a corestore with the rig's
63 autobee, joins the swarm, keeps TCP links to the units it can reach, and serves
64 the page on HTTP + SSE.
65- **Page** = today's web UI, extended. Identical on desktop and phone.
66- **Blender** stays Python and talks to units directly using the grant protocol.
67 It is not a bee writer — its changes become durable via release broadcasts (§6.3).
68
69### 4.1 Two separate links
70
71The autobee lives **in the apps** (every controller holds a replica); the ESP32s
72never run it and never see it. There are two kinds of link, and the autobee is
73never on the link to the lights:
74
75| Link | Transport | Carries |
76|---|---|---|
77| app ↔ light | **plain TCP to port 1337** (as today); mDNS (UDP) only to find addresses | firmware text lines: `grant`, `slot`, `release`, `pwm`, `state` → and back `held`, `free`, state changes |
78| app ↔ app | **Hyperswarm** (encrypted, over UDP) | autobee replication (shared memory), the live relay for off-LAN phones |
79
80Apps **read** the bee and **send TCP** to lights; lights **answer over TCP**; apps
81**write** what happened into the bee.
82
83### 4.2 Walkthrough: one drag
84
851. Alice's phone (on the LAN) sends `grant 1 alice` over TCP straight to the lamp,
86 then `slot 1 …` values as she drags.
872. The lamp broadcasts `held 1 alice …` over TCP to every app connected to it; they
88 all grey out slot 1 and mirror its value.
893. She lets go → the lamp broadcasts `free 1 … <final value> release`.
904. Whichever apps receive that write the final value into the autobee (§6.3).
915. The bee syncs to every other app over Hyperswarm.
92
93Off the LAN, steps 1–3 go phone → Hyperswarm → a LAN app (e.g. stortron) → TCP →
94lamp (§7). Everything else is identical.
95
96## 5. Firmware: command surface v3
97
98Bump `CTHULOID_PROTO` to `3`; controllers check it before using grants.
99
100### 5.1 New state
101- `bootEpoch` (uint32, NVS): incremented **once per boot** (never per grant — no
102 flash wear). Refuse to grant if the NVS write failed.
103- per slot: `holderSocket`, `holderId` (≤ 32 chars), `grantNo` (uint32, RAM),
104 `lastMoveMs`, `grantedMs`.
105- **per-channel fade state** (today one unit-wide `pwm2Fade`; two people on
106 different slots would cancel each other's fades).
107
108### 5.2 Commands
109| Command | Effect |
110|---|---|
111| `grant <slot\|all> <holderId>` | if free (or held by this socket) → `granted <slot> <epoch> <n>`; else `busy <slot> <holderId>`. `all` is atomic: all four or none. |
112| `slot <s> <epoch> <n> <b> <g> <r> [fadeMs]` | applied **only** if this socket holds slot `s` with grant `(epoch,n)`; resets the idle timer. Else `ERR notheld <s>`. |
113| `release <slot\|all>` | ends the grant. |
114| `epochfloor <e>` | if `bootEpoch < e`: `bootEpoch = e + 1` (NVS) — recovery after reflash/NVS erase (§14 T2). |
115| `state` | adds `held <s> <holderId> <epoch> <n>` lines for held slots. |
116| legacy `pwm` / `pwm2` | still work, but only change **unheld** slots. |
117
118### 5.3 Broadcasts (to every connected socket)
119- `held <s> <holderId> <epoch> <n>`
120- `free <s> <epoch> <n> <b> <g> <r> <why>`, where `why` = `release|idle|max|disconnect|blackout`
121 — carries the **final value** so anyone can commit it (§6.3).
122- `values <v1> … <v12>` — the pins changed (from any source, fades included),
123 sent **at most every 250 ms**, so controllers never need to poll `state` (§9).
124- greeting becomes `<name> ready proto=3 epoch=<e>`.
125
126### 5.4 Timers (in `loop()`, `millis()` unsigned subtraction — wrap-safe)
127- idle: no `slot` from the holder for **2 s** → free (`idle`).
128- hard: **30 s** after grant → free (`max`).
129- holder socket gone → free (`disconnect`). Don't rely on `connected()` alone
130 (it lies after FIN; firmware comment) — the idle timer catches half-open sockets.
131- `stopfade`/blackout → frees every slot (`blackout`).
132
133### 5.5 Client slots
134`MAX_CLIENTS` 4 → **6**. When full, evict the least-recently-active socket that
135holds **no** grant; refuse the connection if all hold grants.
136
137### 5.6 Self-protection against chatter
138- **Coalesce:** within one `loop()` pass only the latest pending `slot` line per
139 slot is applied.
140- **Per-socket cap:** a socket sending more than 60 lines/s has the excess dropped
141 (counted, reported in `state`), so no client can monopolise the loop.
142- **Serial console quiet by default:** today every received byte is echoed to the
143 UART and each `pwm` prints 12 "Set pin" lines — roughly 30 ms of blocked loop per
144 command at 115200 baud (estimate from the code). v3 defaults `plevel` to `ack`
145 and `verbose` to 0 with no byte echo; both stay switchable at runtime for
146 debugging. (Debug output only — the INVERT and red-cap defines are untouched.)
147
148### 5.7 Signing (D8)
149- On first boot the unit makes an ed25519 keypair (libsodium, bundled with
150 arduino-esp32 3.3.8) and stores it in NVS; the greeting and `state` carry the
151 public key: `<name> ready proto=3 epoch=<e> pk=<hex>`.
152- **What is signed: the `free` broadcast**, i.e. the final value of a grant:
153 `free <s> <epoch> <n> <b> <g> <r> <why> <sig>` with
154 `sig = Sign(sk, "cthuloid/free/1" ‖ unitId ‖ s ‖ epoch ‖ n ‖ b ‖ g ‖ r)`.
155 That is the only thing that becomes durable, so signing it means no app can
156 store a value the lamp didn't actually end on, nor invent a grant number to lock
157 a slot. `held` lines are live-only and stay unsigned.
158- Cost (measured on studiolight, §16 Q1): one sign ≈ 9.4 ms per drag release,
159 never per drag frame. Verification (≈ 19.5 ms on the ESP32) never runs on the
160 unit — apps verify with sodium-native.
161
162### 5.8 Testing
163Grant/timer/fade logic goes in a header (`grants.h`) with no Arduino
164dependencies, compiled into a **host-side test** (g++) that drives it with a fake
165clock. The sketch includes it. Then OTA to studiolight and an end-to-end test.
166
167## 6. Durable state (autobee)
168
169### 6.1 Setup
170- One autobee per **rig** (the whole lighting setup, not per unit), replicated in
171 every controller app — never in firmware. `optimistic: false` (with the default `true`, anyone who
172 knows the key can write — measured).
173- One `store.namespace()` per autobee (two on one namespace deadlock —
174 favela-peer `lib/namespace.js:544`).
175- `apply` uses an in-memory overlay and flushes **once** per batch (the view is a
176 hyperbee; `view.get` doesn't see unflushed puts; double flush is destructive).
177- `apply` never reads a clock; any time comes from the op.
178
179### 6.2 Keys (hyperbee, JSON values)
180| Key | Value |
181|---|---|
182| `roster/<writerKey>` | `{ name, addedBy, hlc }` — app-level roster; apply ignores ops from non-roster writers |
183| `meta/founder` | founding marker (not `host.genesis` — it doesn't flip mid-batch) |
184| `unit/<unitId>` | `{ label, addr, port, redMax, fadeMs, gamma, freq, proto, hlc }` |
185| `unitkey/<unitId>` | `{ pk, by }` — the unit's public key, trust-on-first-use; changed only by an explicit `rekey` op (after a reflash that wiped NVS) |
186| `epoch/<unitId>` | highest boot epoch seen (for `epochfloor` recovery) |
187| `slot/<unitId>/<n>` | `{ r, g, b, level, epoch, grant, by }` — never deleted |
188| `preset/<name>` | `{ slots: [4 × {r,g,b,level}], by, hlc }` or tombstone `{ deleted: true, hlc }` |
189| `intent/<id>` | `{ unit, slots, values, basedOn, by, hlc, state: pending\|done\|skipped, result }` (§6.4) |
190
191### 6.3 Ops and apply rules
192- **`slot-commit {unit, slot, value, epoch, grant, by}`** → keep if `(epoch, grant)`
193 > stored; on an exact tie keep the stored one (a tie can only be the same grant
194 committed twice, which carries the same value). Deterministic under reorg —
195 verified: three partitioned writers converged on the highest grant.
196- **Who commits:** any bee writer connected to the unit that sees a `free`
197 broadcast commits its final value. Several will — duplicates are idempotent.
198 This is what makes Blender's changes durable, and survives a phone dying
199 mid-release.
200- **`unit-set`, `preset-put`, `preset-del`** → last-writer-wins by a **hybrid
201 logical clock in the op** (autobee's own order is weight-then-writer-clock, so a
202 weight-2 server would otherwise beat a later phone edit).
203- **`admit {key, name}`** (only from a roster writer) → `host.addWriter(key)` + roster
204 entry. **`remove {key}`** → `host.removeWriter` + roster tombstone.
205- **Signature check (D8, §5.7):** a `slot-commit` must carry the unit's signature over
206 `(unit, slot, epoch, grant, value)`, verified against `unitkey/<unit>`; anything
207 else is ignored. A forged grant number or a value the lamp never ended on can't
208 be stored. The first `slot-commit` for a unit with no `unitkey` records the key
209 carried in the op (trust on first use).
210- **`rekey {unit, pk}`** (roster writers only) replaces a unit's key after its NVS
211 was wiped; old commits stay valid (they were checked when applied).
212
213### 6.4 Intents — "set and forget" from anywhere
214
215Live forwarding (§7) needs both your device and a LAN peer online at the same
216time. An **intent** doesn't: you write the change into the bee and can go offline
217as soon as it has synced; whichever LAN controller sees it first carries it out.
218
219- Ops: `intent {id, unit, slots, values (or preset name), basedOn, by, hlc}`,
220 `intent-done {id, epoch, grant}`, `intent-skipped {id, reason}`.
221- **Execution:** a LAN controller connected to the unit takes the grant
222 (`grant all`, or the listed slots), sets the values with the unit's fade,
223 releases, and appends `intent-done` with the grant number. The value itself is
224 committed by the normal release-broadcast path.
225- **No double execution:** carrying it out needs the grant, so only one controller
226 can be doing it at a time; others get `busy` or see `intent-done` and skip. If
227 two still run it, they set the same target value — harmless.
228- **No stale overrides:** `basedOn` is the slot's `(epoch, grant)` when the intent
229 was made. If the slot has moved past that since, the intent is **skipped**
230 (`intent-skipped stale`), not applied — a phone offline for hours can't clobber
231 newer work.
232- Intents are **not** for drags (latency, log growth). Drags stay on the live layer.
233- Pending intents older than a configurable limit (default 1 h) are skipped.
234
235### 6.5 Growth
236No compaction exists in autobee. Measured ~465 B per write → a few hundred
237releases per evening ≈ 0.15 MB, ~50 MB/year per peer. Acceptable.
238
239## 7. Live layer (ephemeral)
240
241- Transport between controllers: **hyperswarm** on the rig's discovery key
242 (same-LAN peers connect directly via hyperdht's LAN shortcut; an off-LAN phone
243 holepunches to a LAN peer; `blind-relay` held in reserve for hard NATs).
244- One **protomux-rpc** channel `cthuloid/live/1` per connection:
245 - `hello {device, reaches: [unitId…]}`;
246 - `relay {unit, line}` → the LAN peer forwards a firmware line (`grant`, `slot`,
247 `release`) on its own unit socket and returns the reply;
248 - event `unit {unit, line}` → fan-out of every `held` / `free` / `state` line.
249- A controller on the LAN talks to units directly; it relays only for peers that
250 can't reach a unit. Nothing in this layer is ever written to the bee.
251- **Unit addresses:** LAN controllers on Linux use avahi (`_cthuloid._tcp`);
252 Android has no avahi, so the phone uses `unit/<id>.addr` from the bee and its
253 peers' `hello`.
254
255## 8. Re-asserting after a unit reboot
256
257A reboot changes the epoch in the greeting. Each connected controller then waits a
258short random delay (0–500 ms), asks `state` once, and pushes the durable value
259only for **unheld** slots that **differ** from what the lamp shows. The first
260controller fixes the lamp; the rest find it already matching and send nothing.
261
262## 9. Load on the lights
263
264The firmware is single-threaded; every message costs it loop time. Rules, by source:
265
266| Source of traffic | Guard |
267|---|---|
268| A drag (any app) | latest value only, ≤ 25 sends/s per unit, identical frames skipped, intermediate frames dropped — never queued (already in `webui/lib/unit.js`) |
269| Several apps connected to one unit | no polling: apps ask `state` **once** on connect, then follow `held` / `free` / `values` broadcasts (§5.3) |
270| Re-push after a unit reboot | check first, push only what differs, random delay (§8) |
271| Intents | executing needs the grant → one executor at a time (§6.4) |
272| Off-LAN relay | the forwarding peer applies the same latest-only / 25 Hz / dedupe rules before anything reaches the lamp |
273| Keepalive | one `hostname` per 60 s per socket, only when otherwise idle |
274| A buggy or flooding client | firmware coalescing + per-socket cap (§5.6) |
275| Serial console | quiet by default (§5.6) |
276| autobee writes | none — the bee never talks to a light |
277
278## 10. The page (web + Android)
279
280- One page, served by the local controller.
281- Per slot: swatch → Blender-style picker (done in 0.1.1). On pointer-down the
282 control moves at once and requests the grant; `busy` → snap back, show
283 "held by X". While held by someone else: greyed, live-mirrored values.
284- Per unit: Blackout, fade, settings, "apply preset".
285- **Presets:** save the four slots of a unit as a named preset; apply to one unit
286 or all ticked units (takes `grant all` per unit, sets with a fade, releases).
287- Pairing screen (invite code / QR), device list, remove device.
288
289## 11. Android app
290
291Template: our **favela-surface-android** — react-native 0.86 +
292react-native-bare-kit 0.14.5 + react-native-webview 14; worklet packed with
293`bare-pack --preset android`; the worklet runs the controller with HTTP bound to
294loopback; the WebView loads `http://127.0.0.1:<port>`; reuse its `lib/host/`
295lifecycle and OTA code. (Unix sockets are blocked by SELinux there — loopback TCP.)
296
297## 12. Pairing
298
299`blind-pairing` in the autopass pattern, adapted to autobee: an existing device
300creates an invite (stored via an op); the new device's candidate request is
301checked against it in `onadd`; the member appends `admit`; `confirm` returns the
302bee key. Drain swarm connections before `close()` or the confirm frame never
303leaves (favela-peer trap).
304
305## 13. Modules
306
307autobee ^2.1 (2.1.1 proven on Bare 1.28.7 in favela-peer; 2.11.x unverified),
308corestore ^7.12, hyperswarm ^4.17, protomux ^3.11, protomux-rpc ^1.10,
309blind-pairing ^2.3, compact-encoding, b4a; Android: react-native-bare-kit 0.14.5,
310react-native-webview 14, bare-pack. Keep the Bare 1.28 `overrides`; check
311`npm ls` for engines > 1.28 before every publish.
312
313## 14. Threats and limits
314
315- **T1 LAN abuse:** anyone on the LAN can open TCP 1337, grab slots, spoof holder
316 ids or fill client slots. Grants stop honest collisions, not attackers. (Q2)
317- **T2 Epoch regression:** a reflash or NVS erase resets `bootEpoch`, so new grants
318 would lose to old commits forever. Fix: controllers send `epochfloor` with the
319 bee's `epoch/<unit>` on connect.
320- **T3 Mixed versions:** a proto-2 unit has no grants — controllers fall back to
321 today's behaviour for it and show "no shared control".
322- **T4 Off-LAN control** needs at least one LAN controller up (inherent).
323
324## 15. Build phases (each tested and verified before the next)
325
3261. **Firmware v3** — `grants.h` + host tests; per-channel fades; `values`
327 broadcasts; coalescing + per-socket cap; quiet serial default; OTA studiolight; e2e.
3282. **Web UI live grants** — per-slot `grant`/`slot`/`release`, greying, "held by";
329 stop `state` polling (follow broadcasts); Blender addon speaks the grant
330 protocol. LAN only, no bee yet.
3313. **autobee** in the controller — durable slots via release broadcasts, presets,
332 HLC, roster, `epochfloor`, check-first re-push, intents.
3334. **Hyperswarm + live relay + pairing** — two controllers, then off-LAN phone
334 path (relay applies the same send limits).
3355. **Android app** from the favela-surface-android template.
336
337## 16. Questions (answered)
338
339- **Q1 Grant signing.** → **Sign** (D8, §5.7): the unit signs the final value of each grant.
340 **Feasibility verified 2026-10-02:** arduino-esp32 3.3.8 bundles libsodium for the
341 ESP32 (`libespressif__libsodium.a`, on the default link line; the four
342 `crypto_sign_ed25519_*` symbols present). Measured **on studiolight** (240 MHz, a
343 temporary OTA bench sketch, then the real firmware flashed back): keypair 8.7 ms
344 (once, first boot), **sign 9.4 ms avg** (9.35–9.69, n=40), verify 19.5 ms (apps
345 only, never on the unit), 0 bad. The unit signs once per grant, at its release —
346 never per drag frame. The bench build (WiFi + OTA + libsodium) was
347 1,089,055 bytes (83 % of the app partition); v3's real size is measured at build.
348- **Q2 LAN auth.** → **Open LAN for now** (D9).
349- **Q3 Blender durable writes.** → **Accepted** (D10).
350
351## 17. Knowledge-base corrections found during the audit
352
353The `autobee` skill says the apply `view` is a batch (it is the working hyperbee;
354`view.write()` gives a batch) and that `node.from.key` is wrong (it works; `from`
355is the core). To fix in `.claude/skills/autobee/SKILL.md`.