| … | … | @@ -117,11 +117,13 @@ Bump `CTHULOID_PROTO` to `3`; controllers check it before using grants. |
| 117 | 117 | |
| 118 | 118 | ### 5.3 Broadcasts (to every connected socket) |
| 119 | 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). | |
| 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`. | |
| 122 | 123 | - `values <v1> … <v12>` — the pins changed (from any source, fades included), |
| 123 | 124 | 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>`. | |
| 125 | 127 | |
| 126 | 128 | ### 5.4 Timers (in `loop()`, `millis()` unsigned subtraction — wrap-safe) |
| 127 | 129 | - 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. |
| 147 | 149 | |
| 148 | 150 | ### 5.7 Signing (D8) |
| 149 | 151 | - 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.) | |
| 152 | 154 | - **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 | |
| 154 | 156 | `sig = Sign(sk, "cthuloid/free/1" ‖ unitId ‖ s ‖ epoch ‖ n ‖ b ‖ g ‖ r)`. |
| 155 | 157 | That is the only thing that becomes durable, so signing it means no app can |
| 156 | 158 | store a value the lamp didn't actually end on, nor invent a grant number to lock |
| … | … | @@ -185,6 +185,58 @@ state <fadeActive 0|1> <freq> <gammaB> <gammaG> <gammaR> <v1> ... <v12> |
| 185 | 185 | `v1..v12` are the current values in protocol space (0–65535, BGR order), live |
| 186 | 186 | during a `pwm2` fade. Added in command-surface version 2 (`CTHULOID_PROTO`). |
| 187 | 187 | |
| 188 | Since proto 3 it is followed by one `held <s> <holder> <epoch> <n>` line per | |
| 189 | held slot and `stats epoch=<e> dropped=<n> pk=<hex>`. | |
| 190 | ||
| 191 | ### Shared control (proto 3) | |
| 192 | ||
| 193 | Several controllers (web UI, phones, Blender) can drive one unit at once. The | |
| 194 | unit is the referee: each of the four slots can be **held** by one controller at | |
| 195 | a time, first come first served. Full design: `docs/DESIGN-collab.md` §5. The | |
| 196 | logic lives in `grants.h` (no Arduino deps) and is host-tested: | |
| 197 | ||
| 198 | ```bash | |
| 199 | g++ -std=c++17 -I. test/grants_test.cpp -o /tmp/grants_test && /tmp/grants_test | |
| 200 | python3 test/e2e_v3.py <unit-ip> # against a real unit (needs PyNaCl) | |
| 201 | ``` | |
| 202 | ||
| 203 | On 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 | ||
| 213 | A grant ends on `release`, **2 s** without a `slot` from the holder, **30 s** | |
| 214 | after it was granted, the holder's socket closing, or `stopfade` (Blackout). | |
| 215 | ||
| 216 | Broadcasts 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 | ||
| 227 | Legacy `pwm` / `pwm2` still work but only change **unheld** slots. Fades are | |
| 228 | per 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** | |
| 230 | grant is evicted, or the newcomer is refused if every socket holds one. | |
| 231 | ||
| 232 | Since proto 3 the serial console is quiet by default (`verbose 0`, `plevel ack`, | |
| 233 | no byte echo): each echoed `pwm` used to cost ~30 ms of loop. `verbose 1` and | |
| 234 | `plevel info` bring it back. | |
| 235 | ||
| 236 | Client 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"), | |
| 238 | so a holder's disconnect went unnoticed until the idle timer. | |
| 239 | ||
| 188 | 240 | #### `freq [hz]` |
| 189 | 241 | Get or set the PWM frequency. With no argument, prints the current frequency. |
| 190 | 242 | With one argument, sets it (range 1 to `maxfreq`, currently 40000). |
| … | … | @@ -11,6 +11,10 @@ |
| 11 | 11 | #include <WiFiClient.h> |
| 12 | 12 | #include <ArduinoOTA.h> |
| 13 | 13 | #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 | |
| 14 | 18 | |
| 15 | 19 | // Default hostname compiled into firmware. Runtime hostname is loaded from NVS |
| 16 | 20 | // on boot; if NVS is empty (first boot after a fresh flash), this is the |
| … | … | @@ -19,8 +23,9 @@ |
| 19 | 23 | |
| 20 | 24 | // Version of the TCP command surface, advertised in the _cthuloid._tcp mDNS TXT |
| 21 | 25 | // 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" | |
| 24 | 29 | |
| 25 | 30 | // Password required to push OTA updates. Change before flashing if you don't |
| 26 | 31 | // want anyone on the WiFi network to be able to overwrite the firmware. |
| … | … | @@ -51,9 +56,6 @@ const int timeout = 30; |
| 51 | 56 | #define RED_MAX_PWM 45000 |
| 52 | 57 | #define MAX_PWM 65535 // full 16-bit range; LEDC handles duty=max via max+1 special case |
| 53 | 58 | |
| 54 | constexpr int NumPeriods = 3; // number of steps in fade | |
| 55 | constexpr int PeriodLength = 1; // sleep for this many ms | |
| 56 | ||
| 57 | 59 | ////////////////////////////////////////////////////////////////////////////////////// |
| 58 | 60 | // 1 kHz matches the bandwidth of an ESP32-direct-driven MOSFET (no gate-driver |
| 59 | 61 | // IC). At higher freqs the MOSFET spends a non-trivial fraction of each cycle |
| … | … | @@ -81,7 +83,20 @@ void computeRes() { |
| 81 | 83 | |
| 82 | 84 | const int PWMPins[] = {32, 33, 25, 26, 27, 14, 12, 13, 2, 23, 22, 21}; |
| 83 | 85 | const int NumPWMPins = sizeof(PWMPins) / sizeof(*PWMPins); |
| 84 | int 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. | |
| 89 | cth::Engine engine; | |
| 90 | uint16_t PinsWritten[NumPWMPins]; | |
| 91 | bool 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). | |
| 96 | unsigned char unitPk[crypto_sign_PUBLICKEYBYTES]; | |
| 97 | unsigned char unitSk[crypto_sign_SECRETKEYBYTES]; | |
| 98 | char unitPkHex[2 * crypto_sign_PUBLICKEYBYTES + 1]; | |
| 99 | uint32_t bootEpoch = 0; | |
| 85 | 100 | |
| 86 | 101 | // tracks whether ledcAttach has been called for each pin yet |
| 87 | 102 | bool PinAttached[NumPWMPins] = {false}; |
| … | … | @@ -105,7 +120,9 @@ const char *MsgPWMTupleFormatError = "Error: takes up to 6 arguments between 0 a |
| 105 | 120 | // bytes from two clients as they arrive would splice two commands together and |
| 106 | 121 | // corrupt both. Bytes are accumulated per client and only handed over once a |
| 107 | 122 | // complete line has arrived, which keeps every command atomic. |
| 108 | const 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). | |
| 125 | const int MAX_CLIENTS = 6; | |
| 109 | 126 | const int CLIENT_BUF_LEN = 160; // longest real command is a 12-value pwm2 |
| 110 | 127 | |
| 111 | 128 | struct ClientSlot { |
| … | … | @@ -113,6 +130,7 @@ struct ClientSlot { |
| 113 | 130 | unsigned long lastByteMs = 0; |
| 114 | 131 | char buf[CLIENT_BUF_LEN]; |
| 115 | 132 | int len = 0; |
| 133 | cth::RateCap cap; // per-socket flood guard (60 lines/s) | |
| 116 | 134 | }; |
| 117 | 135 | ClientSlot clients[MAX_CLIENTS]; |
| 118 | 136 | |
| … | … | @@ -120,20 +138,50 @@ ClientSlot clients[MAX_CLIENTS]; |
| 120 | 138 | // NULL means the command came from the serial console, so replies go to Serial |
| 121 | 139 | // alone. |
| 122 | 140 | WiFiClient *ActiveClient = NULL; |
| 141 | int ActiveIndex = -1; // client slot of the active command, -1 for serial | |
| 123 | 142 | |
| 124 | 143 | const unsigned long CLIENT_IDLE_TIMEOUT_MS = 300000; // 5 min — drop a dead session |
| 125 | 144 | |
| 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. | |
| 154 | const int READ_MORE = 0, READ_IDLE = 1, READ_CLOSED = 2; | |
| 155 | int 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 | ||
| 126 | 166 | // Write to every connected client, for messages not tied to one request. |
| 127 | 167 | void broadcastLine(const char *s) { |
| 128 | 168 | 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); | |
| 130 | 170 | } |
| 131 | 171 | } |
| 132 | 172 | |
| 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. | |
| 177 | void broadcastEvents(); | |
| 178 | ||
| 133 | 179 | // 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. | |
| 136 | bool 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. | |
| 184 | bool diagVerbose = false; | |
| 137 | 185 | |
| 138 | 186 | // Per-channel gamma exponents (indexed by RGB triple position: 0=Blue, 1=Green, 2=Red, |
| 139 | 187 | // matching Blender's BGR send order). Used by pwm2's perceptual interpolation. Higher |
| … | … | @@ -180,22 +228,10 @@ void cmdHostname(); |
| 180 | 228 | void cmdReboot(); |
| 181 | 229 | void cmdStopFade(); |
| 182 | 230 | void cmdState(); |
| 183 | void 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. | |
| 190 | struct 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 | }; | |
| 198 | Pwm2Fade pwm2Fade; | |
| 231 | void cmdGrant(); | |
| 232 | void cmdSlot(); | |
| 233 | void cmdRelease(); | |
| 234 | void cmdEpochFloor(); | |
| 199 | 235 | |
| 200 | 236 | SerialCommand::Entry CommandsList[] = { |
| 201 | 237 | {"plevel", cmdSetPrintLevel}, |
| … | … | @@ -209,6 +245,10 @@ SerialCommand::Entry CommandsList[] = { |
| 209 | 245 | {"reboot", cmdReboot}, |
| 210 | 246 | {"stopfade", cmdStopFade}, |
| 211 | 247 | {"state", cmdState}, |
| 248 | {"grant", cmdGrant}, | |
| 249 | {"slot", cmdSlot}, | |
| 250 | {"release", cmdRelease}, | |
| 251 | {"epochfloor", cmdEpochFloor}, | |
| 212 | 252 | {NULL, NULL} |
| 213 | 253 | }; |
| 214 | 254 | SerialCommand CmdMgr(CommandsList, cmdUnrecognized); |
| … | … | @@ -371,56 +411,41 @@ void cmdPWMPins2() { |
| 371 | 411 | diagPrint((long)(numPeriods * periodLength)); |
| 372 | 412 | diagPrintln(" ms"); |
| 373 | 413 | |
| 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()); | |
| 390 | 422 | |
| 391 | 423 | printAck("OK pwm2 "); |
| 392 | 424 | printAck(count); |
| 393 | 425 | printAck(" pins over "); |
| 394 | printAck((long)pwm2Fade.totalMs); | |
| 426 | printAck((long)totalMs); | |
| 395 | 427 | printlnAck(" ms"); |
| 396 | 428 | diagPrint("OK pwm2 "); |
| 397 | 429 | diagPrintln((long)count); |
| 398 | 430 | } |
| 399 | 431 | |
| 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. | |
| 402 | void 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. | |
| 434 | void 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]; | |
| 416 | 440 | } |
| 417 | if (done) pwm2Fade.active = false; | |
| 441 | pinsWrittenValid = true; | |
| 418 | 442 | } |
| 419 | 443 | |
| 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. | |
| 422 | 447 | void cmdStopFade() { |
| 423 | pwm2Fade.active = false; | |
| 448 | engine.stopAll(); | |
| 424 | 449 | printlnAck("OK stopfade"); |
| 425 | 450 | diagPrintln("OK stopfade"); |
| 426 | 451 | } |
| … | … | @@ -429,25 +454,158 @@ void cmdStopFade() { |
| 429 | 454 | // not send the last command (e.g. a web UI while Blender drives the rig) can show |
| 430 | 455 | // the truth. One line, always sent (not gated by verbose/plevel): |
| 431 | 456 | // 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 | |
| 433 | 458 | // 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. | |
| 463 | void replyLine(const char *buf) { | |
| 464 | Serial.println(buf); | |
| 465 | if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf); | |
| 466 | } | |
| 467 | ||
| 434 | 468 | void cmdState() { |
| 435 | 469 | char buf[200]; |
| 470 | const uint16_t *v = engine.values(); | |
| 436 | 471 | int n = snprintf(buf, sizeof(buf), "state %d %d %.2f %.2f %.2f", |
| 437 | pwm2Fade.active ? 1 : 0, freq, | |
| 472 | engine.anyFading() ? 1 : 0, freq, | |
| 438 | 473 | channelGamma[0], channelGamma[1], channelGamma[2]); |
| 439 | 474 | 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]); | |
| 441 | 476 | } |
| 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. | |
| 494 | bool 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" | |
| 504 | bool 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. | |
| 514 | void 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. | |
| 556 | void 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 | ||
| 579 | void 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. | |
| 588 | void 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); | |
| 444 | 604 | } |
| 445 | 605 | |
| 446 | 606 | // expects space-delimited ints 0-65535, or space delimited <index>:<pwm> pairs |
| 447 | 607 | void cmdPWMPins() { |
| 448 | 608 | diagPrintln("GOT pwm"); |
| 449 | // Hard-write cancels any in-flight pwm2 fade so updateFade() doesn't fight us. | |
| 450 | pwm2Fade.active = false; | |
| 451 | 609 | const char *SetPWMUsageMsg = "Error: takes up to 12 arguments between 0 and 65535 (except argument 3's max is 45000)."; |
| 452 | 610 | |
| 453 | 611 | long channelValues[NumPWMPins]; |
| … | … | @@ -485,22 +643,11 @@ void cmdPWMPins() { |
| 485 | 643 | count++; |
| 486 | 644 | } while ((arg = CmdMgr.next())); |
| 487 | 645 | |
| 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()); | |
| 504 | 651 | |
| 505 | 652 | printAck("OK set "); |
| 506 | 653 | printAck(count); |
| … | … | @@ -532,6 +679,7 @@ void cmdGamma() { |
| 532 | 679 | float v = strtof(arg, NULL); |
| 533 | 680 | if (v >= 0.5f && v <= 5.0f) channelGamma[i] = v; |
| 534 | 681 | } |
| 682 | for (int i = 0; i < 3; i++) engine.gamma[i] = channelGamma[i]; | |
| 535 | 683 | char buf[80]; |
| 536 | 684 | snprintf(buf, sizeof(buf), "gamma B=%.2f G=%.2f R=%.2f", |
| 537 | 685 | channelGamma[0], channelGamma[1], channelGamma[2]); |
| … | … | @@ -627,12 +775,36 @@ void setup() { |
| 627 | 775 | Serial.print("Hostname: "); |
| 628 | 776 | Serial.println(currentHostname); |
| 629 | 777 | |
| 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 | ||
| 630 | 804 | ledsetup(); |
| 631 | 805 | // 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(); | |
| 636 | 808 | // Kick off WiFi here; loop() will poll until associated. Keeping setup() |
| 637 | 809 | // short avoids the TG1 task watchdog firing on a slow cold boot. |
| 638 | 810 | WiFi.begin(ssid, password); |
| … | … | @@ -660,10 +832,8 @@ void loop() { |
| 660 | 832 | const unsigned long FADE_TOTAL_MS = 10000; |
| 661 | 833 | unsigned long elapsed = millis() - fadeStartMs; |
| 662 | 834 | 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(); | |
| 667 | 837 | Serial.println("Boot fade test complete"); |
| 668 | 838 | initState = WAIT_WIFI; |
| 669 | 839 | break; |
| … | … | @@ -741,7 +911,7 @@ void loop() { |
| 741 | 911 | break; |
| 742 | 912 | |
| 743 | 913 | case RUNNING: { |
| 744 | updateFade(); // advance any in-flight pwm2 fade | |
| 914 | engine.tick(millis()); // fades, grant idle/max expiry | |
| 745 | 915 | CmdMgr.readSerial(); |
| 746 | 916 | ArduinoOTA.handle(); |
| 747 | 917 | |
| … | … | @@ -757,31 +927,41 @@ void loop() { |
| 757 | 927 | } |
| 758 | 928 | |
| 759 | 929 | 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 --- | |
| 761 | 931 | WiFiClient incoming = server.accept(); |
| 762 | 932 | if (incoming) { |
| 763 | int slot = -1; | |
| 933 | bool occupied[MAX_CLIENTS], holds[MAX_CLIENTS]; | |
| 934 | uint32_t last[MAX_CLIENTS]; | |
| 764 | 935 | 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; | |
| 766 | 939 | } |
| 940 | int slot = cth::pickSlot(occupied, last, holds, MAX_CLIENTS, millis()); | |
| 767 | 941 | 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); | |
| 775 | 950 | } |
| 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); | |
| 778 | 964 | } |
| 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"); | |
| 785 | 965 | } |
| 786 | 966 | |
| 787 | 967 | // --- service every slot --- |
| … | … | @@ -789,28 +969,32 @@ void loop() { |
| 789 | 969 | ClientSlot &c = clients[i]; |
| 790 | 970 | if (!c.sock) continue; |
| 791 | 971 | |
| 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]; | |
| 801 | 983 | c.lastByteMs = millis(); |
| 802 | Serial.write(ch); | |
| 984 | if (diagVerbose) Serial.write(ch); | |
| 803 | 985 | |
| 804 | 986 | if (ch == '\n' || ch == '\r') { |
| 805 | if (c.len > 0) { | |
| 987 | if (c.len > 0 && c.cap.allow(millis())) { | |
| 806 | 988 | // Hand over one whole command, with this client marked active |
| 807 | 989 | // so its replies come back to it and not to some other session. |
| 808 | 990 | ActiveClient = &c.sock; |
| 991 | ActiveIndex = i; | |
| 809 | 992 | for (int k = 0; k < c.len; k++) CmdMgr.handleChar(c.buf[k]); |
| 810 | 993 | CmdMgr.handleChar('\n'); |
| 811 | 994 | ActiveClient = NULL; |
| 812 | c.len = 0; | |
| 995 | ActiveIndex = -1; | |
| 813 | 996 | } |
| 997 | c.len = 0; | |
| 814 | 998 | } else if (c.len < CLIENT_BUF_LEN - 1) { |
| 815 | 999 | c.buf[c.len++] = ch; |
| 816 | 1000 | } else { |
| … | … | @@ -819,6 +1003,14 @@ void loop() { |
| 819 | 1003 | c.len = 0; |
| 820 | 1004 | Serial.printf("Client %d: line too long, discarded\n", i); |
| 821 | 1005 | } |
| 1006 | } | |
| 1007 | } | |
| 1008 | if (closed) { | |
| 1009 | c.sock.stop(); | |
| 1010 | c.sock = WiFiClient(); | |
| 1011 | c.len = 0; | |
| 1012 | engine.socketClosed(i); | |
| 1013 | continue; | |
| 822 | 1014 | } |
| 823 | 1015 | |
| 824 | 1016 | // Idle-timeout: connected() can lie after FIN. Drop stale sessions. |
| … | … | @@ -827,10 +1019,46 @@ void loop() { |
| 827 | 1019 | c.sock.stop(); |
| 828 | 1020 | c.sock = WiFiClient(); |
| 829 | 1021 | c.len = 0; |
| 1022 | engine.socketClosed(i); | |
| 830 | 1023 | } |
| 831 | 1024 | } |
| 832 | 1025 | } |
| 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 | } | |
| 833 | 1037 | break; |
| 834 | 1038 | } |
| 835 | 1039 | } |
| 836 | 1040 | } |
| 1041 | ||
| 1042 | void 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 |
| … | … | @@ -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 | ||
| 29 | namespace cth { | |
| 30 | ||
| 31 | constexpr int SLOTS = 4; | |
| 32 | constexpr int CHANNELS = 12; | |
| 33 | constexpr uint32_t CHANNEL_MAX = 65535; | |
| 34 | constexpr uint32_t RED_MAX = 45000; // matches RED_MAX_PWM in the sketch | |
| 35 | constexpr uint32_t IDLE_MS = 2000; // no movement from the holder | |
| 36 | constexpr uint32_t MAX_MS = 30000; // hard cap on one grant | |
| 37 | constexpr uint32_t VALUES_EVERY_MS = 250; | |
| 38 | constexpr uint32_t MAX_FADE_MS = 600000; | |
| 39 | constexpr int HOLDER_LEN = 33; // 32 chars + NUL | |
| 40 | constexpr int NO_SOCK = -1; | |
| 41 | constexpr int ALL = -1; // slot argument meaning "all four" | |
| 42 | ||
| 43 | inline uint32_t channelMax(int ch) { return ch % 3 == 2 ? RED_MAX : CHANNEL_MAX; } | |
| 44 | ||
| 45 | enum class Why : uint8_t { Release, Idle, Max, Disconnect, Blackout }; | |
| 46 | ||
| 47 | inline 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 | ||
| 57 | struct 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 | ||
| 67 | class 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. | |
| 377 | inline 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). | |
| 386 | inline 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`). | |
| 401 | struct 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 |
| … | … | @@ -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 | ||
| 6 | Two clients (alice, bob) exercise grants, signed frees, timers, legacy pwm | |
| 7 | skipping held slots, stopfade, state, and epochfloor. Values stay dim. The | |
| 8 | lamp's colours are read at the start and written back at the end (red clamped | |
| 9 | to the 45000 cap the firmware enforces on input). | |
| 10 | ||
| 11 | Needs PyNaCl for signature checks. | |
| 12 | """ | |
| 13 | ||
| 14 | import socket | |
| 15 | import sys | |
| 16 | import threading | |
| 17 | import time | |
| 18 | ||
| 19 | from nacl.signing import VerifyKey | |
| 20 | ||
| 21 | HOST = sys.argv[1] if len(sys.argv) > 1 else "192.168.50.89" | |
| 22 | PORT = 1337 | |
| 23 | failures = [] | |
| 24 | ||
| 25 | ||
| 26 | def 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 | ||
| 32 | class 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 | ||
| 87 | def starts(prefix): | |
| 88 | return lambda l: l.startswith(prefix) | |
| 89 | ||
| 90 | ||
| 91 | def 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 | ||
| 102 | a = Conn("alice") | |
| 103 | b = Conn("bob") | |
| 104 | ||
| 105 | hello = a.wait(starts("hello ")) | |
| 106 | check("greeting line 1 unchanged", a.wait(lambda l: l.endswith(" ready")) is not None) | |
| 107 | check("hello line carries proto 3", hello is not None and "proto=3" in hello, str(hello)) | |
| 108 | fields = dict(kv.split("=", 1) for kv in hello.split()[1:]) if hello else {} | |
| 109 | UNIT, EPOCH, PK = fields.get("unit"), int(fields.get("epoch", "0")), fields.get("pk", "") | |
| 110 | print("unit=%s epoch=%d pk=%s…" % (UNIT, EPOCH, PK[:16])) | |
| 111 | ||
| 112 | # Remember the look to put back afterwards. | |
| 113 | m = a.mark() | |
| 114 | a.send("state") | |
| 115 | st = a.wait(starts("state "), m) | |
| 116 | before = [int(x) for x in st.split()[6:]] if st else None | |
| 117 | check("state reply", before is not None and len(before) == 12, str(st)) | |
| 118 | stats = a.wait(starts("stats "), m) | |
| 119 | check("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 ------------------------------------------------- | |
| 122 | ma, mb = a.mark(), b.mark() | |
| 123 | a.send("grant 0 alice") | |
| 124 | g = a.wait(starts("granted 0 "), ma) | |
| 125 | check("alice granted slot 0", g is not None, str(g)) | |
| 126 | n0 = int(g.split()[3]) if g else 0 | |
| 127 | check("bob sees held broadcast", b.wait(lambda l: l == "held 0 alice %d %d" % (EPOCH, n0), mb) is not None) | |
| 128 | mb = b.mark() | |
| 129 | b.send("grant 0 bob") | |
| 130 | check("bob refused: busy", b.wait(lambda l: l == "busy 0 alice", mb) is not None) | |
| 131 | b.send("slot 0 %d %d 1 1 1" % (EPOCH, n0)) | |
| 132 | check("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 ----------------------------- | |
| 135 | ma, mb = a.mark(), b.mark() | |
| 136 | a.send("slot 0 %d %d 3000 0 0" % (EPOCH, n0)) | |
| 137 | b.send("pwm 1 1 1 2000 0 0 0 0 0 0 0 0") | |
| 138 | vals = b.wait(lambda l: l.startswith("values ") and l.split()[1:4] == ["3000", "0", "0"] | |
| 139 | and l.split()[4] == "2000", mb) | |
| 140 | check("values broadcast: holder's slot kept, legacy applied elsewhere", vals is not None, str(vals)) | |
| 141 | check("slot write is silent on success", a.wait(lambda l: l.startswith("ERR"), ma, timeout=0.5) is None) | |
| 142 | ||
| 143 | # --- release -> signed free -------------------------------------------------------- | |
| 144 | mb = b.mark() | |
| 145 | a.send("release 0") | |
| 146 | fr = b.wait(starts("free 0 "), mb) | |
| 147 | check("release broadcasts free with final value", fr is not None and fr.split()[4:9] == ["3000", "0", "0", "release", "alice"], str(fr)) | |
| 148 | check("free signature verifies", fr is not None and verify_free(fr, UNIT, PK)) | |
| 149 | if 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) --------------------------------------------------------------- | |
| 155 | ma, mb = a.mark(), b.mark() | |
| 156 | a.send("grant 1 alice") | |
| 157 | g1 = a.wait(starts("granted 1 "), ma) | |
| 158 | t0 = time.monotonic() | |
| 159 | fr = b.wait(lambda l: l.startswith("free 1 ") and " idle " in l, mb, timeout=4) | |
| 160 | dt = time.monotonic() - t0 | |
| 161 | check("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 ------------------------------------------------------------------ | |
| 164 | c = Conn("carol") | |
| 165 | c.wait(starts("hello ")) | |
| 166 | mb = b.mark() | |
| 167 | c.send("grant 2 carol") | |
| 168 | b.wait(starts("held 2 carol"), mb) | |
| 169 | c.close() | |
| 170 | t0 = time.monotonic() | |
| 171 | fr = b.wait(lambda l: l.startswith("free 2 ") and " disconnect " in l, mb, timeout=6) | |
| 172 | dt = time.monotonic() - t0 | |
| 173 | check("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 -------------------------------------- | |
| 176 | ma, mb = a.mark(), b.mark() | |
| 177 | b.send("grant 3 bob") | |
| 178 | b.wait(starts("granted 3 "), mb) | |
| 179 | a.send("grant all alice") | |
| 180 | check("grant all refused while bob holds slot 3", a.wait(lambda l: l == "busy 3 bob", ma) is not None) | |
| 181 | mb = b.mark() | |
| 182 | a.send("stopfade") | |
| 183 | check("stopfade frees bob's slot (blackout)", b.wait(lambda l: l.startswith("free 3 ") and " blackout " in l, mb) is not None) | |
| 184 | ma = a.mark() | |
| 185 | a.send("grant all alice") | |
| 186 | granted = [a.wait(starts("granted %d " % s), ma) for s in range(4)] | |
| 187 | check("grant all succeeds when free", all(granted), str(granted)) | |
| 188 | ma = a.mark() | |
| 189 | a.send("state") | |
| 190 | helds = [a.wait(starts("held %d alice" % s), ma) for s in range(4)] | |
| 191 | check("state lists every held slot", all(helds)) | |
| 192 | a.send("release all") | |
| 193 | ||
| 194 | # --- epochfloor ------------------------------------------------------------------------- | |
| 195 | ma = a.mark() | |
| 196 | a.send("epochfloor %d" % (EPOCH + 5)) | |
| 197 | ep = a.wait(starts("epoch "), ma) | |
| 198 | check("epochfloor raises the epoch past the floor", ep == "epoch %d" % (EPOCH + 6), str(ep)) | |
| 199 | ma = a.mark() | |
| 200 | a.send("grant 0 alice") | |
| 201 | g = a.wait(starts("granted 0 "), ma) | |
| 202 | check("new grants use the new epoch, numbering restarted", g == "granted 0 %d 1" % (EPOCH + 6), str(g)) | |
| 203 | a.send("release 0") | |
| 204 | ma = a.mark() | |
| 205 | a.send("epochfloor 1") | |
| 206 | check("epochfloor never lowers", a.wait(starts("epoch "), ma) == "epoch %d" % (EPOCH + 6)) | |
| 207 | ||
| 208 | # --- put the look back ---------------------------------------------------------------- | |
| 209 | time.sleep(0.3) | |
| 210 | if 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) | |
| 214 | a.close() | |
| 215 | b.close() | |
| 216 | ||
| 217 | print("\n%d failure(s)" % len(failures) if failures else "\nALL PASS") | |
| 218 | sys.exit(1 if failures else 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 | ||
| 8 | using namespace cth; | |
| 9 | ||
| 10 | static 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 | ||
| 14 | static Engine fresh(uint32_t epoch = 7) { | |
| 15 | Engine e; | |
| 16 | e.begin(epoch, true); | |
| 17 | return e; | |
| 18 | } | |
| 19 | ||
| 20 | static 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 | ||
| 27 | static 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 | ||
| 42 | static 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 | ||
| 55 | static 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 | ||
| 70 | static 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 | ||
| 89 | static 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 | ||
| 112 | static 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 | ||
| 127 | static 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 | ||
| 139 | static 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 | ||
| 149 | static 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 | ||
| 168 | static 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 | ||
| 182 | static 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 | ||
| 194 | static 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 | ||
| 203 | static 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 | ||
| 216 | static 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 | ||
| 229 | static 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 | ||
| 241 | static 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 | ||
| 261 | static 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 | ||
| 269 | int 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 | } |