// NeoPixel Word Clock // // ============================================================================ // This driver is fundamentally different from the other (tube/VFD/Numitron/ // LED segment) drivers in this codebase: instead of multiplexing digit tubes, // it lights up individual letters/words on a WS2812 ("NeoPixel") LED matrix // mounted behind a laser-cut/printed acrylic panel, so the time is spelled // out as a sentence (e.g. "IT IS HALF PAST TEN") rather than shown as digits. // // Hardware: an XMAX x YMAX (currently 11x11, see clocks.h) grid of WS2812 // LEDs wired in a single serpentine ("boustrophedon") strip, plus a handful // of extra individually-addressed LEDs (weekday row, minute dots, wifi/temp // indicator) appended after the grid. Driven via NeoPixelBrightnessBus. // A 5x7 pixel font (font5x7.h) is reused from the tube drivers to render // small numeric overlays (temperature/humidity/date-time editor) on top of // the same matrix. // // Language variants: the actual word layout (the `matrix[][]` character grid // used to look up each word's on-screen position) and the HH:MM -> sentence // templates (`sequence[][]`) are NOT in this file -- they live in clocks.h, // one block per clock model: // CLOCK_80 = English ("it is quarter past ten") // CLOCK_81 = German ("es ist viertel nach zehn" style, uses "vor/nach halb") // CLOCK_82 = Hungarian ("negyed tiz" / "harom negyed tiz" style) // All three define WORDCLOCK and share this single .ino as their driver; // only the word/sequence/matrix tables (and START_CORNER wiring direction) // differ per config. Hungarian time phrasing is grammatically irregular // (quarters count towards the NEXT hour, e.g. "negyed" = quarter of the // hour to come, "haromnegyed" = three-quarters/quarter-to) -- see showTime() // below for how the generic #1/#2 token mechanism copes with that. // // Note: wordclock_hun.txt in the repo root is NOT read by this code at // runtime -- it is a captured serial debug log (DPRINT output) from a past // firmware build, kept only as human-readable reference documentation of // the Hungarian matrix/sequence layout, not a data file this .ino loads. // ============================================================================ #ifdef WORDCLOCK //#define TEST_WORDCLOCK #include "font5x7.h" char tubeDriver[] = "WORDCLOCK"; //identifies this driver in status/web page; "tubeDriver" name is generic/shared across all drivers #define NUMDIGITS 4 int maxDigits = NUMDIGITS; //read by the core (changeDigit(), etc.) even though this driver has no physical digit tubes; keeps digit[]/newDigit[] sized for the numeric overlay (temp/humidity/IP/editor) long neoBrightness; int colorStep = 1; int maxPos; //highest linear pixel index inside the XMAX*YMAX word grid (excludes the extra indicator LEDs appended after it) int offsetX = 0; //current animation scroll offset (in grid cells), used to slide the whole sentence in/out of view int offsetY = 0; int animOffsetX = 0; //target scroll direction/step for the current prm.animMode (see setAnimOffset()) int animOffsetY = 0; enum LEDSTART {LEFTUP, RIGHTUP, LEFTDOWN, RIGHTDOWN }; //physical corner where LED index 0 of the serpentine strip starts -- set per clock model via START_CORNER in clocks.h enum NEO_RGB_FEATURE {RGB, GRB, BRG, BGR}; #define NUMCOLS 5 #define NUMROWS 7 boolean _upsidedown = false; byte row[NUMROWS]; char asciiConvert[] = "0123456789 -.APC~%oIF"; //maps a raw digit/code value (used as index) to the matching glyph in font5x7.h, for the numeric overlay display char DPchar = 0xFF; //decimal point byte dat[NUMDIGITS][NUMROWS]; //rasterized glyph rows for up to NUMDIGITS characters; in practice only dat[0] and dat[1] (the two digits of a number) are ever filled by writeNumericDisplay() #define FPS 60 //frames per second #define FPS_MSEC 1000/FPS #define COLORSATURATION 255 #define WHITE_INDEX 192 #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); RgbColor yellow(255, 255, 0); RgbColor orange(255,140,0); RgbColor turqouise(0xB1,0xFF,0xEC); RgbColor pink(0xFF,0xA0,0xB0); RgbColor dispColor; //some definitions are in clocks.h CLOCK_80 and CLOCK_81 //(also CLOCK_82, the Hungarian variant -- see: struct wordDef, XMAX/YMAX, matrix[][], // sequence[][], hours[], words[], extraDots[], WEEKDAY_POSX/Y, SENSOR_POSX/Y, START_CORNER) #define WORD_MAX sizeof(words)/sizeof(wordDef) //number of words #define HOUR_MAX sizeof(hours)/sizeof(wordDef) //number of words //Total addressable pixel count: the XMAX*YMAX word-matrix grid plus 4 extra //individually-addressed LEDs wired after it (see extraDots[] in clocks.h) used //for the 1..4 "minute remainder" dots (word clocks only resolve to 5-minute //steps in words, see showTime()/m5 below -- these dots show the 0..4 leftover minutes). const int PixelCount = XMAX*YMAX+4; //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 NeoPixelBrightnessBus strip(PixelCount+10,PixelPin); //NeoEsp32I2s1800KbpsMethod is used here because it is more reliable than NeoEsp32Rmt7Ws2812xMethod! #else #define NEOPIXEL_PIN 3 byte PixelPin = 3; // on 8266 it MUST use GPIO3 (RX pin) NeoPixelBrightnessBus strip(PixelCount+10); #endif NeoGamma colorGamma; RgbColor Wheel(byte WheelPos) { if (WheelPos == WHITE_INDEX) return white; WheelPos = 255 - WheelPos; 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); } } //Converts a logical (column,row) letter position in the word matrix -- 1-based, //x in [1..XMAX], y in [1..YMAX] -- into the linear NeoPixel strip index. //This is necessary because the physical strip is wired as a single serpentine //("boustrophedon": row 0 left-to-right, row 1 right-to-left, row 2 left-to-right, ...) //starting from whichever physical corner START_CORNER says (set per clock model //in clocks.h, since different panel builds route the data-in wire to a different //corner). x/y are first normalized to 0-based, then mirrored/flipped as needed for //the chosen starting corner, then folded into the zig-zag row order, and finally //(for the "up" corners) the whole index is mirrored via maxPos-pos because index 0 //of the physical strip is at the BOTTOM of the panel even when the logical/visual //starting corner is at the top. int inline convertXYtoPOS(int x, int y) { //calculate position, depending on the first LED's position int pos; //DPRINT("X:"); DPRINT(x); DPRINT(" Y:"); DPRINT(y); if ((x>XMAX) || (y>YMAX) || (x<1) || (y<1)) { DPRINT(x); DPRINT("/"); DPRINTLN(y); return(maxPos+5); //out of area: park it just past the real grid+extraDots range (still inside the strip's PixelCount+10 safety buffer) so an out-of-range coordinate can't corrupt another LED nor crash } x--; y--; //move position 1,1 to 0,0 switch (START_CORNER) { case LEFTUP: y = YMAX-y-1; if (y%2==0) pos = (y+1)*XMAX - x-1; else pos = x + y*XMAX; pos = maxPos - pos; //*prm.ledsPerHold; break; case RIGHTUP: x = XMAX-x-1; // POSSIBLE BUG: uses XMAX here where LEFTUP (above) uses YMAX for the same // row-flip computation on y, and the y%2 zig-zag test's polarity is also // inverted vs. LEFTUP (here "if (y%2)", there "if (y%2==0)") without an // obvious reason tied to the extra x-mirroring done for this corner. // With the current hardware XMAX==YMAX==11 this is silently harmless, // but it looks like a copy/paste-and-tweak mistake from the LEFTUP case // rather than an intentional difference, and would break on a // non-square panel (XMAX != YMAX). Left unchanged; needs verification // against a real RIGHTUP-wired panel (CLOCK_81/CLOCK_82 hardware). y = XMAX-y-1; if (y%2) pos = (y+1)*XMAX - x-1; else pos = x + y*XMAX; pos = maxPos - pos; //*prm.ledsPerHold; break; case LEFTDOWN: if (y%2) pos = (y+1)*XMAX - x-1; else pos = x + y*XMAX; //pos = pos*prm.ledsPerHold; break; case RIGHTDOWN: x = XMAX-x-1; if (y%2) pos = (y+1)*XMAX - x-1; else pos = x + y*XMAX; //pos = pos*prm.ledsPerHold; break; default: pos = 0; } //end switch //pos += prm.ledOffset; //DPRINT(" POS:"); DPRINTLN(pos); DPRINT(" "); return(pos); } //Fill the struct's fields with X,Y and len values //For every entry in a word table (words[] or hours[], defined in clocks.h), find //where that literal text substring first occurs inside the `matrix[][]` character //grid (also in clocks.h, one row of text per matrix row) and record its start //column/row + length. This is how the human-readable word list gets turned into //pixel coordinates without having to hand-enter X/Y for every word. //Note: some hour words rely on this "find literal substring" approach in a //deliberately hacky way to dodge false-positive matches -- e.g. the Hungarian //hours[] table (clocks.h, CLOCK_82) uses placeholder text like "__1" and //"TIZEN__1" instead of the real letters "EGY"/"TIZENEGY" ("one"/"eleven"), //because "EGY" is also a substring of other words in that matrix row (e.g. //"NEGYED") and strstr() would otherwise lock onto the wrong occurrence. The //placeholder is unique in the matrix text but still highlights the correct //physical letters when displayed. void generateTable(struct wordDef *w,int maxLine) { DPRINT("\nNumber of lines:"); DPRINTLN(maxLine); char* ret; boolean found; for (int num=0;num=XMAX) { n = 0; for (int i=0;i12) hr-=12; return(hr); } void firstRun() { //switch off (tube-only) settings that don't apply to a word-clock display, e.g. no digit blink/leading-zero-blank/double-blink animation and no cathode-protection exercise cycle exist on a word matrix; called once on first showTime() invocation prm.enableBlink = false; prm.showZero = false; prm.enableTimeDisplay = true; //disable date display prm.dateRepeatMin = 0; prm.enableDoubleBlink = false; prm.interval = 0; //disable cathode protect } /* RgbColor getLedColor(RgbColor in) { RgbColor out; switch (prm.neoRgbFeature) { case RGB: out = in; break; case GRB: out.R = in.G; out.G = in.R; out.B = in.B; break; case BRG: out.R = in.B; out.G = in.R; out.B = in.G; break; case BGR: out.R = in.B; out.G = in.G; out.B = in.R; break; } return (out); } */ void clearScr(){ //blank the whole panel; either instantly (static display, animMode 0) or as a row-by-row wipe (any scroll animation mode), so a transition between two times has a visible "clean slate" moment if (prm.animMode==0) { for (int i=0;itxt)!=0) { //DPRINT(w->txt); DPRINT(" "); strncpy(tmp,w->txt,sizeof(tmp)); //vestigial: this comparison/copy only fed the (now commented-out) DPRINT above; with that print disabled, tmp no longer affects the drawing below, which always executes unconditionally } for (int i=0; ilen; i++) { x1 = w->startX + i + offsetX; y1 = w->startY + offsetY; if ((x1>=1) && (x1<=XMAX) && (y1>=1) && (y1<=YMAX)) { strip.SetPixelColor(convertXYtoPOS(x1,y1),dispColor); } } //endfor } void dispNum(int num){ //display one hour-number word (num must already be in the 1..12 range hours[] is indexed by, see clock24to12()) #ifdef TEST_WORDCLOCK if ((num<0) || (num>12)) { DPRINT("dispNumError:"); DPRINT(num); DPRINT(" "); } else { DPRINT(num); DPRINT(" >> "); DPRINT(hours[num].txt); DPRINT(" "); } #endif disp(&hours[num]); } void dispWord(char* w){ //find a word by its literal text in the words[] table (linear search -- table is tiny, so no need for anything smarter) and light it up int num=0; boolean found = false; do { if (strcmp(w,words[num].txt)==0) { found = true; #ifdef TEST_WORDCLOCK DPRINT(words[num].txt); DPRINT(" "); #endif disp(&words[num]); return; } num++; } while (num=0;i--) { for (int i=0;i0) && (offsetX<0)) offsetX += animOffsetX; else if ((animOffsetX<0) && (offsetX>0)) offsetX += animOffsetX; else offsetX = 0; if ((animOffsetY>0) && (offsetY<0)) offsetY += animOffsetY; else if ((animOffsetY<0) && (offsetY>0)) offsetY += animOffsetY; else offsetY = 0; //DPRINT("\no:"); DPRINT(offsetX); DPRINT("/"); DPRINT(offsetY); DPRINT(" a:");DPRINT(animOffsetX); DPRINT("/"); DPRINT(animOffsetY); showTime(hour(), minute()); } void writeNumericDisplay(int num,int dot,boolean lowerDigitOnly) { //write big numbers to the LED matrix display (temp, humidity) char dispChar; int p,pos; int st,offset; dispColor = askColor(num,dot); num = abs(num); for (int i=0;i<=1;i++) { //generate new line if (i==0) { dispChar = num/10; //upper digit if (dispChar==0) dispChar = ' '; //space instead of leading zero } else dispChar = num%10; //lower digit if (dispChar=0) { pos = convertXYtoPOS(SENSOR_POSX+dot, SENSOR_POSY); strip.SetPixelColor(pos,dispColor); //switch on sensor led; } strip.Show(); delay(50); } void displayAlarmShow() { static boolean phase = true; static unsigned long lastRun = 0; int pos; if ((millis()-lastRun)<500) { return; } lastRun = millis(); neoBrightness = MAXBRIGHTNESS; for (int i=0;i0)) { neoBrightness = (neoBrightness*lx)/long(MAXIMUM_LUX); if (neoBrightness< c_MinBrightness) neoBrightness = c_MinBrightness; } neoBrightness = min(neoBrightness,long(RGB_MAX_BRIGHTNESS)); //safety only if (!radarON) neoBrightness = 0; if (editorRunning) dot = 3; //editor mode else if (newDigit[0] == TEMP_CHARCODE) dot = 0; //Celsius else if (newDigit[0] == (TEMP_CHARCODE+5)) dot = 1; //Fahrenheit else if (newDigit[0] == PERCENT_CHARCODE) dot = 2; //Humidity % //DPRINT(newDigit[0]); DPRINT("/"); DPRINT(dot); DPRINT(" "); if (dot>=0) { for (int i=0;i> 1; else cmask = cmask << 1; rows[r] = rows[r] << (7-NUMCOLS); } } void bPrint(byte in) { for (int i=7;i>2;i--) { boolean b = in & (1<28) col = red; break; case 1: //fahrenheit if (num<=32) col = blue; else if (num>82) col = red; break; case 2: //humidity if (num<40) col = yellow; else if (num>70) col = blue; break; default: col = white; break; } return(col); } void showTime(int h,int m){ //write the word-clock sentence to the LED display static unsigned long lastRun = 0; int ptrSeq,ptrWord,pos; char ch; char tmp[20]; int m5; if (lastRun==0) {firstRun(); lastRun = 1;} //if ((millis()-lastRun)>200) {DPRINT(h); DPRINT(":"); DPRINT(m); DPRINT(" ");lastRun = millis();} if ((h>23) || (m>59)) return; //safety only dispColor = Wheel(prm.rgbFixColor); strip.SetBrightness(neoBrightness); #ifdef TEST_WORDCLOCK DPRINTLN(""); DPRINT(h); DPRINT(":"); DPRINT(m); DPRINT(" "); #endif memset(tmp,0,sizeof(tmp)); m5 = m / 5; //5min step ptrSeq = 0; //pointer to the sequence chars ptrWord = 0; //pointer to the found word chars for (int i=0;i0) { //valid minute? pos = convertXYtoPOS(WEEKDAY_POSX+weekday()-1, WEEKDAY_POSY); strip.SetPixelColor(pos,dispColor); //switch on weekday led int dot = m % 5; //extra dot for 1..4 minutes if (dot>0) { for (int i=0;i0 if (WiFi.status() == WL_CONNECTED) { strip.SetPixelColor(convertXYtoPOS(SENSOR_POSX+3, SENSOR_POSY), dispColor); // 'W' sign for wifi } } //endif animation finished strip.Show(); } //------------------------------------------------------------- void clearTubes() {} void writeDisplaySingle() {} #endif