// ============================================================================ // NeoPixel Tube Backlight subsystem // ---------------------------------------------------------------------------- // Drives addressable WS2812/WS2812B RGB LEDs mounted behind/around the tubes // (Nixie/VFD/Numitron/etc.) using the NeoPixelBrightnessBus library (Makuna's // NeoPixelBus). Each clock config maps its physical LEDs to logical tube // positions via the `byte tubePixels[]` array defined in clocks.h (see the // commented examples below), so the same effect code works regardless of how // many LEDs are wired per tube or in which physical order. // // Enabled per-config via #define USE_NEOPIXEL in clocks.h. As of writing this // is used by the large majority of CLOCK_NN configs that have RGB backlight // hardware (e.g. CLOCK_4, 6, 8, 15, 17, 18, 22, 26, 30-32, 34-36, 40-46, 48-52, // 55-57, 57A and others) - grep clocks.h for "#define USE_NEOPIXEL" (not the // commented-out "//#define" ones) for the authoritative, current list. // Mutually exclusive in practice with the simpler PWM-based backlight in // pwmleds.ino (USE_PWMLEDS), which is used instead by configs that only wire // 3 plain R/G/B PWM pins (e.g. CLOCK_54). // // Called from the core program (ESP32_UniClock2.ino): // - setupNeopixel() : once from setup(), after pins/config are known. // - doAnimationMakuna() : every loop() iteration, AND from inside Fdelay()'s // busy-wait loop, so animations keep advancing even // while the tube digits are mid-transition (Fdelay // blocks for the tube-switch duration). It rate-limits // itself internally (see FPS_MSEC / prm.rgbSpeed). // - darkenNeopixels() : once from alarmSound(), when the alarm's ring // period naturally times out (not when the alarm is // silenced early via the button). // ============================================================================ #ifdef USE_NEOPIXEL long neoBrightness; int colorStep = 1; #define FPS 60 //frames per second #define FPS_MSEC 1000/FPS //ms per animation frame, used as the minimum interval between doAnimationMakuna() updates (frame-rate cap) #define COLORSATURATION 255 #define WHITE_INDEX 192 //reserved "color wheel" position (out of 0..255) that Wheel() special-cases to return pure white instead of a hue #define RANDOM_WHEEL_DISTANCE 30 //minimum wheel-distance a new random color must keep from the existing color(s) to be accepted, in random mode #define RANDOM_MAX_COUNTER 100 //maximum number of attempts to find a sufficiently distinct new random color before giving up #define RANDOM_FROM_ALL_PIXELS true //true or false: when generating a new random color, measure its distance from all pixels' colors, or only from the current pixel's own color RgbColor red(COLORSATURATION, 0, 0); RgbColor red2(COLORSATURATION/2, 0, 0); RgbColor green(0, COLORSATURATION, 0); RgbColor blue(0, 0, COLORSATURATION); RgbColor purple(COLORSATURATION, 0, COLORSATURATION); RgbColor white(COLORSATURATION/2,COLORSATURATION/2,COLORSATURATION/2); RgbColor black(0,0,0); //Definition: which pixels are near which tube? Make sure to set all pixels of the strip! // Valid: 0..(maxDigits-1). If a number is equal to maxDigits or higher, that pixel always stays dark! //byte tubePixels[] = {0,1,2,3}; //4 tubes, single leds //byte tubePixels[] = {3,2,1,0}; //4 tubes, single leds, reverse direction //byte tubePixels[] = {0,9,1,9,2,9,3}; //4 tubes, single leds, 3 leds not used //byte tubePixels[] = {0,0,1,2,3,3}; //4 tubes, 6 leds //byte tubePixels[] = {0,1,2,3,4,5}; //6 tubes, single leds //byte tubePixels[] = {5,4,3,2,1,0}; //6 tubes, single leds, reverse direction //byte tubePixels[] = {0,1,2,3,4,5,6,7}; //8 tubes, single leds //byte tubePixels[] = {3,2,6,1,0}; //Numitron 4 tubes, 4 x single leds + 1. The extra led in the middle is unused and always stays dark! //byte tubePixels[] = {0,1,2,3,3,2,1,0}; //4 tubes, double row, 8 leds //byte tubePixels[] = {0,0,1,1,2,2,3,3}; //4 tubes, double row, 8 leds //byte tubePixels[] = {3,3,2,2,1,1,0,0, 0,0,1,1,2,2,3,3}; //4 tubes, double row, 16 leds (GB) //byte tubePixels[] = {0,0,1,1,2,2,3,3,3,3,2,2,1,1,0,0,0}; //4 tubes, double row, 17 leds (GP) //byte tubePixels[] = {0,0,0,1,1,2,2,3,3,3,3, 3,3,2,2,2,1,1,0,0}; //4 tubes, double row, 20 leds (Robi) // tubePixels is a byte[] so sizeof(tubePixels) already equals its element count; PixelCount is // set 2 *higher* than that count on purpose, as headroom for a couple of always-dark "spare" // pixels at the tail of the strip (see the further +4 used when sizing `strip` below). Every loop // in this file iterates `for (i=0; i= any real maxDigits/tube-index value) for those // positions, preserving the original "always dark / never matches a real tube" intent safely. inline byte tubeForPixel(int i) { return (i < (int)sizeof(tubePixels)) ? tubePixels[i] : 255; } //NeoGrbFeature gives BRGW (G and B swapped) //NeoRgbFeature gives RBGW (G and B swapped) //NeoBrgFeature gives BGRW (G and R swapped) #if defined(ESP32) byte PixelPin = NEOPIXEL_PIN; //on ESP32 usable any pin below 32 //Strip is allocated with PixelCount+4 physical pixels (a few more than //PixelCount, which is itself already tubePixels-length+2 - see note above) //as headroom so the driver never writes past its own frame buffer, even //though every loop only ever indexes strip positions 0..PixelCount-1. //NeoEsp32I2s1800KbpsMethod drives the strip via the ESP32's I2S peripheral //(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. #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. #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 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; #define NEO_STRIP_LEN PixelCount NeoPixelBrightnessBus strip(NEO_STRIP_LEN, PixelPin); #endif //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"); DPRINT("Pixel count: "); DPRINTLN(PixelCount); DPRINT("Brightness:"); DPRINT(c_MinBrightness); DPRINT(" - "); DPRINTLN(c_MaxBrightness); neoBrightness = prm.rgbBrightness; //The bit-bang method sets the pin to OUTPUT in its constructor (before setup()), and Begin() //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 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 } //Classic Adafruit/NeoPixel "color wheel": maps a single byte (0..255) onto a //position around the RGB hue circle, producing a smooth rainbow as WheelPos //increases. Split into 3 arcs of 85 steps (255/3) - red->blue, blue->green, //green->red - each linearly interpolating between the two arc endpoints //(hence the `* 3` scaling: 85*3 = 255, so each arc's channel spans the full //0..255 range). WHITE_INDEX (192) is carved out of the wheel as a special //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; WheelPos = 255 - WheelPos; //invert so increasing WheelPos visually rotates the same direction as prm.rgbDir expects if(WheelPos < 85) { return RgbColor(255 - WheelPos * 3, 0, WheelPos * 3); } else if(WheelPos < 170) { WheelPos -= 85; return RgbColor(0, WheelPos * 3, 255 - WheelPos * 3); } else { WheelPos -= 170; return RgbColor(WheelPos * 3, 255 - WheelPos * 3, 0); } } //Flashes all tube-backlight pixels white on/off at 2Hz (500ms half-period) //while the alarm is ringing, overriding whatever normal rgbEffect was active. //Brightness is hardcoded to 200 here (ignores prm.rgbBrightness/autoBrightness) //so the alarm flash is always clearly visible regardless of the user's normal //backlight/dimming settings. Called every doAnimationMakuna() tick while //alarmON is true (see that function), so the 500ms gate below is what actually //paces the blink - doAnimationMakuna() itself may be called much more often. void alarmLight() { static unsigned long lastRun = 0; static byte counter; if ((millis()-lastRun)<500) return; lastRun = millis(); counter++; strip.SetBrightness(200); for(volatile int i=0; i=maxDigits marks a "no tube here" filler pixel - always kept dark, even during the flash strip.SetPixelColor(i, RgbColor(255,255,255)); else strip.SetPixelColor(i,black); } neoShow(); } //Called once from the core's alarmSound() when the alarm's ring period times //out naturally, to make sure the alarmLight() flashing doesn't get left mid- //flash (i.e. stuck lit white) - forces brightness to 0 and every pixel black, //then pushes it out immediately rather than waiting for the next //doAnimationMakuna() tick to restore the normal effect. void darkenNeopixels() { strip.SetBrightness(0); for (int i=0;i smaller multiplier -> shorter delay -> faster //stepping. max(0, ...) is defensive: rgbSpeed is expected to stay in 1..255 //per the settings JSON, so (258-speed) should never go negative, but this //guards against underflow/overflow if it ever does. void rainbow2() { //Rainbow Stepper static int16_t j=0; static int16_t i=0; static unsigned long lastRun = 0; int steps = 15; unsigned long spd = max(0,steps * (258-prm.rgbSpeed)); if ((millis()-lastRun)=maxDigits) { //walked off the end of the tubes -> wrap back to the first tube and advance the color phase by a full "steps" jump if (j>255) j=0; i=0; j+=steps; } //endif if (i<0) { //same wrap, but for the reverse (prm.rgbDir==0) direction if (j>255) j=0; i=maxDigits-1; j+=steps; } //endif //DPRINT(i); DPRINT("/"); DPRINTLN(j); setPixels(i, Wheel((j==192) ? j+1 : j)); //skip WHITE_INDEX (192) here too, same reasoning as in rainbow() if (prm.rgbDir)i++; else i--; neoShow(); } //All tubes show the same solid color (c), while the overall strip brightness //ramps down from neoBrightness to c_MinBrightness and back up (a "breathing" //dimmer), picking a new random color each time it bottoms out. `counter` is //the amount subtracted from neoBrightness; dir flips its sign at each end of //the ramp. The `(cnt%3==0)` half of the OR is dead in practice: cnt is a //static byte that is declared but never incremented anywhere in this //function, so it is always 0 and `cnt%3==0` is always true - meaning the //`(neoBrightness-counter)>20` guard is effectively bypassed and counter //advances by `dir` (i.e. 1) every single call regardless. Net effect: the //breathing speed is fixed at 1 brightness step per doAnimationMakuna() tick, //not throttled as the "> 20" condition alone would suggest. void effect1() { //color dimmer static int c = 0; //actual color static int dir = 1; //direction static long counter = 0; static byte cnt = 0; strip.SetBrightness(max(0l,neoBrightness - counter)); for(volatile int i=0; i=maxDigits) strip.SetPixelColor(i,black); else strip.SetPixelColor(i, Wheel(c)); } // POSSIBLE BUG: cnt is never incremented (nor reset), so `cnt%3==0` is // always true and this condition always fires - see function comment above. if (((neoBrightness - counter)>20) || (cnt%3 == 0)) { counter += dir; //DPRINTLN(counter); } if (counter <= 0) { dir = 1; //hit the bright end of the ramp -> start dimming again } if (counter >= (neoBrightness-c_MinBrightness)) { c = random(0,256); //hit the dim floor -> pick a new random hue and start brightening back up dir = -1; } neoShow(); } //All tubes show the same solid color, but the color itself jumps to a new //random hue every `100*(258-rgbSpeed)` ms - i.e. higher rgbSpeed means shorter //wait, so colors change faster. Unlike effect1, there's no fade between colors //here, just an instant switch once the timer elapses. void effect2() { //random color picker static int c = random(0,256); //actual color static unsigned long lastRun = 0; if ((millis()-lastRun)>100*max(0,(258-prm.rgbSpeed))) { lastRun = millis(); c = random(0,256); } for(volatile int i=0; i=maxDigits) strip.SetPixelColor(i, black); else strip.SetPixelColor(i, Wheel(c)); } neoShow(); } //Distance between two positions on the 0..255 color wheel, accounting for //wraparound (the wheel is circular: color 255 is adjacent to color 0). d1 is //the naive linear distance; d2 is the distance going "the other way around" //through the wrap point. Returning the smaller of the two gives the true //shortest hue distance, which the random-color pickers below use to make sure //a freshly rolled random color is visually distinct from the current one //(not just numerically different, since e.g. 250 and 5 are close in hue //despite being far apart as raw numbers). int colorDistance(int c1,int c2) { int d1,d2; d1 = abs(c1-c2); d2 = (c1 cycle through the fixed palette table c[] // enableRandom=true -> pick random colors (rejecting ones too // close to the current color, via // colorDistance()/RANDOM_WHEEL_DISTANCE) // eachPixelRandom=true (implies enableRandom) -> every tube gets its own // independent target color instead of all // tubes chasing a single shared color //For each tube, actColor[] is faded step-by-step from oldColor[] towards //newColor[] (see the fade loop near the bottom); once a tube's actColor //catches up to its newColor, the "current tube" pointer i advances (direction //per prm.rgbDir) and, once it wraps around all tubes (or immediately, in //eachPixelRandom mode), a new target color is picked. `step` controls how many //wheel-positions actColor moves per call, chosen so the fade from old to new //color takes roughly 20 calls regardless of how far apart they are. void effect3(boolean enableRandom,boolean eachPixelRandom) { static const int c[] = {255,5,12,22,30,40,54,62,78,85,100,110,122,137,177,WHITE_INDEX,210,227,240}; static const int cMax = sizeof(c) / sizeof(c[0]); //size of array //Fixed-size [10] per-tube state arrays, indexed 0..maxDigits-1: only safe as //long as no config's maxDigits exceeds 10 (all loops below use maxDigits as //the bound, never sizeof(newColor)/sizeof(...), so this isn't re-checked at //runtime - a config with more than 10 tubes would silently overrun these). static int newColor[10]; static int oldColor[10]; static int actColor[10]; static int i = 2; static int step = 1; static int idx = 0; static boolean firstRun = true; static int counter = 0; static int dir = 1; int newC = 0; boolean changeColor = false; boolean colorOK; if (firstRun) { firstRun = false; for (volatile int k=0;k=maxDigits) { i=0; changeColor = true;} else if (i<0) {i=maxDigits-1; changeColor = true;} if (eachPixelRandom) { //each pixel is random color changeColor = true; } if (changeColor) { changeColor = false; if (enableRandom) { counter = 0; while (true) { //random color newC = random(0,256); //get a new random color colorOK = true; if (RANDOM_FROM_ALL_PIXELS) { //require the candidate to be far enough (RANDOM_WHEEL_DISTANCE) from //every tube's current target color, not just this tube's - avoids two //tubes converging on near-identical hues at the same time for (int j=0;jRANDOM_MAX_COUNTER)) break; } //end while //DPRINT("Pix:"); DPRINT(i); DPRINT(" old:"); DPRINT(newColor[i]); DPRINT(" / newC:"); DPRINT(newC); DPRINT(" No of tries:"); DPRINTLN(counter); } else { //new color from table idx++; if (idx>=cMax) idx = 0; newC = c[idx]; } if (eachPixelRandom) { oldColor[i] = newColor[i]; newColor[i] = newC; } else { for (volatile int k=0;k oldColor[i]) dir = 1; else dir = -1; //DPRINT("Old-New simple distance:"); DPRINT(abs(newC-oldColor[i])); DPRINT(" step:"); DPRINTLN(step); } //endif changeColor actColor[i] = oldColor[i]; //starting color } //endif (newColor[i] == actColor[i]) if (actColor[i] != newColor[i]) { //next rainbow color if (dir==1) actColor[i] = min(newColor[i],actColor[i]+step); else actColor[i] = max(newColor[i],actColor[i]-step); } for (int j=0;jnew //color fade on its own schedule (no shared "current tube" pointer like //effect3), so all tubes are constantly, independently drifting between random //hues. Same [10]-element fixed-array-size caveat as effect3 above applies here. void effect4() { //every pixel is random changer static int newColor[10]; static int oldColor[10]; static int actColor[10]; static int step[10]; static boolean firstRun = true; //unsigned long spd = max(0, 25*(258-prm.rgbSpeed)); //if ((millis()-lastRun)=maxDigits)) strip.SetPixelColor(i,black); else strip.SetPixelColor(i,Wheel(col)); } neoShow(); } //Knight Rider "KITT" scanner: a single lit tube (using the fixed //prm.rgbFixColor, not the rainbow wheel) bounces back and forth across all //tubes. counter2 divides doAnimationMakuna()'s call rate by 10 so the scanner //advances one tube every 10 ticks instead of every tick (prm.rgbSpeed is not //consulted here, unlike most other effects - the bounce speed is fixed). void kitt() { static int dir = 1; //direction static int counter = 0; static int counter2 = 0; counter2 ++; if (counter2 >=10) { counter2 = 0; counter += dir; if (counter >= maxDigits-1) dir = -1; //bounce off the last tube else if (counter <=0 ) dir = 1; //bounce off the first tube } for (int i=0;i