Fix tube flicker and review fixes

- The bit-banged WS2812 update disables interrupts (about 30 us per
  pixel), which made the lit tube stay on longer and flash. neoShow()
  now only sends when the pixels changed (fixed colour was resent every
  second) and waits for the dark gap between tubes; the bit-bang strip
  has no headroom pixels. This also fixed digits that seemed to hang.
- 4 WS2812 on this board, one per tube (not 8)
- Saving the firmware URL in the web UI no longer starts an HTTP
  update; never update from an empty URL
- Restart once after the first switch from AP mode to WiFi, so
  ArduinoOTA and MQTT start on the real network
- Optional web UI password (WEB_USER/WEB_PASSWORD in secrets.h);
  /getConfiguration returns the stored WiFi and MQTT passwords
- Cathode protect: random mode now starts at a random tube (the +-1
  start digits were swapped and then overwritten)
- Apply the unused gamma table to wheel colours
- LOOP_DIAG (off by default): main-loop stalls in /getCurrentInfos,
  which now also reports uptime and free heap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-25 22:18:39 +01:00
1 parent e5d1757035
commit 5fbae6247c
7 files changed
+189 -80

No files matched your search

+12 -6
View File
@@ -1,6 +1,6 @@
# Nixie clock: notes for Claude # 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) moved from its closed stock firmware to ESP_UniClock (https://github.com/gpeter62/ESP_UniClock)
with ArduinoOTA and Home Assistant (MQTT) support. 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 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 | | 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 | | 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 | | 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 | | 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` - 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 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. 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/`. - 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 (web UI files: `-t uploadfs`). After the first switch from AP mode to WiFi the clock restarts
(first setup); a power cycle fixes it. 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 - `firmware/stock/` holds the stock flash dump. **Never overwrite or delete it**; it is the
only way back to SV1.3 (`write-flash 0 <dump>`). Dumps are gitignored because the flash only way back to SV1.3 (`write-flash 0 <dump>`). Dumps are gitignored because the flash
holds the WiFi password; never commit them. 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 `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 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. 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.
@@ -591,24 +591,62 @@ void clearDigits() {
// busy-waits for `d` milliseconds but keeps servicing the DNS captive-portal server (AP mode only), // 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 // 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. // 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) { void Fdelay(unsigned long d) {
unsigned long dStart = millis(); unsigned long dStart = millis();
doAnimationMakuna(); doAnimationMakuna();
while ((millis() - dStart) < d) { while ((millis() - dStart) < d) {
if (WiFi.getMode() == 2) dnsServer.processNextRequest(); if (WiFi.getMode() == 2) DIAG("F:dns", dnsServer.processNextRequest());
writeDisplay2(); DIAG("F:writeDisplay2", writeDisplay2());
getLightSensor(); DIAG("F:lightSensor", getLightSensor());
doAnimationMakuna(); DIAG("F:neopixel", doAnimationMakuna());
doAnimationPWM(); DIAG("F:pwmLeds", doAnimationPWM());
alarmSound(); DIAG("F:alarm", alarmSound());
#ifdef USE_GPS #ifdef USE_GPS
smartDelay(1); //feed GPS smartDelay(1); //feed GPS
#endif #endif
#ifdef USE_ARDUINO_OTA #ifdef USE_ARDUINO_OTA
ArduinoOTA.handle(); //also during long animations/startup waits DIAG("F:ota", ArduinoOTA.handle()); //also during long animations/startup waits
#endif #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; // 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. // otherwise the (old) server is simply restarted so the clock keeps running on the current firmware.
void doFirmwareUpdate(){ void doFirmwareUpdate(){
if (strlen(prm.firmwareServer) == 0) {
DPRINTLN("No firmware server set. FirmwareUpdate skipped.");
return;
}
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
DPRINTLN("Wifi disconnected. FirmwareUpdate failed."); DPRINTLN("Wifi disconnected. FirmwareUpdate failed.");
return; return;
@@ -1088,6 +1130,18 @@ void doCathodeProtect() {
// uploader from the repo's data/ folder - see file header), plus a handful of action routes // 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 // (/reset, /factoryreset, /firmwareupdate, /cathodeProtect) and the JSON/form REST-ish API
// (handleConfigChanged/handleSendConfig/handleSendCurrentInfos/handleSendClockDetails, defined below). // (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() { void startServer() {
DPRINTLN("Starting Async Webserver..."); DPRINTLN("Starting Async Webserver...");
@@ -1101,7 +1155,7 @@ void startServer() {
request->send( 204, "text/html", "File Not Found" ); request->send( 204, "text/html", "File Not Found" );
DPRINTLN("/index.html not found"); DPRINTLN("/index.html not found");
} }
}); }) WEB_AUTH;
server.on("/jquery_351.js", HTTP_GET, [](AsyncWebServerRequest * request) { server.on("/jquery_351.js", HTTP_GET, [](AsyncWebServerRequest * request) {
DPRINTLN("Webserver: /jquery_351.js"); DPRINTLN("Webserver: /jquery_351.js");
@@ -1114,7 +1168,7 @@ void startServer() {
request->send( 204, "text/html", "File Not Found" ); request->send( 204, "text/html", "File Not Found" );
DPRINTLN("/jquery_351.js not found"); DPRINTLN("/jquery_351.js not found");
} }
}); }) WEB_AUTH;
server.on("/jquery_360.js", HTTP_GET, [](AsyncWebServerRequest * request) { server.on("/jquery_360.js", HTTP_GET, [](AsyncWebServerRequest * request) {
DPRINTLN("Webserver: /jquery_360.js"); DPRINTLN("Webserver: /jquery_360.js");
@@ -1127,7 +1181,7 @@ void startServer() {
request->send( 204, "text/html", "File Not Found" ); request->send( 204, "text/html", "File Not Found" );
DPRINTLN("/jquery_360.js not found"); DPRINTLN("/jquery_360.js not found");
} }
}); }) WEB_AUTH;
server.on("/page.js", HTTP_GET, [](AsyncWebServerRequest * request) { server.on("/page.js", HTTP_GET, [](AsyncWebServerRequest * request) {
@@ -1141,7 +1195,7 @@ void startServer() {
request->send( 204, "text/html", "File Not Found" ); request->send( 204, "text/html", "File Not Found" );
DPRINTLN("/page.js not found"); DPRINTLN("/page.js not found");
} }
}); }) WEB_AUTH;
server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest * request) { server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest * request) {
DPRINTLN("Webserver: /favicon.ico"); DPRINTLN("Webserver: /favicon.ico");
@@ -1154,7 +1208,7 @@ void startServer() {
request->send( 204, "text/html", "File Not Found" ); request->send( 204, "text/html", "File Not Found" );
DPRINTLN("/favicon.ico not found"); DPRINTLN("/favicon.ico not found");
} }
}); }) WEB_AUTH;
server.on("/generate_204", HTTP_GET, [](AsyncWebServerRequest * request) { server.on("/generate_204", HTTP_GET, [](AsyncWebServerRequest * request) {
DPRINTLN("Webserver: /generate_204"); DPRINTLN("Webserver: /generate_204");
@@ -1186,7 +1240,7 @@ void startServer() {
request->send( 204, "text/html", "File Not Found" ); request->send( 204, "text/html", "File Not Found" );
DPRINTLN("/site.css not found"); DPRINTLN("/site.css not found");
} }
}); }) WEB_AUTH;
//____________________________________________________________________________ //____________________________________________________________________________
server.on("/reset", HTTP_POST, [] (AsyncWebServerRequest *request) { server.on("/reset", HTTP_POST, [] (AsyncWebServerRequest *request) {
DPRINTLN("/reset:"); DPRINTLN("/reset:");
@@ -1194,7 +1248,7 @@ void startServer() {
request->send(200, "text/plain", "Reset: Restarting the Box!"); request->send(200, "text/plain", "Reset: Restarting the Box!");
delay(200); delay(200);
doReset(); doReset();
}); }) WEB_AUTH;
//____________________________________________________________________________ //____________________________________________________________________________
server.on("/factoryreset", HTTP_POST, [] (AsyncWebServerRequest *request) { server.on("/factoryreset", HTTP_POST, [] (AsyncWebServerRequest *request) {
DPRINTLN("/factoryreset:"); DPRINTLN("/factoryreset:");
@@ -1203,22 +1257,22 @@ void startServer() {
delay(200); delay(200);
factoryReset(); factoryReset();
doReset(); doReset();
}); }) WEB_AUTH;
//____________________________________________________________________________ //____________________________________________________________________________
server.on("/firmwareupdate", HTTP_POST, [](AsyncWebServerRequest * request) { server.on("/firmwareupdate", HTTP_POST, [](AsyncWebServerRequest * request) {
makeFirmwareUpdate = true; makeFirmwareUpdate = true;
}); }) WEB_AUTH;
//____________________________________________________________________________ //____________________________________________________________________________
server.on("/cathodeProtect", HTTP_POST, [](AsyncWebServerRequest * request) { server.on("/cathodeProtect", HTTP_POST, [](AsyncWebServerRequest * request) {
makeCathodeProtect = true; makeCathodeProtect = true;
}); }) WEB_AUTH;
server.on("/saveSetting", HTTP_POST, handleConfigChanged); server.on("/saveSetting", HTTP_POST, handleConfigChanged) WEB_AUTH;
server.on("/getConfiguration", HTTP_GET, handleSendConfig); server.on("/getConfiguration", HTTP_GET, handleSendConfig) WEB_AUTH;
server.on("/getCurrentInfos", HTTP_GET, handleSendCurrentInfos); server.on("/getCurrentInfos", HTTP_GET, handleSendCurrentInfos);
server.on("/getClockDetails", HTTP_GET, handleSendClockDetails); server.on("/getClockDetails", HTTP_GET, handleSendClockDetails) WEB_AUTH;
server.onNotFound(handleNotFound); server.onNotFound(handleNotFound);
server.begin(); server.begin();
@@ -1426,12 +1480,10 @@ void handleConfigChanged(AsyncWebServerRequest *request) {
value.toCharArray(prm.NtpServer,sizeof(prm.NtpServer)); value.toCharArray(prm.NtpServer,sizeof(prm.NtpServer));
} }
else if (key == "firmware") { else if (key == "firmware") {
// Side effect: merely changing the firmware-server URL setting also immediately kicks off an // FIXED: saving the URL used to start an update right away ("makeFirmwareUpdate = true; //for
// OTA update attempt (makeFirmwareUpdate, picked up by loop()->doFirmwareUpdate()) - per the // testing only"), so clearing or correcting the field flashed whatever that URL served. Updates
// "for testing only" comment this looks like a leftover dev convenience rather than intended // now only start from the explicit POST /firmwareupdate.
// production behavior (a user just correcting a typo in the URL would trigger a real update).
value.toCharArray(prm.firmwareServer,sizeof(prm.firmwareServer)); value.toCharArray(prm.firmwareServer,sizeof(prm.firmwareServer));
makeFirmwareUpdate = true; //for testing only
} }
else if (key == "mqttBrokerAddr") { else if (key == "mqttBrokerAddr") {
value.toCharArray(prm.mqttBrokerAddr,sizeof(prm.mqttBrokerAddr)); value.toCharArray(prm.mqttBrokerAddr,sizeof(prm.mqttBrokerAddr));
@@ -1754,7 +1806,12 @@ if (useTemp > 1)
} }
else else
doc["lx"] = 255; 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; String json;
serializeJson(doc, json); serializeJson(doc, json);
request->send(200, "application/json", 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(tmp,digit,sizeof(tmp));
memcpy(tmpDP,digitDP,sizeof(tmpDP)); memcpy(tmpDP,digitDP,sizeof(tmpDP));
memset(fin,0,sizeof(fin)); 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) { switch (dir) {
case -1: case -1:
nextStoppedDigit = 0; nextStoppedDigit = maxDigits-1;
break; break;
case 0: case 0:
nextStoppedDigit = random(maxDigits); nextStoppedDigit = random(maxDigits);
break; break;
case 1: case 1:
nextStoppedDigit = maxDigits-1; default:
nextStoppedDigit = 0;
break; 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) { while(true) {
for (int i=0;i<maxDigits;i++) { for (int i=0;i<maxDigits;i++) {
finish = ((millis()-started) >= t); finish = ((millis()-started) >= t);
@@ -2970,7 +3024,16 @@ void resetWiFi(void) {
if (WiFi.status() == WL_CONNECTED) return; if (WiFi.status() == WL_CONNECTED) return;
//findBestWifi(); //findBestWifi();
boolean wasAP = (WiFi.getMode() & WIFI_AP);
startWifiMode(); 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; return;
// POSSIBLE BUG / NOTE: unconditional `return` above makes everything below permanently unreachable - // 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 // 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). // quickly and can't run these long operations inline).
void loop(void) { void loop(void) {
//checkPrm(); //checkPrm();
writeDisplay2(); DIAG("writeDisplay2", writeDisplay2());
if (WiFi.status() != WL_CONNECTED) if (WiFi.status() != WL_CONNECTED)
dnsServer.processNextRequest(); DIAG("dns", dnsServer.processNextRequest());
timeProgram(); DIAG("timeProgram", timeProgram());
//writeDisplaySingle(); //writeDisplaySingle();
writeDisplay2(); DIAG("writeDisplay2", writeDisplay2());
doAnimationMakuna(); DIAG("neopixel", doAnimationMakuna());
doAnimationPWM(); DIAG("pwmLeds", doAnimationPWM());
alarmSound(); DIAG("alarm", alarmSound());
checkTubePowerOnOff(); DIAG("tubePower", checkTubePowerOnOff());
getLightSensor(); DIAG("lightSensor", getLightSensor());
//checkWifiMode(); //checkWifiMode();
if (prm.wifiMode) { //WiFi Clock Mode if (prm.wifiMode) { //WiFi Clock Mode
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
if (prm.mqttEnable) mqttSend(); if (prm.mqttEnable) DIAG("mqtt", mqttSend());
} }
else { else {
resetWiFi(); DIAG("resetWiFi", resetWiFi());
} }
} }
editor(); DIAG("editor", editor());
#ifdef USE_ARDUINO_OTA #ifdef USE_ARDUINO_OTA
ArduinoOTA.handle(); DIAG("ota", ArduinoOTA.handle());
#endif #endif
if (makeFirmwareUpdate) { if (makeFirmwareUpdate) {
makeFirmwareUpdate = false; makeFirmwareUpdate = false;
@@ -3288,9 +3351,9 @@ void loop(void) {
} }
if (makeCathodeProtect) { if (makeCathodeProtect) {
makeCathodeProtect = false; 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(), // Clean reboot: drops the WiFi radio (and, on ESP8266, its TX power) before calling ESP.restart(),
+2 -1
View File
@@ -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 NEON_POS 1 //the dots are lit in this tube's multiplex slot, as on stock
#define USE_NEOPIXEL #define USE_NEOPIXEL
#define NEOPIXEL_PIN 12 //bit-banged, GRB 800 kHz #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 USE_RTC //DS3231 on I2C, detected at startup (address 0x68); the stock firmware never used it
#define PIN_SDA 4 #define PIN_SDA 4
#define PIN_SCL 5 #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 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 MQTT_PREFIX "homeassistant" //Home Assistant's default discovery prefix
#define FIRMWARE_SERVER "" //no web-triggered HTTP update; use ArduinoOTA #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 USE_ARDUINO_OTA
#define OTA_HOSTNAME "nixie-in12" #define OTA_HOSTNAME "nixie-in12"
#if __has_include("secrets.h") #if __has_include("secrets.h")
+1 -1
View File
@@ -218,7 +218,7 @@ byte rgbToWheel(byte r, byte g, byte b) {
} }
HALight::RGBColor wheelToRgb(byte pos) { 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; HALight::RGBColor color;
color.red = c.R; color.green = c.G; color.blue = c.B; color.red = c.R; color.green = c.G; color.blue = c.B;
color.isSet = true; color.isSet = true;
@@ -36,6 +36,16 @@ const int PWMrefresh = 11000;
const int PWM_min = 1000; const int PWM_min = 1000;
const int PWM_max = 10000; 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(), // 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()). // so the caller must keep the ISR out (see clearTubes()).
static inline void IRAM_ATTR write595(uint16_t w) { 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]; if (w) w |= 1 << anodeBits[pos];
} }
write595(w); write595(w);
displayBlank = (w == 0);
timer1_write(timer); timer1_write(timer);
} }
@@ -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); // 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. // otherwise set the WiFi from the clock's web page.
#define DEFAULT_SSID "" #define DEFAULT_SSID ""
#define DEFAULT_PSW "" #define DEFAULT_PSW ""
#define OTA_PASSWORD "" //ArduinoOTA password; remove the line for no password #define OTA_PASSWORD "" //ArduinoOTA password (also add --auth=<password> to upload_flags in platformio.ini)
#define WEB_USER "admin" //web UI login
#define WEB_PASSWORD ""
@@ -99,26 +99,47 @@ inline byte tubeForPixel(int i) {
//(DMA-fed) instead of RMT; this is more tolerant of interrupts/WiFi jitter //(DMA-fed) instead of RMT; this is more tolerant of interrupts/WiFi jitter
//than NeoEsp32Rmt7Ws2812xMethod, which can glitch the strict WS2812 bit //than NeoEsp32Rmt7Ws2812xMethod, which can glitch the strict WS2812 bit
//timing (~1.25us/bit) if an ISR delays the RMT hardware mid-frame. //timing (~1.25us/bit) if an ISR delays the RMT hardware mid-frame.
NeoPixelBrightnessBus<NeoGrbFeature, NeoEsp32I2s1800KbpsMethod> strip(PixelCount+4,PixelPin); //NeoEsp32I2s1800KbpsMethod is used here because it is more reliable than NeoEsp32Rmt7Ws2812xMethod! #define NEO_STRIP_LEN (PixelCount+4)
NeoPixelBrightnessBus<NeoGrbFeature, NeoEsp32I2s1800KbpsMethod> strip(NEO_STRIP_LEN,PixelPin); //NeoEsp32I2s1800KbpsMethod is used here because it is more reliable than NeoEsp32Rmt7Ws2812xMethod!
#elif NEOPIXEL_PIN == 3 //default (see clocks.h) #elif NEOPIXEL_PIN == 3 //default (see clocks.h)
byte PixelPin = 3; // on 8266 it MUST use GPIO3 (RX pin) byte PixelPin = 3; // on 8266 it MUST use GPIO3 (RX pin)
//8266 has no I2S/RMT WS2812 helper here; Neo800KbpsMethod bit-bangs the //8266 has no I2S/RMT WS2812 helper here; Neo800KbpsMethod bit-bangs the
//protocol with cycle-counted delays, which is why GPIO3/RX is mandated - //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. //it's one of the few pins whose timing isn't disturbed by the UART/boot ROM.
NeoPixelBrightnessBus<NeoGrbFeature, Neo800KbpsMethod> strip(PixelCount+4); #define NEO_STRIP_LEN (PixelCount+4)
NeoPixelBrightnessBus<NeoGrbFeature, Neo800KbpsMethod> strip(NEO_STRIP_LEN);
#else #else
//Any other 8266 pin: cycle-counted bit-bang. Interrupts are off while Show() //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; byte PixelPin = NEOPIXEL_PIN;
NeoPixelBrightnessBus<NeoGrbFeature, NeoEsp8266BitBang800KbpsMethod> strip(PixelCount+4, PixelPin); #define NEO_STRIP_LEN PixelCount
NeoPixelBrightnessBus<NeoGrbFeature, NeoEsp8266BitBang800KbpsMethod> strip(NEO_STRIP_LEN, PixelPin);
#endif #endif
//Gamma-correction lookup table object - NOTE: currently declared but never //Gamma-correction lookup table, applied in Wheel() so hues look even (without it,
//applied (colorGamma.Correct(...) is not called anywhere in this file), so //mixed colours such as yellow or cyan look washed out).
//colors are sent to the strip linearly, not perceptually corrected. Possible // FIXED: was declared but never used.
//leftover from an earlier version / an intended-but-unfinished improvement.
NeoGamma<NeoGammaTableMethod> colorGamma; NeoGamma<NeoGammaTableMethod> 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() { void setupNeopixel() {
DPRINTLN("Setup NeoPixel LEDS"); DPRINTLN("Setup NeoPixel LEDS");
regPin(PixelPin,"NEOPIXEL_PIN"); 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). //does not. A driver may have claimed the pin since (nixie595.ino: SPI.begin() makes GPIO12 MISO).
pinMode(PixelPin, OUTPUT); pinMode(PixelPin, OUTPUT);
strip.Begin(); //initialize the underlying I2S/RMT/bit-bang driver and GPIO 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); strip.SetBrightness(neoBrightness);
fixColor(prm.rgbFixColor); //apply the persisted fixed color immediately so the backlight is correct even before the first doAnimationMakuna() tick 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 //sentinel value that instead returns solid white, since a pure hue rotation
//can never itself produce white (R=G=B). //can never itself produce white (R=G=B).
RgbColor Wheel(byte WheelPos) { 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) if (WheelPos == WHITE_INDEX)
return white; return white;
@@ -180,7 +206,7 @@ void alarmLight() {
else else
strip.SetPixelColor(i,black); strip.SetPixelColor(i,black);
} }
strip.Show(); neoShow();
} }
//Called once from the core's alarmSound() when the alarm's ring period times //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<PixelCount;i++) { for (int i=0;i<PixelCount;i++) {
strip.SetPixelColor(i,black); strip.SetPixelColor(i,black);
} }
strip.Show(); neoShow();
} }
//"Flow" rainbow: each physical pixel gets a color offset by its own index i //"Flow" rainbow: each physical pixel gets a color offset by its own index i
@@ -219,7 +245,7 @@ void rainbow() {
} }
if (prm.rgbDir) { j++; } if (prm.rgbDir) { j++; }
else { j--; } else { j--; }
strip.Show(); neoShow();
} }
//"Rainbow Stepper": unlike rainbow() which shifts every pixel together every //"Rainbow Stepper": unlike rainbow() which shifts every pixel together every
@@ -261,7 +287,7 @@ void rainbow2() { //Rainbow Stepper
if (prm.rgbDir)i++; if (prm.rgbDir)i++;
else i--; else i--;
strip.Show(); neoShow();
} }
//All tubes show the same solid color (c), while the overall strip brightness //All tubes show the same solid color (c), while the overall strip brightness
@@ -304,7 +330,7 @@ void effect1() { //color dimmer
c = random(0,256); //hit the dim floor -> pick a new random hue and start brightening back up c = random(0,256); //hit the dim floor -> pick a new random hue and start brightening back up
dir = -1; dir = -1;
} }
strip.Show(); neoShow();
} }
@@ -327,7 +353,7 @@ void effect2() { //random color picker
else else
strip.SetPixelColor(i, Wheel(c)); strip.SetPixelColor(i, Wheel(c));
} }
strip.Show(); neoShow();
} }
//Distance between two positions on the 0..255 color wheel, accounting for //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<maxDigits;j++) for (int j=0;j<maxDigits;j++)
setPixels(j, Wheel(actColor[j])); setPixels(j, Wheel(actColor[j]));
strip.Show(); neoShow();
//DPRINT("Pix:"); DPRINT(i); DPRINT(" Old:"); DPRINT(oldColor[i]); DPRINT(" ActCol:"); DPRINT(actColor[i]); DPRINT(" New:"); DPRINT(newColor[i]); DPRINT(" Step:"); DPRINTLN(step); //DPRINT("Pix:"); DPRINT(i); DPRINT(" Old:"); DPRINT(oldColor[i]); DPRINT(" ActCol:"); DPRINT(actColor[i]); DPRINT(" New:"); DPRINT(newColor[i]); DPRINT(" Step:"); DPRINTLN(step);
} }
@@ -520,7 +546,7 @@ void effect4() { //every pixel is random changer
} }
//DPRINT("Pix:"); DPRINT(i); DPRINT(" ActCol:"); DPRINTLN(actColor); //DPRINT("Pix:"); DPRINT(i); DPRINT(" ActCol:"); DPRINTLN(actColor);
} //end for t } //end for t
strip.Show(); neoShow();
} }
//Sets the color of every physical pixel that belongs to logical tube `tubeNo` //Sets the color of every physical pixel that belongs to logical tube `tubeNo`
@@ -548,7 +574,7 @@ void fixColor(int col) {
else else
strip.SetPixelColor(i,Wheel(col)); strip.SetPixelColor(i,Wheel(col));
} }
strip.Show(); neoShow();
} }
//Knight Rider "KITT" scanner: a single lit tube (using the fixed //Knight Rider "KITT" scanner: a single lit tube (using the fixed
@@ -573,7 +599,7 @@ void kitt() {
} }
setPixels(counter,Wheel(prm.rgbFixColor)); setPixels(counter,Wheel(prm.rgbFixColor));
strip.Show(); neoShow();
} }