OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / ad5afd5

esp324xlight: make pwm2 non-blocking, add stopfade
pwm2 ran its interpolation inside the command handler: a vTaskDelay loop over numPeriods steps that did not return until the fade finished. Since total fade time is numPeriods x periodMs, up to 1000 x 1000 ms, a long fade froze the main loop for as much as ~1000 s -- no serial, no telnet, no ArduinoOTA.handle(), no WiFi reconnect check, and no way to interrupt it short of a reset. cmdPWMPins2 now stores start/end in a Pwm2Fade struct and returns immediately. updateFade(), called from loop() in the RUNNING state, advances the fade from elapsed wall-clock time (t = elapsed / totalMs) and writes the pins; it costs a single branch when no fade is active. Interpolation still happens in perceptual space (input^(1/gamma), per-channel via channelGamma[i % 3]), so perceived brightness changes linearly. The start point is PinCurrentValues, which updateFade keeps live, so a pwm2 issued mid-fade continues from where the LED actually is rather than from where the previous fade began. Because interpolation is now continuous rather than stepped, numPeriods no longer controls step count -- the pair only sets total duration, and any decomposition of the same total behaves identically (pwm2 1 500 == pwm2 5 100). Two cancellation paths: a plain pwm clears the fade before hard-writing, so updateFade cannot fight it; and a new stopfade command freezes the rig at whatever is currently on the pins, for holding a look or recovering remotely. README also documents the OTA discovery gotcha: arduino-cli indexes network ports by IP and does not resolve mDNS itself, so --port studiolight.local fails with "port not found" -- pass the IP, or resolve it with getent hosts first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ad5afd5c30e53e7d2bd8f9e57a0b3f1d849c41e8
by plankatron <fleeky@gmail.com> · 2026-08-30T19:09:15.000Z

Changed files (2) +106 -40

