Files
nixie-clock/firmware/uniclock/ESP32_UniClock2/ZM1500.ino
T
mangavalkandClaude Opus 5.5 b347f1c63f Import ESP_UniClock (upstream 1189810)
Vendored from https://github.com/gpeter62/ESP_UniClock: the sketch
source, the web UI and the libraries the ESP8266 build needs, unpacked
from the bundled zips. Adds a PlatformIO project for the ESP-12. The
source is unchanged; local changes follow as separate commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:48:04 +01:00

243 lines
12 KiB
Arduino

// ============================================================================
// ZM1500 Clock driver by UNFI
// ----------------------------------------------------------------------------
// Hardware: 4x 74HC595N serial-in/parallel-out shift registers (32 bits total)
// feeding a bank of optocouplers ("opto PCB" wiring), which in turn switch the
// actual ZM1500 (12-position) display element grid/segments. Data is bit-banged
// out over a plain 3-wire interface (dataPin/clkPin/latchPin - classic 74HC595
// SPI-like protocol: shift bits in on clkPin rising edges while dataPin holds
// the current bit, then pulse latchPin to move the whole shift-register content
// to the (glitch-free) output latches in one go).
//
// This is the driver currently active in clocks.h (CLOCK_72 defines
// ZM1500_ESP32 - see clocks.h). The 32-bit value sent out each refresh packs
// the 8 segment-enable bits (from segmentEnablePins[], bits 0-7) and the
// 12 possible digit/tube-select bits (from digitEnablePins[], each shifted up
// by +8, so bits 8-19) into one word, of which only the low 20 bits are
// actually clocked out (see the 20-iteration loop in writeDisplay() below) -
// this only supports the single "opto PCB" pin layout (no alternate
// "transistor PCB" wiring mode), unlike an earlier work-in-progress revision
// of this file that attempted to support both via a SEGMENTS_FIRST toggle.
// That revision was abandoned half-finished and has been removed; this is the
// last known-working version, restored from a backup (ZM1500 .sav).
// ============================================================================
#ifdef ZM1500_ESP32
char tubeDriver[] = "ZM1500_ESP32";
#if defined(ESP32)
#else
#error "Only ESP32 Board is supported!"
#endif
// NOTE: despite the "msec" wording in the original comment, these are raw ESP32 hardware-timer
// tick counts passed straight into timerAlarmWrite() below, not real milliseconds - the actual
// tick period depends on the timer's prescaler (set up once, elsewhere, when the timer object is
// created in the core .ino's startTimer()). Treat these as "bigger number = longer phase", not as
// an absolute time unit.
int DRAM_ATTR PWMrefresh=11000; //msec, Multiplex time period. Greater value => slower multiplex frequency
int DRAM_ATTR PWM_min = 2000;
int DRAM_ATTR PWM_max = 10000;
//#define dataPin 14 //D5
//#define latchPin 27 //12 //D6
//#define clkPin 26 //13 //D7
// Segment bit patterns per displayable glyph code (index = the value stored in digit[]/newDigit[]).
// Each byte is a bitmask over 8 abstract "segment slots" (bit7=a .. bit0=DP by convention elsewhere in
// this codebase); generateBitTable() below translates these abstract bits into the actual physical
// shift-register output bits via segmentEnablePins[] (defined per-CLOCK_NN in clocks.h). Codes 0-9 are
// digits, 10=blank, 11=minus, 12=a standalone decimal-point glyph, 13+ are extra characters (A, P, C,
// degree symbol, %, etc.) used for date/temperature/humidity/IP-address display - see clocks.h's
// TEMP_CHARCODE/GRAD_CHARCODE/PERCENT_CHARCODE and the core file's displayTemp()/displayHumid()/writeIpTag().
byte DRAM_ATTR charDefinition[] = {
B11111100, //0: abcdef
B01100000, //1: bc
B11011010, //2: abdeg
B11110010, //3: abcdg
B01100110, //4: bcfg
B10110110, //5: acdfg
B10111110, //6: acdefg
B11100000, //7: abc
B11111110, //8: abcdefg
B11110110, //9: abcdfg
B00000000, // : BLANK (10)
B00000010, //-: minus (11)
B00000001, // decimal point (12)
B11101110, // A abcefg (13)
B11001110, // P abefg (14)
B10011100, // C adef (15)
B11000110, //grad (upper circle) abfg (16)
B10110100, //% acdf (17)
B00111010, //lower circle cdeg (18)
B01100000, //I bc (19)
B10001110 //F aefg (20)
};
#define MAXCHARS sizeof(charDefinition)
#define MAXSEGMENTS sizeof(segmentEnablePins)
int maxDigits = sizeof(digitEnablePins);
uint32_t DRAM_ATTR charTable[MAXCHARS]; //generated pin table from segmentDefinitions
uint32_t DRAM_ATTR segmentEnableBits[MAXSEGMENTS]; //bitmaps, generated from EnablePins tables
uint32_t DRAM_ATTR animationMaskBits[5];
uint32_t DRAM_ATTR digitEnableBits[12];
// This driver refreshes the whole display purely from the writeDisplay() ISR below (hardware-timer
// driven multiplex), so there is nothing left to do for a "blocking, one-shot" write - required by
// the common driver interface, but intentionally a no-op here.
void IRAM_ATTR writeDisplaySingle() { }
void setup_pins(){
DPRINT("ZM1500 clock "); DPRINT(maxDigits); DPRINTLN(" digits - setup pins");
pinMode(dataPin, OUTPUT); regPin(dataPin,"dataPin");
pinMode(latchPin,OUTPUT); regPin(latchPin,"latchPin");
pinMode(clkPin,OUTPUT); regPin(clkPin,"clkPin");
digitalWrite(latchPin,LOW);
digitalWrite(clkPin,LOW);
generateBitTable(); //pre-compute charTable[]/digitEnableBits[]/animationMaskBits[] from clocks.h's pin arrays
startTimer(); //arms the ESP32 hardware timer that drives writeDisplay() below
}
// Hardware-timer ISR: multiplexes one digit position per "ON" call, using a two-phase (ON/OFF)
// duty-cycle so the *ratio* of ON time to OFF time controls perceived brightness (a software PWM),
// then re-arms itself (timerAlarmWrite/timerAlarmEnable) with however long the next phase should
// last. Runs continuously in the background once startTimer() has been called from setup_pins().
void IRAM_ATTR writeDisplay(){ //void IRAM_ATTR writeDisplay(){
static DRAM_ATTR int pos = maxDigits;
static DRAM_ATTR boolean state=0;
static DRAM_ATTR int brightness;
static DRAM_ATTR uint32_t PWMtimeBrightness=PWM_min;
uint32_t timer = PWMrefresh;
uint32_t val = 0;
portENTER_CRITICAL_ISR(&timerMux);
noInterrupts();
intCounter++;
if (state) { //ON state: advance to the next digit position and compute its bit pattern
pos++;
if (pos>maxDigits-1) { //go to the tube#0
pos = 0;
// Brightness (and the resulting PWMtimeBrightness ON-duration) is only recomputed once per
// full multiplex sweep (i.e. when we wrap back to position 0), not on every single digit -
// this keeps all digits at the same, consistently-updated brightness within one sweep.
brightness = displayON ? prm.dayBright : prm.nightBright;
if (brightness>MAXBRIGHTNESS) brightness = MAXBRIGHTNESS; //only for safety
if (autoBrightness && displayON) { //change brightness only on the tube#0
PWMtimeBrightness = max(PWM_min,PWM_max*lx/MAXIMUM_LUX);
}
else
PWMtimeBrightness = max(PWM_min,PWM_max*brightness/MAXBRIGHTNESS);
}
// Look up this position's live glyph, apply the wipe-animation mask (changeDigit()'s animMode 5,
// in the core .ino) and OR in a standalone decimal-point glyph (index 12) if this tube's dot
// should be lit - digit[]/animMask[]/digitDP[] are all written by the core file's display
// pipeline (displayTimeX()/changeDigit()) before this ISR ever reads them.
val = uint32_t(charTable[digit[pos]]); //the full bitmap to send to the shift-register chain
if (animMask[pos]>0) val &= uint32_t(animationMaskBits[animMask[pos]-1]); //animationMode 6, mask characters from up to down and back
if (digitDP[pos]) val = val | uint32_t(charTable[12]); //Decimal Point
timer = PWMtimeBrightness;
timerON = timer; //debug only
timerOFF = PWMrefresh-PWMtimeBrightness; //debug only
}
else { //OFF state: blank the shift-register output for the remainder of this digit's time-slice
timer = PWMrefresh-PWMtimeBrightness;
val = 0;
}
if (timer<500) timer = 500; //safety only...
if ( (brightness == 0) || (!radarON)) val = 0; //blank digit
val = val | digitEnableBits[pos]; //combine segment bits (val) with this tube's select bit
// Bit-bang val out LSB-first over 20 clock pulses - only the low 20 bits of the 32-bit val
// actually reach the hardware (8 segment bits + up to 12 digit-select bits, see the file header).
for (int i=0; i<20; i++) {
if (val & uint32_t(1 << i)) //reverse: (1 << (19 - i)))
digitalWrite(dataPin, HIGH);
else
digitalWrite(dataPin, LOW);
digitalWrite(clkPin,HIGH); //asm volatile ("nop"); //Clock impulse
digitalWrite(clkPin,LOW); //asm volatile ("nop");
} //end for
// Pulse latchPin to atomically move the just-shifted-in 20 bits from the 74HC595's internal shift
// register to its output latches - this is the instant the physical display actually changes.
digitalWrite(latchPin,HIGH); //asm volatile ("nop");
digitalWrite(latchPin,LOW);
state = !state;
portEXIT_CRITICAL_ISR(&timerMux);
timerAlarmWrite(ESP32timer, timer, true); //re-arm the hardware timer for the just-computed phase duration
timerAlarmEnable(ESP32timer);
interrupts();
}
// One-time setup helper (called from setup_pins()): pre-computes the lookup tables writeDisplay()'s
// ISR needs every cycle, so the ISR itself never has to do bit-shifting arithmetic based on the raw
// clocks.h pin arrays - just plain table lookups, keeping the interrupt handler fast.
void generateBitTable() {
uint32_t out;
DPRINTLN("--- Generating segment pins bitmap ---");
for (int i=0;i<MAXSEGMENTS;i++) {
segmentEnableBits[i] = uint32_t(1<<segmentEnablePins[i]);
DPRINT(i); DPRINT(": "); DPRINTLN(segmentEnableBits[i],BIN);
}
animationMaskBits[0] = uint32_t(1<<segmentEnablePins[0]); //a
animationMaskBits[1] = animationMaskBits[0] | uint32_t(1<<segmentEnablePins[1]) | uint32_t(1<<segmentEnablePins[5]); //bf
animationMaskBits[2] = animationMaskBits[1] | uint32_t(1<<segmentEnablePins[6]); //g
animationMaskBits[3] = animationMaskBits[2] | uint32_t(1<<segmentEnablePins[4]) | uint32_t(1<<segmentEnablePins[2]); //ec
animationMaskBits[4] = animationMaskBits[3] | uint32_t(1<<segmentEnablePins[3]); //d
DPRINTLN("--- Animation mask ---");
for (int i=0;i<5;i++) {
animationMaskBits[i] = ~animationMaskBits[i]; //invert bits
DPRINTLN(animationMaskBits[i],HEX);
}
DPRINTLN("---- Generated Character / Pins table -----");
for (int i=0;i<MAXCHARS;i++) {
out = 0;
DPRINT(i); DPRINT(": ");
DPRINT(charDefinition[i],BIN); DPRINT(" = ");
for (int j=0;j<=7;j++) //from a to g
if ((charDefinition[i] & 1<<(7-j)) != 0) {
out = out | segmentEnableBits[j]; //DPRINT("1");
}
//else DPRINT("0");
//DPRINT(" >> ");
charTable[i] = out;
DPRINTLN(charTable[i],BIN);
} //end for
// "+8" shifts the digit-select bit up past the 8 segment-enable bit positions (0-7, from
// segmentEnablePins[] above), so a combined value (segment bits | digitEnableBits[pos]) never
// collides: segment data occupies bits 0-7, tube selection occupies bits 8-19 (up to 12 tubes).
DPRINTLN("--- Generating digit pins bitmap ---");
for (int i=0;i<maxDigits;i++) {
digitEnableBits[i] = uint32_t(1 << uint32_t(digitEnablePins[i]+8));
DPRINT(i); DPRINT(": "); DPRINT(digitEnableBits[i],HEX); DPRINTLN(" ");
}
// Debug-only sanity dump: shows what writeDisplay() would send for each tube if it always
// displayed its own character-table index (not the real runtime digit[] value) - lets a developer
// eyeball the generated bitmaps against the datasheet/wiring before trusting the live display.
DPRINTLN("--- DEMO ---");
for (int i=0;i<maxDigits;i++) {
uint32_t val = uint32_t(charTable[i]); //the full bitmap to send to the shift-register chain
val = val | digitEnableBits[i];
DPRINT(i); DPRINT(": "); DPRINTLN(val,HEX);
}
}
// Empty no-op: nothing in the core .ino calls clearTubes() any more (its only former caller,
// disableDisplay(), has been removed entirely as dead code), so this is purely for interface
// compliance. If an active blank is ever needed, this would have to bit-bang an all-zero frame
// out to the shift-register chain instead of just returning.
void IRAM_ATTR clearTubes() {}
#endif