OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / esp324xlight / grants.h

420 lines · c ref:
// 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