diff --git a/CLAUDE.md b/CLAUDE.md index 6cba898..4380d4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Nixie clock: notes for Claude -An AliExpress WiFi nixie clock (4x IN-12 + 2x IN-3 neon dots, 8x WS2812) that is being +An AliExpress WiFi nixie clock (4x IN-12 + 2x IN-3 neon dots, 4x WS2812, one under each tube) that is being moved from its closed stock firmware to ESP_UniClock (https://github.com/gpeter62/ESP_UniClock) with ArduinoOTA and Home Assistant (MQTT) support. @@ -42,7 +42,7 @@ on the clock with UniClock; "Binary" = only confirmed in the stock SV1.3 code. |---|---|---| | 74HC595 DATA / CLOCK (OE tied to GND) | 13 / 14 = HSPI MOSI / SCK (hardware SPI, MSB first) | Hardware | | 74HC595 LATCH | 15, plain `digitalWrite` (has 1k pull-down) | Hardware | -| WS2812 data | 12 (NeoPixel bit-bang, GRB 800 kHz; 8 fitted, stock assumes 12). `SPI.begin()` turns GPIO12 into MISO: set it back to OUTPUT afterwards | Hardware | +| WS2812 data | 12 (NeoPixel bit-bang, GRB 800 kHz; 4 fitted, stock assumes 12). `SPI.begin()` turns GPIO12 into MISO: set it back to OUTPUT afterwards | Hardware | | Photoresistor (LDR, 10k to GND) | ADC (A0) | Hardware | | BTN1 / BTN2 (not fitted) | 0 / 2, INPUT_PULLUP. Stock: GPIO0 is a button, GPIO2 a motion/presence input that wakes the display | Binary | | DFPlayer Mini (stock feature) | UART0: GPIO1 TX / GPIO3 RX, 9600 Bd | Binary | @@ -70,9 +70,11 @@ GPIO0 to GND, tap RST to GND, then release GPIO0. - esptool is installed for the user (`python -m esptool`, v5.6.0). After `--after no-reset` the stub keeps running at the new baud rate and the next esptool call can't connect: do everything in one call, or re-enter flash mode. -- Normal updates go over WiFi: `pio run -e nixie_in12_ota -t upload` in `firmware/uniclock/`. - ArduinoOTA does not answer after the clock switched from AP mode to WiFi without a reboot - (first setup); a power cycle fixes it. +- Normal updates go over WiFi: `pio run -e nixie_in12_ota -t upload` in `firmware/uniclock/` + (web UI files: `-t uploadfs`). After the first switch from AP mode to WiFi the clock restarts + once by itself, so ArduinoOTA/MQTT start on the real network. +- `/getCurrentInfos` (no password) has `uptime` and `heap`; with `LOOP_DIAG` in CLOCK_90 also + `stalls` (main-loop gaps of 250 ms or more, by step name). - `firmware/stock/` holds the stock flash dump. **Never overwrite or delete it**; it is the only way back to SV1.3 (`write-flash 0 `). Dumps are gitignored because the flash holds the WiFi password; never commit them. @@ -94,4 +96,8 @@ GPIO0 to GND, tap RST to GND, then release GPIO0. `mqtt.ino` rewritten for ArduinoHA 2.1.0: switches Tubes/Auto dim, numbers Day/Night brightness, light Backlight (on/off, brightness, RGB -> UniClock colour wheel), select LED effect, button Cathode protect, sensors Light/WiFi signal. Broker settings are read at boot. -5. Open: OTA password via `secrets.h`; ArduinoOTA after the AP -> WiFi switch. +5. Done (2026-10-25): tube flicker fixed (the bit-banged WS2812 update blocks interrupts, so + it is now only sent when the pixels change, and only in the dark gap between tubes; this also + fixed digits that seemed to hang). Saving the firmware URL no longer starts an update. + Optional web password, cathode-protect random start, LED gamma. +6. Open: the user can create `secrets.h` (gitignored, never read it) for OTA and web passwords. diff --git a/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino b/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino index 568294e..76d4b71 100644 --- a/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino +++ b/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino @@ -591,24 +591,62 @@ void clearDigits() { // busy-waits for `d` milliseconds but keeps servicing the DNS captive-portal server (AP mode only), // RGB/PWM background animations, the alarm buzzer, the light sensor and (if USE_GPS) the GPS serial // feed, so none of those subsystems stalls during a long animation/connect sequence. +// LOOP_DIAG (temporary, clocks.h): finds what stalls the main loop. Every step in loop() and Fdelay() +// reports via DIAG() when it is done; a gap of LOOP_DIAG_MS or more since the previous report is +// recorded with the step's name. The last stalls and the longest one are in /getCurrentInfos ("stalls"). +#ifdef LOOP_DIAG + #define LOOP_DIAG_MS 250 + #define DIAG(step, call) do { call; diagTick(step); } while (0) + struct DiagStall { const char* step; uint16_t ms; uint32_t at; }; + DiagStall diagStalls[8]; + byte diagStallPtr = 0; + DiagStall diagWorst = {"", 0, 0}; + char diagText[260]; + + void diagTick(const char* step) { + static unsigned long last = 0; + unsigned long now = millis(); + unsigned long gap = now - last; + last = now; + if (gap < LOOP_DIAG_MS) return; + DiagStall s = {step, (uint16_t)min(gap, 65535ul), now / 1000}; + diagStalls[diagStallPtr] = s; + diagStallPtr = (diagStallPtr + 1) % 8; + if (s.ms > diagWorst.ms) diagWorst = s; + } + + //"worst: step ms @uptime s | step ms @s, ..." (newest first) + const char* diagReport() { + int n = snprintf(diagText, sizeof(diagText), "worst: %s %ums @%lus |", diagWorst.step, diagWorst.ms, (unsigned long)diagWorst.at); + for (int i = 1; i <= 8 && n < (int)sizeof(diagText); i++) { + DiagStall& s = diagStalls[(diagStallPtr + 8 - i) % 8]; + if (s.ms == 0) break; + n += snprintf(diagText + n, sizeof(diagText) - n, " %s %u @%lu,", s.step, s.ms, (unsigned long)s.at); + } + return diagText; + } +#else + #define DIAG(step, call) call +#endif + void Fdelay(unsigned long d) { unsigned long dStart = millis(); doAnimationMakuna(); while ((millis() - dStart) < d) { - if (WiFi.getMode() == 2) dnsServer.processNextRequest(); - writeDisplay2(); - getLightSensor(); - doAnimationMakuna(); - doAnimationPWM(); - alarmSound(); + if (WiFi.getMode() == 2) DIAG("F:dns", dnsServer.processNextRequest()); + DIAG("F:writeDisplay2", writeDisplay2()); + DIAG("F:lightSensor", getLightSensor()); + DIAG("F:neopixel", doAnimationMakuna()); + DIAG("F:pwmLeds", doAnimationPWM()); + DIAG("F:alarm", alarmSound()); #ifdef USE_GPS smartDelay(1); //feed GPS #endif #ifdef USE_ARDUINO_OTA - ArduinoOTA.handle(); //also during long animations/startup waits + DIAG("F:ota", ArduinoOTA.handle()); //also during long animations/startup waits #endif - yield(); + DIAG("F:yield", yield()); } } @@ -973,6 +1011,10 @@ void startStandaloneMode() { // and only calls doReset() to actually reboot into the new firmware if BOTH updates reported success; // otherwise the (old) server is simply restarted so the clock keeps running on the current firmware. void doFirmwareUpdate(){ + if (strlen(prm.firmwareServer) == 0) { + DPRINTLN("No firmware server set. FirmwareUpdate skipped."); + return; + } if (WiFi.status() != WL_CONNECTED) { DPRINTLN("Wifi disconnected. FirmwareUpdate failed."); return; @@ -1088,6 +1130,18 @@ void doCathodeProtect() { // uploader from the repo's data/ folder - see file header), plus a handful of action routes // (/reset, /factoryreset, /firmwareupdate, /cathodeProtect) and the JSON/form REST-ish API // (handleConfigChanged/handleSendConfig/handleSendCurrentInfos/handleSendClockDetails, defined below). +// Optional password for the web UI (WEB_PASSWORD, and WEB_USER, default "admin", in secrets.h). +// Everything except /generate_204 (captive-portal check) and /getCurrentInfos (time and sensor +// values only) needs it; /getConfiguration returns the stored WiFi and MQTT passwords. +#ifdef WEB_PASSWORD + #ifndef WEB_USER + #define WEB_USER "admin" + #endif + #define WEB_AUTH .setAuthentication(WEB_USER, WEB_PASSWORD) +#else + #define WEB_AUTH +#endif + void startServer() { DPRINTLN("Starting Async Webserver..."); @@ -1101,7 +1155,7 @@ void startServer() { request->send( 204, "text/html", "File Not Found" ); DPRINTLN("/index.html not found"); } - }); + }) WEB_AUTH; server.on("/jquery_351.js", HTTP_GET, [](AsyncWebServerRequest * request) { DPRINTLN("Webserver: /jquery_351.js"); @@ -1114,7 +1168,7 @@ void startServer() { request->send( 204, "text/html", "File Not Found" ); DPRINTLN("/jquery_351.js not found"); } - }); + }) WEB_AUTH; server.on("/jquery_360.js", HTTP_GET, [](AsyncWebServerRequest * request) { DPRINTLN("Webserver: /jquery_360.js"); @@ -1127,7 +1181,7 @@ void startServer() { request->send( 204, "text/html", "File Not Found" ); DPRINTLN("/jquery_360.js not found"); } - }); + }) WEB_AUTH; server.on("/page.js", HTTP_GET, [](AsyncWebServerRequest * request) { @@ -1141,7 +1195,7 @@ void startServer() { request->send( 204, "text/html", "File Not Found" ); DPRINTLN("/page.js not found"); } - }); + }) WEB_AUTH; server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest * request) { DPRINTLN("Webserver: /favicon.ico"); @@ -1154,7 +1208,7 @@ void startServer() { request->send( 204, "text/html", "File Not Found" ); DPRINTLN("/favicon.ico not found"); } - }); + }) WEB_AUTH; server.on("/generate_204", HTTP_GET, [](AsyncWebServerRequest * request) { DPRINTLN("Webserver: /generate_204"); @@ -1186,7 +1240,7 @@ void startServer() { request->send( 204, "text/html", "File Not Found" ); DPRINTLN("/site.css not found"); } - }); + }) WEB_AUTH; //____________________________________________________________________________ server.on("/reset", HTTP_POST, [] (AsyncWebServerRequest *request) { DPRINTLN("/reset:"); @@ -1194,7 +1248,7 @@ void startServer() { request->send(200, "text/plain", "Reset: Restarting the Box!"); delay(200); doReset(); - }); + }) WEB_AUTH; //____________________________________________________________________________ server.on("/factoryreset", HTTP_POST, [] (AsyncWebServerRequest *request) { DPRINTLN("/factoryreset:"); @@ -1203,22 +1257,22 @@ void startServer() { delay(200); factoryReset(); doReset(); - }); + }) WEB_AUTH; //____________________________________________________________________________ server.on("/firmwareupdate", HTTP_POST, [](AsyncWebServerRequest * request) { makeFirmwareUpdate = true; - }); + }) WEB_AUTH; //____________________________________________________________________________ server.on("/cathodeProtect", HTTP_POST, [](AsyncWebServerRequest * request) { makeCathodeProtect = true; - }); + }) WEB_AUTH; - server.on("/saveSetting", HTTP_POST, handleConfigChanged); - server.on("/getConfiguration", HTTP_GET, handleSendConfig); + server.on("/saveSetting", HTTP_POST, handleConfigChanged) WEB_AUTH; + server.on("/getConfiguration", HTTP_GET, handleSendConfig) WEB_AUTH; server.on("/getCurrentInfos", HTTP_GET, handleSendCurrentInfos); - server.on("/getClockDetails", HTTP_GET, handleSendClockDetails); + server.on("/getClockDetails", HTTP_GET, handleSendClockDetails) WEB_AUTH; server.onNotFound(handleNotFound); server.begin(); @@ -1426,12 +1480,10 @@ void handleConfigChanged(AsyncWebServerRequest *request) { value.toCharArray(prm.NtpServer,sizeof(prm.NtpServer)); } else if (key == "firmware") { - // Side effect: merely changing the firmware-server URL setting also immediately kicks off an - // OTA update attempt (makeFirmwareUpdate, picked up by loop()->doFirmwareUpdate()) - per the - // "for testing only" comment this looks like a leftover dev convenience rather than intended - // production behavior (a user just correcting a typo in the URL would trigger a real update). + // FIXED: saving the URL used to start an update right away ("makeFirmwareUpdate = true; //for + // testing only"), so clearing or correcting the field flashed whatever that URL served. Updates + // now only start from the explicit POST /firmwareupdate. value.toCharArray(prm.firmwareServer,sizeof(prm.firmwareServer)); - makeFirmwareUpdate = true; //for testing only } else if (key == "mqttBrokerAddr") { value.toCharArray(prm.mqttBrokerAddr,sizeof(prm.mqttBrokerAddr)); @@ -1754,7 +1806,12 @@ if (useTemp > 1) } else doc["lx"] = 255; - doc["rssi"] = WiFi.RSSI(); + doc["rssi"] = WiFi.RSSI(); + doc["uptime"] = millis() / 1000; + doc["heap"] = ESP.getFreeHeap(); + #ifdef LOOP_DIAG + doc["stalls"] = diagReport(); + #endif String json; serializeJson(doc, json); request->send(200, "application/json", json); @@ -2197,24 +2254,21 @@ void newCathodeProtect(unsigned long t,int dir) { //t = time in msec, dir = d memcpy(tmp,digit,sizeof(tmp)); memcpy(tmpDP,digitDP,sizeof(tmpDP)); memset(fin,0,sizeof(fin)); + // FIXED: the first stop digit was overwritten by "dir>0 ? 0 : maxDigits-1" after this switch, so a + // random run (dir==0) never started at a random digit. The +-1 cases below were swapped too (the + // overwrite hid that): dir>0 counts up from 0, dir<0 counts down from the last digit (see further down). switch (dir) { case -1: - nextStoppedDigit = 0; + nextStoppedDigit = maxDigits-1; break; case 0: nextStoppedDigit = random(maxDigits); break; case 1: - nextStoppedDigit = maxDigits-1; + default: + nextStoppedDigit = 0; break; } - // POSSIBLE BUG: this line unconditionally overwrites whatever the switch above just computed, - // including the `case 0` (dir==0, "random") result - so the FIRST digit to stop is never actually - // random, it's always index 0 (dir<=0) or maxDigits-1 (dir>0), identical to the dir<0/dir>0 cases. - // The random-order behavior only actually shows up LATER, in the `else if (sum<=maxDigits-2)` branch - // below that picks each SUBSEQUENT stop digit randomly - so dir==0 does look random from the second - // stopped digit onward, just not on the very first one, which is likely not what was intended. - nextStoppedDigit = dir>0 ? 0 : maxDigits-1; while(true) { for (int i=0;i= t); @@ -2970,7 +3024,16 @@ void resetWiFi(void) { if (WiFi.status() == WL_CONNECTED) return; //findBestWifi(); + boolean wasAP = (WiFi.getMode() & WIFI_AP); startWifiMode(); + // First connection after running as an access point (e.g. WiFi just configured on 192.168.4.1): + // restart, so everything set up at boot (ArduinoOTA, mDNS, MQTT) starts on the real network. + // Without this ArduinoOTA did not answer until the next power cycle. + if (wasAP && (WiFi.status() == WL_CONNECTED)) { + DPRINTLN("Connected to WiFi after AP mode. Restarting..."); + delay(1000); + doReset(); + } return; // POSSIBLE BUG / NOTE: unconditional `return` above makes everything below permanently unreachable - // including a 3-hour "give up and force a full reboot" counter/message. As written, resetWiFi() will @@ -3258,29 +3321,29 @@ void checkPrm() { // quickly and can't run these long operations inline). void loop(void) { //checkPrm(); - writeDisplay2(); + DIAG("writeDisplay2", writeDisplay2()); if (WiFi.status() != WL_CONNECTED) - dnsServer.processNextRequest(); - timeProgram(); + DIAG("dns", dnsServer.processNextRequest()); + DIAG("timeProgram", timeProgram()); //writeDisplaySingle(); - writeDisplay2(); - doAnimationMakuna(); - doAnimationPWM(); - alarmSound(); - checkTubePowerOnOff(); - getLightSensor(); + DIAG("writeDisplay2", writeDisplay2()); + DIAG("neopixel", doAnimationMakuna()); + DIAG("pwmLeds", doAnimationPWM()); + DIAG("alarm", alarmSound()); + DIAG("tubePower", checkTubePowerOnOff()); + DIAG("lightSensor", getLightSensor()); //checkWifiMode(); if (prm.wifiMode) { //WiFi Clock Mode if (WiFi.status() == WL_CONNECTED) { - if (prm.mqttEnable) mqttSend(); + if (prm.mqttEnable) DIAG("mqtt", mqttSend()); } else { - resetWiFi(); + DIAG("resetWiFi", resetWiFi()); } } - editor(); + DIAG("editor", editor()); #ifdef USE_ARDUINO_OTA - ArduinoOTA.handle(); + DIAG("ota", ArduinoOTA.handle()); #endif if (makeFirmwareUpdate) { makeFirmwareUpdate = false; @@ -3288,9 +3351,9 @@ void loop(void) { } if (makeCathodeProtect) { makeCathodeProtect = false; - doCathodeProtect(); + DIAG("cathodeProtect", doCathodeProtect()); } - yield(); + DIAG("yield", yield()); } // Clean reboot: drops the WiFi radio (and, on ESP8266, its TX power) before calling ESP.restart(), diff --git a/firmware/uniclock/ESP32_UniClock2/clocks.h b/firmware/uniclock/ESP32_UniClock2/clocks.h index 19a0a61..5394144 100644 --- a/firmware/uniclock/ESP32_UniClock2/clocks.h +++ b/firmware/uniclock/ESP32_UniClock2/clocks.h @@ -132,7 +132,7 @@ byte anodeBits[] = {14, 13, 12, 11}; //595 word bit of each tube's anode: posi #define NEON_POS 1 //the dots are lit in this tube's multiplex slot, as on stock #define USE_NEOPIXEL #define NEOPIXEL_PIN 12 //bit-banged, GRB 800 kHz -byte tubePixels[] = {0, 0, 1, 1, 2, 2, 3, 3}; //8 WS2812, two per tube (strip order not verified yet) +byte tubePixels[] = {0, 1, 2, 3}; //4 WS2812, one per tube (chain order not verified: maybe {3,2,1,0}) #define USE_RTC //DS3231 on I2C, detected at startup (address 0x68); the stock firmware never used it #define PIN_SDA 4 #define PIN_SCL 5 @@ -144,6 +144,7 @@ byte tubePixels[] = {0, 0, 1, 1, 2, 2, 3, 3}; //8 WS2812, two per tube (strip #define USE_MQTT //Home Assistant: broker address/user/password on the web page, then reboot #define MQTT_PREFIX "homeassistant" //Home Assistant's default discovery prefix #define FIRMWARE_SERVER "" //no web-triggered HTTP update; use ArduinoOTA +//#define LOOP_DIAG //report main-loop stalls in /getCurrentInfos ("stalls"); for debugging only #define USE_ARDUINO_OTA #define OTA_HOSTNAME "nixie-in12" #if __has_include("secrets.h") diff --git a/firmware/uniclock/ESP32_UniClock2/mqtt.ino b/firmware/uniclock/ESP32_UniClock2/mqtt.ino index ee3fd79..9406d32 100644 --- a/firmware/uniclock/ESP32_UniClock2/mqtt.ino +++ b/firmware/uniclock/ESP32_UniClock2/mqtt.ino @@ -218,7 +218,7 @@ byte rgbToWheel(byte r, byte g, byte b) { } HALight::RGBColor wheelToRgb(byte pos) { - RgbColor c = Wheel(pos); + RgbColor c = WheelRaw(pos); //without gamma correction, so it matches what HA sent HALight::RGBColor color; color.red = c.R; color.green = c.G; color.blue = c.B; color.isSet = true; diff --git a/firmware/uniclock/ESP32_UniClock2/nixie595.ino b/firmware/uniclock/ESP32_UniClock2/nixie595.ino index 198195b..ee162f4 100644 --- a/firmware/uniclock/ESP32_UniClock2/nixie595.ino +++ b/firmware/uniclock/ESP32_UniClock2/nixie595.ino @@ -36,6 +36,16 @@ const int PWMrefresh = 11000; const int PWM_min = 1000; const int PWM_max = 10000; +volatile boolean displayBlank = true; //true while the ISR outputs an all-off word (gap between tubes) + +// Wait (at most one tube slot) until the tubes are dark. Used before work that blocks interrupts for a +// few hundred microseconds (the bit-banged WS2812 update): if the ISR is delayed in the dark gap, only the +// gap gets longer. Delayed while a tube is lit, that tube would stay on longer and flash brighter. +void displayWaitBlank() { + uint32_t started = micros(); + while (!displayBlank && ((micros() - started) < 3000)) {} +} + // Shift one 16-bit word into both 595s and latch it. Also called from main code via clearTubes(), // so the caller must keep the ISR out (see clearTubes()). static inline void IRAM_ATTR write595(uint16_t w) { @@ -139,6 +149,7 @@ void IRAM_ATTR writeDisplay() { //timer1 ISR, single-shot: must re-arm timer1 if (w) w |= 1 << anodeBits[pos]; } write595(w); + displayBlank = (w == 0); timer1_write(timer); } diff --git a/firmware/uniclock/ESP32_UniClock2/secrets.h.example b/firmware/uniclock/ESP32_UniClock2/secrets.h.example index 1d9c212..8f09bb0 100644 --- a/firmware/uniclock/ESP32_UniClock2/secrets.h.example +++ b/firmware/uniclock/ESP32_UniClock2/secrets.h.example @@ -1,6 +1,8 @@ -// Copy to secrets.h (gitignored) and fill in. All entries are optional. +// Copy to secrets.h (gitignored) and fill in. All entries are optional: remove a line to leave it unset. // DEFAULT_SSID/DEFAULT_PSW are only applied on a factory reset (first boot with a new EEPROM layout); // otherwise set the WiFi from the clock's web page. #define DEFAULT_SSID "" #define DEFAULT_PSW "" -#define OTA_PASSWORD "" //ArduinoOTA password; remove the line for no password +#define OTA_PASSWORD "" //ArduinoOTA password (also add --auth= to upload_flags in platformio.ini) +#define WEB_USER "admin" //web UI login +#define WEB_PASSWORD "" diff --git a/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino b/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino index 9abbb7c..774fa56 100644 --- a/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino +++ b/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino @@ -99,26 +99,47 @@ inline byte tubeForPixel(int i) { //(DMA-fed) instead of RMT; this is more tolerant of interrupts/WiFi jitter //than NeoEsp32Rmt7Ws2812xMethod, which can glitch the strict WS2812 bit //timing (~1.25us/bit) if an ISR delays the RMT hardware mid-frame. - NeoPixelBrightnessBus strip(PixelCount+4,PixelPin); //NeoEsp32I2s1800KbpsMethod is used here because it is more reliable than NeoEsp32Rmt7Ws2812xMethod! + #define NEO_STRIP_LEN (PixelCount+4) + NeoPixelBrightnessBus strip(NEO_STRIP_LEN,PixelPin); //NeoEsp32I2s1800KbpsMethod is used here because it is more reliable than NeoEsp32Rmt7Ws2812xMethod! #elif NEOPIXEL_PIN == 3 //default (see clocks.h) byte PixelPin = 3; // on 8266 it MUST use GPIO3 (RX pin) //8266 has no I2S/RMT WS2812 helper here; Neo800KbpsMethod bit-bangs the //protocol with cycle-counted delays, which is why GPIO3/RX is mandated - //it's one of the few pins whose timing isn't disturbed by the UART/boot ROM. - NeoPixelBrightnessBus strip(PixelCount+4); + #define NEO_STRIP_LEN (PixelCount+4) + NeoPixelBrightnessBus strip(NEO_STRIP_LEN); #else //Any other 8266 pin: cycle-counted bit-bang. Interrupts are off while Show() - //runs (about 30 us per pixel), which briefly delays the multiplex ISR. + //runs (about 30 us per pixel), which delays the multiplex ISR - see neoShow(). + //No headroom pixels here: every loop stops at PixelCount-1, and each extra + //pixel makes the interrupt-free time longer. byte PixelPin = NEOPIXEL_PIN; - NeoPixelBrightnessBus strip(PixelCount+4, PixelPin); + #define NEO_STRIP_LEN PixelCount + NeoPixelBrightnessBus strip(NEO_STRIP_LEN, PixelPin); #endif -//Gamma-correction lookup table object - NOTE: currently declared but never -//applied (colorGamma.Correct(...) is not called anywhere in this file), so -//colors are sent to the strip linearly, not perceptually corrected. Possible -//leftover from an earlier version / an intended-but-unfinished improvement. +//Gamma-correction lookup table, applied in Wheel() so hues look even (without it, +//mixed colours such as yellow or cyan look washed out). +// FIXED: was declared but never used. NeoGamma colorGamma; +//Sends the strip, but only if its buffer changed since the last send: the effects call this on +//every tick (fixed colour once a second) although the colours often stay the same. With the +//nixie595 driver it also waits for a dark gap between tubes first, see displayWaitBlank(). +void neoShow() { + static uint8_t lastFrame[NEO_STRIP_LEN * 3]; + static boolean sent = false; + size_t n = min(strip.PixelsSize(), sizeof(lastFrame)); + + if (sent && (memcmp(lastFrame, strip.Pixels(), n) == 0)) return; + memcpy(lastFrame, strip.Pixels(), n); + sent = true; + #ifdef NIXIE595_MULTIPLEX + displayWaitBlank(); + #endif + strip.Show(); +} + void setupNeopixel() { DPRINTLN("Setup NeoPixel LEDS"); regPin(PixelPin,"NEOPIXEL_PIN"); @@ -129,7 +150,7 @@ void setupNeopixel() { //does not. A driver may have claimed the pin since (nixie595.ino: SPI.begin() makes GPIO12 MISO). pinMode(PixelPin, OUTPUT); strip.Begin(); //initialize the underlying I2S/RMT/bit-bang driver and GPIO - strip.Show(); //push the (still all-black, default-constructed) buffer out so the strip starts dark, not showing stale/garbage pixel data + neoShow(); //push the (still all-black, default-constructed) buffer out so the strip starts dark, not showing stale/garbage pixel data strip.SetBrightness(neoBrightness); fixColor(prm.rgbFixColor); //apply the persisted fixed color immediately so the backlight is correct even before the first doAnimationMakuna() tick } @@ -143,6 +164,11 @@ void setupNeopixel() { //sentinel value that instead returns solid white, since a pure hue rotation //can never itself produce white (R=G=B). RgbColor Wheel(byte WheelPos) { + return colorGamma.Correct(WheelRaw(WheelPos)); +} + +//The uncorrected wheel colour (also used by mqtt.ino to report the colour to Home Assistant). +RgbColor WheelRaw(byte WheelPos) { if (WheelPos == WHITE_INDEX) return white; @@ -180,7 +206,7 @@ void alarmLight() { else strip.SetPixelColor(i,black); } - strip.Show(); + neoShow(); } //Called once from the core's alarmSound() when the alarm's ring period times @@ -193,7 +219,7 @@ void darkenNeopixels() { for (int i=0;i pick a new random hue and start brightening back up dir = -1; } - strip.Show(); + neoShow(); } @@ -327,7 +353,7 @@ void effect2() { //random color picker else strip.SetPixelColor(i, Wheel(c)); } - strip.Show(); + neoShow(); } //Distance between two positions on the 0..255 color wheel, accounting for @@ -468,7 +494,7 @@ void effect3(boolean enableRandom,boolean eachPixelRandom) { for (int j=0;j