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

@@ -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<maxDigits;i++) {
finish = ((millis()-started) >= 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(),