Run ESP_UniClock on the NixieIN12v2 clock

Adds CLOCK_90 and the nixie595.ino driver: two 74HC595s on hardware
SPI (GPIO13/14, latch GPIO15), multiplexed from an IRAM-only timer1
ISR. Neon dots on P1; P2 is the decimal-point line on this board.

Also:
- NeoPixel on any ESP8266 pin via bit-bang (GPIO12), and take the pin
  back after SPI.begin() claimed it as MISO
- ArduinoOTA (USE_ARDUINO_OTA), with an OTA upload env in platformio.ini
- DST: EU end date (last Sunday of October) instead of the first Sunday
  of November, and recompute the time from the rules when DST is
  toggled instead of shifting it by 1 h
- forward declarations so PlatformIO's .ino conversion compiles
- FIRMWARE_SERVER can be overridden; empty for CLOCK_90

Verified on the clock: tubes, order, dots, LEDs, LDR, NTP time with DST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-25 21:23:10 +01:00
1 parent b347f1c63f
commit 0d3e878051
7 files changed
+304 -34

No files matched your search

@@ -44,6 +44,11 @@
#include <stddef.h>
//#define byte uint8_t
#include "clocks.h" //DEFINE YOUR CLOCKS SETUP IN THIS FILE!!! - clocks.h picks the single active CLOCK_NN block, which #defines the driver macro, pins and feature flags read throughout this file
// PlatformIO turns the .ino files into one .cpp and puts prototypes for every function near the top,
// without evaluating #ifdefs. Declare the types of optional libraries here so those prototypes compile
// when the feature (and its #include) is switched off.
class AsyncWiFiManager;
struct RgbColor;
// Detect which generation of the ESP8266 Arduino core is in use: older cores (2.6.x/2.7.x) name the
// RAM-resident-ISR attribute ICACHE_RAM_ATTR, while newer cores renamed it to IRAM_ATTR (shared with
@@ -161,7 +166,9 @@ unsigned long intCounter = 0; //for testing only, interrupt counter
byte c_MinBrightness = RGB_MIN_BRIGHTNESS; //minimum LED brightness
byte c_MaxBrightness = RGB_MAX_BRIGHTNESS; //maximum LED brightness
//--------------------------------------------------------------------------------------------------
#define FIRMWARE_SERVER "http://c.landventure.hu/store" //default OTA update server (see doFirmwareUpdate()); overridable per-clock via prm.firmwareServer / the web admin "firmware" field
#ifndef FIRMWARE_SERVER
#define FIRMWARE_SERVER "http://c.landventure.hu/store" //default OTA update server (see doFirmwareUpdate()); overridable per-clock via prm.firmwareServer / the web admin "firmware" field
#endif
String usedPinsStr; //human-readable list of registered GPIOs, built by listPins(), shown on the web admin "clock details" page
String driverSetupStr; //driver-specific extra setup info string (set by some drivers), also shown on the web admin page
@@ -237,6 +244,9 @@ DNSServer dnsServer;
#include <Wire.h>
#include <EEPROM.h>
#include "ArduinoJson.h"
#ifdef USE_ARDUINO_OTA
#include <ArduinoOTA.h>
#endif
#if defined(USE_NEOPIXEL) || defined(WORDCLOCK)
#include <NeoPixelBrightnessBus.h>
@@ -441,8 +451,10 @@ IPAddress ip;
// switchover DATES are not configurable from the web UI. myTZ is rebuilt with the current offsets
// every time updateTimefromTimeserver() runs (WiFi/NTP path only - the GPS-only fallback in gps.ino
// uses a simpler, always-on +1h DST rule, see CODE_REVIEW.md).
// NOTE: upstream ended DST on the first Sunday of November (the US date). The EU switches at 01:00 UTC:
// last Sunday of March 02:00 standard time, last Sunday of October 03:00 summer time.
TimeChangeRule myDST = {"DST", Last, Sun, Mar, 2, 0};
TimeChangeRule mySTD = {"STD", First, Sun, Nov, 2, 0};
TimeChangeRule mySTD = {"STD", Last, Sun, Oct, 3, 0};
Timezone myTZ(myDST, mySTD);
time_t prevTime = 0; //last second() value that was actually rendered to the display - timeProgram() only redraws when now()!=prevTime, i.e. at most once per real second
time_t protectTimer = 0; //timestamp of the last cathode-protect animation, used by timeProgram()'s prm.interval-minute scheduler
@@ -589,6 +601,9 @@ void Fdelay(unsigned long d) {
#ifdef USE_GPS
smartDelay(1); //feed GPS
#endif
#ifdef USE_ARDUINO_OTA
ArduinoOTA.handle(); //also during long animations/startup waits
#endif
yield();
}
}
@@ -877,6 +892,39 @@ void startMDNS() {
#endif
}
// Optional ArduinoOTA (USE_ARDUINO_OTA in clocks.h): push a new firmware from PlatformIO/Arduino IDE
// over WiFi ("pio run -t upload --upload-port <ip>"). Shows the progress in percent on the two right
// tubes. Password from OTA_PASSWORD (secrets.h) if defined.
void startArduinoOTA() {
#ifdef USE_ARDUINO_OTA
static boolean otaStarted = false;
if (otaStarted) return;
ArduinoOTA.setHostname(OTA_HOSTNAME);
#ifdef OTA_PASSWORD
ArduinoOTA.setPassword(OTA_PASSWORD);
#endif
ArduinoOTA.onStart([]() {
DPRINTLN(F("ArduinoOTA: update started"));
clearDigits();
});
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
int p = (int)(progress / (total / 100 + 1));
digit[1] = (p / 10) % 10;
digit[0] = p % 10;
});
ArduinoOTA.onEnd([]() {
DPRINTLN(F("ArduinoOTA: done, rebooting"));
});
ArduinoOTA.onError([](ota_error_t error) {
DPRINTF("ArduinoOTA: error %u\n", error);
clearDigits();
});
ArduinoOTA.begin();
otaStarted = true;
DPRINT(F("ArduinoOTA ready: ")); DPRINTLN(OTA_HOSTNAME);
#endif
}
// The Standalone(AP)-mode path (prm.wifiMode==false, or a fallback from a fully-failed STA connect):
// the clock hosts its own WiFi hotspot at the fixed address 192.168.4.1 (prm.ApSsid/prm.ApPsw) and
// runs a DNS server that answers every query with its own IP (dnsServer.start(53,"*",...)) so most
@@ -1242,6 +1290,9 @@ void handleConfigChanged(AsyncWebServerRequest *request) {
if (old_utc_offset != prm.utc_offset) { //Change time zone
setTime(now()+(prm.utc_offset-old_utc_offset)*3600);
updateRTC();
mySTD.offset = prm.utc_offset * 60; //keep myTZ current for a later enableDST change
myDST.offset = mySTD.offset + (prm.enableDST ? 60 : 0);
myTZ = Timezone(myDST, mySTD);
}
}
else if (key == "set12_24") {
@@ -1262,13 +1313,15 @@ void handleConfigChanged(AsyncWebServerRequest *request) {
// running clock by +-1h right away and re-syncs the RTC, instead of only taking effect on the
// next scheduled NTP refresh (myTZ itself is rebuilt from prm.enableDST inside
// updateTimefromTimeserver() on that next refresh).
// FIXED: this used to shift the clock by a blind +-1h, which is wrong outside the summer period
// (switching DST on in winter put the clock 1h ahead until the next NTP sync). Now the local
// time is converted back to UTC with the old rules and forward with the new ones.
prm.enableDST = (value == "true");
if (oldDST && !prm.enableDST) { //Switching off DST
setTime(now()-3600);
updateRTC();
}
if (!oldDST && prm.enableDST) { //Switching on DST
setTime(now()+3600);
if (oldDST != prm.enableDST) {
time_t utc = myTZ.toUTC(now());
myDST.offset = mySTD.offset + (prm.enableDST ? 60 : 0);
myTZ = Timezone(myDST, mySTD);
setTime(myTZ.toLocal(utc));
updateRTC();
}
}
@@ -1889,10 +1942,11 @@ void setup() {
startMDNS();
startServer();
startArduinoOTA();
#ifdef USE_MQTT
setupMqtt();
#endif
showMyIp();
Fdelay(500);
clearDigits();
@@ -3221,6 +3275,9 @@ void loop(void) {
}
}
editor();
#ifdef USE_ARDUINO_OTA
ArduinoOTA.handle();
#endif
if (makeFirmwareUpdate) {
makeFirmwareUpdate = false;
doFirmwareUpdate();
+34 -1
View File
@@ -52,6 +52,7 @@
ZM1500_ESP32 (CURRENTLY ACTIVE, CLOCK_72 below) -> ZM1500.ino
WORDCLOCK -> wordclock.ino (+ font5x7.h for its scrolling/overlay font)
DUMMY -> dummy.ino (sensor-only "clock", no physical display)
NIXIE595_MULTIPLEX (CLOCK_90) -> nixie595.ino
*/
//#define CLOCK_1 //8266, UNFI PCB clock, 4x IN-16 tubes
@@ -110,11 +111,43 @@
//#define CLOCK_57A //V2-WROOM ESP32, UNFI 2xHV5222 6xZ57xM clock /flat panel
//#define CLOCK_70 //1 tube esp8266 Nixie Clock by UNFI
//#define CLOCK_71 //2x VQC10 clock by UNFI
#define CLOCK_72 //ESP32, UNFI board, ZM1500
//#define CLOCK_72 //ESP32, UNFI board, ZM1500
//#define CLOCK_80 //word clock, english version
//#define CLOCK_81 //word clock, german version
//#define CLOCK_82 //word clock, hungarian version
//#define CLOCK_99 //Dummy clock, sensor box with MQTT connector
#define CLOCK_90 //8266, RetroModding NixieIN12v2 HV2.0 (5 V): 4x IN-12, 2x IN-3, 2x 74HC595 multiplex
#ifdef CLOCK_90 //8266, RetroModding NixieIN12v2 HV2.0 (5 V): 4x IN-12, 2x IN-3, 2x 74HC595 multiplex
// Pins and shift-register bit map confirmed from the stock firmware SV1.3
// (docs/stock-firmware-pins.md in the nixie-clock repo).
#define DEBUG
#define FW "fw90" //firmware name
#define MAXBRIGHTNESS 100
#define NIXIE595_MULTIPLEX
#define PIN_LATCH 15 //74HC595 storage clock. DATA = GPIO13 (HSPI MOSI), CLOCK = GPIO14 (HSPI SCK)
#define SPI_FREQ 4000000 //stock uses 25 MHz; 16 bits per slot need far less
byte anodeBits[] = {14, 13, 12, 11}; //595 word bit of each tube's anode: position 0 = rightmost (A4) ... 3 = leftmost (A1)
#define NEON_BITS (1 << 10) //IN-3 dots on P1. P2 (bit 15) drives the IN-12B decimal points on this board (JP2 closed)
#define NEON_POS 1 //the dots are lit in this tube's multiplex slot, as on stock
#define USE_NEOPIXEL
#define NEOPIXEL_PIN 12 //bit-banged, GRB 800 kHz
byte tubePixels[] = {0, 0, 1, 1, 2, 2, 3, 3}; //8 WS2812, two per tube (strip order not verified yet)
#define USE_RTC //DS3231 on I2C, detected at startup (address 0x68); the stock firmware never used it
#define PIN_SDA 4
#define PIN_SCL 5
#define LIGHT_SENSOR_PIN A0 //LDR to Vcc, 10k to GND (REF_RESISTANCE default)
#define MAXIMUM_LUX 100 //Lux level for maximum tube brightness
#define AP_NAME "UNICLOCK"
#define AP_PASSWORD ""
#define WEBNAME "IN-12 Nixie Clock"
#define FIRMWARE_SERVER "" //no web-triggered HTTP update; use ArduinoOTA
#define USE_ARDUINO_OTA
#define OTA_HOSTNAME "nixie-in12"
#if __has_include("secrets.h")
#include "secrets.h" //optional, gitignored: DEFAULT_SSID, DEFAULT_PSW, OTA_PASSWORD (see secrets.h.example)
#endif
#endif
//______________________ESP8266 CLOCKS by UNFI and GP ______________________________________________________
#ifdef CLOCK_1 //8266, UNFI PCB clock, 4x IN-16 tubes
@@ -0,0 +1,154 @@
// ============================================================================
// Nixie driver: two cascaded 74HC595s on hardware SPI, multiplexed anodes
// ----------------------------------------------------------------------------
// Hardware: RetroModding NixieIN12v2 (CLOCK_90). The two 595s hold one 16-bit
// word that drives the whole display:
// bits 0..9 cathodes for digits 0..9 (via 2x ULN2003, shared by all tubes)
// bits 11..14 tube anodes (anodeBits[] in clocks.h), one tube at a time
// bits 10, 15 neon dots P1/P2 (NEON_BITS in clocks.h)
// The high byte is shifted out first (ends up in the second 595), MSB first.
// DATA and CLOCK are the HSPI MOSI/SCK pins (GPIO13/GPIO14), PIN_LATCH is the
// 595 storage clock. Pinout and bit map come from the stock firmware, see
// docs/stock-firmware-pins.md in the nixie-clock repo.
//
// The multiplex ISR writes the SPI1 and GPIO registers directly instead of
// calling the SPI library, so it runs entirely from IRAM and keeps refreshing
// while the flash cache is off (EEPROM/SPIFFS writes, OTA).
//
// Platform: ESP8266 ONLY (timer1 + SPI1 registers). The ISR state machine
// (brightness by blanking time, animMask wipe) follows multiplex74141.ino.
// ============================================================================
#ifdef NIXIE595_MULTIPLEX
#include <SPI.h>
char tubeDriver[] = "NIXIE595_MULTIPLEX";
#if !defined(ESP8266)
#error "NIXIE595_MULTIPLEX is for ESP8266 only!"
#endif
int maxDigits = sizeof(anodeBits); //anodeBits is byte[], so sizeof == element count
int maxDig = maxDigits;
// timer1 runs at TIM_DIV16 = 5 MHz (0.2 us per tick): 11000 ticks = 2.2 ms per tube slot,
// about 110 Hz for 4 tubes. PWM_min/PWM_max are the lit part of a slot (brightness).
// const, so the ISR's static locals are constant-initialized (no flash-resident init guard).
const int PWMrefresh = 11000;
const int PWM_min = 1000;
const int PWM_max = 10000;
// Shift one 16-bit word into both 595s and latch it. Also called from main code via clearTubes(),
// so the caller must keep the ISR out (see clearTubes()).
static inline void IRAM_ATTR write595(uint16_t w) {
while (SPI1CMD & SPIBUSY) {}
SPI1W0 = (w >> 8) | ((w & 0xFF) << 8); //SPI1W0 byte 0 goes out first: high byte first
SPI1CMD |= SPIBUSY;
while (SPI1CMD & SPIBUSY) {}
GPOS = 1 << PIN_LATCH; //rising edge copies the shift registers to the outputs
__asm__ __volatile__("nop; nop; nop; nop;");
GPOC = 1 << PIN_LATCH;
}
void setup_pins() {
DPRINTLN("Nixie clock - setup pins - 74HC595 multiplex mode...");
pinMode(PIN_LATCH, OUTPUT);
digitalWrite(PIN_LATCH, LOW);
regPin(PIN_LATCH, "PIN_LATCH");
SPI.begin(); //HSPI: GPIO14 = SCK, GPIO13 = MOSI. It also claims GPIO12 (MISO), which the NeoPixel setup takes back later.
SPI.setFrequency(SPI_FREQ);
SPI.setBitOrder(MSBFIRST);
SPI.setDataMode(SPI_MODE0);
regPin(13, "PIN_DATA (HSPI MOSI)");
regPin(14, "PIN_CLK (HSPI SCK)");
//16-bit transfers, so one write fills both 595s
SPI1U1 = (SPI1U1 & ~((SPIMMOSI << SPILMOSI) | (SPIMMISO << SPILMISO)))
| (15 << SPILMOSI) | (15 << SPILMISO);
write595(0); //all anodes, cathodes and dots off
DPRINT("Tubes: "); DPRINTLN(maxDigits);
startTimer();
}
void IRAM_ATTR writeDisplay() { //timer1 ISR, single-shot: must re-arm timer1 at the end of every call
static byte pos = 0;
static byte state = 0;
static int timer = PWMrefresh;
static byte num;
static int brightness;
static int PWMtimeBrightness = PWM_min;
uint16_t w;
if (EEPROMsaving) { //keep the tubes dark while settings are written
write595(0);
timer1_write(PWMrefresh);
return;
}
intCounter++;
brightness = displayON ? prm.dayBright : prm.nightBright;
if (brightness > MAXBRIGHTNESS) brightness = MAXBRIGHTNESS; //only for safety
timer = PWMrefresh;
// Same state machine as multiplex74141.ino:
// state 0: next tube; show the old digit (animation) or the current digit
// state 1: show the new digit for the rest of the lit time (only while animating)
// state 2: blank for the rest of the slot (this sets the brightness)
// state 3: blank output, then back to state 0
switch (state) {
case 0:
pos++;
if (pos > maxDig - 1) {
pos = 0;
if (autoBrightness && displayON)
PWMtimeBrightness = max(PWM_min, PWM_max * lx / MAXIMUM_LUX);
else
PWMtimeBrightness = max(PWM_min, PWM_max * brightness / MAXBRIGHTNESS);
}
if (animMask[pos] > 0) {
num = oldDigit[pos];
timer = (PWMtimeBrightness * (20 - animMask[pos])) / 20;
state = 1;
}
else {
num = digit[pos];
timer = PWMtimeBrightness;
state = 2;
}
break;
case 1:
num = newDigit[pos];
timer = (PWMtimeBrightness * animMask[pos]) / 20;
state = 2;
break;
case 2:
timer = PWMrefresh - PWMtimeBrightness;
state = 3;
break;
}
if (timer < 500) timer = 500; //a too short timer1 reload can miss the interrupt
if ((brightness == 0) || (state == 3) || (!radarON)) {
state = 0;
w = 0;
}
else {
w = 0;
if (num <= 9) w |= 1 << num; //cathode; 10 = blank, other glyphs are not available on a Nixie
if ((pos == NEON_POS) && colonBlinkState) w |= NEON_BITS;
if (w) w |= 1 << anodeBits[pos];
}
write595(w);
timer1_write(timer);
}
void clearTubes() {
noInterrupts(); //the ISR also writes the 595s
write595(0);
interrupts();
}
// Not needed: the ISR refreshes the display continuously (same as multiplex74141.ino).
void writeDisplaySingle() {}
#endif
@@ -0,0 +1,6 @@
// Copy to secrets.h (gitignored) and fill in. All entries are optional.
// DEFAULT_SSID/DEFAULT_PSW are only applied on a factory reset (first boot with a new EEPROM layout);
// otherwise set the WiFi from the clock's web page.
#define DEFAULT_SSID ""
#define DEFAULT_PSW ""
#define OTA_PASSWORD "" //ArduinoOTA password; remove the line for no password
@@ -100,13 +100,17 @@ inline byte tubeForPixel(int i) {
//than NeoEsp32Rmt7Ws2812xMethod, which can glitch the strict WS2812 bit
//timing (~1.25us/bit) if an ISR delays the RMT hardware mid-frame.
NeoPixelBrightnessBus<NeoGrbFeature, NeoEsp32I2s1800KbpsMethod> strip(PixelCount+4,PixelPin); //NeoEsp32I2s1800KbpsMethod is used here because it is more reliable than NeoEsp32Rmt7Ws2812xMethod!
#else
#define NEOPIXEL_PIN 3
#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.
NeoPixelBrightnessBus<NeoGrbFeature, Neo800KbpsMethod> strip(PixelCount+4);
#else
//Any other 8266 pin: cycle-counted bit-bang. Interrupts are off while Show()
//runs (about 30 us per pixel), which briefly delays the multiplex ISR.
byte PixelPin = NEOPIXEL_PIN;
NeoPixelBrightnessBus<NeoGrbFeature, NeoEsp8266BitBang800KbpsMethod> strip(PixelCount+4, PixelPin);
#endif
//Gamma-correction lookup table object - NOTE: currently declared but never
@@ -121,7 +125,10 @@ void setupNeopixel() {
DPRINT("Pixel count: "); DPRINTLN(PixelCount);
DPRINT("Brightness:"); DPRINT(c_MinBrightness); DPRINT(" - "); DPRINTLN(c_MaxBrightness);
neoBrightness = prm.rgbBrightness;
strip.Begin(); //initialize the underlying I2S/RMT/bit-bang driver and GPIO
//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
strip.Show(); //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