OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / 8f1a7a8

esp324xlight: proto 3 — the unit referees shared control
Per-slot grants (first come first served; release / 2 s idle / 30 s max / disconnect / stopfade), per-channel fades (the old unit-wide pwm2 fade let two people cancel each other), legacy pwm/pwm2 only touch unheld slots, signed `free` broadcasts carrying the final value (ed25519 via the core's libsodium, key in NVS), paced `values` broadcasts so nobody polls, per-boot epoch in NVS + `epochfloor` recovery, 6 clients with grant-aware eviction, 60 lines/s per socket, quiet serial by default. Decisions live in grants.h (no Arduino deps): 68 host checks with a fake clock, each of 7 deliberate mutations caught. e2e_v3.py: 23/23 on studiolight, incl. signature verify + tamper reject. Found on the way: core 3.3.8's NetworkClient::available() swallows a peer's close (0-byte recv read as "no data"), so a holder's disconnect was only seen by the idle timer. Client sockets are now read with recv() on the fd: close detected in 0.02 s (was 2.01 s). Proto-2 controllers keep working: greeting line 1 and the state line are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8f1a7a8edf27b3cd03c070e047006b910fc5b872
by plankatron <fleeky@gmail.com> · 2026-10-02T15:04:59.000Z

Changed files (6) +1340 -132

docs/DESIGN-collab.md +8 -6
……@@ -117,11 +117,13 @@ Bump `CTHULOID_PROTO` to `3`; controllers check it before using grants.
117117
118118### 5.3 Broadcasts (to every connected socket)
119119- `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).
120- `free <s> <epoch> <n> <b> <g> <r> <why> <holder> <sig>`, where `why` =
121 `release|idle|max|disconnect|blackout` — carries the **final value** so anyone
122 can commit it (§6.3); `holder` becomes the commit's `by`.
122123- `values <v1> … <v12>` — the pins changed (from any source, fades included),
123124 sent **at most every 250 ms**, so controllers never need to poll `state` (§9).
124- greeting becomes `<name> ready proto=3 epoch=<e>`.
125- greeting line 1 stays `<name> ready` (proto-2 controllers keep parsing it);
126 line 2 is `hello proto=3 unit=<name> epoch=<e> pk=<hex>`.
125127
126128### 5.4 Timers (in `loop()`, `millis()` unsigned subtraction — wrap-safe)
127129- idle: no `slot` from the holder for **2 s** → free (`idle`).
……@@ -147,10 +149,10 @@ holds **no** grant; refuse the connection if all hold grants.
147149
148150### 5.7 Signing (D8)
149151- 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 arduino-esp32 3.3.8) and stores it in NVS; the `hello` line and `state`'s
153 `stats` line carry the public key. (Verified: it survives an OTA reflash.)
152154- **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
155 `free <s> <epoch> <n> <b> <g> <r> <why> <holder> <sig>` with
154156 `sig = Sign(sk, "cthuloid/free/1" ‖ unitId ‖ s ‖ epoch ‖ n ‖ b ‖ g ‖ r)`.
155157 That is the only thing that becomes durable, so signing it means no app can
156158 store a value the lamp didn't actually end on, nor invent a grant number to lock
esp324xlight/README.md +52 -0
……@@ -185,6 +185,58 @@ state <fadeActive 0|1> <freq> <gammaB> <gammaG> <gammaR> <v1> ... <v12>
185185`v1..v12` are the current values in protocol space (0–65535, BGR order), live
186186during a `pwm2` fade. Added in command-surface version 2 (`CTHULOID_PROTO`).
187187
188Since proto 3 it is followed by one `held <s> <holder> <epoch> <n>` line per
189held slot and `stats epoch=<e> dropped=<n> pk=<hex>`.
190
191### Shared control (proto 3)
192
193Several controllers (web UI, phones, Blender) can drive one unit at once. The
194unit is the referee: each of the four slots can be **held** by one controller at
195a time, first come first served. Full design: `docs/DESIGN-collab.md` §5. The
196logic lives in `grants.h` (no Arduino deps) and is host-tested:
197
198```bash
199g++ -std=c++17 -I. test/grants_test.cpp -o /tmp/grants_test && /tmp/grants_test
200python3 test/e2e_v3.py <unit-ip> # against a real unit (needs PyNaCl)
201```
202
203On connect the unit sends `<name> ready` (unchanged) then
204`hello proto=3 unit=<name> epoch=<e> pk=<hex>`.
205
206| Command | Reply |
207|---|---|
208| `grant <0-3\|all> <holderId>` | `granted <s> <epoch> <n>` per slot, or `busy <s> <holder>`. `all` is atomic. |
209| `slot <s> <epoch> <n> <b> <g> <r> [fadeMs]` | silent on success (sent ~25×/s while dragging); `ERR notheld <s>` if this socket doesn't hold that grant |
210| `release <0-3\|all>` | `ERR notheld` if not held |
211| `epochfloor <e>` | `epoch <e'>` — raises the boot epoch past `e` (recovery after NVS was wiped) |
212
213A grant ends on `release`, **2 s** without a `slot` from the holder, **30 s**
214after it was granted, the holder's socket closing, or `stopfade` (Blackout).
215
216Broadcasts to every connected client:
217
218- `held <s> <holder> <epoch> <n>`
219- `free <s> <epoch> <n> <b> <g> <r> <why> <holder> <sig>` — `why` is
220 `release|idle|max|disconnect|blackout`; `<b g r>` is the value the slot ends
221 on; `sig` is ed25519 (hex) over the ASCII
222 `cthuloid/free/1 <unit> <s> <epoch> <n> <b> <g> <r>`, by the unit's key
223 (made on first boot, kept in NVS). Controllers store only verified frees.
224- `values <v1> … <v12>` — at most every 250 ms when the pins changed, so
225 controllers never need to poll `state`.
226
227Legacy `pwm` / `pwm2` still work but only change **unheld** slots. Fades are
228per channel. Each socket may send ≤ 60 lines/s (excess dropped, counted in
229`stats`). Up to 6 clients; when full, the stalest socket that holds **no**
230grant is evicted, or the newcomer is refused if every socket holds one.
231
232Since proto 3 the serial console is quiet by default (`verbose 0`, `plevel ack`,
233no byte echo): each echoed `pwm` used to cost ~30 ms of loop. `verbose 1` and
234`plevel info` bring it back.
235
236Client sockets are read with `recv()` on the fd directly: core 3.3.8's
237`available()` swallows a peer's close (it treats the 0-byte read as "no data"),
238so a holder's disconnect went unnoticed until the idle timer.
239
188240#### `freq [hz]`
189241Get or set the PWM frequency. With no argument, prints the current frequency.
190242With one argument, sets it (range 1 to `maxfreq`, currently 40000).
esp324xlight/esp324xlight.ino +354 -126
……@@ -11,6 +11,10 @@
1111#include <WiFiClient.h>
1212#include <ArduinoOTA.h>
1313#include <Preferences.h>
14#include <sodium.h>
15#include <lwip/sockets.h>
16#include <errno.h>
17#include "grants.h" // shared-control engine; host-tested in test/grants_test.cpp
1418
1519// Default hostname compiled into firmware. Runtime hostname is loaded from NVS
1620// on boot; if NVS is empty (first boot after a fresh flash), this is the
……@@ -19,8 +23,9 @@
1923
2024// Version of the TCP command surface, advertised in the _cthuloid._tcp mDNS TXT
2125// record so a controller can tell what a unit understands before sending to it.
22// 2 = adds `state`.
23#define CTHULOID_PROTO "2"
26// 2 = adds `state`. 3 = shared control: per-slot grants, signed `free`,
27// `values` broadcasts (docs/DESIGN-collab.md §5).
28#define CTHULOID_PROTO "3"
2429
2530// Password required to push OTA updates. Change before flashing if you don't
2631// want anyone on the WiFi network to be able to overwrite the firmware.
……@@ -51,9 +56,6 @@ const int timeout = 30;
5156#define RED_MAX_PWM 45000
5257#define MAX_PWM 65535 // full 16-bit range; LEDC handles duty=max via max+1 special case
5358
54constexpr int NumPeriods = 3; // number of steps in fade
55constexpr int PeriodLength = 1; // sleep for this many ms
56
5759//////////////////////////////////////////////////////////////////////////////////////
5860// 1 kHz matches the bandwidth of an ESP32-direct-driven MOSFET (no gate-driver
5961// IC). At higher freqs the MOSFET spends a non-trivial fraction of each cycle
……@@ -81,7 +83,20 @@ void computeRes() {
8183
8284const int PWMPins[] = {32, 33, 25, 26, 27, 14, 12, 13, 2, 23, 22, 21};
8385const int NumPWMPins = sizeof(PWMPins) / sizeof(*PWMPins);
84int PinCurrentValues[NumPWMPins] = {0};
86
87// Grants, timers, fades and the current/target value of every channel live in
88// the engine; the sketch only writes what it says to the pins.
89cth::Engine engine;
90uint16_t PinsWritten[NumPWMPins];
91bool pinsWrittenValid = false;
92
93// This unit's signing identity (NVS). It signs every `free` -- the only thing
94// that becomes durable -- so controllers can reject a forged grant number or a
95// value the lamp never ended on (DESIGN-collab.md §5.7).
96unsigned char unitPk[crypto_sign_PUBLICKEYBYTES];
97unsigned char unitSk[crypto_sign_SECRETKEYBYTES];
98char unitPkHex[2 * crypto_sign_PUBLICKEYBYTES + 1];
99uint32_t bootEpoch = 0;
85100
86101// tracks whether ledcAttach has been called for each pin yet
87102bool PinAttached[NumPWMPins] = {false};
……@@ -105,7 +120,9 @@ const char *MsgPWMTupleFormatError = "Error: takes up to 6 arguments between 0 a
105120// bytes from two clients as they arrive would splice two commands together and
106121// corrupt both. Bytes are accumulated per client and only handed over once a
107122// complete line has arrived, which keeps every command atomic.
108const int MAX_CLIENTS = 4;
123// 6, not 4: stortron, Blender and a few phones must fit without anyone being
124// evicted; eviction never picks a socket that holds a grant (cth::pickSlot).
125const int MAX_CLIENTS = 6;
109126const int CLIENT_BUF_LEN = 160; // longest real command is a 12-value pwm2
110127
111128struct ClientSlot {
……@@ -113,6 +130,7 @@ struct ClientSlot {
113130 unsigned long lastByteMs = 0;
114131 char buf[CLIENT_BUF_LEN];
115132 int len = 0;
133 cth::RateCap cap; // per-socket flood guard (60 lines/s)
116134};
117135ClientSlot clients[MAX_CLIENTS];
118136
……@@ -120,20 +138,50 @@ ClientSlot clients[MAX_CLIENTS];
120138// NULL means the command came from the serial console, so replies go to Serial
121139// alone.
122140WiFiClient *ActiveClient = NULL;
141int ActiveIndex = -1; // client slot of the active command, -1 for serial
123142
124143const unsigned long CLIENT_IDLE_TIMEOUT_MS = 300000; // 5 min — drop a dead session
125144
145// Client sockets are READ directly with recv() on the fd, never through
146// NetworkClient's available()/read()/connected(). Measured on studiolight
147// (core 3.3.8): when a peer closes, available() refills its buffer with recv(),
148// gets 0 (the FIN) and treats it as "no data" -- the close is swallowed and
149// nothing can see it afterwards; connected() then reads a stale errno and keeps
150// saying "connected" (arduino-esp32 #1527). A grant holder that disconnected was
151// only noticed by the 2 s idle timer. Owning the read, 0 always means "closed".
152// int, not an enum: the Arduino builder hoists function prototypes above any
153// type declared in the sketch.
154const int READ_MORE = 0, READ_IDLE = 1, READ_CLOSED = 2;
155int readSome(WiFiClient &c, uint8_t *buf, int cap, int &got) {
156 got = 0;
157 int fd = c.fd();
158 if (fd < 0) return READ_CLOSED;
159 errno = 0;
160 int res = recv(fd, buf, cap, MSG_DONTWAIT);
161 if (res > 0) { got = res; return READ_MORE; }
162 if (res == 0) return READ_CLOSED;
163 return (errno == EWOULDBLOCK || errno == EAGAIN) ? READ_IDLE : READ_CLOSED;
164}
165
126166// Write to every connected client, for messages not tied to one request.
127167void broadcastLine(const char *s) {
128168 for (int i = 0; i < MAX_CLIENTS; i++) {
129 if (clients[i].sock && clients[i].sock.connected()) clients[i].sock.println(s);
169 if (clients[i].sock) clients[i].sock.println(s);
130170 }
131171}
132172
173// Turn engine events into broadcasts. A `free` is signed: it carries the value
174// the slot ends on, which is the only thing controllers store, so they verify it
175// came from this unit (DESIGN-collab.md §5.7). ~9.4 ms per signature, measured
176// on studiolight -- once per release, never per drag frame.
177void broadcastEvents();
178
133179// When true, command handlers also write GOT/OK/ERR markers to the active telnet
134// client so you can see whether commands are reaching the parser. Toggle at runtime
135// with "verbose 0" or "verbose 1". Default on for debugging.
136bool diagVerbose = true;
180// client, and every received byte is echoed to the serial console, so you can see
181// whether commands are reaching the parser. Toggle at runtime with "verbose 0" or
182// "verbose 1". Default OFF since proto 3: the byte echo and markers cost ~30 ms of
183// loop per pwm at 115200 baud, on every command from every controller.
184bool diagVerbose = false;
137185
138186// Per-channel gamma exponents (indexed by RGB triple position: 0=Blue, 1=Green, 2=Red,
139187// matching Blender's BGR send order). Used by pwm2's perceptual interpolation. Higher
……@@ -180,22 +228,10 @@ void cmdHostname();
180228void cmdReboot();
181229void cmdStopFade();
182230void cmdState();
183void updateFade();
184
185// --- non-blocking pwm2 fade state ---
186// cmdPWMPins2 fills this and returns immediately; updateFade() (called every
187// loop()) advances the interpolation based on wall-clock time and writes the
188// pins. PinCurrentValues stays live during the fade, so a new pwm2 mid-fade
189// picks up from where the LED actually is.
190struct Pwm2Fade {
191 bool active = false;
192 unsigned long startMs = 0;
193 unsigned long totalMs = 0;
194 int count = 0;
195 float perceptualStart[NumPWMPins];
196 float perceptualEnd [NumPWMPins];
197};
198Pwm2Fade pwm2Fade;
231void cmdGrant();
232void cmdSlot();
233void cmdRelease();
234void cmdEpochFloor();
199235
200236SerialCommand::Entry CommandsList[] = {
201237 {"plevel", cmdSetPrintLevel},
……@@ -209,6 +245,10 @@ SerialCommand::Entry CommandsList[] = {
209245 {"reboot", cmdReboot},
210246 {"stopfade", cmdStopFade},
211247 {"state", cmdState},
248 {"grant", cmdGrant},
249 {"slot", cmdSlot},
250 {"release", cmdRelease},
251 {"epochfloor", cmdEpochFloor},
212252 {NULL, NULL}
213253};
214254SerialCommand CmdMgr(CommandsList, cmdUnrecognized);
……@@ -371,56 +411,41 @@ void cmdPWMPins2() {
371411 diagPrint((long)(numPeriods * periodLength));
372412 diagPrintln(" ms");
373413
374 // --- store fade state and return immediately ---
375 // Interpolation is done in perceptual space (input^(1/gamma)) so perceived
376 // brightness changes linearly over time. Per-channel gamma (channelGamma[i%3])
377 // lets us slow more-efficient diodes (red) relative to less-efficient ones.
378 // PinCurrentValues — kept live by updateFade() — is the start point, so a new
379 // pwm2 mid-fade picks up from where the LED actually is, not where the prior
380 // fade started.
381 for (int i = 0; i < count; i++) {
382 float invG = 1.0f / channelGamma[i % 3];
383 pwm2Fade.perceptualStart[i] = powf(PinCurrentValues[i] / 65535.0f, invG);
384 pwm2Fade.perceptualEnd[i] = powf(channelValues[i] / 65535.0f, invG);
385 }
386 pwm2Fade.count = count;
387 pwm2Fade.totalMs = (unsigned long)numPeriods * (unsigned long)periodLength;
388 pwm2Fade.startMs = millis();
389 pwm2Fade.active = true;
414 // --- hand to the engine and return immediately ---
415 // Per-CHANNEL perceptual fades (input^(1/gamma)), each starting from where its
416 // LED actually is. Channels of a slot someone holds are skipped: a whole-unit
417 // pwm2 must not override a drag in progress.
418 uint32_t vals[NumPWMPins];
419 for (int i = 0; i < count; i++) vals[i] = (uint32_t)channelValues[i];
420 unsigned long totalMs = (unsigned long)numPeriods * (unsigned long)periodLength;
421 engine.legacy(vals, count, totalMs, millis());
390422
391423 printAck("OK pwm2 ");
392424 printAck(count);
393425 printAck(" pins over ");
394 printAck((long)pwm2Fade.totalMs);
426 printAck((long)totalMs);
395427 printlnAck(" ms");
396428 diagPrint("OK pwm2 ");
397429 diagPrintln((long)count);
398430}
399431
400// Called every loop() iteration. Advances any active pwm2 fade by elapsed
401// wall-clock time and writes the pins. Cheap when inactive — single branch.
402void updateFade() {
403 if (!pwm2Fade.active) return;
404 unsigned long elapsed = millis() - pwm2Fade.startMs;
405 bool done = elapsed >= pwm2Fade.totalMs;
406 float t = done ? 1.0f : (elapsed / (float)pwm2Fade.totalMs);
407
408 for (int i = 0; i < pwm2Fade.count; i++) {
409 float p = pwm2Fade.perceptualStart[i]
410 + (pwm2Fade.perceptualEnd[i] - pwm2Fade.perceptualStart[i]) * t;
411 float lum = powf(p, channelGamma[i % 3]);
412 unsigned long v = (unsigned long)(lum * 65535.0f);
413 if (v > 65535) v = 65535;
414 PinCurrentValues[i] = v;
415 myHRWrite(PWMPins[i], v);
432// Write the engine's current values to any pin that differs from what was last
433// written. Called every loop(); 12 compares when nothing moved.
434void writePins() {
435 const uint16_t *v = engine.values();
436 for (int i = 0; i < NumPWMPins; i++) {
437 if (pinsWrittenValid && PinsWritten[i] == v[i]) continue;
438 myHRWrite(PWMPins[i], v[i]);
439 PinsWritten[i] = v[i];
416440 }
417 if (done) pwm2Fade.active = false;
441 pinsWrittenValid = true;
418442}
419443
420// "stopfade" — freeze the rig at whatever values are currently on the pins.
421// Useful for live shows (hold this look) and remote recovery.
444// "stopfade" — freeze the rig at whatever values are currently on the pins, and
445// end every grant. Useful for live shows (hold this look) and remote recovery;
446// Blackout sends it first, so it overrides anyone's drag.
422447void cmdStopFade() {
423 pwm2Fade.active = false;
448 engine.stopAll();
424449 printlnAck("OK stopfade");
425450 diagPrintln("OK stopfade");
426451}
……@@ -429,25 +454,158 @@ void cmdStopFade() {
429454// not send the last command (e.g. a web UI while Blender drives the rig) can show
430455// the truth. One line, always sent (not gated by verbose/plevel):
431456// state <fadeActive 0|1> <freq> <gammaB> <gammaG> <gammaR> <v1> ... <v12>
432// v1..v12 are PinCurrentValues in protocol space (0..65535, BGR order), live
457// v1..v12 are the engine's current values (0..65535, BGR order), live
433458// during a pwm2 fade.
459//
460// Proto 3 follows it with one `held <s> <holder> <epoch> <n>` line per held slot
461// and a `stats epoch=<e> dropped=<n> pk=<hex>` line, so a controller that joins
462// late learns who holds what without waiting for the next broadcast.
463void replyLine(const char *buf) {
464 Serial.println(buf);
465 if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf);
466}
467
434468void cmdState() {
435469 char buf[200];
470 const uint16_t *v = engine.values();
436471 int n = snprintf(buf, sizeof(buf), "state %d %d %.2f %.2f %.2f",
437 pwm2Fade.active ? 1 : 0, freq,
472 engine.anyFading() ? 1 : 0, freq,
438473 channelGamma[0], channelGamma[1], channelGamma[2]);
439474 for (int i = 0; i < NumPWMPins && n < (int)sizeof(buf); i++) {
440 n += snprintf(buf + n, sizeof(buf) - n, " %d", PinCurrentValues[i]);
475 n += snprintf(buf + n, sizeof(buf) - n, " %u", (unsigned)v[i]);
441476 }
442 Serial.println(buf);
443 if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf);
477 replyLine(buf);
478 for (int s = 0; s < cth::SLOTS; s++) {
479 const char *who;
480 uint32_t gn;
481 if (!engine.held(s, who, gn)) continue;
482 snprintf(buf, sizeof(buf), "held %d %s %lu %lu", s, who,
483 (unsigned long)engine.epoch(), (unsigned long)gn);
484 replyLine(buf);
485 }
486 unsigned long dropped = 0;
487 for (int i = 0; i < MAX_CLIENTS; i++) dropped += clients[i].cap.dropped;
488 snprintf(buf, sizeof(buf), "stats epoch=%lu dropped=%lu pk=%s",
489 (unsigned long)engine.epoch(), dropped, unitPkHex);
490 replyLine(buf);
491}
492
493// Parse a whole decimal token into [0, max]. False on junk or out of range.
494bool parseU32(const char *arg, uint32_t max, uint32_t &out) {
495 if (!arg || !*arg) return false;
496 char *end;
497 unsigned long v = strtoul(arg, &end, 10);
498 if (*end != '\0' || v > max) return false;
499 out = (uint32_t)v;
500 return true;
501}
502
503// slot argument: 0..3 or "all"
504bool parseSlot(const char *arg, int &slot) {
505 if (arg && strcmp(arg, "all") == 0) { slot = cth::ALL; return true; }
506 uint32_t v;
507 if (!parseU32(arg, cth::SLOTS - 1, v)) return false;
508 slot = (int)v;
509 return true;
510}
511
512// grant <slot|all> <holderId> -> `granted <s> <epoch> <n>` per slot, or
513// `busy <s> <holder>`. First come, first served; the unit is the referee.
514void cmdGrant() {
515 int slot;
516 if (ActiveIndex < 0) { replyLine("ERR grant needs a network client"); return; }
517 if (!parseSlot(CmdMgr.next(), slot)) { replyLine("ERR grant <0-3|all> <holderId>"); return; }
518 const char *holder = CmdMgr.next();
519 if (!holder || !*holder || strlen(holder) >= (size_t)cth::HOLDER_LEN) {
520 replyLine("ERR grant <0-3|all> <holderId>");
521 return;
522 }
523 int busy;
524 char buf[80];
525 switch (engine.grant(slot, ActiveIndex, holder, millis(), busy)) {
526 case cth::Engine::Granted: {
527 int lo = slot == cth::ALL ? 0 : slot, hi = slot == cth::ALL ? cth::SLOTS - 1 : slot;
528 for (int s = lo; s <= hi; s++) {
529 const char *who;
530 uint32_t gn;
531 engine.held(s, who, gn);
532 snprintf(buf, sizeof(buf), "granted %d %lu %lu", s, (unsigned long)engine.epoch(), (unsigned long)gn);
533 replyLine(buf);
534 }
535 break;
536 }
537 case cth::Engine::Busy: {
538 const char *who = "";
539 uint32_t gn;
540 engine.held(busy, who, gn);
541 snprintf(buf, sizeof(buf), "busy %d %s", busy, who);
542 replyLine(buf);
543 break;
544 }
545 case cth::Engine::Disabled:
546 replyLine("ERR grants disabled (epoch not persisted)");
547 break;
548 default:
549 replyLine("ERR grant bad slot");
550 }
551}
552
553// slot <s> <epoch> <n> <b> <g> <r> [fadeMs] — silent on success (it arrives up
554// to 25 times a second during a drag; an ack each time would double the
555// traffic). Errors are always reported.
556void cmdSlot() {
557 uint32_t s, e, gn, b, g, r, fadeMs = 0;
558 bool ok = parseU32(CmdMgr.next(), cth::SLOTS - 1, s) &&
559 parseU32(CmdMgr.next(), 0xFFFFFFFFu, e) &&
560 parseU32(CmdMgr.next(), 0xFFFFFFFFu, gn) &&
561 parseU32(CmdMgr.next(), MAX_PWM, b) &&
562 parseU32(CmdMgr.next(), MAX_PWM, g) &&
563 parseU32(CmdMgr.next(), RED_MAX_PWM, r);
564 const char *f = ok ? CmdMgr.next() : NULL;
565 if (ok && f && !parseU32(f, cth::MAX_FADE_MS, fadeMs)) ok = false;
566 if (!ok) { replyLine("ERR slot <0-3> <epoch> <n> <b> <g> <r<=45000> [fadeMs]"); return; }
567 char buf[48];
568 switch (engine.set((int)s, ActiveIndex, e, gn, b, g, r, fadeMs, millis())) {
569 case cth::Engine::SetOk: break;
570 case cth::Engine::NotHeld:
571 snprintf(buf, sizeof(buf), "ERR notheld %lu", (unsigned long)s);
572 replyLine(buf);
573 break;
574 default:
575 replyLine("ERR slot value");
576 }
577}
578
579void cmdRelease() {
580 int slot;
581 if (!parseSlot(CmdMgr.next(), slot)) { replyLine("ERR release <0-3|all>"); return; }
582 if (!engine.release(slot, ActiveIndex, millis())) replyLine("ERR notheld");
583}
584
585// epochfloor <e> — a controller saw this unit use epoch <e> before (its bee
586// remembers). If our epoch is not above it -- NVS was wiped or the unit was
587// reflashed -- jump past it, so new grants can't lose to old commits forever.
588void cmdEpochFloor() {
589 uint32_t e;
590 if (!parseU32(CmdMgr.next(), 0xFFFFFFFEu, e)) { replyLine("ERR epochfloor <e>"); return; }
591 if (engine.epoch() <= e) {
592 uint32_t next = e + 1;
593 if (prefs.putUInt("epoch", next) == sizeof(uint32_t)) {
594 bootEpoch = next;
595 engine.setEpoch(next);
596 } else {
597 replyLine("ERR epochfloor: NVS write failed");
598 return;
599 }
600 }
601 char buf[48];
602 snprintf(buf, sizeof(buf), "epoch %lu", (unsigned long)engine.epoch());
603 replyLine(buf);
444604}
445605
446606// expects space-delimited ints 0-65535, or space delimited <index>:<pwm> pairs
447607void cmdPWMPins() {
448608 diagPrintln("GOT pwm");
449 // Hard-write cancels any in-flight pwm2 fade so updateFade() doesn't fight us.
450 pwm2Fade.active = false;
451609 const char *SetPWMUsageMsg = "Error: takes up to 12 arguments between 0 and 65535 (except argument 3's max is 45000).";
452610
453611 long channelValues[NumPWMPins];
……@@ -485,22 +643,11 @@ void cmdPWMPins() {
485643 count++;
486644 } while ((arg = CmdMgr.next()));
487645
488 // interpolate over a period of time
489 const TickType_t xDelay = PeriodLength / portTICK_PERIOD_MS;
490
491 for (int period = 1; period < 1+NumPeriods; period++) {
492 float fraction = period / (float)NumPeriods;
493 for (int i = 0; i < count; i++) {
494 unsigned long v = PinCurrentValues[i] + (channelValues[i] - PinCurrentValues[i]) * fraction;
495 myHRWrite(PWMPins[i], v);
496 }
497 }
498
499 for (int i = 0; i < count; i++) {
500 unsigned long v = channelValues[i];
501 PinCurrentValues[i] = v;
502 myHRWrite(PWMPins[i], v);
503 }
646 // Immediate write (cancels any fade on those channels), skipping channels of
647 // a slot someone holds. Pins are written by writePins() in loop().
648 uint32_t vals[NumPWMPins];
649 for (int i = 0; i < count; i++) vals[i] = (uint32_t)channelValues[i];
650 engine.legacy(vals, count, 0, millis());
504651
505652 printAck("OK set ");
506653 printAck(count);
……@@ -532,6 +679,7 @@ void cmdGamma() {
532679 float v = strtof(arg, NULL);
533680 if (v >= 0.5f && v <= 5.0f) channelGamma[i] = v;
534681 }
682 for (int i = 0; i < 3; i++) engine.gamma[i] = channelGamma[i];
535683 char buf[80];
536684 snprintf(buf, sizeof(buf), "gamma B=%.2f G=%.2f R=%.2f",
537685 channelGamma[0], channelGamma[1], channelGamma[2]);
……@@ -627,12 +775,36 @@ void setup() {
627775 Serial.print("Hostname: ");
628776 Serial.println(currentHostname);
629777
778 // Console quiet by default since proto 3 (see diagVerbose); `plevel info`
779 // brings the per-pin lines back for debugging.
780 PrintLevel::set("ack");
781
782 // Boot epoch: +1 every boot, persisted BEFORE any grant is issued. Grant
783 // numbers are (epoch, counter), and a commit with a higher one wins, so an
784 // epoch that failed to persist could make new grants lose to old commits --
785 // refuse grants instead (legacy pwm still works).
786 bootEpoch = prefs.getUInt("epoch", 0) + 1;
787 bool epochSaved = prefs.putUInt("epoch", bootEpoch) == sizeof(uint32_t);
788 Serial.printf("Boot epoch %lu%s\n", (unsigned long)bootEpoch, epochSaved ? "" : " (NOT persisted: grants off)");
789
790 // Signing keypair: made once, kept in NVS for the life of the unit.
791 if (sodium_init() < 0) Serial.println("sodium_init failed");
792 if (prefs.getBytesLength("edsk") != sizeof(unitSk) ||
793 prefs.getBytes("edsk", unitSk, sizeof(unitSk)) != sizeof(unitSk) ||
794 prefs.getBytes("edpk", unitPk, sizeof(unitPk)) != sizeof(unitPk)) {
795 crypto_sign_ed25519_keypair(unitPk, unitSk);
796 prefs.putBytes("edsk", unitSk, sizeof(unitSk));
797 prefs.putBytes("edpk", unitPk, sizeof(unitPk));
798 Serial.println("Generated a new signing keypair");
799 }
800 for (size_t i = 0; i < sizeof(unitPk); i++) sprintf(unitPkHex + 2 * i, "%02x", unitPk[i]);
801
802 engine.begin(bootEpoch, epochSaved);
803
630804 ledsetup();
631805 // Init pins to 0 — with INVERT, that's dutyMax → pin high → LEDs off.
632 for (int i=0; i < NumPWMPins; i++){
633 myHRWrite(PWMPins[i], 0);
634 PinCurrentValues[i] = 0;
635 }
806 engine.forceAll(0);
807 writePins();
636808 // Kick off WiFi here; loop() will poll until associated. Keeping setup()
637809 // short avoids the TG1 task watchdog firing on a slow cold boot.
638810 WiFi.begin(ssid, password);
……@@ -660,10 +832,8 @@ void loop() {
660832 const unsigned long FADE_TOTAL_MS = 10000;
661833 unsigned long elapsed = millis() - fadeStartMs;
662834 if (elapsed >= FADE_TOTAL_MS) {
663 for (int i = 0; i < NumPWMPins; i++) {
664 myHRWrite(PWMPins[i], 65535); // off
665 PinCurrentValues[i] = 65535;
666 }
835 engine.forceAll(65535); // unchanged pre-v3 boot behaviour
836 writePins();
667837 Serial.println("Boot fade test complete");
668838 initState = WAIT_WIFI;
669839 break;
……@@ -741,7 +911,7 @@ void loop() {
741911 break;
742912
743913 case RUNNING: {
744 updateFade(); // advance any in-flight pwm2 fade
914 engine.tick(millis()); // fades, grant idle/max expiry
745915 CmdMgr.readSerial();
746916 ArduinoOTA.handle();
747917
……@@ -757,31 +927,41 @@ void loop() {
757927 }
758928
759929 if (serverStarted) {
760 // --- accept into a free slot; never evict a healthy client ---
930 // --- accept: a free slot, else the stalest socket holding NO grant ---
761931 WiFiClient incoming = server.accept();
762932 if (incoming) {
763 int slot = -1;
933 bool occupied[MAX_CLIENTS], holds[MAX_CLIENTS];
934 uint32_t last[MAX_CLIENTS];
764935 for (int i = 0; i < MAX_CLIENTS; i++) {
765 if (!clients[i].sock || !clients[i].sock.connected()) { slot = i; break; }
936 occupied[i] = (bool)clients[i].sock; // closed sockets are reset below
937 holds[i] = occupied[i] && engine.holdsAny(i);
938 last[i] = clients[i].lastByteMs;
766939 }
940 int slot = cth::pickSlot(occupied, last, holds, MAX_CLIENTS, millis());
767941 if (slot < 0) {
768 // Genuinely full: drop the least recently active, which is the one
769 // most likely to be dead. Only reachable with MAX_CLIENTS live
770 // sessions, not on every new connection as before.
771 unsigned long oldest = ~0UL; // avoids depending on <limits.h>
772 slot = 0;
773 for (int i = 0; i < MAX_CLIENTS; i++) {
774 if (clients[i].lastByteMs < oldest) { oldest = clients[i].lastByteMs; slot = i; }
942 // Every socket is mid-drag: refuse the newcomer rather than drop a drag.
943 incoming.println("busy all clients hold grants");
944 incoming.stop();
945 } else {
946 if (clients[slot].sock) {
947 Serial.printf("Client slots full — evicting slot %d\n", slot);
948 clients[slot].sock.stop();
949 engine.socketClosed(slot);
775950 }
776 Serial.printf("Client slots full — evicting slot %d\n", slot);
777 clients[slot].sock.stop();
951 clients[slot].sock = incoming;
952 clients[slot].lastByteMs = millis();
953 clients[slot].len = 0;
954 clients[slot].cap = cth::RateCap();
955 Serial.printf("New client in slot %d\n", slot);
956 // Line 1 is unchanged from proto 2, so older controllers still parse
957 // it; proto 3 details follow on a line of their own.
958 clients[slot].sock.print(currentHostname);
959 clients[slot].sock.println(" ready");
960 char hello[160];
961 snprintf(hello, sizeof(hello), "hello proto=%s unit=%s epoch=%lu pk=%s",
962 CTHULOID_PROTO, currentHostname, (unsigned long)engine.epoch(), unitPkHex);
963 clients[slot].sock.println(hello);
778964 }
779 clients[slot].sock = incoming;
780 clients[slot].lastByteMs = millis();
781 clients[slot].len = 0;
782 Serial.printf("New client in slot %d\n", slot);
783 clients[slot].sock.print(currentHostname);
784 clients[slot].sock.println(" ready");
785965 }
786966
787967 // --- service every slot ---
……@@ -789,28 +969,32 @@ void loop() {
789969 ClientSlot &c = clients[i];
790970 if (!c.sock) continue;
791971
792 if (!c.sock.connected()) {
793 c.sock.stop();
794 c.sock = WiFiClient();
795 c.len = 0;
796 continue;
797 }
798
799 while (c.sock.available()) {
800 char ch = c.sock.read();
972 // Drain what has arrived, then act on a close -- in that order, so a
973 // last command sent just before closing (e.g. `release`) still runs.
974 bool closed = false;
975 uint8_t rx[128];
976 for (;;) {
977 int got;
978 int rr = readSome(c.sock, rx, sizeof(rx), got);
979 if (rr == READ_CLOSED) { closed = true; break; }
980 if (rr == READ_IDLE) break;
981 for (int k = 0; k < got; k++) {
982 char ch = (char)rx[k];
801983 c.lastByteMs = millis();
802 Serial.write(ch);
984 if (diagVerbose) Serial.write(ch);
803985
804986 if (ch == '\n' || ch == '\r') {
805 if (c.len > 0) {
987 if (c.len > 0 && c.cap.allow(millis())) {
806988 // Hand over one whole command, with this client marked active
807989 // so its replies come back to it and not to some other session.
808990 ActiveClient = &c.sock;
991 ActiveIndex = i;
809992 for (int k = 0; k < c.len; k++) CmdMgr.handleChar(c.buf[k]);
810993 CmdMgr.handleChar('\n');
811994 ActiveClient = NULL;
812 c.len = 0;
995 ActiveIndex = -1;
813996 }
997 c.len = 0;
814998 } else if (c.len < CLIENT_BUF_LEN - 1) {
815999 c.buf[c.len++] = ch;
8161000 } else {
……@@ -819,6 +1003,14 @@ void loop() {
8191003 c.len = 0;
8201004 Serial.printf("Client %d: line too long, discarded\n", i);
8211005 }
1006 }
1007 }
1008 if (closed) {
1009 c.sock.stop();
1010 c.sock = WiFiClient();
1011 c.len = 0;
1012 engine.socketClosed(i);
1013 continue;
8221014 }
8231015
8241016 // Idle-timeout: connected() can lie after FIN. Drop stale sessions.
……@@ -827,10 +1019,46 @@ void loop() {
8271019 c.sock.stop();
8281020 c.sock = WiFiClient();
8291021 c.len = 0;
1022 engine.socketClosed(i);
8301023 }
8311024 }
8321025 }
1026
1027 engine.tick(millis()); // expiries from the commands just handled
1028 writePins();
1029 broadcastEvents();
1030 if (engine.valuesDue(millis())) {
1031 char buf[120];
1032 const uint16_t *v = engine.values();
1033 int n = snprintf(buf, sizeof(buf), "values");
1034 for (int k = 0; k < NumPWMPins; k++) n += snprintf(buf + n, sizeof(buf) - n, " %u", (unsigned)v[k]);
1035 broadcastLine(buf);
1036 }
8331037 break;
8341038 }
8351039 }
8361040}
1041
1042void broadcastEvents() {
1043 cth::Event e;
1044 char line[300];
1045 while (engine.popEvent(e)) {
1046 if (e.kind == cth::Event::Held) {
1047 snprintf(line, sizeof(line), "held %d %s %lu %lu", e.slot, e.holder,
1048 (unsigned long)e.epoch, (unsigned long)e.n);
1049 } else {
1050 char msg[160];
1051 int mlen = cth::freeMessage(msg, sizeof(msg), currentHostname, e);
1052 unsigned char sig[crypto_sign_BYTES];
1053 crypto_sign_ed25519_detached(sig, NULL, (const unsigned char *)msg, mlen, unitSk);
1054 char sigHex[2 * crypto_sign_BYTES + 1];
1055 for (size_t i = 0; i < sizeof(sig); i++) sprintf(sigHex + 2 * i, "%02x", sig[i]);
1056 snprintf(line, sizeof(line), "free %d %lu %lu %lu %lu %lu %s %s %s", e.slot,
1057 (unsigned long)e.epoch, (unsigned long)e.n,
1058 (unsigned long)e.b, (unsigned long)e.g, (unsigned long)e.r,
1059 cth::whyName(e.why), e.holder, sigHex);
1060 }
1061 broadcastLine(line);
1062 }
1063}
1064
esp324xlight/grants.h +419 -0
……@@ -0,0 +1,419 @@
1// Shared-control engine for esp324xlight (command surface v3).
2//
3// Plain C++ with no Arduino dependencies, so the whole thing is tested on a
4// host with a fake clock (test/grants_test.cpp) before it ever reaches a unit.
5// The sketch owns sockets, NVS, signing and pins; this owns the decisions.
6//
7// What it decides (docs/DESIGN-collab.md §5):
8// * Per-slot exclusive grants, first come first served. A unit is the
9// referee because every controller has to reach it to change a light.
10// * Grants end on release, 2 s without a `slot` from the holder, 30 s after
11// the grant, the holder's socket going away, or stopfade/blackout.
12// * Per-CHANNEL fades. The old firmware had one unit-wide pwm2 fade, so two
13// people working different slots would have cancelled each other's fades.
14// * Legacy whole-unit pwm/pwm2 still works, but only changes UNHELD slots.
15// * A `values` broadcast at most every 250 ms when pins changed, so
16// controllers never have to poll `state`.
17//
18// Values are protocol space throughout: 0..65535, BGR per slot, red capped.
19// All times are uint32 milliseconds and only ever compared by subtraction, so
20// millis() wrapping after ~49 days is harmless.
21
22#pragma once
23
24#include <math.h>
25#include <stdint.h>
26#include <stdio.h>
27#include <string.h>
28
29namespace cth {
30
31constexpr int SLOTS = 4;
32constexpr int CHANNELS = 12;
33constexpr uint32_t CHANNEL_MAX = 65535;
34constexpr uint32_t RED_MAX = 45000; // matches RED_MAX_PWM in the sketch
35constexpr uint32_t IDLE_MS = 2000; // no movement from the holder
36constexpr uint32_t MAX_MS = 30000; // hard cap on one grant
37constexpr uint32_t VALUES_EVERY_MS = 250;
38constexpr uint32_t MAX_FADE_MS = 600000;
39constexpr int HOLDER_LEN = 33; // 32 chars + NUL
40constexpr int NO_SOCK = -1;
41constexpr int ALL = -1; // slot argument meaning "all four"
42
43inline uint32_t channelMax(int ch) { return ch % 3 == 2 ? RED_MAX : CHANNEL_MAX; }
44
45enum class Why : uint8_t { Release, Idle, Max, Disconnect, Blackout };
46
47inline const char *whyName(Why w) {
48 switch (w) {
49 case Why::Release: return "release";
50 case Why::Idle: return "idle";
51 case Why::Max: return "max";
52 case Why::Disconnect: return "disconnect";
53 default: return "blackout";
54 }
55}
56
57struct Event {
58 enum Kind : uint8_t { Held, Free } kind;
59 int slot;
60 char holder[HOLDER_LEN];
61 uint32_t epoch;
62 uint32_t n;
63 uint32_t b, g, r; // Free: the value the slot ends on
64 Why why; // Free only
65};
66
67class Engine {
68 public:
69 float gamma[3] = {1.0f, 1.0f, 1.0f}; // B, G, R — perceptual fade exponents
70
71 // epoch: this boot's number (NVS). grantsEnabled false when the epoch could
72 // not be persisted: a grant numbered from a stale epoch could lose to an old
73 // commit forever, so the unit refuses grants rather than issue one.
74 void begin(uint32_t epoch, bool grantsEnabled) {
75 epoch_ = epoch;
76 enabled_ = grantsEnabled;
77 counter_ = 0;
78 for (int s = 0; s < SLOTS; s++) slots_[s] = Slot();
79 for (int c = 0; c < CHANNELS; c++) { fades_[c] = Fade(); cur_[c] = 0; target_[c] = 0; }
80 evHead_ = evCount_ = 0;
81 dirty_ = false;
82 lastValuesMs_ = 0;
83 valuesEverSent_ = false;
84 }
85
86 uint32_t epoch() const { return epoch_; }
87
88 // `epochfloor` recovery: switch to a higher epoch while running. Every grant
89 // under the old epoch ends first (frees carry the OLD epoch, so they stay
90 // valid commits); numbering restarts under the new one. Never lowers.
91 void setEpoch(uint32_t epoch) {
92 if (epoch <= epoch_) return;
93 for (int s = 0; s < SLOTS; s++) if (slots_[s].sock != NO_SOCK) freeSlot(s, Why::Blackout);
94 epoch_ = epoch;
95 counter_ = 0;
96 enabled_ = true;
97 }
98 bool grantsEnabled() const { return enabled_; }
99
100 // ---- grants --------------------------------------------------------------
101
102 enum GrantRc { Granted, Busy, Disabled, BadSlot };
103
104 // slot 0..3, or ALL (atomic: all four or none). On Busy, busySlot names the
105 // first slot that someone else holds. Re-granting a slot this socket already
106 // holds keeps its number and its 30 s clock (a re-grab can't extend a drag).
107 GrantRc grant(int slot, int sock, const char *holder, uint32_t now, int &busySlot) {
108 busySlot = -1;
109 if (!enabled_) return Disabled;
110 int lo, hi;
111 if (!range(slot, lo, hi)) return BadSlot;
112 for (int s = lo; s <= hi; s++) {
113 if (slots_[s].sock != NO_SOCK && slots_[s].sock != sock) { busySlot = s; return Busy; }
114 }
115 for (int s = lo; s <= hi; s++) {
116 Slot &h = slots_[s];
117 if (h.sock == sock) {
118 h.lastMoveMs = now;
119 continue;
120 }
121 h.sock = sock;
122 copyHolder(h.holder, holder);
123 h.n = ++counter_;
124 h.grantedMs = now;
125 h.lastMoveMs = now;
126 Event e = blankEvent(Event::Held, s);
127 copyHolder(e.holder, h.holder);
128 e.n = h.n;
129 push(e);
130 }
131 return Granted;
132 }
133
134 // Holder ends its grant. False if this socket holds none of the slots.
135 bool release(int slot, int sock, uint32_t now) {
136 int lo, hi;
137 if (!range(slot, lo, hi)) return false;
138 bool any = false;
139 for (int s = lo; s <= hi; s++) {
140 if (slots_[s].sock == sock) { freeSlot(s, Why::Release); any = true; }
141 }
142 (void)now;
143 return any;
144 }
145
146 enum SetRc { SetOk, NotHeld, BadValue };
147
148 // Holder sets a slot's colour, optionally fading to it. Requires the socket
149 // AND the grant number to match, so a stale client can't write with an old
150 // grant after someone else took the slot.
151 SetRc set(int slot, int sock, uint32_t epoch, uint32_t n,
152 uint32_t b, uint32_t g, uint32_t r, uint32_t fadeMs, uint32_t now) {
153 if (slot < 0 || slot >= SLOTS) return NotHeld;
154 Slot &h = slots_[slot];
155 if (h.sock == NO_SOCK || h.sock != sock || epoch != epoch_ || n != h.n) return NotHeld;
156 if (b > CHANNEL_MAX || g > CHANNEL_MAX || r > RED_MAX || fadeMs > MAX_FADE_MS) return BadValue;
157 const uint32_t v[3] = {b, g, r};
158 for (int k = 0; k < 3; k++) setChannel(slot * 3 + k, v[k], fadeMs, now);
159 h.lastMoveMs = now;
160 return SetOk;
161 }
162
163 // The holder's socket went away (closed, evicted, idle-dropped).
164 void socketClosed(int sock) {
165 for (int s = 0; s < SLOTS; s++) if (slots_[s].sock == sock) freeSlot(s, Why::Disconnect);
166 }
167
168 // `stopfade`: freeze every channel where it is, and end every grant. This is
169 // the emergency stop (Blackout sends it before an all-zero pwm), so it
170 // overrides anyone's drag.
171 void stopAll() {
172 for (int c = 0; c < CHANNELS; c++) {
173 fades_[c].active = false;
174 target_[c] = cur_[c];
175 }
176 for (int s = 0; s < SLOTS; s++) if (slots_[s].sock != NO_SOCK) freeSlot(s, Why::Blackout);
177 }
178
179 // Legacy whole-unit pwm (fadeMs 0) / pwm2: channels 0..count-1, skipping any
180 // channel whose slot is held. Values must already be validated by the caller
181 // (the sketch's parsePWM does, with the red cap). Returns channels changed.
182 int legacy(const uint32_t *vals, int count, uint32_t fadeMs, uint32_t now) {
183 int applied = 0;
184 if (count > CHANNELS) count = CHANNELS;
185 for (int c = 0; c < count; c++) {
186 if (slots_[c / 3].sock != NO_SOCK) continue;
187 setChannel(c, vals[c], fadeMs, now);
188 applied++;
189 }
190 return applied;
191 }
192
193 // Set every channel at once, ignoring grants (boot only).
194 void forceAll(uint32_t v) {
195 for (int c = 0; c < CHANNELS; c++) {
196 fades_[c].active = false;
197 cur_[c] = target_[c] = v > CHANNEL_MAX ? CHANNEL_MAX : (uint16_t)v;
198 }
199 dirty_ = true;
200 }
201
202 // ---- time ----------------------------------------------------------------
203
204 // Advance fades and grant timers. Returns true when any channel's current
205 // value changed (the sketch then writes the pins).
206 bool tick(uint32_t now) {
207 bool changed = false;
208 for (int c = 0; c < CHANNELS; c++) {
209 Fade &f = fades_[c];
210 if (!f.active) continue;
211 uint32_t elapsed = now - f.startMs;
212 bool done = elapsed >= f.totalMs;
213 uint16_t v = done ? target_[c] : fadeValue(c, f, elapsed);
214 if (v != cur_[c]) { cur_[c] = v; changed = true; }
215 if (done) f.active = false;
216 }
217 for (int s = 0; s < SLOTS; s++) {
218 Slot &h = slots_[s];
219 if (h.sock == NO_SOCK) continue;
220 if (now - h.grantedMs >= MAX_MS) freeSlot(s, Why::Max);
221 else if (now - h.lastMoveMs >= IDLE_MS) freeSlot(s, Why::Idle);
222 }
223 if (changed) dirty_ = true;
224 return changed;
225 }
226
227 // True once per 250 ms window when values changed since the last broadcast.
228 bool valuesDue(uint32_t now) {
229 if (!dirty_) return false;
230 if (valuesEverSent_ && now - lastValuesMs_ < VALUES_EVERY_MS) return false;
231 dirty_ = false;
232 lastValuesMs_ = now;
233 valuesEverSent_ = true;
234 return true;
235 }
236
237 // ---- reads -----------------------------------------------------------------
238
239 const uint16_t *values() const { return cur_; }
240 uint16_t value(int c) const { return cur_[c]; }
241
242 bool anyFading() const {
243 for (int c = 0; c < CHANNELS; c++) if (fades_[c].active) return true;
244 return false;
245 }
246
247 bool held(int slot, const char *&holder, uint32_t &n) const {
248 if (slot < 0 || slot >= SLOTS || slots_[slot].sock == NO_SOCK) return false;
249 holder = slots_[slot].holder;
250 n = slots_[slot].n;
251 return true;
252 }
253
254 bool holdsAny(int sock) const {
255 for (int s = 0; s < SLOTS; s++) if (slots_[s].sock == sock) return true;
256 return false;
257 }
258
259 bool popEvent(Event &out) {
260 if (evCount_ == 0) return false;
261 out = events_[evHead_];
262 evHead_ = (evHead_ + 1) % QUEUE;
263 evCount_--;
264 return true;
265 }
266
267 int pendingEvents() const { return evCount_; }
268
269 private:
270 struct Slot {
271 int sock = NO_SOCK;
272 char holder[HOLDER_LEN] = {0};
273 uint32_t n = 0;
274 uint32_t grantedMs = 0;
275 uint32_t lastMoveMs = 0;
276 };
277 struct Fade {
278 bool active = false;
279 uint32_t startMs = 0;
280 uint32_t totalMs = 0;
281 float p0 = 0, p1 = 0; // perceptual start/end: (v/65535)^(1/gamma)
282 };
283
284 static constexpr int QUEUE = 32;
285
286 bool range(int slot, int &lo, int &hi) const {
287 if (slot == ALL) { lo = 0; hi = SLOTS - 1; return true; }
288 if (slot < 0 || slot >= SLOTS) return false;
289 lo = hi = slot;
290 return true;
291 }
292
293 static void copyHolder(char *dst, const char *src) {
294 strncpy(dst, src ? src : "", HOLDER_LEN - 1);
295 dst[HOLDER_LEN - 1] = '\0';
296 }
297
298 Event blankEvent(Event::Kind k, int slot) const {
299 Event e;
300 memset(&e, 0, sizeof(e));
301 e.kind = k;
302 e.slot = slot;
303 e.epoch = epoch_;
304 return e;
305 }
306
307 void freeSlot(int s, Why why) {
308 Slot &h = slots_[s];
309 Event e = blankEvent(Event::Free, s);
310 copyHolder(e.holder, h.holder);
311 e.n = h.n;
312 // The value the slot ENDS on (a fade may still be running): that is what
313 // the lamp will show, and what controllers commit as durable state.
314 e.b = target_[s * 3];
315 e.g = target_[s * 3 + 1];
316 e.r = target_[s * 3 + 2];
317 e.why = why;
318 h = Slot();
319 push(e);
320 }
321
322 void push(const Event &e) {
323 if (evCount_ == QUEUE) { // never expected (sketch drains every loop): drop oldest
324 evHead_ = (evHead_ + 1) % QUEUE;
325 evCount_--;
326 }
327 events_[(evHead_ + evCount_) % QUEUE] = e;
328 evCount_++;
329 }
330
331 void setChannel(int c, uint32_t v, uint32_t fadeMs, uint32_t now) {
332 target_[c] = (uint16_t)v;
333 Fade &f = fades_[c];
334 if (fadeMs == 0) {
335 f.active = false;
336 if (cur_[c] != v) { cur_[c] = (uint16_t)v; dirty_ = true; }
337 return;
338 }
339 // Start from where the LED actually is, so a new fade mid-fade picks up
340 // from the live value rather than the previous start.
341 float invG = 1.0f / gamma[c % 3];
342 f.p0 = powf(cur_[c] / 65535.0f, invG);
343 f.p1 = powf(v / 65535.0f, invG);
344 f.startMs = now;
345 f.totalMs = fadeMs;
346 f.active = true;
347 }
348
349 uint16_t fadeValue(int c, const Fade &f, uint32_t elapsed) const {
350 float t = elapsed / (float)f.totalMs;
351 float p = f.p0 + (f.p1 - f.p0) * t;
352 float lum = powf(p, gamma[c % 3]);
353 long v = (long)(lum * 65535.0f);
354 if (v < 0) v = 0;
355 if (v > (long)channelMax(c)) v = channelMax(c);
356 return (uint16_t)v;
357 }
358
359 uint32_t epoch_ = 0;
360 bool enabled_ = false;
361 uint32_t counter_ = 0;
362 Slot slots_[SLOTS];
363 Fade fades_[CHANNELS];
364 uint16_t cur_[CHANNELS] = {0};
365 uint16_t target_[CHANNELS] = {0};
366 Event events_[QUEUE];
367 int evHead_ = 0, evCount_ = 0;
368 bool dirty_ = false;
369 uint32_t lastValuesMs_ = 0;
370 bool valuesEverSent_ = false;
371};
372
373// The exact bytes a unit signs for a `free`. Plain ASCII so controllers in any
374// language rebuild it from the broadcast line alone:
375// "cthuloid/free/1 <unitId> <slot> <epoch> <n> <b> <g> <r>"
376// `why` is deliberately NOT signed: it is advisory, never stored.
377inline int freeMessage(char *buf, size_t len, const char *unitId, const Event &e) {
378 return snprintf(buf, len, "cthuloid/free/1 %s %d %lu %lu %lu %lu %lu", unitId, e.slot,
379 (unsigned long)e.epoch, (unsigned long)e.n,
380 (unsigned long)e.b, (unsigned long)e.g, (unsigned long)e.r);
381}
382
383// Which client slot to give a new connection. Returns a free slot if there is
384// one; otherwise the least recently active socket that holds NO grant; -1 if
385// every socket holds a grant (refuse the newcomer rather than drop a drag).
386inline int pickSlot(const bool *occupied, const uint32_t *lastByteMs, const bool *holds,
387 int n, uint32_t now) {
388 for (int i = 0; i < n; i++) if (!occupied[i]) return i;
389 int best = -1;
390 uint32_t bestAge = 0;
391 for (int i = 0; i < n; i++) {
392 if (holds[i]) continue;
393 uint32_t age = now - lastByteMs[i];
394 if (best < 0 || age > bestAge) { best = i; bestAge = age; }
395 }
396 return best;
397}
398
399// Per-socket flood guard: at most `limit` command lines per 1 s window; the
400// excess is dropped and counted (reported in `state`).
401struct RateCap {
402 uint32_t windowStart = 0;
403 uint32_t count = 0;
404 uint32_t dropped = 0;
405 bool started = false;
406
407 bool allow(uint32_t now, uint32_t limit = 60) {
408 if (!started || now - windowStart >= 1000) {
409 started = true;
410 windowStart = now;
411 count = 0;
412 }
413 if (count >= limit) { dropped++; return false; }
414 count++;
415 return true;
416 }
417};
418
419} // namespace cth
esp324xlight/test/e2e_v3.py +218 -0
……@@ -0,0 +1,218 @@
1#!/usr/bin/env python3
2"""End-to-end test of command surface v3 against a REAL unit.
3
4 python3 test/e2e_v3.py 192.168.50.89
5
6Two clients (alice, bob) exercise grants, signed frees, timers, legacy pwm
7skipping held slots, stopfade, state, and epochfloor. Values stay dim. The
8lamp's colours are read at the start and written back at the end (red clamped
9to the 45000 cap the firmware enforces on input).
10
11Needs PyNaCl for signature checks.
12"""
13
14import socket
15import sys
16import threading
17import time
18
19from nacl.signing import VerifyKey
20
21HOST = sys.argv[1] if len(sys.argv) > 1 else "192.168.50.89"
22PORT = 1337
23failures = []
24
25
26def check(name, ok, detail=""):
27 print(("PASS " if ok else "FAIL ") + name + ("" if ok else " " + detail))
28 if not ok:
29 failures.append(name)
30
31
32class Conn:
33 def __init__(self, label):
34 self.label = label
35 self.sock = socket.create_connection((HOST, PORT), timeout=5)
36 self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
37 self.lines = []
38 self.lock = threading.Lock()
39 self.alive = True
40 threading.Thread(target=self._read, daemon=True).start()
41
42 def _read(self):
43 buf = b""
44 while self.alive:
45 try:
46 d = self.sock.recv(4096)
47 except OSError:
48 break
49 if not d:
50 break
51 buf += d
52 while b"\n" in buf:
53 line, buf = buf.split(b"\n", 1)
54 line = line.decode(errors="replace").strip()
55 if line:
56 with self.lock:
57 self.lines.append((time.monotonic(), line))
58
59 def send(self, line):
60 self.sock.sendall((line + "\n").encode())
61
62 def mark(self):
63 with self.lock:
64 return len(self.lines)
65
66 def wait(self, pred, since=0, timeout=4.0):
67 end = time.monotonic() + timeout
68 while time.monotonic() < end:
69 with self.lock:
70 for _, line in self.lines[since:]:
71 if pred(line):
72 return line
73 time.sleep(0.02)
74 return None
75
76 def close(self):
77 # shutdown() first: a bare close() while the reader thread sits in recv()
78 # doesn't actually close the socket on Linux, so no FIN reaches the unit.
79 self.alive = False
80 try:
81 self.sock.shutdown(socket.SHUT_RDWR)
82 except OSError:
83 pass
84 self.sock.close()
85
86
87def starts(prefix):
88 return lambda l: l.startswith(prefix)
89
90
91def verify_free(line, unit, pk_hex):
92 # free <s> <epoch> <n> <b> <g> <r> <why> <holder> <sig>
93 p = line.split()
94 msg = "cthuloid/free/1 %s %s %s %s %s %s %s" % (unit, p[1], p[2], p[3], p[4], p[5], p[6])
95 try:
96 VerifyKey(bytes.fromhex(pk_hex)).verify(msg.encode(), bytes.fromhex(p[9]))
97 return True
98 except Exception:
99 return False
100
101
102a = Conn("alice")
103b = Conn("bob")
104
105hello = a.wait(starts("hello "))
106check("greeting line 1 unchanged", a.wait(lambda l: l.endswith(" ready")) is not None)
107check("hello line carries proto 3", hello is not None and "proto=3" in hello, str(hello))
108fields = dict(kv.split("=", 1) for kv in hello.split()[1:]) if hello else {}
109UNIT, EPOCH, PK = fields.get("unit"), int(fields.get("epoch", "0")), fields.get("pk", "")
110print("unit=%s epoch=%d pk=%s…" % (UNIT, EPOCH, PK[:16]))
111
112# Remember the look to put back afterwards.
113m = a.mark()
114a.send("state")
115st = a.wait(starts("state "), m)
116before = [int(x) for x in st.split()[6:]] if st else None
117check("state reply", before is not None and len(before) == 12, str(st))
118stats = a.wait(starts("stats "), m)
119check("state ends with stats (epoch, pk)", stats is not None and ("epoch=%d" % EPOCH) in stats and PK in stats, str(stats))
120
121# --- first come, first served -------------------------------------------------
122ma, mb = a.mark(), b.mark()
123a.send("grant 0 alice")
124g = a.wait(starts("granted 0 "), ma)
125check("alice granted slot 0", g is not None, str(g))
126n0 = int(g.split()[3]) if g else 0
127check("bob sees held broadcast", b.wait(lambda l: l == "held 0 alice %d %d" % (EPOCH, n0), mb) is not None)
128mb = b.mark()
129b.send("grant 0 bob")
130check("bob refused: busy", b.wait(lambda l: l == "busy 0 alice", mb) is not None)
131b.send("slot 0 %d %d 1 1 1" % (EPOCH, n0))
132check("bob can't write alice's slot", b.wait(lambda l: l == "ERR notheld 0", mb) is not None)
133
134# --- holder writes; legacy pwm skips the held slot -----------------------------
135ma, mb = a.mark(), b.mark()
136a.send("slot 0 %d %d 3000 0 0" % (EPOCH, n0))
137b.send("pwm 1 1 1 2000 0 0 0 0 0 0 0 0")
138vals = b.wait(lambda l: l.startswith("values ") and l.split()[1:4] == ["3000", "0", "0"]
139 and l.split()[4] == "2000", mb)
140check("values broadcast: holder's slot kept, legacy applied elsewhere", vals is not None, str(vals))
141check("slot write is silent on success", a.wait(lambda l: l.startswith("ERR"), ma, timeout=0.5) is None)
142
143# --- release -> signed free --------------------------------------------------------
144mb = b.mark()
145a.send("release 0")
146fr = b.wait(starts("free 0 "), mb)
147check("release broadcasts free with final value", fr is not None and fr.split()[4:9] == ["3000", "0", "0", "release", "alice"], str(fr))
148check("free signature verifies", fr is not None and verify_free(fr, UNIT, PK))
149if fr:
150 bad = fr.split()
151 bad[4] = "3001"
152 check("tampered free fails verification", not verify_free(" ".join(bad), UNIT, PK))
153
154# --- idle expiry (2 s) ---------------------------------------------------------------
155ma, mb = a.mark(), b.mark()
156a.send("grant 1 alice")
157g1 = a.wait(starts("granted 1 "), ma)
158t0 = time.monotonic()
159fr = b.wait(lambda l: l.startswith("free 1 ") and " idle " in l, mb, timeout=4)
160dt = time.monotonic() - t0
161check("idle grant freed after ~2 s", fr is not None and 1.8 < dt < 2.8, "dt=%.2f %s" % (dt, fr))
162
163# --- disconnect frees ------------------------------------------------------------------
164c = Conn("carol")
165c.wait(starts("hello "))
166mb = b.mark()
167c.send("grant 2 carol")
168b.wait(starts("held 2 carol"), mb)
169c.close()
170t0 = time.monotonic()
171fr = b.wait(lambda l: l.startswith("free 2 ") and " disconnect " in l, mb, timeout=6)
172dt = time.monotonic() - t0
173check("holder disconnect frees its slot at once (not via idle)", fr is not None and dt < 0.5, "dt=%.2f %s" % (dt, fr))
174
175# --- grant all is atomic; stopfade frees everything --------------------------------------
176ma, mb = a.mark(), b.mark()
177b.send("grant 3 bob")
178b.wait(starts("granted 3 "), mb)
179a.send("grant all alice")
180check("grant all refused while bob holds slot 3", a.wait(lambda l: l == "busy 3 bob", ma) is not None)
181mb = b.mark()
182a.send("stopfade")
183check("stopfade frees bob's slot (blackout)", b.wait(lambda l: l.startswith("free 3 ") and " blackout " in l, mb) is not None)
184ma = a.mark()
185a.send("grant all alice")
186granted = [a.wait(starts("granted %d " % s), ma) for s in range(4)]
187check("grant all succeeds when free", all(granted), str(granted))
188ma = a.mark()
189a.send("state")
190helds = [a.wait(starts("held %d alice" % s), ma) for s in range(4)]
191check("state lists every held slot", all(helds))
192a.send("release all")
193
194# --- epochfloor -------------------------------------------------------------------------
195ma = a.mark()
196a.send("epochfloor %d" % (EPOCH + 5))
197ep = a.wait(starts("epoch "), ma)
198check("epochfloor raises the epoch past the floor", ep == "epoch %d" % (EPOCH + 6), str(ep))
199ma = a.mark()
200a.send("grant 0 alice")
201g = a.wait(starts("granted 0 "), ma)
202check("new grants use the new epoch, numbering restarted", g == "granted 0 %d 1" % (EPOCH + 6), str(g))
203a.send("release 0")
204ma = a.mark()
205a.send("epochfloor 1")
206check("epochfloor never lowers", a.wait(starts("epoch "), ma) == "epoch %d" % (EPOCH + 6))
207
208# --- put the look back ----------------------------------------------------------------
209time.sleep(0.3)
210if before:
211 restore = [min(v, 45000) if i % 3 == 2 else v for i, v in enumerate(before)]
212 a.send("pwm " + " ".join(str(v) for v in restore))
213 time.sleep(0.5)
214a.close()
215b.close()
216
217print("\n%d failure(s)" % len(failures) if failures else "\nALL PASS")
218sys.exit(1 if failures else 0)
esp324xlight/test/grants_test.cpp +289 -0
……@@ -0,0 +1,289 @@
1// Host tests for grants.h — the shared-control engine — with a fake clock.
2// g++ -std=c++17 -Wall -Wextra -I.. grants_test.cpp -o grants_test && ./grants_test
3#include "grants.h"
4
5#include <stdio.h>
6#include <string.h>
7
8using namespace cth;
9
10static int failures = 0, checks = 0;
11#define CHECK(cond) do { checks++; if (!(cond)) { failures++; \
12 printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } } while (0)
13
14static Engine fresh(uint32_t epoch = 7) {
15 Engine e;
16 e.begin(epoch, true);
17 return e;
18}
19
20static int drain(Engine &e, Event *out, int max) {
21 int n = 0;
22 Event ev;
23 while (e.popEvent(ev)) if (n < max) out[n++] = ev;
24 return n;
25}
26
27static void test_first_come() {
28 Engine e = fresh();
29 int busy;
30 CHECK(e.grant(1, 0, "alice", 1000, busy) == Engine::Granted);
31 CHECK(e.grant(1, 1, "bob", 1001, busy) == Engine::Busy);
32 CHECK(busy == 1);
33 CHECK(e.grant(2, 1, "bob", 1002, busy) == Engine::Granted); // other slot: fine
34 const char *who; uint32_t n;
35 CHECK(e.held(1, who, n) && strcmp(who, "alice") == 0 && n == 1);
36 CHECK(e.held(2, who, n) && strcmp(who, "bob") == 0 && n == 2);
37 Event ev[8];
38 int k = drain(e, ev, 8);
39 CHECK(k == 2 && ev[0].kind == Event::Held && ev[0].slot == 1 && ev[0].epoch == 7);
40}
41
42static void test_grant_all_is_atomic() {
43 Engine e = fresh();
44 int busy;
45 CHECK(e.grant(2, 1, "bob", 0, busy) == Engine::Granted);
46 CHECK(e.grant(ALL, 0, "alice", 0, busy) == Engine::Busy);
47 CHECK(busy == 2);
48 const char *who; uint32_t n;
49 CHECK(!e.held(0, who, n)); // nothing taken by the failed `all`
50 CHECK(e.release(2, 1, 0));
51 CHECK(e.grant(ALL, 0, "alice", 0, busy) == Engine::Granted);
52 for (int s = 0; s < SLOTS; s++) CHECK(e.held(s, who, n) && strcmp(who, "alice") == 0);
53}
54
55static void test_set_needs_holder_and_grant() {
56 Engine e = fresh();
57 int busy;
58 e.grant(0, 3, "alice", 0, busy);
59 const char *who; uint32_t n;
60 e.held(0, who, n);
61 CHECK(e.set(0, 4, 7, n, 1, 2, 3, 0, 10) == Engine::NotHeld); // wrong socket
62 CHECK(e.set(0, 3, 7, n + 1, 1, 2, 3, 0, 10) == Engine::NotHeld); // wrong grant
63 CHECK(e.set(0, 3, 6, n, 1, 2, 3, 0, 10) == Engine::NotHeld); // wrong epoch
64 CHECK(e.set(0, 3, 7, n, 1, 2, RED_MAX + 1, 0, 10) == Engine::BadValue);
65 CHECK(e.set(0, 3, 7, n, 100, 200, 300, 0, 10) == Engine::SetOk);
66 CHECK(e.value(0) == 100 && e.value(1) == 200 && e.value(2) == 300);
67 CHECK(e.set(1, 3, 7, n, 1, 1, 1, 0, 10) == Engine::NotHeld); // slot not held
68}
69
70static void test_idle_expiry_reports_final_value() {
71 Engine e = fresh();
72 int busy;
73 e.grant(3, 0, "alice", 0, busy);
74 const char *who; uint32_t n;
75 e.held(3, who, n);
76 e.set(3, 0, 7, n, 10, 20, 30, 0, 1500); // movement resets the idle clock
77 e.tick(3400);
78 CHECK(e.held(3, who, n)); // 1900 ms since the last move
79 e.tick(3500);
80 CHECK(!e.held(3, who, n)); // 2000 ms: freed
81 Event ev[8];
82 int k = drain(e, ev, 8);
83 CHECK(k == 2);
84 CHECK(ev[1].kind == Event::Free && ev[1].why == Why::Idle && ev[1].slot == 3);
85 CHECK(ev[1].b == 10 && ev[1].g == 20 && ev[1].r == 30 && ev[1].n == n);
86 CHECK(strcmp(ev[1].holder, "alice") == 0);
87}
88
89static void test_max_expiry_even_while_moving() {
90 Engine e = fresh();
91 int busy;
92 e.grant(0, 0, "alice", 0, busy);
93 const char *who; uint32_t n;
94 e.held(0, who, n);
95 uint32_t t = 0;
96 for (; t < MAX_MS; t += 500) { e.set(0, 0, 7, n, t % 1000, 0, 0, 0, t); e.tick(t); }
97 e.tick(MAX_MS);
98 CHECK(!e.held(0, who, n));
99 Event ev[80];
100 int k = drain(e, ev, 80);
101 CHECK(ev[k - 1].kind == Event::Free && ev[k - 1].why == Why::Max);
102 // A re-grab can't extend a drag: re-granting a held slot keeps its clock.
103 Engine f = fresh();
104 f.grant(0, 0, "alice", 0, busy);
105 f.grant(0, 0, "alice", 29000, busy);
106 f.tick(29500);
107 f.grant(0, 0, "alice", 29600, busy); // re-grab mid-drag
108 f.tick(30000);
109 CHECK(!f.held(0, who, n));
110}
111
112static void test_release_and_disconnect() {
113 Engine e = fresh();
114 int busy;
115 e.grant(0, 0, "alice", 0, busy);
116 e.grant(1, 1, "bob", 0, busy);
117 CHECK(!e.release(0, 1, 5)); // bob can't release alice's slot
118 CHECK(e.release(0, 0, 5));
119 e.socketClosed(1);
120 const char *who; uint32_t n;
121 CHECK(!e.held(0, who, n) && !e.held(1, who, n));
122 Event ev[8];
123 int k = drain(e, ev, 8);
124 CHECK(k == 4 && ev[2].why == Why::Release && ev[3].why == Why::Disconnect);
125}
126
127static void test_stopall_overrides_drags() {
128 Engine e = fresh();
129 int busy;
130 e.grant(ALL, 0, "alice", 0, busy);
131 e.stopAll();
132 const char *who; uint32_t n;
133 for (int s = 0; s < SLOTS; s++) CHECK(!e.held(s, who, n));
134 Event ev[16];
135 int k = drain(e, ev, 16);
136 CHECK(k == 8 && ev[7].why == Why::Blackout);
137}
138
139static void test_legacy_skips_held_slots() {
140 Engine e = fresh();
141 int busy;
142 e.grant(1, 0, "alice", 0, busy);
143 uint32_t v[CHANNELS];
144 for (int c = 0; c < CHANNELS; c++) v[c] = 1000 + c;
145 CHECK(e.legacy(v, CHANNELS, 0, 0) == 9);
146 CHECK(e.value(0) == 1000 && e.value(3) == 0 && e.value(5) == 0 && e.value(6) == 1006);
147}
148
149static void test_per_channel_fades_independent() {
150 Engine e = fresh();
151 int busy;
152 e.grant(0, 0, "alice", 0, busy);
153 e.grant(1, 1, "bob", 0, busy);
154 const char *who; uint32_t na, nb;
155 e.held(0, who, na);
156 e.held(1, who, nb);
157 e.set(0, 0, 7, na, 0, 0, 40000, 1000, 0); // alice fades slot 0 over 1 s
158 e.tick(500);
159 e.set(1, 1, 7, nb, 30000, 0, 0, 0, 500); // bob writes slot 1 mid-fade
160 e.tick(500);
161 CHECK(e.value(2) > 15000 && e.value(2) < 25000); // alice's fade continued (linear, gamma 1)
162 CHECK(e.value(3) == 30000);
163 e.tick(1000);
164 CHECK(e.value(2) == 40000);
165 CHECK(!e.anyFading());
166}
167
168static void test_fade_reports_target_on_release() {
169 Engine e = fresh();
170 int busy;
171 e.grant(0, 0, "alice", 0, busy);
172 const char *who; uint32_t n;
173 e.held(0, who, n);
174 e.set(0, 0, 7, n, 60000, 0, 0, 5000, 0);
175 e.tick(100);
176 e.release(0, 0, 100);
177 Event ev[4];
178 int k = drain(e, ev, 4);
179 CHECK(ev[k - 1].b == 60000); // ends on the target, not the mid-fade value
180}
181
182static void test_wraparound() {
183 Engine e = fresh();
184 int busy;
185 uint32_t t0 = 0xFFFFFF00u; // just before millis() wraps
186 e.grant(0, 0, "alice", t0, busy);
187 const char *who; uint32_t n;
188 e.tick(t0 + 1000); // wrapped past zero
189 CHECK(e.held(0, who, n));
190 e.tick(t0 + 2000);
191 CHECK(!e.held(0, who, n));
192}
193
194static void test_disabled_without_epoch() {
195 Engine e;
196 e.begin(0, false);
197 int busy;
198 CHECK(e.grant(0, 0, "alice", 0, busy) == Engine::Disabled);
199 uint32_t v[3] = {1, 2, 3};
200 CHECK(e.legacy(v, 3, 0, 0) == 3); // legacy control still works
201}
202
203static void test_values_broadcast_pacing() {
204 Engine e = fresh();
205 uint32_t v[CHANNELS] = {0};
206 v[0] = 5;
207 e.legacy(v, CHANNELS, 0, 0);
208 CHECK(e.valuesDue(0));
209 v[0] = 6;
210 e.legacy(v, CHANNELS, 0, 100);
211 CHECK(!e.valuesDue(100)); // inside the 250 ms window
212 CHECK(e.valuesDue(250));
213 CHECK(!e.valuesDue(600)); // nothing changed since
214}
215
216static void test_free_message_format() {
217 Event ev;
218 memset(&ev, 0, sizeof(ev));
219 ev.kind = Event::Free;
220 ev.slot = 2;
221 ev.epoch = 9;
222 ev.n = 41;
223 ev.b = 1; ev.g = 2; ev.r = 45000;
224 char buf[160];
225 freeMessage(buf, sizeof(buf), "studiolight", ev);
226 CHECK(strcmp(buf, "cthuloid/free/1 studiolight 2 9 41 1 2 45000") == 0);
227}
228
229static void test_pick_slot() {
230 bool occ[3] = {true, true, true};
231 uint32_t last[3] = {100, 50, 10};
232 bool holds[3] = {false, false, true};
233 CHECK(pickSlot(occ, last, holds, 3, 200) == 1); // oldest WITHOUT a grant
234 occ[0] = false;
235 CHECK(pickSlot(occ, last, holds, 3, 200) == 0); // a free slot first
236 occ[0] = true;
237 bool all[3] = {true, true, true};
238 CHECK(pickSlot(occ, last, all, 3, 200) == -1); // everyone holds: refuse
239}
240
241static void test_set_epoch_live() {
242 // epochfloor recovery: a reflashed unit raises its epoch while running. Grants
243 // under the old epoch end (blackout semantics), numbering restarts under the new.
244 Engine e = fresh(3);
245 int busy;
246 e.grant(0, 0, "alice", 0, busy);
247 e.setEpoch(12);
248 const char *who; uint32_t n;
249 CHECK(!e.held(0, who, n));
250 CHECK(e.epoch() == 12);
251 Event ev[4];
252 int k = drain(e, ev, 4);
253 CHECK(k == 2 && ev[1].kind == Event::Free && ev[1].epoch == 3); // freed under the OLD epoch
254 e.grant(0, 0, "alice", 0, busy);
255 e.held(0, who, n);
256 CHECK(n == 1);
257 k = drain(e, ev, 4);
258 CHECK(k == 1 && ev[0].epoch == 12);
259}
260
261static void test_rate_cap() {
262 RateCap r;
263 int ok = 0;
264 for (int i = 0; i < 100; i++) ok += r.allow(500);
265 CHECK(ok == 60 && r.dropped == 40);
266 CHECK(r.allow(1500)); // new window
267}
268
269int main() {
270 test_first_come();
271 test_grant_all_is_atomic();
272 test_set_needs_holder_and_grant();
273 test_idle_expiry_reports_final_value();
274 test_max_expiry_even_while_moving();
275 test_release_and_disconnect();
276 test_stopall_overrides_drags();
277 test_legacy_skips_held_slots();
278 test_per_channel_fades_independent();
279 test_fade_reports_target_on_release();
280 test_wraparound();
281 test_disabled_without_epoch();
282 test_values_broadcast_pacing();
283 test_free_message_format();
284 test_pick_slot();
285 test_set_epoch_live();
286 test_rate_cap();
287 printf("%d/%d checks passed\n", checks - failures, checks);
288 return failures ? 1 : 0;
289}