// Arduino sketch which outputs PWM for a set of channels based on serial input.
//
// Migrated from 32wifi12frak.ino for ESP32 Arduino core 3.x:
// - ledcSetup() + ledcAttachPin() replaced with ledcAttach() / ledcChangeFrequency()
// - ledcWrite() is now keyed off the GPIO pin, not the channel
// - WiFiServer::available() replaced with accept()
#include <WiFi.h>
#include <ESPmDNS.h>
#include <WiFiClient.h>
#include <ArduinoOTA.h>
#include <Preferences.h>
#include <sodium.h>
#include <lwip/sockets.h>
#include <errno.h>
#include "grants.h" // shared-control engine; host-tested in test/grants_test.cpp
// Default hostname compiled into firmware. Runtime hostname is loaded from NVS
// on boot; if NVS is empty (first boot after a fresh flash), this is the
// fallback. Change at runtime with the "hostname <name>" command, then "reboot".
#define HOSTNAME "studiolight"
// Version of the TCP command surface, advertised in the _cthuloid._tcp mDNS TXT
// record so a controller can tell what a unit understands before sending to it.
// 2 = adds `state`. 3 = shared control: per-slot grants, signed `free`,
// `values` broadcasts (docs/DESIGN-collab.md §5).
#define CTHULOID_PROTO "3"
// Password required to push OTA updates. Change before flashing if you don't
// want anyone on the WiFi network to be able to overwrite the firmware.
#define OTA_PASSWORD "studiolight-ota"
// Persistent storage and runtime hostname
Preferences prefs;
char currentHostname[64];
const char* ssid = "prsm space";
const char* password = "";
// TCP server at port 1337
WiFiServer server(1337);
const int timeout = 30;
// Serial commands accepted:
// - pwm <pwm1> <pwm2> ..., where <pwmN> is 0-65535 and N is up to NumChannels
// - pwm <index1>:<pwm1> <index2>:<pwm2> ..., where <indexN> is 1-based index into the PWMPins list
// - plevel <level>, change the amount of response text
#include <SC.h>
#include <PrintLevel.h>
// Active-low LED drivers: GPIO high = LED off, GPIO low = LED on.
// With INVERT, input 0 → output dutyMax (pin high) → LED off (intuitive 0=off).
#define INVERT_HIGH_AND_LOW
// Red diodes have lower voltage tolerance than green/blue — cap their input
// so they can't be driven to full brightness. Applied to the 3rd pin in each
// RGB triple (count % 3 == 2). Input range is 0..65535; 45000 ≈ 69% max.
#define RED_MAX_PWM 45000
#define MAX_PWM 65535 // full 16-bit range; LEDC handles duty=max via max+1 special case
//////////////////////////////////////////////////////////////////////////////////////
// 1 kHz matches the bandwidth of an ESP32-direct-driven MOSFET (no gate-driver
// IC). At higher freqs the MOSFET spends a non-trivial fraction of each cycle
// in its linear region, and switching losses dim the LEDs (worst on red, which
// carries more current). 1 kHz also keeps full 16-bit resolution (dutyMax=65535).
// At runtime, change with `freq <hz>` — but expect dimming above ~1.2 kHz on this
// hardware until a proper gate driver IC is added.
int freq = 1000;
int res = 16; // recomputed from freq in computeRes()
int dutyMax = 65535; // (1 << res) - 1
int steps = 1;
int defaultlight = 4000;
int maxfreq = 40000;
// ESP32 LEDC clock — APB is 80 MHz. Max resolution at a given freq is
// floor(log2(80_000_000 / freq))
// e.g. 20 kHz → 11 bits, 1 kHz → 16 bits.
void computeRes() {
const long ledcClk = 80000000L;
int r = 16;
while (r > 1 && ((long)freq << r) > ledcClk) r--;
res = r;
dutyMax = (1 << res) - 1;
}
const int PWMPins[] = {32, 33, 25, 26, 27, 14, 12, 13, 2, 23, 22, 21};
const int NumPWMPins = sizeof(PWMPins) / sizeof(*PWMPins);
// Grants, timers, fades and the current/target value of every channel live in
// the engine; the sketch only writes what it says to the pins.
cth::Engine engine;
uint16_t PinsWritten[NumPWMPins];
bool pinsWrittenValid = false;
// This unit's signing identity (NVS). It signs every `free` -- the only thing
// that becomes durable -- so controllers can reject a forged grant number or a
// value the lamp never ended on (DESIGN-collab.md §5.7).
unsigned char unitPk[crypto_sign_PUBLICKEYBYTES];
unsigned char unitSk[crypto_sign_SECRETKEYBYTES];
char unitPkHex[2 * crypto_sign_PUBLICKEYBYTES + 1];
uint32_t bootEpoch = 0;
// tracks whether ledcAttach has been called for each pin yet
bool PinAttached[NumPWMPins] = {false};
// this flag will invert PWM output (65535 - PWM), for active-low devices
const char *MsgPWMTupleFormatError = "Error: takes up to 6 arguments between 0 and 255";
/////////////////////////////////////////////////////////////////////////////////////////
// TCP client tracking + diagnostic helpers
// Works around arduino-esp32 issues #1527 (connected() lies after FIN) and #4773
// (server.accept() can leave stale clients in place).
/////////////////////////////////////////////////////////////////////////////////////////
// Several clients may be connected at once (e.g. the Blender addon driving the
// rig while a telnet session is open to inspect it). Previously a single
// LastClient was kept and every new connection stop()ed the incumbent, so two
// controllers evicted each other indefinitely rather than coexisting.
//
// Each slot carries its OWN line buffer. That is required, not cosmetic:
// CmdMgr is one SerialCommand with a single internal buffer, so feeding it
// bytes from two clients as they arrive would splice two commands together and
// corrupt both. Bytes are accumulated per client and only handed over once a
// complete line has arrived, which keeps every command atomic.
// 6, not 4: stortron, Blender and a few phones must fit without anyone being
// evicted; eviction never picks a socket that holds a grant (cth::pickSlot).
const int MAX_CLIENTS = 6;
const int CLIENT_BUF_LEN = 160; // longest real command is a 12-value pwm2
struct ClientSlot {
WiFiClient sock;
unsigned long lastByteMs = 0;
char buf[CLIENT_BUF_LEN];
int len = 0;
cth::RateCap cap; // per-socket flood guard (60 lines/s)
};
ClientSlot clients[MAX_CLIENTS];
// Set for the duration of one command dispatch so replies reach whoever asked.
// NULL means the command came from the serial console, so replies go to Serial
// alone.
WiFiClient *ActiveClient = NULL;
int ActiveIndex = -1; // client slot of the active command, -1 for serial
const unsigned long CLIENT_IDLE_TIMEOUT_MS = 300000; // 5 min — drop a dead session
// Client sockets are READ directly with recv() on the fd, never through
// NetworkClient's available()/read()/connected(). Measured on studiolight
// (core 3.3.8): when a peer closes, available() refills its buffer with recv(),
// gets 0 (the FIN) and treats it as "no data" -- the close is swallowed and
// nothing can see it afterwards; connected() then reads a stale errno and keeps
// saying "connected" (arduino-esp32 #1527). A grant holder that disconnected was
// only noticed by the 2 s idle timer. Owning the read, 0 always means "closed".
// int, not an enum: the Arduino builder hoists function prototypes above any
// type declared in the sketch.
const int READ_MORE = 0, READ_IDLE = 1, READ_CLOSED = 2;
int readSome(WiFiClient &c, uint8_t *buf, int cap, int &got) {
got = 0;
int fd = c.fd();
if (fd < 0) return READ_CLOSED;
errno = 0;
int res = recv(fd, buf, cap, MSG_DONTWAIT);
if (res > 0) { got = res; return READ_MORE; }
if (res == 0) return READ_CLOSED;
return (errno == EWOULDBLOCK || errno == EAGAIN) ? READ_IDLE : READ_CLOSED;
}
// Write to every connected client, for messages not tied to one request.
void broadcastLine(const char *s) {
for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i].sock) clients[i].sock.println(s);
}
}
// Turn engine events into broadcasts. A `free` is signed: it carries the value
// the slot ends on, which is the only thing controllers store, so they verify it
// came from this unit (DESIGN-collab.md §5.7). ~9.4 ms per signature, measured
// on studiolight -- once per release, never per drag frame.
void broadcastEvents();
// When true, command handlers also write GOT/OK/ERR markers to the active telnet
// client, and every received byte is echoed to the serial console, so you can see
// whether commands are reaching the parser. Toggle at runtime with "verbose 0" or
// "verbose 1". Default OFF since proto 3: the byte echo and markers cost ~30 ms of
// loop per pwm at 115200 baud, on every command from every controller.
bool diagVerbose = false;
// Per-channel gamma exponents (indexed by RGB triple position: 0=Blue, 1=Green, 2=Red,
// matching Blender's BGR send order). Used by pwm2's perceptual interpolation. Higher
// gamma slows that channel's perceived ramp; tune red higher if it visually leads.
// Adjust at runtime with "gamma <b> <g> <r>" (range 0.5–5.0).
// Default 1.0 = linear interpolation in input value (no gamma correction).
float channelGamma[3] = {1.0f, 1.0f, 1.0f};
void diagPrint(const char *s) {
Serial.print(s);
if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->print(s);
}
void diagPrint(long v) {
Serial.print(v);
if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->print(v);
}
void diagPrintln(const char *s) {
Serial.println(s);
if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->println(s);
}
void diagPrintln(long v) {
Serial.println(v);
if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->println(v);
}
/////////////////////////////////////////////////////////////////////////////////////////
// Command Manager and forward declarations for commands accessible from text interface
/////////////////////////////////////////////////////////////////////////////////////////
struct IDTuple {
int id;
long value;
};
int parseID(const char *arg);
int parseListOfIDs(int *outIDs, int maxIDs);
void cmdUnrecognized(const char *cmd);
void cmdSetFrequency();
void cmdPWMPins();
void cmdPWMPins2();
void cmdSetPrintLevel();
void cmdServo();
void cmdVerbose();
void cmdGamma();
void cmdHostname();
void cmdReboot();
void cmdStopFade();
void cmdState();
void cmdGrant();
void cmdSlot();
void cmdRelease();
void cmdEpochFloor();
SerialCommand::Entry CommandsList[] = {
{"plevel", cmdSetPrintLevel},
{"freq", cmdSetFrequency},
{"pwm", cmdPWMPins},
{"pwm2", cmdPWMPins2},
{"s", cmdServo},
{"verbose", cmdVerbose},
{"gamma", cmdGamma},
{"hostname", cmdHostname},
{"reboot", cmdReboot},
{"stopfade", cmdStopFade},
{"state", cmdState},
{"grant", cmdGrant},
{"slot", cmdSlot},
{"release", cmdRelease},
{"epochfloor", cmdEpochFloor},
{NULL, NULL}
};
SerialCommand CmdMgr(CommandsList, cmdUnrecognized);
/////////////////////////////////////////////////////////////////////////////////////////////
// helpers
/////////////////////////////////////////////////////////////////////////////////////////////
// Callers always pass values in 0..65535 (the protocol range).
// We scale down to the actual duty range, then invert if configured.
//
// Clamp at dutyMax-1 instead of dutyMax to avoid the LEDC peripheral's
// "full ON" special case: when duty == max_duty exactly, the driver bumps
// it internally to max_duty+1 ("ON for the whole period, no transition"),
// which causes a subtle one-tick brightness pop at the endpoints of any
// full-range fade. Capping at max-1 keeps the behaviour smooth — we lose
// one duty level out of 65536, which is imperceptible.
unsigned long invertPWM(unsigned long c) {
if (c > 65535) c = 65535;
unsigned long scaled = (c * dutyMax) / 65535;
#ifdef INVERT_HIGH_AND_LOW
scaled = dutyMax - scaled;
#endif
if (scaled >= (unsigned long)dutyMax) scaled = dutyMax - 1;
return scaled;
}
void myHRWrite(int pin, unsigned value) {
ledcWrite(pin, invertPWM(value));
}
void updateFrequency() {
computeRes();
for (int i = 0; i < NumPWMPins; i++) {
if (PinAttached[i]) ledcDetach(PWMPins[i]);
if (!ledcAttach(PWMPins[i], freq, res)) {
Serial.printf("ledcAttach FAILED for pin %d at %d Hz / %d-bit\n",
PWMPins[i], freq, res);
PinAttached[i] = false;
} else {
PinAttached[i] = true;
}
}
Serial.printf("PWM: %d Hz at %d-bit (dutyMax=%d)\n", freq, res, dutyMax);
}
void ledsetup() {
updateFrequency();
}
// Takes a string of an integer (numeric chars only). Returns the integer on success.
// Prints error and returns 0 if there is a parse error or the ID is out of range.
int parseID(const char *arg) {
char *end;
int id = strtol(arg, &end, 10);
if (*end != '\0' || id < 1 || id > 253) {
printlnError("Error: ID must be between 1 and 253");
return 0;
}
return id;
}
// Takes a string of an integer (numeric chars only). Returns the integer on success.
// Prints error and returns -1 if there is a parse error or the PWM value is out of range.
long parsePWM(const char *arg, long maxPWM) {
char *end;
long v = strtol(arg, &end, 10);
if (*end != '\0' || v < 0 || v > maxPWM) {
printlnError("Error: PWM value must be between 0 and ");
printlnError(maxPWM);
return -1;
}
return v;
}
void cmdUnrecognized(const char *cmd) {
printlnError("unrecognized command");
diagPrint("ERR unrecognized: ");
diagPrintln(cmd);
}
// if no argument, prints the frequency
// if one argument, sets the frequency and then prints it
void cmdSetFrequency() {
diagPrintln("GOT freq");
if(char *arg = CmdMgr.next()) {
if(CmdMgr.next()) {
printlnError("Error: takes 0 or 1 arguments");
diagPrintln("ERR freq too many args");
return;
}
char *end;
double v = strtod(arg, &end);
if(*end != '\0' || v < 1 || v > maxfreq) {
printlnError("Error: frequency must be between 1 and maxfreq Hz");
diagPrintln("ERR freq out of range");
return;
}
freq = round(v);
}
updateFrequency();
printAck("frequency: ");
printlnAck(freq);
// Report the actual chosen freq + the auto-selected resolution back to telnet
char buf[80];
snprintf(buf, sizeof(buf), "OK freq %d Hz at %d-bit (dutyMax=%d)", freq, res, dutyMax);
Serial.println(buf);
if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->println(buf);
}
// pwm2 <numPeriods> <periodLengthMs> <v1> ... <v12>
// Interpolates from each pin's current value to <vN> over numPeriods steps of
// periodLengthMs ms each. Total fade time = numPeriods * periodLengthMs.
// Examples:
// pwm2 100 10 0 0 0 ... 0 → fade all to "on" over 1 second (100 × 10ms)
// pwm2 50 100 65535 ... 65535 → fade all to "off" over 5 seconds (50 × 100ms)
void cmdPWMPins2() {
diagPrintln("GOT pwm2");
const char *Usage = "Usage: pwm2 <numPeriods 1-1000> <periodMs 1-1000> <v1> ... <v12>";
// --- parse numPeriods ---
char *arg = CmdMgr.next();
if (arg == NULL) { printlnError(Usage); diagPrintln("ERR pwm2 no numPeriods"); return; }
long numPeriods = parsePWM(arg, 1000);
if (numPeriods < 1) { printlnError(Usage); diagPrintln("ERR pwm2 bad numPeriods"); return; }
// --- parse periodLength ---
arg = CmdMgr.next();
if (arg == NULL) { printlnError(Usage); diagPrintln("ERR pwm2 no periodMs"); return; }
long periodLength = parsePWM(arg, 1000);
if (periodLength < 1) { printlnError(Usage); diagPrintln("ERR pwm2 bad periodMs"); return; }
// --- parse channel values ---
long channelValues[NumPWMPins];
int count = 0;
while ((arg = CmdMgr.next())) {
if (count >= NumPWMPins) { printlnError(Usage); diagPrintln("ERR pwm2 too many args"); return; }
long value = parsePWM(arg, count % 3 == 2 ? RED_MAX_PWM : MAX_PWM);
if (value < 0) {
printlnError(Usage);
diagPrint("ERR pwm2 bad arg at index ");
diagPrintln((long)count);
return;
}
channelValues[count++] = value;
}
if (count == 0) { printlnError(Usage); diagPrintln("ERR pwm2 no channel values"); return; }
diagPrint("pwm2 parsed ");
diagPrint((long)count);
diagPrint(" values, fade ");
diagPrint((long)(numPeriods * periodLength));
diagPrintln(" ms");
// --- hand to the engine and return immediately ---
// Per-CHANNEL perceptual fades (input^(1/gamma)), each starting from where its
// LED actually is. Channels of a slot someone holds are skipped: a whole-unit
// pwm2 must not override a drag in progress.
uint32_t vals[NumPWMPins];
for (int i = 0; i < count; i++) vals[i] = (uint32_t)channelValues[i];
unsigned long totalMs = (unsigned long)numPeriods * (unsigned long)periodLength;
engine.legacy(vals, count, totalMs, millis());
printAck("OK pwm2 ");
printAck(count);
printAck(" pins over ");
printAck((long)totalMs);
printlnAck(" ms");
diagPrint("OK pwm2 ");
diagPrintln((long)count);
}
// Write the engine's current values to any pin that differs from what was last
// written. Called every loop(); 12 compares when nothing moved.
void writePins() {
const uint16_t *v = engine.values();
for (int i = 0; i < NumPWMPins; i++) {
if (pinsWrittenValid && PinsWritten[i] == v[i]) continue;
myHRWrite(PWMPins[i], v[i]);
PinsWritten[i] = v[i];
}
pinsWrittenValid = true;
}
// "stopfade" — freeze the rig at whatever values are currently on the pins, and
// end every grant. Useful for live shows (hold this look) and remote recovery;
// Blackout sends it first, so it overrides anyone's drag.
void cmdStopFade() {
engine.stopAll();
printlnAck("OK stopfade");
diagPrintln("OK stopfade");
}
// "state" — report what the pins are ACTUALLY doing, so a controller that did
// not send the last command (e.g. a web UI while Blender drives the rig) can show
// the truth. One line, always sent (not gated by verbose/plevel):
// state <fadeActive 0|1> <freq> <gammaB> <gammaG> <gammaR> <v1> ... <v12>
// v1..v12 are the engine's current values (0..65535, BGR order), live
// during a pwm2 fade.
//
// Proto 3 follows it with one `held <s> <holder> <epoch> <n>` line per held slot
// and a `stats epoch=<e> dropped=<n> pk=<hex>` line, so a controller that joins
// late learns who holds what without waiting for the next broadcast.
void replyLine(const char *buf) {
Serial.println(buf);
if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf);
}
void cmdState() {
char buf[200];
const uint16_t *v = engine.values();
int n = snprintf(buf, sizeof(buf), "state %d %d %.2f %.2f %.2f",
engine.anyFading() ? 1 : 0, freq,
channelGamma[0], channelGamma[1], channelGamma[2]);
for (int i = 0; i < NumPWMPins && n < (int)sizeof(buf); i++) {
n += snprintf(buf + n, sizeof(buf) - n, " %u", (unsigned)v[i]);
}
replyLine(buf);
for (int s = 0; s < cth::SLOTS; s++) {
const char *who;
uint32_t gn;
if (!engine.held(s, who, gn)) continue;
snprintf(buf, sizeof(buf), "held %d %s %lu %lu", s, who,
(unsigned long)engine.epoch(), (unsigned long)gn);
replyLine(buf);
}
unsigned long dropped = 0;
for (int i = 0; i < MAX_CLIENTS; i++) dropped += clients[i].cap.dropped;
snprintf(buf, sizeof(buf), "stats epoch=%lu dropped=%lu pk=%s",
(unsigned long)engine.epoch(), dropped, unitPkHex);
replyLine(buf);
}
// Parse a whole decimal token into [0, max]. False on junk or out of range.
bool parseU32(const char *arg, uint32_t max, uint32_t &out) {
if (!arg || !*arg) return false;
char *end;
unsigned long v = strtoul(arg, &end, 10);
if (*end != '\0' || v > max) return false;
out = (uint32_t)v;
return true;
}
// slot argument: 0..3 or "all"
bool parseSlot(const char *arg, int &slot) {
if (arg && strcmp(arg, "all") == 0) { slot = cth::ALL; return true; }
uint32_t v;
if (!parseU32(arg, cth::SLOTS - 1, v)) return false;
slot = (int)v;
return true;
}
// grant <slot|all> <holderId> -> `granted <s> <epoch> <n>` per slot, or
// `busy <s> <holder>`. First come, first served; the unit is the referee.
void cmdGrant() {
int slot;
if (ActiveIndex < 0) { replyLine("ERR grant needs a network client"); return; }
if (!parseSlot(CmdMgr.next(), slot)) { replyLine("ERR grant <0-3|all> <holderId>"); return; }
const char *holder = CmdMgr.next();
if (!holder || !*holder || strlen(holder) >= (size_t)cth::HOLDER_LEN) {
replyLine("ERR grant <0-3|all> <holderId>");
return;
}
int busy;
char buf[80];
switch (engine.grant(slot, ActiveIndex, holder, millis(), busy)) {
case cth::Engine::Granted: {
int lo = slot == cth::ALL ? 0 : slot, hi = slot == cth::ALL ? cth::SLOTS - 1 : slot;
for (int s = lo; s <= hi; s++) {
const char *who;
uint32_t gn;
engine.held(s, who, gn);
snprintf(buf, sizeof(buf), "granted %d %lu %lu", s, (unsigned long)engine.epoch(), (unsigned long)gn);
replyLine(buf);
}
break;
}
case cth::Engine::Busy: {
const char *who = "";
uint32_t gn;
engine.held(busy, who, gn);
snprintf(buf, sizeof(buf), "busy %d %s", busy, who);
replyLine(buf);
break;
}
case cth::Engine::Disabled:
replyLine("ERR grants disabled (epoch not persisted)");
break;
default:
replyLine("ERR grant bad slot");
}
}
// slot <s> <epoch> <n> <b> <g> <r> [fadeMs] — silent on success (it arrives up
// to 25 times a second during a drag; an ack each time would double the
// traffic). Errors are always reported.
void cmdSlot() {
uint32_t s, e, gn, b, g, r, fadeMs = 0;
bool ok = parseU32(CmdMgr.next(), cth::SLOTS - 1, s) &&
parseU32(CmdMgr.next(), 0xFFFFFFFFu, e) &&
parseU32(CmdMgr.next(), 0xFFFFFFFFu, gn) &&
parseU32(CmdMgr.next(), MAX_PWM, b) &&
parseU32(CmdMgr.next(), MAX_PWM, g) &&
parseU32(CmdMgr.next(), RED_MAX_PWM, r);
const char *f = ok ? CmdMgr.next() : NULL;
if (ok && f && !parseU32(f, cth::MAX_FADE_MS, fadeMs)) ok = false;
if (!ok) { replyLine("ERR slot <0-3> <epoch> <n> <b> <g> <r<=45000> [fadeMs]"); return; }
char buf[48];
switch (engine.set((int)s, ActiveIndex, e, gn, b, g, r, fadeMs, millis())) {
case cth::Engine::SetOk: break;
case cth::Engine::NotHeld:
snprintf(buf, sizeof(buf), "ERR notheld %lu", (unsigned long)s);
replyLine(buf);
break;
default:
replyLine("ERR slot value");
}
}
void cmdRelease() {
int slot;
if (!parseSlot(CmdMgr.next(), slot)) { replyLine("ERR release <0-3|all>"); return; }
if (!engine.release(slot, ActiveIndex, millis())) replyLine("ERR notheld");
}
// epochfloor <e> — a controller saw this unit use epoch <e> before (its bee
// remembers). If our epoch is not above it -- NVS was wiped or the unit was
// reflashed -- jump past it, so new grants can't lose to old commits forever.
void cmdEpochFloor() {
uint32_t e;
if (!parseU32(CmdMgr.next(), 0xFFFFFFFEu, e)) { replyLine("ERR epochfloor <e>"); return; }
if (engine.epoch() <= e) {
uint32_t next = e + 1;
if (prefs.putUInt("epoch", next) == sizeof(uint32_t)) {
bootEpoch = next;
engine.setEpoch(next);
} else {
replyLine("ERR epochfloor: NVS write failed");
return;
}
}
char buf[48];
snprintf(buf, sizeof(buf), "epoch %lu", (unsigned long)engine.epoch());
replyLine(buf);
}
// expects space-delimited ints 0-65535, or space delimited <index>:<pwm> pairs
void cmdPWMPins() {
diagPrintln("GOT pwm");
const char *SetPWMUsageMsg = "Error: takes up to 12 arguments between 0 and 65535 (except argument 3's max is 45000).";
long channelValues[NumPWMPins];
int count = 0;
char *arg = CmdMgr.next();
if (arg == NULL) {
printlnError("Error: no arguments");
diagPrintln("ERR pwm no args");
return;
}
do {
int index;
long value;
if (count >= NumPWMPins) {
printlnError(SetPWMUsageMsg);
return;
}
char *end;
index = count;
value = parsePWM(arg, count % 3 == 2 ? RED_MAX_PWM : MAX_PWM);
if (value < 0) {
printlnError(SetPWMUsageMsg);
return;
}
printInfo("Set pin ");
printInfo(PWMPins[index]);
printInfo(" to ");
printlnInfo(value);
channelValues[index] = value;
count++;
} while ((arg = CmdMgr.next()));
// Immediate write (cancels any fade on those channels), skipping channels of
// a slot someone holds. Pins are written by writePins() in loop().
uint32_t vals[NumPWMPins];
for (int i = 0; i < count; i++) vals[i] = (uint32_t)channelValues[i];
engine.legacy(vals, count, 0, millis());
printAck("OK set ");
printAck(count);
printlnAck(" pins");
diagPrint("OK pwm ");
diagPrintln((long)count);
}
// verbose 0 | verbose 1 — toggle telnet GOT/OK/ERR markers
void cmdVerbose() {
if (char *arg = CmdMgr.next()) {
diagVerbose = (atoi(arg) != 0);
}
Serial.print("verbose ");
Serial.println(diagVerbose ? "1" : "0");
if (ActiveClient && ActiveClient->connected()) {
ActiveClient->print("verbose ");
ActiveClient->println(diagVerbose ? "1" : "0");
}
}
// gamma <b> <g> <r> — set per-channel gamma exponents (BGR order, range 0.5–5.0).
// Higher gamma slows that channel's perceived ramp during pwm2 fades.
// "gamma" with no args reports the current values.
void cmdGamma() {
for (int i = 0; i < 3; i++) {
char *arg = CmdMgr.next();
if (!arg) break;
float v = strtof(arg, NULL);
if (v >= 0.5f && v <= 5.0f) channelGamma[i] = v;
}
for (int i = 0; i < 3; i++) engine.gamma[i] = channelGamma[i];
char buf[80];
snprintf(buf, sizeof(buf), "gamma B=%.2f G=%.2f R=%.2f",
channelGamma[0], channelGamma[1], channelGamma[2]);
Serial.println(buf);
if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf);
}
// hostname [name] — get or set the mDNS/OTA hostname. Set persists to NVS and
// takes effect after a reboot (since mDNS and OTA bind their names at start).
// Valid: 1–63 chars, ASCII letters/digits/hyphens.
void cmdHostname() {
if (char *arg = CmdMgr.next()) {
size_t len = strlen(arg);
if (len < 1 || len > 63) {
printlnError("hostname must be 1-63 characters");
diagPrintln("ERR hostname bad length");
return;
}
// Basic sanity: alphanumeric + hyphen
for (size_t i = 0; i < len; i++) {
char c = arg[i];
bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-';
if (!ok) {
printlnError("hostname must be ASCII letters, digits, or hyphens");
diagPrintln("ERR hostname invalid chars");
return;
}
}
prefs.putString("hostname", arg);
char buf[128];
snprintf(buf, sizeof(buf),
"hostname stored as '%s' — `reboot` to apply", arg);
Serial.println(buf);
if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf);
} else {
char buf[128];
snprintf(buf, sizeof(buf), "hostname: %s", currentHostname);
Serial.println(buf);
if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf);
}
}
// reboot — soft-restart the chip. Useful after setting hostname or to recover
// from a stuck state.
void cmdReboot() {
Serial.println("Rebooting...");
broadcastLine("Rebooting...");
for (int i = 0; i < MAX_CLIENTS; i++) {
if (!clients[i].sock) continue;
clients[i].sock.flush();
clients[i].sock.stop();
}
delay(200); // let buffers flush
ESP.restart();
}
void cmdSetPrintLevel() {
if (char *arg = CmdMgr.next()) {
if (CmdMgr.next()) {
printlnAlways("Error: takes 0 or 1 arguments");
return;
}
if (!PrintLevel::set(arg)) {
PrintLevel::printErrorString();
return;
}
}
printAck("print level ");
printlnAck(PrintLevel::toString());
}
void cmdServo() {
Serial.print("Got Servo shit");
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////
// setup & loop
///////////////////////////////////////////////////////////////////////////////////////////////////////////
void setup() {
Serial.begin(115200);
delay(50); // let USB-UART settle so the first prints don't get clipped
// Load hostname from NVS (set via `hostname <name>` command). Falls back to
// the compiled-in HOSTNAME #define if nothing has been stored yet.
prefs.begin("studiolight", false);
String stored = prefs.getString("hostname", HOSTNAME);
strncpy(currentHostname, stored.c_str(), sizeof(currentHostname) - 1);
currentHostname[sizeof(currentHostname) - 1] = '\0';
Serial.print("Hostname: ");
Serial.println(currentHostname);
// Console quiet by default since proto 3 (see diagVerbose); `plevel info`
// brings the per-pin lines back for debugging.
PrintLevel::set("ack");
// Boot epoch: +1 every boot, persisted BEFORE any grant is issued. Grant
// numbers are (epoch, counter), and a commit with a higher one wins, so an
// epoch that failed to persist could make new grants lose to old commits --
// refuse grants instead (legacy pwm still works).
bootEpoch = prefs.getUInt("epoch", 0) + 1;
bool epochSaved = prefs.putUInt("epoch", bootEpoch) == sizeof(uint32_t);
Serial.printf("Boot epoch %lu%s\n", (unsigned long)bootEpoch, epochSaved ? "" : " (NOT persisted: grants off)");
// Signing keypair: made once, kept in NVS for the life of the unit.
if (sodium_init() < 0) Serial.println("sodium_init failed");
if (prefs.getBytesLength("edsk") != sizeof(unitSk) ||
prefs.getBytes("edsk", unitSk, sizeof(unitSk)) != sizeof(unitSk) ||
prefs.getBytes("edpk", unitPk, sizeof(unitPk)) != sizeof(unitPk)) {
crypto_sign_ed25519_keypair(unitPk, unitSk);
prefs.putBytes("edsk", unitSk, sizeof(unitSk));
prefs.putBytes("edpk", unitPk, sizeof(unitPk));
Serial.println("Generated a new signing keypair");
}
for (size_t i = 0; i < sizeof(unitPk); i++) sprintf(unitPkHex + 2 * i, "%02x", unitPk[i]);
engine.begin(bootEpoch, epochSaved);
ledsetup();
// Init pins to 0 — with INVERT, that's dutyMax → pin high → LEDs off.
engine.forceAll(0);
writePins();
// Kick off WiFi here; loop() will poll until associated. Keeping setup()
// short avoids the TG1 task watchdog firing on a slow cold boot.
WiFi.begin(ssid, password);
WiFi.setSleep(false); // arduino-esp32 forum: improves persistent-server stability
Serial.println("WiFi connecting...");
}
// loop()-side init state machine. Each loop() invocation does a small amount
// of work and returns, which feeds the watchdog.
enum InitState { FADE_TEST, WAIT_WIFI, START_SERVICES, RUNNING };
InitState initState = FADE_TEST;
int wifiWaitCount = 0;
bool serverStarted = false;
unsigned long fadeStartMs = 0;
// Client slots declared at the top with the diag helpers.
// 10-second boot self-test: fade all pins off→on→off so we can see the chip
// is alive. Runs before WiFi so it's visible on every cold boot.
// Protocol input space (with INVERT_HIGH_AND_LOW for active-high LEDs):
// input 65535 → LED off, input 0 → LED full on.
void loop() {
switch (initState) {
case FADE_TEST: {
if (fadeStartMs == 0) fadeStartMs = millis();
const unsigned long FADE_TOTAL_MS = 10000;
unsigned long elapsed = millis() - fadeStartMs;
if (elapsed >= FADE_TOTAL_MS) {
engine.forceAll(65535); // unchanged pre-v3 boot behaviour
writePins();
Serial.println("Boot fade test complete");
initState = WAIT_WIFI;
break;
}
// Triangle wave: brightness 0→1 over first half, 1→0 over second half.
float t = elapsed / (float)FADE_TOTAL_MS;
float brightness = (t < 0.5f) ? (t * 2.0f) : ((1.0f - t) * 2.0f);
unsigned long input = (unsigned long)((1.0f - brightness) * 65535.0f);
for (int i = 0; i < NumPWMPins; i++) {
unsigned long pinInput = input;
// Red channels (3rd pin of each RGB triple) honor RED_MAX_PWM, same as parsePWM.
if (i % 3 == 2 && pinInput > RED_MAX_PWM) pinInput = RED_MAX_PWM;
myHRWrite(PWMPins[i], pinInput);
}
delay(50); // ~20 fps; also feeds the watchdog
break;
}
case WAIT_WIFI:
if (WiFi.status() == WL_CONNECTED) {
Serial.println();
Serial.print("Connected to ");
Serial.println(ssid);
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
initState = START_SERVICES;
} else if (wifiWaitCount < timeout) {
Serial.print(".");
wifiWaitCount++;
delay(500);
} else {
Serial.println(" (giving up on WiFi)");
initState = RUNNING; // run anyway, accept serial commands
}
break;
case START_SERVICES:
if (MDNS.begin(currentHostname)) {
Serial.print("mDNS responder started: ");
Serial.print(currentHostname);
Serial.println(".local");
MDNS.addService("telnet", "tcp", 23);
// The real command server, under a name only these units use, so a
// controller can discover lights without guessing from telnet adverts.
MDNS.addService("cthuloid", "tcp", 1337);
MDNS.addServiceTxt("cthuloid", "tcp", "proto", CTHULOID_PROTO);
} else {
Serial.println("MDNS.begin failed — continuing without mDNS");
}
server.begin();
Serial.println("TCP server started on port 1337");
// ArduinoOTA — accept firmware uploads over WiFi
ArduinoOTA.setHostname(currentHostname);
ArduinoOTA.setPassword(OTA_PASSWORD);
ArduinoOTA.onStart([]() {
Serial.println("OTA: upload starting");
});
ArduinoOTA.onEnd([]() {
Serial.println("OTA: upload complete, rebooting");
});
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
if ((progress * 100 / total) % 10 == 0) {
Serial.printf("OTA: %u%%\n", progress * 100 / total);
}
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("OTA error %u\n", error);
});
ArduinoOTA.begin();
Serial.println("ArduinoOTA started on port 3232");
serverStarted = true;
initState = RUNNING;
break;
case RUNNING: {
engine.tick(millis()); // fades, grant idle/max expiry
CmdMgr.readSerial();
ArduinoOTA.handle();
// Periodic WiFi health check. If association drops, retry — without this
// the chip would happily sit forever with no network after a router blip.
static unsigned long lastWifiCheckMs = 0;
if (millis() - lastWifiCheckMs > 10000) {
lastWifiCheckMs = millis();
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi dropped — reconnecting");
WiFi.reconnect();
}
}
if (serverStarted) {
// --- accept: a free slot, else the stalest socket holding NO grant ---
WiFiClient incoming = server.accept();
if (incoming) {
bool occupied[MAX_CLIENTS], holds[MAX_CLIENTS];
uint32_t last[MAX_CLIENTS];
for (int i = 0; i < MAX_CLIENTS; i++) {
occupied[i] = (bool)clients[i].sock; // closed sockets are reset below
holds[i] = occupied[i] && engine.holdsAny(i);
last[i] = clients[i].lastByteMs;
}
int slot = cth::pickSlot(occupied, last, holds, MAX_CLIENTS, millis());
if (slot < 0) {
// Every socket is mid-drag: refuse the newcomer rather than drop a drag.
incoming.println("busy all clients hold grants");
incoming.stop();
} else {
if (clients[slot].sock) {
Serial.printf("Client slots full — evicting slot %d\n", slot);
clients[slot].sock.stop();
engine.socketClosed(slot);
}
clients[slot].sock = incoming;
clients[slot].lastByteMs = millis();
clients[slot].len = 0;
clients[slot].cap = cth::RateCap();
Serial.printf("New client in slot %d\n", slot);
// Line 1 is unchanged from proto 2, so older controllers still parse
// it; proto 3 details follow on a line of their own.
clients[slot].sock.print(currentHostname);
clients[slot].sock.println(" ready");
char hello[160];
snprintf(hello, sizeof(hello), "hello proto=%s unit=%s epoch=%lu pk=%s",
CTHULOID_PROTO, currentHostname, (unsigned long)engine.epoch(), unitPkHex);
clients[slot].sock.println(hello);
}
}
// --- service every slot ---
for (int i = 0; i < MAX_CLIENTS; i++) {
ClientSlot &c = clients[i];
if (!c.sock) continue;
// Drain what has arrived, then act on a close -- in that order, so a
// last command sent just before closing (e.g. `release`) still runs.
bool closed = false;
uint8_t rx[128];
for (;;) {
int got;
int rr = readSome(c.sock, rx, sizeof(rx), got);
if (rr == READ_CLOSED) { closed = true; break; }
if (rr == READ_IDLE) break;
for (int k = 0; k < got; k++) {
char ch = (char)rx[k];
c.lastByteMs = millis();
if (diagVerbose) Serial.write(ch);
if (ch == '\n' || ch == '\r') {
if (c.len > 0 && c.cap.allow(millis())) {
// Hand over one whole command, with this client marked active
// so its replies come back to it and not to some other session.
ActiveClient = &c.sock;
ActiveIndex = i;
for (int k = 0; k < c.len; k++) CmdMgr.handleChar(c.buf[k]);
CmdMgr.handleChar('\n');
ActiveClient = NULL;
ActiveIndex = -1;
}
c.len = 0;
} else if (c.len < CLIENT_BUF_LEN - 1) {
c.buf[c.len++] = ch;
} else {
// Overlong line: discard it rather than dispatch a truncated
// command that would be parsed as something else.
c.len = 0;
Serial.printf("Client %d: line too long, discarded\n", i);
}
}
}
if (closed) {
c.sock.stop();
c.sock = WiFiClient();
c.len = 0;
engine.socketClosed(i);
continue;
}
// Idle-timeout: connected() can lie after FIN. Drop stale sessions.
if (millis() - c.lastByteMs > CLIENT_IDLE_TIMEOUT_MS) {
Serial.printf("Client %d idle — dropping\n", i);
c.sock.stop();
c.sock = WiFiClient();
c.len = 0;
engine.socketClosed(i);
}
}
}
engine.tick(millis()); // expiries from the commands just handled
writePins();
broadcastEvents();
if (engine.valuesDue(millis())) {
char buf[120];
const uint16_t *v = engine.values();
int n = snprintf(buf, sizeof(buf), "values");
for (int k = 0; k < NumPWMPins; k++) n += snprintf(buf + n, sizeof(buf) - n, " %u", (unsigned)v[k]);
broadcastLine(buf);
}
break;
}
}
}
void broadcastEvents() {
cth::Event e;
char line[300];
while (engine.popEvent(e)) {
if (e.kind == cth::Event::Held) {
snprintf(line, sizeof(line), "held %d %s %lu %lu", e.slot, e.holder,
(unsigned long)e.epoch, (unsigned long)e.n);
} else {
char msg[160];
int mlen = cth::freeMessage(msg, sizeof(msg), currentHostname, e);
unsigned char sig[crypto_sign_BYTES];
crypto_sign_ed25519_detached(sig, NULL, (const unsigned char *)msg, mlen, unitSk);
char sigHex[2 * crypto_sign_BYTES + 1];
for (size_t i = 0; i < sizeof(sig); i++) sprintf(sigHex + 2 * i, "%02x", sig[i]);
snprintf(line, sizeof(line), "free %d %lu %lu %lu %lu %lu %s %s %s", e.slot,
(unsigned long)e.epoch, (unsigned long)e.n,
(unsigned long)e.b, (unsigned long)e.g, (unsigned long)e.r,
cth::whyName(e.why), e.holder, sigHex);
}
broadcastLine(line);
}
}