// Shared-control engine for esp324xlight (command surface v3).
//
// Plain C++ with no Arduino dependencies, so the whole thing is tested on a
// host with a fake clock (test/grants_test.cpp) before it ever reaches a unit.
// The sketch owns sockets, NVS, signing and pins; this owns the decisions.
//
// What it decides (docs/DESIGN-collab.md §5):
// * Per-slot exclusive grants, first come first served. A unit is the
// referee because every controller has to reach it to change a light.
// * Grants end on release, 2 s without a `slot` from the holder, 30 s after
// the grant, the holder's socket going away, or stopfade/blackout.
// * Per-CHANNEL fades. The old firmware had one unit-wide pwm2 fade, so two
// people working different slots would have cancelled each other's fades.
// * Legacy whole-unit pwm/pwm2 still works, but only changes UNHELD slots.
// * A `values` broadcast at most every 250 ms when pins changed, so
// controllers never have to poll `state`.
//
// Values are protocol space throughout: 0..65535, BGR per slot, red capped.
// All times are uint32 milliseconds and only ever compared by subtraction, so
// millis() wrapping after ~49 days is harmless.
#pragma once
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace cth {
constexpr int SLOTS = 4;
constexpr int CHANNELS = 12;
constexpr uint32_t CHANNEL_MAX = 65535;
constexpr uint32_t RED_MAX = 45000; // matches RED_MAX_PWM in the sketch
constexpr uint32_t IDLE_MS = 2000; // no movement from the holder
constexpr uint32_t MAX_MS = 30000; // hard cap on one grant
constexpr uint32_t VALUES_EVERY_MS = 250;
constexpr uint32_t MAX_FADE_MS = 600000;
constexpr int HOLDER_LEN = 33; // 32 chars + NUL
constexpr int NO_SOCK = -1;
constexpr int ALL = -1; // slot argument meaning "all four"
inline uint32_t channelMax(int ch) { return ch % 3 == 2 ? RED_MAX : CHANNEL_MAX; }
enum class Why : uint8_t { Release, Idle, Max, Disconnect, Blackout };
inline const char *whyName(Why w) {
switch (w) {
case Why::Release: return "release";
case Why::Idle: return "idle";
case Why::Max: return "max";
case Why::Disconnect: return "disconnect";
default: return "blackout";
}
}
struct Event {
enum Kind : uint8_t { Held, Free } kind;
int slot;
char holder[HOLDER_LEN];
uint32_t epoch;
uint32_t n;
uint32_t b, g, r; // Free: the value the slot ends on
Why why; // Free only
};
class Engine {
public:
float gamma[3] = {1.0f, 1.0f, 1.0f}; // B, G, R — perceptual fade exponents
// epoch: this boot's number (NVS). grantsEnabled false when the epoch could
// not be persisted: a grant numbered from a stale epoch could lose to an old
// commit forever, so the unit refuses grants rather than issue one.
void begin(uint32_t epoch, bool grantsEnabled) {
epoch_ = epoch;
enabled_ = grantsEnabled;
counter_ = 0;
for (int s = 0; s < SLOTS; s++) slots_[s] = Slot();
for (int c = 0; c < CHANNELS; c++) { fades_[c] = Fade(); cur_[c] = 0; target_[c] = 0; }
evHead_ = evCount_ = 0;
dirty_ = false;
lastValuesMs_ = 0;
valuesEverSent_ = false;
}
uint32_t epoch() const { return epoch_; }
// `epochfloor` recovery: switch to a higher epoch while running. Every grant
// under the old epoch ends first (frees carry the OLD epoch, so they stay
// valid commits); numbering restarts under the new one. Never lowers.
void setEpoch(uint32_t epoch) {
if (epoch <= epoch_) return;
for (int s = 0; s < SLOTS; s++) if (slots_[s].sock != NO_SOCK) freeSlot(s, Why::Blackout);
epoch_ = epoch;
counter_ = 0;
enabled_ = true;
}
bool grantsEnabled() const { return enabled_; }
// ---- grants --------------------------------------------------------------
enum GrantRc { Granted, Busy, Disabled, BadSlot };
// slot 0..3, or ALL (atomic: all four or none). On Busy, busySlot names the
// first slot that someone else holds. Re-granting a slot this socket already
// holds keeps its number and its 30 s clock (a re-grab can't extend a drag).
GrantRc grant(int slot, int sock, const char *holder, uint32_t now, int &busySlot) {
busySlot = -1;
if (!enabled_) return Disabled;
int lo, hi;
if (!range(slot, lo, hi)) return BadSlot;
for (int s = lo; s <= hi; s++) {
if (slots_[s].sock != NO_SOCK && slots_[s].sock != sock) { busySlot = s; return Busy; }
}
for (int s = lo; s <= hi; s++) {
Slot &h = slots_[s];
if (h.sock == sock) {
h.lastMoveMs = now;
continue;
}
h.sock = sock;
copyHolder(h.holder, holder);
h.n = ++counter_;
h.grantedMs = now;
h.lastMoveMs = now;
Event e = blankEvent(Event::Held, s);
copyHolder(e.holder, h.holder);
e.n = h.n;
push(e);
}
return Granted;
}
// Holder ends its grant. False if this socket holds none of the slots.
bool release(int slot, int sock, uint32_t now) {
int lo, hi;
if (!range(slot, lo, hi)) return false;
bool any = false;
for (int s = lo; s <= hi; s++) {
if (slots_[s].sock == sock) { freeSlot(s, Why::Release); any = true; }
}
(void)now;
return any;
}
enum SetRc { SetOk, NotHeld, BadValue };
// Holder sets a slot's colour, optionally fading to it. Requires the socket
// AND the grant number to match, so a stale client can't write with an old
// grant after someone else took the slot.
SetRc set(int slot, int sock, uint32_t epoch, uint32_t n,
uint32_t b, uint32_t g, uint32_t r, uint32_t fadeMs, uint32_t now) {
if (slot < 0 || slot >= SLOTS) return NotHeld;
Slot &h = slots_[slot];
if (h.sock == NO_SOCK || h.sock != sock || epoch != epoch_ || n != h.n) return NotHeld;
if (b > CHANNEL_MAX || g > CHANNEL_MAX || r > RED_MAX || fadeMs > MAX_FADE_MS) return BadValue;
const uint32_t v[3] = {b, g, r};
for (int k = 0; k < 3; k++) setChannel(slot * 3 + k, v[k], fadeMs, now);
h.lastMoveMs = now;
return SetOk;
}
// The holder's socket went away (closed, evicted, idle-dropped).
void socketClosed(int sock) {
for (int s = 0; s < SLOTS; s++) if (slots_[s].sock == sock) freeSlot(s, Why::Disconnect);
}
// `stopfade`: freeze every channel where it is, and end every grant. This is
// the emergency stop (Blackout sends it before an all-zero pwm), so it
// overrides anyone's drag.
void stopAll() {
for (int c = 0; c < CHANNELS; c++) {
fades_[c].active = false;
target_[c] = cur_[c];
}
for (int s = 0; s < SLOTS; s++) if (slots_[s].sock != NO_SOCK) freeSlot(s, Why::Blackout);
}
// Legacy whole-unit pwm (fadeMs 0) / pwm2: channels 0..count-1, skipping any
// channel whose slot is held. Values must already be validated by the caller
// (the sketch's parsePWM does, with the red cap). Returns channels changed.
int legacy(const uint32_t *vals, int count, uint32_t fadeMs, uint32_t now) {
int applied = 0;
if (count > CHANNELS) count = CHANNELS;
for (int c = 0; c < count; c++) {
if (slots_[c / 3].sock != NO_SOCK) continue;
setChannel(c, vals[c], fadeMs, now);
applied++;
}
return applied;
}
// Set every channel at once, ignoring grants (boot only).
void forceAll(uint32_t v) {
for (int c = 0; c < CHANNELS; c++) {
fades_[c].active = false;
cur_[c] = target_[c] = v > CHANNEL_MAX ? CHANNEL_MAX : (uint16_t)v;
}
dirty_ = true;
}
// ---- time ----------------------------------------------------------------
// Advance fades and grant timers. Returns true when any channel's current
// value changed (the sketch then writes the pins).
bool tick(uint32_t now) {
bool changed = false;
for (int c = 0; c < CHANNELS; c++) {
Fade &f = fades_[c];
if (!f.active) continue;
uint32_t elapsed = now - f.startMs;
bool done = elapsed >= f.totalMs;
uint16_t v = done ? target_[c] : fadeValue(c, f, elapsed);
if (v != cur_[c]) { cur_[c] = v; changed = true; }
if (done) f.active = false;
}
for (int s = 0; s < SLOTS; s++) {
Slot &h = slots_[s];
if (h.sock == NO_SOCK) continue;
if (now - h.grantedMs >= MAX_MS) freeSlot(s, Why::Max);
else if (now - h.lastMoveMs >= IDLE_MS) freeSlot(s, Why::Idle);
}
if (changed) dirty_ = true;
return changed;
}
// True once per 250 ms window when values changed since the last broadcast.
bool valuesDue(uint32_t now) {
if (!dirty_) return false;
if (valuesEverSent_ && now - lastValuesMs_ < VALUES_EVERY_MS) return false;
dirty_ = false;
lastValuesMs_ = now;
valuesEverSent_ = true;
return true;
}
// ---- reads -----------------------------------------------------------------
const uint16_t *values() const { return cur_; }
uint16_t value(int c) const { return cur_[c]; }
bool anyFading() const {
for (int c = 0; c < CHANNELS; c++) if (fades_[c].active) return true;
return false;
}
bool held(int slot, const char *&holder, uint32_t &n) const {
if (slot < 0 || slot >= SLOTS || slots_[slot].sock == NO_SOCK) return false;
holder = slots_[slot].holder;
n = slots_[slot].n;
return true;
}
bool holdsAny(int sock) const {
for (int s = 0; s < SLOTS; s++) if (slots_[s].sock == sock) return true;
return false;
}
bool popEvent(Event &out) {
if (evCount_ == 0) return false;
out = events_[evHead_];
evHead_ = (evHead_ + 1) % QUEUE;
evCount_--;
return true;
}
int pendingEvents() const { return evCount_; }
private:
struct Slot {
int sock = NO_SOCK;
char holder[HOLDER_LEN] = {0};
uint32_t n = 0;
uint32_t grantedMs = 0;
uint32_t lastMoveMs = 0;
};
struct Fade {
bool active = false;
uint32_t startMs = 0;
uint32_t totalMs = 0;
float p0 = 0, p1 = 0; // perceptual start/end: (v/65535)^(1/gamma)
};
static constexpr int QUEUE = 32;
bool range(int slot, int &lo, int &hi) const {
if (slot == ALL) { lo = 0; hi = SLOTS - 1; return true; }
if (slot < 0 || slot >= SLOTS) return false;
lo = hi = slot;
return true;
}
static void copyHolder(char *dst, const char *src) {
strncpy(dst, src ? src : "", HOLDER_LEN - 1);
dst[HOLDER_LEN - 1] = '\0';
}
Event blankEvent(Event::Kind k, int slot) const {
Event e;
memset(&e, 0, sizeof(e));
e.kind = k;
e.slot = slot;
e.epoch = epoch_;
return e;
}
void freeSlot(int s, Why why) {
Slot &h = slots_[s];
Event e = blankEvent(Event::Free, s);
copyHolder(e.holder, h.holder);
e.n = h.n;
// The value the slot ENDS on (a fade may still be running): that is what
// the lamp will show, and what controllers commit as durable state.
e.b = target_[s * 3];
e.g = target_[s * 3 + 1];
e.r = target_[s * 3 + 2];
e.why = why;
h = Slot();
push(e);
}
void push(const Event &e) {
if (evCount_ == QUEUE) { // never expected (sketch drains every loop): drop oldest
evHead_ = (evHead_ + 1) % QUEUE;
evCount_--;
}
events_[(evHead_ + evCount_) % QUEUE] = e;
evCount_++;
}
void setChannel(int c, uint32_t v, uint32_t fadeMs, uint32_t now) {
target_[c] = (uint16_t)v;
Fade &f = fades_[c];
if (fadeMs == 0) {
f.active = false;
if (cur_[c] != v) { cur_[c] = (uint16_t)v; dirty_ = true; }
return;
}
// Start from where the LED actually is, so a new fade mid-fade picks up
// from the live value rather than the previous start.
float invG = 1.0f / gamma[c % 3];
f.p0 = powf(cur_[c] / 65535.0f, invG);
f.p1 = powf(v / 65535.0f, invG);
f.startMs = now;
f.totalMs = fadeMs;
f.active = true;
}
uint16_t fadeValue(int c, const Fade &f, uint32_t elapsed) const {
float t = elapsed / (float)f.totalMs;
float p = f.p0 + (f.p1 - f.p0) * t;
float lum = powf(p, gamma[c % 3]);
long v = (long)(lum * 65535.0f);
if (v < 0) v = 0;
if (v > (long)channelMax(c)) v = channelMax(c);
return (uint16_t)v;
}
uint32_t epoch_ = 0;
bool enabled_ = false;
uint32_t counter_ = 0;
Slot slots_[SLOTS];
Fade fades_[CHANNELS];
uint16_t cur_[CHANNELS] = {0};
uint16_t target_[CHANNELS] = {0};
Event events_[QUEUE];
int evHead_ = 0, evCount_ = 0;
bool dirty_ = false;
uint32_t lastValuesMs_ = 0;
bool valuesEverSent_ = false;
};
// The exact bytes a unit signs for a `free`. Plain ASCII so controllers in any
// language rebuild it from the broadcast line alone:
// "cthuloid/free/1 <unitId> <slot> <epoch> <n> <b> <g> <r>"
// `why` is deliberately NOT signed: it is advisory, never stored.
inline int freeMessage(char *buf, size_t len, const char *unitId, const Event &e) {
return snprintf(buf, len, "cthuloid/free/1 %s %d %lu %lu %lu %lu %lu", unitId, e.slot,
(unsigned long)e.epoch, (unsigned long)e.n,
(unsigned long)e.b, (unsigned long)e.g, (unsigned long)e.r);
}
// Which client slot to give a new connection. Returns a free slot if there is
// one; otherwise the least recently active socket that holds NO grant; -1 if
// every socket holds a grant (refuse the newcomer rather than drop a drag).
inline int pickSlot(const bool *occupied, const uint32_t *lastByteMs, const bool *holds,
int n, uint32_t now) {
for (int i = 0; i < n; i++) if (!occupied[i]) return i;
int best = -1;
uint32_t bestAge = 0;
for (int i = 0; i < n; i++) {
if (holds[i]) continue;
uint32_t age = now - lastByteMs[i];
if (best < 0 || age > bestAge) { best = i; bestAge = age; }
}
return best;
}
// Per-socket flood guard: at most `limit` command lines per 1 s window; the
// excess is dropped and counted (reported in `state`).
struct RateCap {
uint32_t windowStart = 0;
uint32_t count = 0;
uint32_t dropped = 0;
bool started = false;
bool allow(uint32_t now, uint32_t limit = 60) {
if (!started || now - windowStart >= 1000) {
started = true;
windowStart = now;
count = 0;
}
if (count >= limit) { dropped++; return false; }
count++;
return true;
}
};
} // namespace cth