esp324xlight/README.md +43 -11
……@@ -72,16 +72,32 @@ Examples: `studiolight`, `studiolight1`, `studiolight2`, `studiolight-stage-left
7272## OTA (over-the-air) firmware updates
7373
7474Once a unit is flashed and on the network, you can push new firmware without
75plugging in USB:
75plugging in USB.
76
77First, discover the unit's IP (arduino-cli's network port discovery indexes by
78IP, not mDNS hostname):
79
80```bash
81arduino-cli board list --discovery-timeout 5s
82# or:
83getent hosts studiolight.local
84```
85
86Then push, passing the IP as `--port`:
7687
7788```bash
7889arduino-cli upload \
7990 --fqbn esp32:esp32:esp32wrover:FlashFreq=80,UploadSpeed=921600,PartitionScheme=default,DebugLevel=none \
80 --port studiolight.local \
91 --port 192.168.x.x \
8192 --protocol network \
93 --upload-field password=studiolight-ota \
8294 esp324xlight.ino
8395```
8496
97Note: `--port studiolight.local` will fail with "port not found" — arduino-cli's
98network discovery doesn't resolve mDNS names itself. Either pass the IP, or
99`getent hosts <name>` to look it up first.
100
85101In the Arduino IDE: Tools → Port → Network Ports → select `studiolight at 192.168.x.x`.
86102
87103The unit requires the OTA password defined by `#define OTA_PASSWORD` in the
……@@ -119,25 +135,41 @@ Fade interpolation from current value to target over `numPeriods × periodMs` ms
119135
120136| Arg | Range | Meaning |
121137|---|---|---|
122| `numPeriods` | 1–1000 | number of interpolation steps |
123| `periodMs` | 1–1000 | milliseconds between steps |
138| `numPeriods` | 1–1000 | (see note) used only to compute total duration |
139| `periodMs` | 1–1000 | (see note) used only to compute total duration |
124140| `v1..vN` | 0–65535 | target value per channel (BGR order, ≤12) |
125141
126Total fade time = `numPeriods × periodMs` ms.
142Total fade time = `numPeriods × periodMs` ms. Max ~1000 s.
143
144**The fade is non-blocking.** `pwm2` stores the start/end values and returns
145immediately; interpolation runs from `loop()` each iteration, so telnet, OTA,
146serial, and new commands all stay responsive during the fade. As a side effect,
147`numPeriods` no longer controls the actual step count — interpolation is
148continuous based on elapsed wall-clock time. Pick any decomposition of total
149ms; e.g. `pwm2 1 500` and `pwm2 5 100` both fade for 500ms.
127150
128For maximum smoothness over a long fade: max out `numPeriods` (1000), set
129`periodMs` to `total_ms / 1000`.
151A new `pwm2` mid-fade picks up from where the LED actually is (not where the
152prior fade started). A plain `pwm` mid-fade cancels the fade and hard-writes
153the new value. Use `stopfade` to freeze without changing values.
130154
131155```
132pwm2 100 10 0 0 0 0 0 0 0 0 0 0 0 0 # 1s fade to off, all 12
133pwm2 1000 30 65534 65534 30000 ... (×4) # 30s perceptually-smooth fade to white
156pwm2 1 500 0 0 0 ... 0 # 500ms fade to off
157pwm2 5 1000 65534 ... 65534 # 5s fade to white
158pwm2 10 1000 65534 0 0 ... ×4 # 10s fade to all-blue (per-quad)
134159```
135160
136161Interpolation is **perceptually corrected** via per-channel gamma — see the
137162`gamma` command. Default gamma is 1.0 (linear); raise to perceptually equalize
138163ramp rates across colors.
139164
140Response: `OK pwm2 <count>` on success.
165Response: `OK pwm2 <count> pins over <ms> ms` on accept (not on completion).
166
167#### `stopfade`
168Cancel any in-flight `pwm2` fade and freeze the rig at the current PWM values.
169Useful for live shows ("hold this look"), debugging, or remote recovery if the
170animation source misbehaves.
171
172Response: `OK stopfade`.
141173
142174#### `freq [hz]`
143175Get or set the PWM frequency. With no argument, prints the current frequency.
……@@ -241,7 +273,7 @@ This firmware is designed to run for days/weeks unattended. Audit notes:
241273| WiFi reconnect after drop | OK | Every 10 seconds in the `RUNNING` state, `WiFi.status()` is checked; if disconnected, `WiFi.reconnect()` is called |
242274| Brownout / power glitch | Hardware-managed | ESP32's brownout detector resets the chip; sketch reboots cleanly with no persisted state |
243275| LEDC channel exhaustion on `freq` change | OK | `updateFrequency` does `ledcDetach` before re-attach; channels are recycled |
244| Long `pwm2` blocks new connections | Known limitation | While `pwm2` is interpolating (up to 1000s with max args), the main loop is blocked. New telnet connections will queue but not be accepted until the fade completes. Typical fades are seconds — not an issue in practice |
276| `pwm2` interaction with other commands | Non-blocking | `pwm2` returns immediately; fade advances from `loop()`. Telnet, OTA, and new commands stay responsive throughout. A new `pwm2` re-bases from the live PWM value; a plain `pwm` cancels the fade; `stopfade` freezes it |
245277| Multiple units sharing hostname | Manual | If two units boot with the same `HOSTNAME`, mDNS will be a coin flip on which one resolves. Use unique `#define HOSTNAME` per unit |
246278| OTA blocks main loop during upload | By design | OTA upload takes ~5s during which commands are ignored; chip reboots into new firmware automatically |
247279
esp324xlight/esp324xlight.ino +63 -29
……@@ -142,6 +142,23 @@ void cmdVerbose();
142142void cmdGamma();
143143void cmdHostname();
144144void cmdReboot();
145void cmdStopFade();
146void updateFade();
147
148// --- non-blocking pwm2 fade state ---
149// cmdPWMPins2 fills this and returns immediately; updateFade() (called every
150// loop()) advances the interpolation based on wall-clock time and writes the
151// pins. PinCurrentValues stays live during the fade, so a new pwm2 mid-fade
152// picks up from where the LED actually is.
153struct Pwm2Fade {
154 bool active = false;
155 unsigned long startMs = 0;
156 unsigned long totalMs = 0;
157 int count = 0;
158 float perceptualStart[NumPWMPins];
159 float perceptualEnd [NumPWMPins];
160};
161Pwm2Fade pwm2Fade;
145162
146163SerialCommand::Entry CommandsList[] = {
147164 {"plevel", cmdSetPrintLevel},
……@@ -153,6 +170,7 @@ SerialCommand::Entry CommandsList[] = {
153170 {"gamma", cmdGamma},
154171 {"hostname", cmdHostname},
155172 {"reboot", cmdReboot},
173 {"stopfade", cmdStopFade},
156174 {NULL, NULL}
157175};
158176SerialCommand CmdMgr(CommandsList, cmdUnrecognized);
……@@ -315,50 +333,65 @@ void cmdPWMPins2() {
315333 diagPrint((long)(numPeriods * periodLength));
316334 diagPrintln(" ms");
317335
318 // --- interpolate with per-channel gamma correction ---
319 // Eye perceives brightness logarithmically; linear duty makes the dim end "pop"
320 // and the bright end "crawl." We interpolate in perceptual space (input^(1/gamma))
321 // so the perceived brightness changes linearly over time. Per-channel gamma
322 // (channelGamma[i % 3]) lets us slow more-efficient diodes (red) relative to
323 // less-efficient ones (blue/green).
324 float perceptualStart[NumPWMPins];
325 float perceptualEnd[NumPWMPins];
336 // --- store fade state and return immediately ---
337 // Interpolation is done in perceptual space (input^(1/gamma)) so perceived
338 // brightness changes linearly over time. Per-channel gamma (channelGamma[i%3])
339 // lets us slow more-efficient diodes (red) relative to less-efficient ones.
340 // PinCurrentValues — kept live by updateFade() — is the start point, so a new
341 // pwm2 mid-fade picks up from where the LED actually is, not where the prior
342 // fade started.
326343 for (int i = 0; i < count; i++) {
327344 float invG = 1.0f / channelGamma[i % 3];
328 perceptualStart[i] = powf(PinCurrentValues[i] / 65535.0f, invG);
329 perceptualEnd[i] = powf(channelValues[i] / 65535.0f, invG);
330 }
331
332 const TickType_t xDelay = pdMS_TO_TICKS(periodLength);
333 for (long period = 1; period <= numPeriods; period++) {
334 float t = period / (float)numPeriods;
335 for (int i = 0; i < count; i++) {
336 float p = perceptualStart[i] + (perceptualEnd[i] - perceptualStart[i]) * t;
337 float lum = powf(p, channelGamma[i % 3]);
338 unsigned long v = (unsigned long)(lum * 65535.0f);
339 myHRWrite(PWMPins[i], v);
340 }
341 vTaskDelay(xDelay); // yields to other tasks and feeds the watchdog
342 }
343
344 // snap to final target
345 for (int i = 0; i < count; i++) {
346 PinCurrentValues[i] = channelValues[i];
347 myHRWrite(PWMPins[i], channelValues[i]);
345 pwm2Fade.perceptualStart[i] = powf(PinCurrentValues[i] / 65535.0f, invG);
346 pwm2Fade.perceptualEnd[i] = powf(channelValues[i] / 65535.0f, invG);
348347 }
348 pwm2Fade.count = count;
349 pwm2Fade.totalMs = (unsigned long)numPeriods * (unsigned long)periodLength;
350 pwm2Fade.startMs = millis();
351 pwm2Fade.active = true;
349352
350353 printAck("OK pwm2 ");
351354 printAck(count);
352355 printAck(" pins over ");
353 printAck(numPeriods * periodLength);
356 printAck((long)pwm2Fade.totalMs);
354357 printlnAck(" ms");
355358 diagPrint("OK pwm2 ");
356359 diagPrintln((long)count);
357360}
358361
362// Called every loop() iteration. Advances any active pwm2 fade by elapsed
363// wall-clock time and writes the pins. Cheap when inactive — single branch.
364void updateFade() {
365 if (!pwm2Fade.active) return;
366 unsigned long elapsed = millis() - pwm2Fade.startMs;
367 bool done = elapsed >= pwm2Fade.totalMs;
368 float t = done ? 1.0f : (elapsed / (float)pwm2Fade.totalMs);
369
370 for (int i = 0; i < pwm2Fade.count; i++) {
371 float p = pwm2Fade.perceptualStart[i]
372 + (pwm2Fade.perceptualEnd[i] - pwm2Fade.perceptualStart[i]) * t;
373 float lum = powf(p, channelGamma[i % 3]);
374 unsigned long v = (unsigned long)(lum * 65535.0f);
375 if (v > 65535) v = 65535;
376 PinCurrentValues[i] = v;
377 myHRWrite(PWMPins[i], v);
378 }
379 if (done) pwm2Fade.active = false;
380}
381
382// "stopfade" — freeze the rig at whatever values are currently on the pins.
383// Useful for live shows (hold this look) and remote recovery.
384void cmdStopFade() {
385 pwm2Fade.active = false;
386 printlnAck("OK stopfade");
387 diagPrintln("OK stopfade");
388}
389
359390// expects space-delimited ints 0-65535, or space delimited <index>:<pwm> pairs
360391void cmdPWMPins() {
361392 diagPrintln("GOT pwm");
393 // Hard-write cancels any in-flight pwm2 fade so updateFade() doesn't fight us.
394 pwm2Fade.active = false;
362395 const char *SetPWMUsageMsg = "Error: takes up to 12 arguments between 0 and 65535 (except argument 3's max is 45000).";
363396
364397 long channelValues[NumPWMPins];
……@@ -647,6 +680,7 @@ void loop() {
647680 break;
648681
649682 case RUNNING: {
683 updateFade(); // advance any in-flight pwm2 fade
650684 CmdMgr.readSerial();
651685 ArduinoOTA.handle();
652686