From ae87849e75ac92b1eed483ef190fb65366328561 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Sun, 25 Oct 2026 23:32:25 +0100 Subject: [PATCH] Add SignalRGB control via WLED emulation The clock answers like a WLED device (mDNS _wled._tcp, /json/info, /json/state, realtime UDP on 21324 and DDP on 4048), so SignalRGB can drive the 4 backlight LEDs, one per tube, LED 0 = left tube. Frames are shown at most 30 times a second (WLED_MAX_FPS); the normal effect returns 2.5 s after they stop, and the alarm flash still wins. Three new settings on the RGB page: enable (also a Home Assistant switch), stay dark while the tubes are off, and scale by the RGB brightness. They are stored in EEPROM after prm with their own magic value, so the update does not factory-reset the clock. page.js and site.css are now sent with no-cache: browsers kept them for a year and ran the old script after a web UI update. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 9 +- README.md | 21 +- .../ESP32_UniClock2/ESP32_UniClock2.ino | 59 +++- firmware/uniclock/ESP32_UniClock2/clocks.h | 2 + .../uniclock/ESP32_UniClock2/data/index.html | 26 ++ .../uniclock/ESP32_UniClock2/data/page.js | 17 +- firmware/uniclock/ESP32_UniClock2/mqtt.ino | 22 +- firmware/uniclock/ESP32_UniClock2/wled.ino | 271 ++++++++++++++++++ .../uniclock/ESP32_UniClock2/z_neopixel.ino | 39 ++- 9 files changed, 446 insertions(+), 20 deletions(-) create mode 100644 firmware/uniclock/ESP32_UniClock2/wled.ino diff --git a/CLAUDE.md b/CLAUDE.md index 4380d4f..ed2e81f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,4 +100,11 @@ GPIO0 to GND, tap RST to GND, then release GPIO0. 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. +6. Done (2026-10-25): SignalRGB via WLED emulation (`wled.ino`, `USE_WLED_REALTIME`, + `WLED_MAX_FPS` 30: no flicker, no loop stalls). SignalRGB's WLED plugin only sends as many LEDs as the channel's component has (needs 4). LED order checked: + SignalRGB LED 0 = left tube, `tubePixels` {0,1,2,3} is right. Settings `wledEnable` / + `wledDarkWithTubes` / `wledUseBrightness` live in `prmExt`, stored in EEPROM after `prm` with + their own magic value: never add fields to `prm` (a `MAGIC_VALUE` bump factory-resets the + clock, WiFi included). HA switch "SignalRGB". `page.js`/`site.css` were cached by browsers for a + year; now sent with `no-cache`. +7. Open: the user can create `secrets.h` (gitignored, never read it) for OTA and web passwords. diff --git a/README.md b/README.md index 3c0637d..3c0bbae 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ WiFi updates and Home Assistant control. - UniClock's web page for all settings - Firmware updates over WiFi (ArduinoOTA) - Home Assistant over MQTT discovery: tubes on/off, day/night brightness, auto dim, backlight - (on/off, brightness, colour), LED effect, cathode protect, light and WiFi-signal sensors + (on/off, brightness, colour), LED effect, cathode protect, SignalRGB on/off, light and + WiFi-signal sensors +- SignalRGB: the backlight can show up in SignalRGB as a WLED device (see below) ## The hardware @@ -70,6 +72,21 @@ and restart the clock; it then shows up under Settings > Devices > MQTT. Optional passwords for the web page and OTA updates go in `firmware/uniclock/ESP32_UniClock2/secrets.h` (see `secrets.h.example`; the file is gitignored). +## SignalRGB + +Turn on **SignalRGB (WLED)** on the RGB page of the web UI (or the SignalRGB switch in Home +Assistant). The clock then answers like a WLED device with one LED per tube, LED 0 = left tube: +mDNS `_wled._tcp`, `/json/info` and `/json/state` without a password, and colour frames over UDP +(WLED realtime on port 21324, DDP on 4048). SignalRGB should find it by itself; if not, add it by +IP address. SignalRGB only drives as many LEDs as the component on the device's channel has, so +give the channel a component with 4 LEDs. While SignalRGB sends colours it controls the LEDs (at most 30 frames per second, +`WLED_MAX_FPS` in `clocks.h`); 2.5 s after it stops, the normal effect comes back. The alarm +flash always wins. Two more switches on the same page: + +- **SignalRGB off with tubes**: the LEDs stay dark at night and while the tubes are off. +- **SignalRGB uses RGB Brightness**: colours are scaled by the RGB brightness and auto dim; + off = colours as SignalRGB sends them. + ## Changes to ESP_UniClock Besides the new driver and configuration: @@ -77,6 +94,8 @@ Besides the new driver and configuration: - NeoPixels on any ESP8266 pin, sent only when they change and between tube slots (the bit-banged update blocks interrupts and made the tubes flicker) - ArduinoOTA; Home Assistant integration rewritten for ArduinoHA 2.1 +- WLED emulation for SignalRGB (`wled.ino`); its settings are stored after the UniClock + settings in EEPROM, so the update keeps all existing settings - EU summer-time rules (upstream ended summer time on the US date); switching DST on no longer shifts the clock by an hour in winter - Saving the firmware URL no longer starts an HTTP update; optional web password diff --git a/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino b/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino index 76d4b71..74101d2 100644 --- a/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino +++ b/firmware/uniclock/ESP32_UniClock2/ESP32_UniClock2.ino @@ -270,6 +270,7 @@ extern char tubeDriver[]; //human-readable driver name string, shown on the web char webName[] = WEBNAME; AsyncWebServer server(80); #define CACHE_MAX_AGE "max-age=31536000" //maximum is: 31536000 +#define CACHE_REVALIDATE "no-cache" //page.js and site.css change with web UI updates (uploadfs): don't let browsers keep an old copy #ifdef USE_WIFIMANAGER #include @@ -342,6 +343,7 @@ bool colonBlinkState = false; boolean radarON = true; boolean mqttRadarON = true; volatile boolean tubesOn = true; //Tubes switch from Home Assistant (runtime only, not saved); honoured by nixie595.ino +boolean wledLive = false; //SignalRGB/WLED frames are arriving and drive the backlight (wled.ino) unsigned long radarLastOn = 0; boolean makeFirmwareUpdate = false; boolean makeCathodeProtect = false; @@ -441,7 +443,19 @@ struct { int magic = MAGIC_VALUE; //magic value, to check EEPROM version when starting the clock - see loadEEPROM()/factoryReset() and the MAGIC_VALUE comment above } prm; -#define EEPROM_SIZE sizeof(prm) //EEPROM.begin() is sized exactly to the struct - no extra reserved space for future growth, so any struct change is effectively a breaking EEPROM-layout change (mitigated only by the magic-number factory-reset above) +// Settings added by this port, stored in EEPROM right after `prm` with a magic value of their own. +// Adding them to `prm` would change its layout, and the MAGIC_VALUE check would then factory-reset +// the clock (WiFi settings included). If the magic value doesn't match (first boot after the update), +// loadEEPROM() sets these defaults and leaves `prm` alone. +#define MAGIC_VALUE_EXT 0x5701 +struct { + boolean wledEnable = false; //SignalRGB/WLED realtime control of the backlight (wled.ino) + boolean wledDarkWithTubes = true; //WLED frames stay dark while the tubes/backlight are off + boolean wledUseBrightness = true; //WLED frames are scaled by the RGB brightness (and auto dim) + uint16_t magic = MAGIC_VALUE_EXT; +} prmExt; + +#define EEPROM_SIZE (sizeof(prm) + sizeof(prmExt)) //no extra reserved space for future growth: a change to either struct needs its own magic value bumped //------------------------------------------------------------------------------------------------------- @@ -646,6 +660,7 @@ void Fdelay(unsigned long d) { #ifdef USE_ARDUINO_OTA DIAG("F:ota", ArduinoOTA.handle()); //also during long animations/startup waits #endif + DIAG("F:wled", wledHandle()); DIAG("F:yield", yield()); } } @@ -1187,8 +1202,8 @@ void startServer() { DPRINTLN("Webserver: /page.js"); if (SPIFFS.exists("/page.js")) { - AsyncWebServerResponse *response = request->beginResponse(SPIFFS, "/page.js", "text/js"); - response->addHeader("Cache-Control", CACHE_MAX_AGE); + AsyncWebServerResponse *response = request->beginResponse(SPIFFS, "/page.js", "text/js"); + response->addHeader("Cache-Control", CACHE_REVALIDATE); request->send(response); } else { @@ -1232,8 +1247,8 @@ void startServer() { server.on("/site.css", HTTP_GET, [](AsyncWebServerRequest * request) { DPRINTLN("Webserver: /site.css"); if (SPIFFS.exists("/site.css")) { - AsyncWebServerResponse *response = request->beginResponse(SPIFFS, "/site.css", "text/css"); - response->addHeader("Cache-Control", CACHE_MAX_AGE); + AsyncWebServerResponse *response = request->beginResponse(SPIFFS, "/site.css", "text/css"); + response->addHeader("Cache-Control", CACHE_REVALIDATE); request->send(response); } else { @@ -1273,6 +1288,7 @@ void startServer() { server.on("/getConfiguration", HTTP_GET, handleSendConfig) WEB_AUTH; server.on("/getCurrentInfos", HTTP_GET, handleSendCurrentInfos); server.on("/getClockDetails", HTTP_GET, handleSendClockDetails) WEB_AUTH; + wledRoutes(); //WLED JSON API for SignalRGB, without password (wled.ino) server.onNotFound(handleNotFound); server.begin(); @@ -1457,6 +1473,17 @@ void handleConfigChanged(AsyncWebServerRequest *request) { else if (key == "rgbMinBrightness") { c_MinBrightness = value.toInt(); } + #ifdef USE_WLED_REALTIME + else if (key == "wledEnable") { //wledHandle() starts/stops the listeners + prmExt.wledEnable = (value == "true"); + } + else if (key == "wledDarkWithTubes") { + prmExt.wledDarkWithTubes = (value == "true"); + } + else if (key == "wledUseBrightness") { + prmExt.wledUseBrightness = (value == "true"); + } + #endif else if (key == "wifiSsid") { value.toCharArray(prm.wifiSsid,sizeof(prm.wifiSsid)); } @@ -1683,6 +1710,11 @@ if (useTemp > 1) doc["rgbSpeed"] = prm.rgbSpeed; // 1..255 doc["rgbDir"] = prm.rgbDir; // 0 = right direction, 1 = left direction doc["rgbMinBrightness"] = c_MinBrightness; //minimum brightness for range check!! + #ifdef USE_WLED_REALTIME //the web page hides these switches when they are missing + doc["wledEnable"] = prmExt.wledEnable; + doc["wledDarkWithTubes"] = prmExt.wledDarkWithTubes; + doc["wledUseBrightness"] = prmExt.wledUseBrightness; + #endif doc["wifiMode"] = prm.wifiMode; doc["wifiSsid"] = prm.wifiSsid; doc["wifiPsw"] = prm.wifiPsw; @@ -1809,6 +1841,9 @@ if (useTemp > 1) doc["rssi"] = WiFi.RSSI(); doc["uptime"] = millis() / 1000; doc["heap"] = ESP.getFreeHeap(); + #ifdef USE_WLED_REALTIME + doc["wledLive"] = wledLive; //SignalRGB is sending frames + #endif #ifdef LOOP_DIAG doc["stalls"] = diagReport(); #endif @@ -2004,6 +2039,7 @@ void setup() { startMDNS(); startServer(); startArduinoOTA(); + wledSetup(); //after startArduinoOTA(): uses its mDNS responder #ifdef USE_MQTT setupMqtt(); #endif @@ -2140,6 +2176,16 @@ void loadEEPROM() { EEPROM.get(EEPROM_addr, prm); DPRINT(" version:"); DPRINTLN(prm.magic); + EEPROM.get(EEPROM_addr + sizeof(prm), prmExt); + if (prmExt.magic != MAGIC_VALUE_EXT) resetPrmExt(); +} + +// Defaults for prmExt (not saved here: the caller saves when it needs to). +void resetPrmExt() { + prmExt.wledEnable = false; + prmExt.wledDarkWithTubes = true; + prmExt.wledUseBrightness = true; + prmExt.magic = MAGIC_VALUE_EXT; } // Raw single-block write of the whole `prm` struct back to EEPROM (address 0) - called after every @@ -2148,6 +2194,7 @@ void loadEEPROM() { // full-struct flash write, not just the changed bytes). void saveEEPROM() { EEPROM.put(EEPROM_addr, prm); + EEPROM.put(EEPROM_addr + sizeof(prm), prmExt); EEPROM.commit(); DPRINTLN("Settings saved to EEPROM!"); } @@ -2228,6 +2275,7 @@ void factoryReset() { prm.corrH0 = 0; prm.corrH1 = 0; prm.magic = MAGIC_VALUE; //magic value to check the EEPROM version + resetPrmExt(); saveEEPROM(); calcTime(); } @@ -3345,6 +3393,7 @@ void loop(void) { #ifdef USE_ARDUINO_OTA DIAG("ota", ArduinoOTA.handle()); #endif + DIAG("wled", wledHandle()); if (makeFirmwareUpdate) { makeFirmwareUpdate = false; doFirmwareUpdate(); diff --git a/firmware/uniclock/ESP32_UniClock2/clocks.h b/firmware/uniclock/ESP32_UniClock2/clocks.h index 5394144..299a480 100644 --- a/firmware/uniclock/ESP32_UniClock2/clocks.h +++ b/firmware/uniclock/ESP32_UniClock2/clocks.h @@ -147,6 +147,8 @@ byte tubePixels[] = {0, 1, 2, 3}; //4 WS2812, one per tube (chain order not ve //#define LOOP_DIAG //report main-loop stalls in /getCurrentInfos ("stalls"); for debugging only #define USE_ARDUINO_OTA #define OTA_HOSTNAME "nixie-in12" +#define USE_WLED_REALTIME //SignalRGB: shows up as a WLED device (switch on the web page / in HA), wled.ino +#define WLED_MAX_FPS 30 //lower (e.g. 15) if the tubes flicker while SignalRGB is running #if __has_include("secrets.h") #include "secrets.h" //optional, gitignored: DEFAULT_SSID, DEFAULT_PSW, OTA_PASSWORD (see secrets.h.example) #endif diff --git a/firmware/uniclock/ESP32_UniClock2/data/index.html b/firmware/uniclock/ESP32_UniClock2/data/index.html index bccf306..2302434 100644 --- a/firmware/uniclock/ESP32_UniClock2/data/index.html +++ b/firmware/uniclock/ESP32_UniClock2/data/index.html @@ -320,6 +320,32 @@ + +
+ + + off + + on +
+
+ + + off + + on +
+
+ + + off + + on +
+
+ + +
diff --git a/firmware/uniclock/ESP32_UniClock2/data/page.js b/firmware/uniclock/ESP32_UniClock2/data/page.js index 8c47aae..f88f4ed 100644 --- a/firmware/uniclock/ESP32_UniClock2/data/page.js +++ b/firmware/uniclock/ESP32_UniClock2/data/page.js @@ -54,7 +54,11 @@ var controlInfos = { "rgbBrightness": "RGB leds brightness 0..255", "rgbFixColor": "RGB color, if FixColor Effect is used", "rgbSpeed": "RGB animation speed", - + "wledEnable": "Let SignalRGB control the LEDs: the clock shows up there as a WLED device", + "wledDarkWithTubes": "LEDs stay dark while the tubes / backlight are off, also when SignalRGB is running", + "wledUseBrightness": "Scale SignalRGB colours by RGB Brightness (and auto dim). Off: colours as sent", + "wledLive": "SignalRGB is sending colours right now", + //Wifi settings "wifiSsid": "WiFi network SSID to connect", "wifiPsw": "WiFI password", @@ -132,7 +136,11 @@ var configuration = { "rgbBrightness": 100, "rgbFixColor": 150, "rgbSpeed": 50, - + "wledEnable": false, + "wledDarkWithTubes": true, + "wledUseBrightness": true, + "wledLive": false, + //Wifi settings "wifiSsid": "mywifi", "wifiPsw": "mypsw", @@ -232,6 +240,7 @@ function setCurrentInfos(){ $('#currentTime').html(getCurrentDate(configuration["currentDate"]) + " " + configuration["currentTime"]); $('#lux').html(configuration["lux"]); $('#rssi').html(configuration["rssi"]); + $('#wledLive').html(configuration["wledLive"] ? "yes" : "no"); $('#pressure').html(round(configuration["pressure"],1)); $('#humidity').html(round(configuration["humidity"],1)); $('#humidity2').html(round(configuration["humidity2"],1)); @@ -420,7 +429,8 @@ function Init(){ index == 'rgbDir' || index == 'manualOverride' || index == 'enableAutoDim' || index == 'enableRadar' || index == 'enableDoubleBlink' || index == 'enableTimeDisplay' || - index == 'mqttEnable' || index == 'tempCF' || index == "wifiMode" + index == 'mqttEnable' || index == 'tempCF' || index == "wifiMode" || + index == 'wledEnable' || index == 'wledDarkWithTubes' || index == 'wledUseBrightness' ) && !!value ){ $('#'+index).prop('checked',true); @@ -438,6 +448,7 @@ function Init(){ $('.temperature-holder').toggleClass('hidden',configuration['temperature'] == 255); $('.temperature-holder2').toggleClass('hidden',configuration['temperature2'] == 255); $('.rgb-holder').toggleClass('hidden',configuration['rgbEffect'] == 255); + $('.wled-holder').toggleClass('hidden',configuration['wledEnable'] === undefined); //firmware without USE_WLED_REALTIME $('.tube-holder').toggleClass('hidden',configuration['tubeDriver'] == "DUMMY"); $('.wordclock-holder').toggleClass('hidden',configuration['tubeDriver'] == "WORDCLOCK"); $('.sensors-holder').toggleClass('hidden',configuration['temperature'] == 255); diff --git a/firmware/uniclock/ESP32_UniClock2/mqtt.ino b/firmware/uniclock/ESP32_UniClock2/mqtt.ino index 9406d32..47c2644 100644 --- a/firmware/uniclock/ESP32_UniClock2/mqtt.ino +++ b/firmware/uniclock/ESP32_UniClock2/mqtt.ino @@ -11,7 +11,7 @@ // Sensors: temperature/humidity/pressure/lux/radar (whichever exist on this clock), WiFi signal // Controls: Tubes (switch, runtime only), Day/Night brightness (numbers), Auto dim (switch), // Cathode protect (button); with USE_NEOPIXEL also Backlight (light: on/off, brightness, colour) -// and LED effect (select). +// and LED effect (select); with USE_WLED_REALTIME also SignalRGB (switch, = prmExt.wledEnable). // Changes made on the clock's own web page are reported back to HA by mqttSend(). // Settings changed from HA are saved to EEPROM a few seconds after the last change (one flash write per burst). // @@ -85,6 +85,9 @@ HAButton mqttCathodeProtect("cathode_protect"); HASelect mqttLedEffect("led_effect"); byte mqttLastEffect = 2; //effect restored when the backlight is switched on from HA (rainbow flow by default) #endif +#ifdef USE_WLED_REALTIME + HASwitch mqttWled("signalrgb"); +#endif //Bounded copy of an MQTT payload into a zero-terminated buffer. // FIXED: the old onMqttMessage() memcpy'd the payload into a 100-byte stack buffer without a length check or a @@ -263,6 +266,14 @@ void onLedEffectCommand(int8_t index, HASelect* sender) { } #endif +#ifdef USE_WLED_REALTIME +void onWledCommand(bool state, HASwitch* sender) { + prmExt.wledEnable = state; //wledHandle() starts/stops the listeners + sender->setState(state); + mqttRequestSave(); +} +#endif + void setupMqtt() { char mactmp[20]; @@ -364,6 +375,12 @@ void setupMqtt() { mqttLedEffect.onCommand(onLedEffectCommand); #endif + #ifdef USE_WLED_REALTIME + mqttWled.setName("SignalRGB"); + mqttWled.setIcon("mdi:monitor-shimmer"); + mqttWled.onCommand(onWledCommand); + #endif + mqtt.onMessage(onMqttMessage); mqtt.onConnected(onMqttConnected); @@ -405,6 +422,9 @@ void mqttReportState() { mqttBacklight.setRGBColor(wheelToRgb(prm.rgbFixColor)); mqttLedEffect.setState(prm.rgbEffect); #endif + #ifdef USE_WLED_REALTIME + mqttWled.setState(prmExt.wledEnable); + #endif } void mqttSend() { diff --git a/firmware/uniclock/ESP32_UniClock2/wled.ino b/firmware/uniclock/ESP32_UniClock2/wled.ino new file mode 100644 index 0000000..e700536 --- /dev/null +++ b/firmware/uniclock/ESP32_UniClock2/wled.ino @@ -0,0 +1,271 @@ +#ifdef USE_WLED_REALTIME +//========================================================================================================================= +// wled.ino - lets SignalRGB (or any other WLED client) drive the tube backlight, by answering like a WLED device +// (compiled only if USE_WLED_REALTIME is defined for the active CLOCK_NN in clocks.h; ESP8266 + USE_NEOPIXEL only) +// +// SignalRGB's WLED support needs three things, all provided here while prmExt.wledEnable is set: +// - mDNS service "_wled._tcp" on port 80 with a "mac" TXT record (added to ArduinoOTA's mDNS responder, so it needs +// USE_ARDUINO_OTA; without it the clock can still be added in SignalRGB by IP address) +// - the read-only part of WLED's JSON API: GET /json/info (LED count = number of tubes, name, MAC), /json/state +// and /json. POSTs are acknowledged and ignored. No password, as SignalRGB can't send one. +// - realtime frames over UDP: WLED's WARLS/DRGB/DNRGB on port 21324 (byte 1 = timeout in seconds) and DDP on 4048. +// LED 0 is the leftmost tube. While frames arrive (wledLive), doAnimationMakuna() leaves the LEDs alone; the alarm +// flash still wins. After the timeout the normal effect comes back by itself. +// Frames are shown at most WLED_MAX_FPS times a second: every WS2812 update blocks interrupts and waits for a dark +// gap between tubes (neoShow()), so fewer updates mean less risk of tube flicker. +// Settings (web page and the Home Assistant "SignalRGB" switch): prmExt.wledEnable, prmExt.wledDarkWithTubes (dark +// while the backlight would be off: night, radar, HA Tubes switch), prmExt.wledUseBrightness (scale by the RGB +// brightness and auto dim instead of only the RGB_MAX_BRIGHTNESS cap). +//_______________________________________________________________________________________________________________________ + +#if !defined(ESP8266) || !defined(USE_NEOPIXEL) + #error "USE_WLED_REALTIME needs an ESP8266 and USE_NEOPIXEL" +#endif + +#include + +#ifndef WLED_MAX_FPS + #define WLED_MAX_FPS 30 +#endif +#define WLED_UDP_PORT 21324 //WLED realtime (WARLS/DRGB/DRGBW/DNRGB) +#define WLED_DDP_PORT 4048 //DDP +#define WLED_DEFAULT_TIMEOUT 2500 //ms without frames before the normal effect returns (WLED's default) +#define WLED_MAX_TIMEOUT 10000 //cap for the timeout a packet asks for (255 = "forever" in WLED) +#define WLED_MAX_PACKETS 8 //packets read per wledHandle() call, so a burst can't stall the loop + +WiFiUDP wledUdp; +WiFiUDP wledDdp; +MDNSResponder::hMDNSService wledMdnsService = nullptr; +boolean wledReady = false; //set by wledSetup(): WiFi and mDNS are up +boolean wledRunning = false; //sockets open +uint8_t wledFrame[BUFSIZE * 3]; //newest frame, R,G,B per tube, leftmost tube first +uint8_t wledPacket[128]; //plenty for a few LEDs; longer packets are cut off, which only drops LEDs we don't have +unsigned long wledLastPacket = 0; +unsigned long wledTimeout = WLED_DEFAULT_TIMEOUT; +char wledMac[13]; //MAC as 12 lowercase hex digits, as WLED reports it + +//Copies pixel data that starts at byte offset `offset` of the strip into wledFrame. +static void wledWrite(size_t offset, const uint8_t* data, size_t len) { + size_t size = (size_t)maxDigits * 3; + if (offset >= size) return; + memcpy(wledFrame + offset, data, min(len, size - offset)); +} + +//Returns true if the packet held pixel data. +static boolean wledParseRealtime(const uint8_t* p, int len) { + if (len < 2) return false; + byte t = p[1]; + wledTimeout = (t == 0) ? WLED_DEFAULT_TIMEOUT : min((unsigned long)t * 1000ul, (unsigned long)WLED_MAX_TIMEOUT); + switch (p[0]) { + case 1: //WARLS: index, R, G, B + for (int i = 2; i + 4 <= len; i += 4) wledWrite((size_t)p[i] * 3, p + i + 1, 3); + return true; + case 2: //DRGB: R, G, B from LED 0 + wledWrite(0, p + 2, len - 2); + return true; + case 3: //DRGBW: R, G, B, W from LED 0 (W dropped) + for (int i = 2, led = 0; i + 4 <= len; i += 4, led++) wledWrite((size_t)led * 3, p + i, 3); + return true; + case 4: //DNRGB: start index (16 bit), then R, G, B + if (len < 4) return false; + wledWrite((size_t)((p[2] << 8) | p[3]) * 3, p + 4, len - 4); + return true; + } + return false; +} + +static boolean wledParseDdp(const uint8_t* p, int len) { + if ((len < 10) || ((p[0] & 0xC0) != 0x40)) return false; //DDP version 1 + int header = (p[0] & 0x10) ? 14 : 10; //+4 bytes with a timecode + if (len < header) return false; + uint32_t offset = ((uint32_t)p[4] << 24) | ((uint32_t)p[5] << 16) | ((uint32_t)p[6] << 8) | p[7]; + int dataLen = min((p[8] << 8) | p[9], len - header); + wledTimeout = WLED_DEFAULT_TIMEOUT; + wledWrite(offset, p + header, dataLen); + return true; +} + +static void wledReceive(WiFiUDP& udp, boolean ddp) { + for (int n = 0; n < WLED_MAX_PACKETS; n++) { + int size = udp.parsePacket(); + if (size <= 0) return; + int len = udp.read(wledPacket, sizeof(wledPacket)); + if (len <= 0) continue; + if (ddp ? wledParseDdp(wledPacket, len) : wledParseRealtime(wledPacket, len)) { + if (!wledLive) DPRINTLN("WLED: realtime frames started"); + wledLive = true; + wledLastPacket = millis(); + } + } +} + +static void wledBegin() { + wledUdp.begin(WLED_UDP_PORT); + wledDdp.begin(WLED_DDP_PORT); + if (MDNS.isRunning() && !wledMdnsService) { + wledMdnsService = MDNS.addService(nullptr, "wled", "tcp", 80); + if (wledMdnsService) MDNS.addServiceTxt(wledMdnsService, "mac", wledMac); + } + memset(wledFrame, 0, sizeof(wledFrame)); + wledRunning = true; + DPRINTLN("WLED: listening for SignalRGB"); +} + +static void wledEnd() { + wledUdp.stop(); + wledDdp.stop(); + if (wledMdnsService) { + MDNS.removeService(wledMdnsService); + wledMdnsService = nullptr; + } + wledRunning = false; + wledLive = false; + DPRINTLN("WLED: stopped"); +} + +void wledSetup() { + uint8_t m[6]; + WiFi.macAddress(m); + sprintf(wledMac, "%02x%02x%02x%02x%02x%02x", m[0], m[1], m[2], m[3], m[4], m[5]); + wledReady = true; +} + +//Called from loop() and Fdelay(). Follows prmExt.wledEnable, reads frames and shows them. +void wledHandle() { + static unsigned long lastShow = 0; + + if (!wledReady) return; + if (prmExt.wledEnable != wledRunning) { + if (prmExt.wledEnable) wledBegin(); + else wledEnd(); + } + if (!wledRunning) return; + + wledReceive(wledUdp, false); + wledReceive(wledDdp, true); + if (!wledLive) return; + + if ((millis() - wledLastPacket) > wledTimeout) { + wledLive = false; //doAnimationMakuna() takes over again + DPRINTLN("WLED: realtime frames stopped"); + return; + } + if (alarmON || EEPROMsaving) return; //the alarm flash wins + if ((millis() - lastShow) < (1000ul / WLED_MAX_FPS)) return; + lastShow = millis(); + + long bri = prmExt.wledUseBrightness ? neoUserBrightness() : long(RGB_MAX_BRIGHTNESS); + if (prmExt.wledDarkWithTubes && (!displayON || !radarON || !tubesOn)) bri = 0; + neoShowFrame(wledFrame, bri); //unchanged frames are not sent again (neoShow()) +} + +//--- WLED JSON API ------------------------------------------------------------------------------------------------------ + +static void wledInfo(JsonObject o) { + o["ver"] = "0.14.4"; + o["vid"] = 2405180; + JsonObject leds = o.createNestedObject("leds"); + leds["count"] = maxDigits; + leds["pwr"] = 0; + leds["fps"] = WLED_MAX_FPS; + leds["maxpwr"] = 0; + leds["maxseg"] = 1; + leds.createNestedArray("seglc").add(1); + leds["lc"] = 1; + leds["rgbw"] = false; + leds["wv"] = 0; + leds["cct"] = 0; + o["str"] = false; + o["name"] = webName; + o["udpport"] = WLED_UDP_PORT; + o["live"] = wledLive; + o["liveseg"] = -1; + o["lm"] = ""; + o["lip"] = ""; + o["ws"] = 0; + o["fxcount"] = 1; + o["palcount"] = 1; + JsonObject wifi = o.createNestedObject("wifi"); + wifi["bssid"] = WiFi.BSSIDstr(); + wifi["rssi"] = WiFi.RSSI(); + wifi["signal"] = constrain(2 * (WiFi.RSSI() + 100), 0, 100); + wifi["channel"] = WiFi.channel(); + o["arch"] = "esp8266"; + o["core"] = ESP.getCoreVersion(); + o["freeheap"] = ESP.getFreeHeap(); + o["uptime"] = millis() / 1000; + o["opt"] = 0; + o["brand"] = "WLED"; + o["product"] = "FOSS"; + o["mac"] = wledMac; + o["ip"] = WiFi.localIP().toString(); +} + +static void wledState(JsonObject o) { + o["on"] = true; + o["bri"] = 255; + o["transition"] = 0; + o["ps"] = -1; + o["pl"] = -1; + o["live"] = wledLive; + o["lor"] = 0; + JsonObject seg = o.createNestedArray("seg").createNestedObject(); + seg["id"] = 0; + seg["start"] = 0; + seg["stop"] = maxDigits; + seg["len"] = maxDigits; + seg["grp"] = 1; + seg["spc"] = 0; + seg["on"] = true; + seg["bri"] = 255; + JsonArray col = seg.createNestedArray("col"); + for (int i = 0; i < 3; i++) { + JsonArray c = col.createNestedArray(); + byte v = (i == 0) ? 255 : 0; + c.add(v); c.add(v); c.add(v); + } + seg["fx"] = 0; + seg["sx"] = 128; + seg["ix"] = 128; + seg["pal"] = 0; + seg["sel"] = true; + seg["rev"] = false; + seg["mi"] = false; +} + +//what: 'i' = /json/info, 's' = /json/state, 'a' = /json (everything) +static void wledSendJson(AsyncWebServerRequest *request, char what) { + if (!prmExt.wledEnable) { + request->send(404, "text/plain", "WLED emulation is off"); + return; + } + DynamicJsonDocument doc(1536); + if (what == 'i') wledInfo(doc.to()); + else if (what == 's') wledState(doc.to()); + else { + wledState(doc.createNestedObject("state")); + wledInfo(doc.createNestedObject("info")); + doc.createNestedArray("effects").add("Solid"); + doc.createNestedArray("palettes").add("Default"); + } + String json; + serializeJson(doc, json); + request->send(200, "application/json", json); +} + +//Registered in startServer(), before onNotFound. "/json" also matches "/json/...", so it comes last. +void wledRoutes() { + server.on("/json/info", HTTP_GET, [](AsyncWebServerRequest *request) { wledSendJson(request, 'i'); }); + server.on("/json/state", HTTP_GET | HTTP_POST, [](AsyncWebServerRequest *request) { wledSendJson(request, 's'); }); + server.on("/json", HTTP_GET, [](AsyncWebServerRequest *request) { wledSendJson(request, 'a'); }); + server.on("/json", HTTP_POST, [](AsyncWebServerRequest *request) { + if (prmExt.wledEnable) request->send(200, "application/json", "{\"success\":true}"); + else request->send(404, "text/plain", "WLED emulation is off"); + }); +} + +#else + void wledSetup() {} + void wledHandle() {} + void wledRoutes() {} +#endif diff --git a/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino b/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino index 774fa56..e8eeb8e 100644 --- a/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino +++ b/firmware/uniclock/ESP32_UniClock2/z_neopixel.ino @@ -603,16 +603,43 @@ void kitt() { } +//prm.rgbBrightness, scaled by the light sensor when auto dim is on. Also used by wled.ino. +long neoUserBrightness() { + long b = prm.rgbBrightness; + if (autoBrightness) { + b = (b*lx)/long(MAXIMUM_LUX/2); + if (b < c_MinBrightness) + b = c_MinBrightness; + } + return min(b,long(RGB_MAX_BRIGHTNESS)); //safety only +} + +//Shows a frame from wled.ino: rgb[] holds one R,G,B triple per tube, leftmost tube first +//(digit position 0 is the rightmost tube, hence the reversed index). +void neoShowFrame(const uint8_t* rgb, long brightness) { + strip.SetBrightness(brightness); //before SetPixelColor(), which scales by it + for (int i=0;i