Files
nixie-clock/firmware/uniclock/ESP32_UniClock2/wled.ino
T
mangavalkandClaude Opus 5.5 ae87849e75 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 <noreply@anthropic.com>
2026-10-25 23:32:25 +01:00

272 lines
10 KiB
Arduino

#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 <ESP8266mDNS.h>
#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<JsonObject>());
else if (what == 's') wledState(doc.to<JsonObject>());
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