| … | … | @@ -72,16 +72,32 @@ Examples: `studiolight`, `studiolight1`, `studiolight2`, `studiolight-stage-left |
| 72 | 72 | ## OTA (over-the-air) firmware updates |
| 73 | 73 | |
| 74 | 74 | Once a unit is flashed and on the network, you can push new firmware without |
| 75 | plugging in USB: | |
| 75 | plugging in USB. | |
| 76 | ||
| 77 | First, discover the unit's IP (arduino-cli's network port discovery indexes by | |
| 78 | IP, not mDNS hostname): | |
| 79 | ||
| 80 | ```bash | |
| 81 | arduino-cli board list --discovery-timeout 5s | |
| 82 | # or: | |
| 83 | getent hosts studiolight.local | |
| 84 | ``` | |
| 85 | ||
| 86 | Then push, passing the IP as `--port`: | |
| 76 | 87 | |
| 77 | 88 | ```bash |
| 78 | 89 | arduino-cli upload \ |
| 79 | 90 | --fqbn esp32:esp32:esp32wrover:FlashFreq=80,UploadSpeed=921600,PartitionScheme=default,DebugLevel=none \ |
| 80 | --port studiolight.local \ | |
| 91 | --port 192.168.x.x \ | |
| 81 | 92 | --protocol network \ |
| 93 | --upload-field password=studiolight-ota \ | |
| 82 | 94 | esp324xlight.ino |
| 83 | 95 | ``` |
| 84 | 96 | |
| 97 | Note: `--port studiolight.local` will fail with "port not found" — arduino-cli's | |
| 98 | network discovery doesn't resolve mDNS names itself. Either pass the IP, or | |
| 99 | `getent hosts <name>` to look it up first. | |
| 100 | ||
| 85 | 101 | In the Arduino IDE: Tools → Port → Network Ports → select `studiolight at 192.168.x.x`. |
| 86 | 102 | |
| 87 | 103 | The 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 |
| 119 | 135 | |
| 120 | 136 | | Arg | Range | Meaning | |
| 121 | 137 | |---|---|---| |
| 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 | | |
| 124 | 140 | | `v1..vN` | 0–65535 | target value per channel (BGR order, ≤12) | |
| 125 | 141 | |
| 126 | Total fade time = `numPeriods × periodMs` ms. | |
| 142 | Total fade time = `numPeriods × periodMs` ms. Max ~1000 s. | |
| 143 | ||
| 144 | **The fade is non-blocking.** `pwm2` stores the start/end values and returns | |
| 145 | immediately; interpolation runs from `loop()` each iteration, so telnet, OTA, | |
| 146 | serial, and new commands all stay responsive during the fade. As a side effect, | |
| 147 | `numPeriods` no longer controls the actual step count — interpolation is | |
| 148 | continuous based on elapsed wall-clock time. Pick any decomposition of total | |
| 149 | ms; e.g. `pwm2 1 500` and `pwm2 5 100` both fade for 500ms. | |
| 127 | 150 | |
| 128 | For maximum smoothness over a long fade: max out `numPeriods` (1000), set | |
| 129 | `periodMs` to `total_ms / 1000`. | |
| 151 | A new `pwm2` mid-fade picks up from where the LED actually is (not where the | |
| 152 | prior fade started). A plain `pwm` mid-fade cancels the fade and hard-writes | |
| 153 | the new value. Use `stopfade` to freeze without changing values. | |
| 130 | 154 | |
| 131 | 155 | ``` |
| 132 | pwm2 100 10 0 0 0 0 0 0 0 0 0 0 0 0 # 1s fade to off, all 12 | |
| 133 | pwm2 1000 30 65534 65534 30000 ... (×4) # 30s perceptually-smooth fade to white | |
| 156 | pwm2 1 500 0 0 0 ... 0 # 500ms fade to off | |
| 157 | pwm2 5 1000 65534 ... 65534 # 5s fade to white | |
| 158 | pwm2 10 1000 65534 0 0 ... ×4 # 10s fade to all-blue (per-quad) | |
| 134 | 159 | ``` |
| 135 | 160 | |
| 136 | 161 | Interpolation is **perceptually corrected** via per-channel gamma — see the |
| 137 | 162 | `gamma` command. Default gamma is 1.0 (linear); raise to perceptually equalize |
| 138 | 163 | ramp rates across colors. |
| 139 | 164 | |
| 140 | Response: `OK pwm2 <count>` on success. | |
| 165 | Response: `OK pwm2 <count> pins over <ms> ms` on accept (not on completion). | |
| 166 | ||
| 167 | #### `stopfade` | |
| 168 | Cancel any in-flight `pwm2` fade and freeze the rig at the current PWM values. | |
| 169 | Useful for live shows ("hold this look"), debugging, or remote recovery if the | |
| 170 | animation source misbehaves. | |
| 171 | ||
| 172 | Response: `OK stopfade`. | |
| 141 | 173 | |
| 142 | 174 | #### `freq [hz]` |
| 143 | 175 | Get 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: |
| 241 | 273 | | WiFi reconnect after drop | OK | Every 10 seconds in the `RUNNING` state, `WiFi.status()` is checked; if disconnected, `WiFi.reconnect()` is called | |
| 242 | 274 | | Brownout / power glitch | Hardware-managed | ESP32's brownout detector resets the chip; sketch reboots cleanly with no persisted state | |
| 243 | 275 | | 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 | | |
| 245 | 277 | | 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 | |
| 246 | 278 | | OTA blocks main loop during upload | By design | OTA upload takes ~5s during which commands are ignored; chip reboots into new firmware automatically | |
| 247 | 279 |
| … | … | @@ -142,6 +142,23 @@ void cmdVerbose(); |
| 142 | 142 | void cmdGamma(); |
| 143 | 143 | void cmdHostname(); |
| 144 | 144 | void cmdReboot(); |
| 145 | void cmdStopFade(); | |
| 146 | void 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. | |
| 153 | struct 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 | }; | |
| 161 | Pwm2Fade pwm2Fade; | |
| 145 | 162 | |
| 146 | 163 | SerialCommand::Entry CommandsList[] = { |
| 147 | 164 | {"plevel", cmdSetPrintLevel}, |
| … | … | @@ -153,6 +170,7 @@ SerialCommand::Entry CommandsList[] = { |
| 153 | 170 | {"gamma", cmdGamma}, |
| 154 | 171 | {"hostname", cmdHostname}, |
| 155 | 172 | {"reboot", cmdReboot}, |
| 173 | {"stopfade", cmdStopFade}, | |
| 156 | 174 | {NULL, NULL} |
| 157 | 175 | }; |
| 158 | 176 | SerialCommand CmdMgr(CommandsList, cmdUnrecognized); |
| … | … | @@ -315,50 +333,65 @@ void cmdPWMPins2() { |
| 315 | 333 | diagPrint((long)(numPeriods * periodLength)); |
| 316 | 334 | diagPrintln(" ms"); |
| 317 | 335 | |
| 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. | |
| 326 | 343 | for (int i = 0; i < count; i++) { |
| 327 | 344 | 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); | |
| 348 | 347 | } |
| 348 | pwm2Fade.count = count; | |
| 349 | pwm2Fade.totalMs = (unsigned long)numPeriods * (unsigned long)periodLength; | |
| 350 | pwm2Fade.startMs = millis(); | |
| 351 | pwm2Fade.active = true; | |
| 349 | 352 | |
| 350 | 353 | printAck("OK pwm2 "); |
| 351 | 354 | printAck(count); |
| 352 | 355 | printAck(" pins over "); |
| 353 | printAck(numPeriods * periodLength); | |
| 356 | printAck((long)pwm2Fade.totalMs); | |
| 354 | 357 | printlnAck(" ms"); |
| 355 | 358 | diagPrint("OK pwm2 "); |
| 356 | 359 | diagPrintln((long)count); |
| 357 | 360 | } |
| 358 | 361 | |
| 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. | |
| 364 | void 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. | |
| 384 | void cmdStopFade() { | |
| 385 | pwm2Fade.active = false; | |
| 386 | printlnAck("OK stopfade"); | |
| 387 | diagPrintln("OK stopfade"); | |
| 388 | } | |
| 389 | ||
| 359 | 390 | // expects space-delimited ints 0-65535, or space delimited <index>:<pwm> pairs |
| 360 | 391 | void cmdPWMPins() { |
| 361 | 392 | diagPrintln("GOT pwm"); |
| 393 | // Hard-write cancels any in-flight pwm2 fade so updateFade() doesn't fight us. | |
| 394 | pwm2Fade.active = false; | |
| 362 | 395 | const char *SetPWMUsageMsg = "Error: takes up to 12 arguments between 0 and 65535 (except argument 3's max is 45000)."; |
| 363 | 396 | |
| 364 | 397 | long channelValues[NumPWMPins]; |
| … | … | @@ -647,6 +680,7 @@ void loop() { |
| 647 | 680 | break; |
| 648 | 681 | |
| 649 | 682 | case RUNNING: { |
| 683 | updateFade(); // advance any in-flight pwm2 fade | |
| 650 | 684 | CmdMgr.readSerial(); |
| 651 | 685 | ArduinoOTA.handle(); |
| 652 | 686 |