Files
nixie-clock/firmware/uniclock/ESP32_UniClock2/1tube.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

307 lines
13 KiB
Arduino

/*
* ======================================================================
* 1TUBE.INO - driver for the UNFI "1TUBE" single-Nixie-tube clock
* ======================================================================
* Hardware: ONE physical Nixie tube (e.g. IN-16/Z-series) fed by a single
* BCD-to-decimal decoder/driver IC (e.g. 74141/K155ID1-class chip) wired
* to the 4 "ABCDPins" GPIOs. Because there is only one tube, the classic
* simultaneous-multiplex approach used by multi-tube drivers cannot show
* several digits at once; instead this driver walks through the 4 logical
* digit positions (H,H,M,M held in digit[0..3]) ONE AT A TIME, holding
* each on the single tube for a while and cross-fading (PWM wipe) between
* the old and new character of that position before advancing.
* Board target: ESP8266 (uses the ESP8266 timer1 API: timer1_write(),
* ICACHE_RAM_ATTR); this driver is NOT built for ESP32.
* Protocol: 4 parallel GPIO lines (ABCDPins[0..3]) carry the BCD nibble
* that the decoder IC translates into "light cathode N" on the tube.
* BCD codes 10 (0xA) and 15 (0xF) are both outside the valid 0-9 range,
* so the decoder lights no cathode for them - used here as "blank" codes.
* Used by CLOCK_NN configs (see clocks.h): CLOCK_70 ("1 tube esp8266
* Nixie Clock by UNFI") is the only config that #defines TUBE1CLOCK.
* ======================================================================
*/
#ifdef TUBE1CLOCK
char tubeDriver[] = "1TUBE";
//used by UNFI 1TUBE Clock
//byte ABCDPins[4] = {2,5,4,15}; //defined in clocks.h
int maxDigits = 4; //4 LOGICAL digit positions (H,H,M,M) even though there is only 1 PHYSICAL tube
int pos = 0; //pointer of show phase - which of the 4 logical digit positions is currently on the single tube
int PWMrefresh = 15000; //msec, multiplex time period. Greater value => slower multiplex frequency
int PWM_min = 2000;
int PWM_max = 15000;
int maxDig=maxDigits;
void setup_pins() {
char tmp[30];
DPRINTLN("1TUBE Clock - setup pins");
for (int i=0;i<4;i++) {
pinMode(ABCDPins[i], OUTPUT);
sprintf(tmp,"Pin[%c]",char('A'+i));
regPin(ABCDPins[i],tmp);
}
startTimer();
}
void ICACHE_RAM_ATTR writeBits(byte num) {
//Send the 4-bit BCD code to the decoder IC, LSB first on ABCDPins[0].
//Note: digitalWrite() treats ANY non-zero value as HIGH, so passing the
//raw masked bit (0,1,2,4,8) instead of a strict 0/1 is fine here.
for (int j=0;j<4;j++) {
digitalWrite(ABCDPins[j],num & (1<<j));
}
}
void ICACHE_RAM_ATTR writeDisplay(){ //https://circuits4you.com/2018/01/02/esp8266-timer-ticker-example/
//This is the ESP8266 hardware-timer1 ISR (single-shot mode: it must
//re-arm itself via timer1_write() at the end of every call - see
//startTimer() in the core .ino, which sets timer1 to TIM_SINGLE).
//Because there is only ONE physical tube, "multiplex" here does not mean
//cycling through several tubes per refresh - it means running a tiny
//state machine (0/1/2) that, for the CURRENT logical position `pos`
//only, PWM-blends between the old and new character (cross-fade "wipe"
//animation) and then blanks the tube for the rest of the PWM period to
//control brightness. Advancing `pos` itself is NOT done here; it is
//done by writeDisplay2()/makeAnim(), which run from the main loop.
static volatile byte state=0;
static int timer = PWMrefresh;
static int brightness;
static int PWMtimeBrightness=PWM_min;
byte animM;
byte num;
if (EEPROMsaving) { //stop refresh while an EEPROM write is in progress!
timer1_write(PWMrefresh);
return;
}
//POSSIBLE BUG (ISR race condition): digit[], newDigit[], oldDigit[], animMask[]
//and `pos` are all read here from the timer ISR while makeAnim()/writeDisplay2()
//write to the very same globals from the main loop (no lock, no `volatile`
//on the arrays). On ESP8266 a torn read of an int-sized array element is
//unlikely but not architecturally guaranteed; worth a closer look if visual
//glitches during animation are ever reported.
animM = 0;
if (animMask[pos] > 0) animM = animMask[pos];
brightness = displayON ? prm.dayBright : prm.nightBright;
if (brightness>MAXBRIGHTNESS) brightness = MAXBRIGHTNESS; //only for safety
if ((brightness ==0) || !radarON){
writeBits(0xA); //black display - BCD code 10 is outside 0-9, decoder lights no cathode
#if DECIMALPOINT_PIN>=0
digitalWrite(DECIMALPOINT_PIN,LOW);
#endif
#if COLON_PIN >= 0
digitalWrite(COLON_PIN,LOW); // colon pin OFF
#endif
timer1_write(100*PWMrefresh); //re-check only occasionally while display is fully off (saves ISR overhead)
return;
}
else {
digit[4] = 0xA; //extra "5th" slot (blank) used as the makeAnim() end-of-cycle marker/target position - see writeDisplay2()
if (state==0) {
if (animMask[pos]==0) num = digit[pos];
else num = oldDigit[pos]; //mid-animation: still fading OUT the previous character
}
else if (state==1) num = newDigit[pos]; //mid-animation: now fading IN the new character
else num = 0xF; //BCD code 15 - also outside 0-9, another "blank" pattern (belt-and-braces vs 0xA)
writeBits(num);
switch (state) { //state machine: 0=show current/old char, 1=show new char (animation only), 2=blank (brightness/PWM off-time)
case 0:
#if DECIMALPOINT_PIN>=0
if (decimalpointON) digitalWrite(DECIMALPOINT_PIN,HIGH);
#endif
#if COLON_PIN>=0
digitalWrite(COLON_PIN,colonBlinkState); // Blink colon pin
#endif
if (autoBrightness && displayON) {
PWMtimeBrightness = max(PWM_min,PWM_max*lx/MAXIMUM_LUX); //ambient-light-driven ON-time within the PWM period
}
else {
PWMtimeBrightness = max(PWM_min,PWM_max*brightness/MAXBRIGHTNESS); //fixed day/night brightness setting -> ON-time
}
if (animM > 0) { //Animation running for this position? animM (1..19) is the wipe progress from animMask[pos]
timer = (PWMtimeBrightness * (20-animM))/20; //old-character share of the ON-time shrinks as animM grows
state = 1; //next state is: show newDigits
}
else {
timer = PWMtimeBrightness;
if (brightness>=MAXBRIGHTNESS) state = 0; //at max brightness skip the blank phase entirely (always-on, no flicker)
else state = 2; //default next state is: BLANK display
}
break;
case 1: //show new character, if animation
timer = (PWMtimeBrightness * animM)/20; //new-character share of the ON-time grows as animM grows
if (brightness>=MAXBRIGHTNESS) state = 0;
else state = 2; //default next state is: BLANK display
break;
case 2: //blank display
#if DECIMALPOINT_PIN>=0
digitalWrite(DECIMALPOINT_PIN,LOW);
#endif
#if COLON_PIN>=0
digitalWrite(COLON_PIN,LOW); // colon pin OFF
#endif
state = 0;
timer = PWMrefresh-PWMtimeBrightness; //remainder of the fixed-length PWM period -> keeps overall refresh rate constant
break;
} //end switch
} //end else
if ((brightness == 0) || (!radarON)) {timer = PWM_max; state = 0;} //no time sharing is needed
if (timer<500) timer = 500; //safety only... (timer1 min practical reload value; too-short reload could starve other ISRs/WiFi)
timer1_write(timer);
}
//Cooperative/non-blocking-ish delay: since the whole time-showing sequence
//in writeDisplay2()/makeAnim() is written as a straight-line blocking loop
//(one call per animation step), we can't just call delay(d) - that would
//starve WiFi, the captive-portal DNS, the alarm buzzer and GPS parsing for
//the whole animation. So Adelay() busy-waits but keeps servicing them.
void Adelay(unsigned long d) {
unsigned long dStart = millis();
doAnimationMakuna();
while ((millis() - dStart) < d) {
if (WiFi.getMode() == 2) dnsServer.processNextRequest(); //WiFi.getMode()==2 is WIFI_AP: service the captive-portal DNS while in AP config mode
alarmSound();
#ifdef USE_GPS
smartDelay(1); //feed GPS
#endif
yield(); //let the ESP8266 core service WiFi/SDK background tasks (required to avoid WDT resets)
}
}
//Drives the crossfade of the SINGLE current position `pos` from its old
//character to its new one. This is only ever meaningful for one tube at a
//time (unlike multi-tube drivers' changeDigit(), which is bypassed
//entirely for TUBE1CLOCK - see core .ino changeDigit()). Called once per
//position from writeDisplay2()'s position-walk loop.
void makeAnim() {
static byte oldDig = 10; //remembers the digit that was actually shown last time THIS function ran (persists across positions/cycles)
if (cathodeProtRunning) {
//Anti-cathode-poisoning routine: just snap to the target digit, no fade,
//no PWM animation bookkeeping - doCathodeProtect() drives digit[] itself.
memset(animMask,0,sizeof(animMask));
digit[pos] = newDigit[pos];
return;
}
else if ((prm.animMode==0) || ipShowRunning ){
//Animation disabled by user setting, or showing the boot-time IP address: snap instantly.
digit[pos] = newDigit[pos];
memset(animMask,0,sizeof(animMask));
Adelay(ANIMSPEED*20);
return;
}
//Time showing
if (oldDig == newDigit[pos]) oldDig = 10; //if the old digit == new digit, use space instead (avoids a pointless fade from a digit onto an identical digit)
oldDigit[pos] = oldDig; //last displayed digit
//DPRINT(oldDigit[pos]); DPRINT("/"); DPRINTLN(newDigit[pos]);
for (int i = 1; i < 20; i++) {
animMask[pos] = i; //drives the writeDisplay() ISR's PWM wipe ratio (old-share shrinks, new-share grows) for this position, step by step
Adelay(ANIMSPEED);
}
oldDig = newDigit[pos]; //save last displayed digit
digit[pos] = newDigit[pos];
animMask[pos] = 0;
Adelay(5*ANIMSPEED);
if (pos==2) {
//pos==2 is the "hours ones" digit - digit[3]=hour tens, digit[2]=hour ones,
//digit[1]=minute tens, digit[0]=minute ones (set in core .ino's time-digit
//code). The walk in writeDisplay2() goes 3,2,1,0, so after showing the HH
//part (positions 3 and 2) we insert a blank gap here so the HH/MM halves
//of the time are visually separated on the single tube instead of running together.
digit[pos] = 10;
oldDigit[pos] = 10;
oldDig = 10;
Adelay(20*ANIMSPEED);
}
}
//Required common-interface function ("blocking, non-interrupt, one-shot
//write of the display buffer") - but for this single-tube driver the
//actual pixel/segment output is always produced asynchronously by the
//writeDisplay() timer ISR, driven by `pos`. So this implementation does
//NOT push bits to hardware itself; it only redirects `pos` to the single
//physical tube's slot (index 3, reused as the cathode-protect "digit")
//so that the next ISR tick renders whatever doCathodeProtect() just wrote
//into digit[3]/newDigit[3]. Outside cathode-protect it is a no-op, so
//normal time updates still rely entirely on writeDisplay2() below
//(called from the main loop - see changeDigit() in the core .ino, which
//for TUBE1CLOCK short-circuits to just calling this function and returning).
void writeDisplaySingle() {
if (cathodeProtRunning) {
pos = 3;
}
}
//NOT part of the required driver interface (setup_pins/writeDisplay/
//writeDisplaySingle/clearTubes/maxDigits/tubeDriver) - this is an EXTRA
//function that the core .ino calls directly (only for TUBE1CLOCK/VQC10/
//WORDCLOCK, see the `writeDisplay2` stub at the bottom of the core file)
//from the main loop to drive the single tube's sequential "flying digit"
//display, since a single tube can't be updated purely by changeDigit()'s
//normal per-position-array animation.
void writeDisplay2(void){
static unsigned long lastShow = 0;
if (cathodeProtRunning) { //show cathode protect, only one digit
pos = 3;
return;
}
if (ipShowRunning) { //boot-time diagnostic: show the device's IP address, 3 digits at a time, on the single tube
memset(oldDigit,10,sizeof(oldDigit));
newDigit[3] = 10;
digit[3] = 10;
for (int i=2;i>=0;i--) {
pos = i;
makeAnim();
digit[pos] = 10;
Adelay(200);
}
pos = 3;
makeAnim();
pos = 0;
ipShowRunning = false;
memset(digit,10,sizeof(digit));
memset(newDigit,10,sizeof(newDigit));
memset(oldDigit,10,sizeof(oldDigit));
memset(animMask,0,sizeof(animMask));
Adelay(500);
return;
}
if ((millis()-lastShow)<3000) return; //show time - only start a new HH/MM sweep once every 3s; between sweeps the last-shown digit just stays lit via the ISR
for (int i=3;i>=0;i--) {
pos = i;
makeAnim();
if (prm.animMode==0) { //make some pause
digit[pos] = 10;
Adelay((i==2)? 400 : 200); //extra-long pause after the HH half (i==2) to emphasize the HH/MM boundary
}
}
newDigit[4] = 10;
pos = 4; //parks the state machine on the unused 5th slot (always 0xA/blank, see digit[4]=0xA in writeDisplay()) so the tube goes dark between sweeps
makeAnim();
memcpy(digit,newDigit,sizeof(digit));
memcpy(oldDigit,newDigit,sizeof(oldDigit));
memset(animMask,0,sizeof(animMask));
lastShow = millis();
}
//Required common-interface function, but intentionally a no-op here: the core .ino no longer has
//any caller for clearTubes() at all (its only former call site, disableDisplay(), has been removed
//entirely as dead code), so there is nothing to implement. Actual blanking of the single tube is
//handled inside writeDisplay()'s own brightness==0 / !radarON branch instead.
void clearTubes() {}
#endif