2026-10-25 20:48:04 +01:00
/*
Universal Clock v3.xx (Nixie, VFD, LED, Numitron) for ESP8266 or ESP32
with optional Dallas Thermometer and DS3231 RTC, Neopixel strip, GPS and more...
03/04/2022
Copyright (C) 2020 Peter Gautier
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License <http://www.gnu.org/licenses/> for more details.
Always check the usable pins of 8266: https://randomnerdtutorials.com/esp8266-pinout-reference-gpios/
and usable pins for ESP32: https://randomnerdtutorials.com/esp32-pinout-reference-gpios/
//https://lastminuteengineers.com/esp32-arduino-ide-tutorial/
Web page files, which are found in the "dat" subfolder, must be uploaded to SPIFFS !
ESP8266: https://randomnerdtutorials.com/install-esp8266-filesystem-uploader-arduino-ide/
ESP32: https://randomnerdtutorials.com/install-esp32-filesystem-uploader-arduino-ide/
*/
//====================================================================================================
// ESP32_UniClock2.ino - CORE file of the project. This is the ONE file shared by all ~65 physical
// clock builds (see clocks.h) - it contains everything that is NOT specific to how a digit gets
// written to the hardware: setup()/loop(), WiFi connection management + web admin server (REST-ish
// /configChanged API + JSON status endpoints), EEPROM-persisted settings (`prm` struct), time-source
// selection (NTP/GPS/RTC priority chain in calcTime()), the display/animation pipeline
// (displayTimeN()->changeDigit()->writeDisplaySingle()), alarm buzzer, day/night brightness,
// radar-based auto shutoff, light-sensor auto-dim, and cathode/element "protect" (anti burn-in) test
// patterns. The Arduino build concatenates every .ino file in this sketch folder into one compilation
// unit, and exactly one of the ~25 driver .ino files (e.g. MAX6921.ino, ZM1500.ino, wordclock.ino)
// actually compiles in per build, selected by whichever driver macro the active CLOCK_NN block in
// clocks.h defines - this core file calls that driver only through a small set of `extern` functions
// (setup_pins/writeDisplay/writeDisplaySingle/clearTubes) and shared global buffers (digit[]/newDigit[]
// /oldDigit[]/digitDP[]/animMask[]), so it never needs to know which physical driver is active.
// See docs/DEVELOPER_GUIDE.md for the full architecture writeup (this file is section 4) and
// docs/CODE_REVIEW.md for the list of known bugs/dead-code spots (also marked in-place below with
// "// POSSIBLE BUG:" / "// NOTE:").
//====================================================================================================
#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
2026-10-25 21:23:10 +01:00
// 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 ;
2026-10-25 20:48:04 +01:00
// 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
// ESP32) - the #define below normalizes ICACHE_RAM_ATTR to mean IRAM_ATTR on newer cores so driver
// .ino files can use either name interchangeably regardless of installed core version.
#if defined(ESP8266)
#if defined(ARDUINO_ESP8266_RELEASE_2_6_0) || \
defined(ARDUINO_ESP8266_RELEASE_2_6_1) || \
defined(ARDUINO_ESP8266_RELEASE_2_6_2) || \
defined(ARDUINO_ESP8266_RELEASE_2_6_3) || \
defined(ARDUINO_ESP8266_RELEASE_2_7_0) || \
defined(ARDUINO_ESP8266_RELEASE_2_7_1) || \
defined(ARDUINO_ESP8266_RELEASE_2_7_2) || \
defined(ARDUINO_ESP8266_RELEASE_2_7_3) || \
defined(ARDUINO_ESP8266_RELEASE_2_7_4)
#define ESP8266_CORE_2xx
#pragma message "8266_Core 2.xx"
#else
#pragma message "8266_Core_3.xx"
#define ICACHE_RAM_ATTR IRAM_ATTR
#endif
#endif
/*_______________________________ USABLE PARAMETERS ____________________________________________
//#define DEBUG //Enable Serial Monitor, 115200baud (only, if TX pin is not used anywhere!!!)
//---------------------------- CLOCK EXTRA OPTION PARAMETERS ---------------------------------
//#define USE_DALLAS_TEMP //TEMP_DALLAS_PIN is used to connect DS18B20 temperature sensors
//#define USE_DHT_TEMP //TEMP_DHT_PIN is the DHT sensor's data pin
//#define USE_BME280 //I2C Temperature + humidity + pressure, SDA+SCL I2C pins are used!
//#define USE_BMP280 //I2C Temperature + barometric pressure, SDA+SCL I2C pins are used!
//#define USE_AHTX0 //I2C Temperature + humidity, SDA+SCL I2C pins are used!
//#define USE_SHT21 //I2C Temperature + humidity, SDA+SCL I2C pins are used!
//#define USE_BH1750 //I2C Luxmeter sensor, SDA+SCL I2C pins are used!
//#define USE_RTC //DS3231 realtime clock, SDA+SCL I2C pins are used!
//#define USE_GPS //use for standalone clock, without wifi internet access
//#define USE_NEOPIXEL //WS2812B led stripe, for tubes backlight. Don't forget to define tubePixels[] !
//#define USE_PWMLEDS //WWM led driver on 3 pins for RG
//#define USE_MQTT //Home Assistant integration: https://www.home-assistant.io/
//#define USE_WIFIMANAGER //if no WiFi defined, use Wifimanager
//----- DRIVER SELECTION ------ Use only 1 driver from the following options in the clocks.h file!
//#define MULTIPLEX74141_ESP32 //4..8 Nixie tubes generic driver for ESP32
//#define MAX6921_ESP32 //4..8 VFD tubes (IV18) driver for ESP8232
//#define HV5122 //4..8 Nixie driver - development in progress
//-------------- 8266 clock drivers --------------------------------------------------
//#define MULTIPLEX74141 //4..8 Nixie tubes generic driver for ESP8266
//#define MAX6921 //4..8 VFD tubes (IV18) driver for ESP8266
//#define NO_MULTIPLEX74141 //4..6 Nixie tubes, serial latch driver, 74141 for each tube
//#define MM5450 //6..8 LEDS
//#define MAX7219CNG //4..8 LED
//#define Numitron_4511N //Numitron 4x tube clock
//#define SN75512 //4..8 VFD tubes
//#define samsung //samsung serial display
//#define PCF_74141 //PCF pin expander for tube selection
//#define PT6355 //VFD clock - development in progress
//--------------------- PINOUT & PIN PARAMETERS ---------------------------------------
//#define PIN_SDA xx // you can set the used SDA and SCL pins
//#define PIN_SCL xx // if it is not default value
//#define COLON_PIN -1 //Blinking Colon pin. If not used, SET TO -1
//#define TEMP_DALLAS_PIN -1 //Dallas DS18B20 temp sensor pin. If not used, SET TO -1
//#define TEMP_DHT_PIN -1 //DHT temp sensor pin. If not used, SET TO -1
//#define DHTTYPE DHT22 //DHT sensor type, if used...
//#define LED_SWITCH_PIN -1 //external led backlight ON/OFF. If not used, SET TO -1
//#define DECIMALPOINT_PIN -1 //Nixie decimal point between digits. If not used, SET TO -1
//#define ALARMSPEAKER_PIN -1 //Alarm buzzer pin
//#define ALARMBUTTON_PIN -1 //Alarm switch off button pin
//#define ALARM_ON HIGH //HIGH or LOW level is needed to switch ON the buzzer?
//#define NEOPIXEL_PIN 3 //8266 Neopixel LEDstripe pin is always the RX pin!!!
//#define RGB_MIN_BRIGHTNESS 8 //Neopixel leds minimum brightness
//#define RGB_MAX_BRIGHTNESS 255 //Neopixel leds maximum brightness
//#define RADAR_PIN -1 //Radar sensor pin
//#define RADAR_TIMEOUT 5 //Automatic switch off tubes (without radar detecting somebody) after xxx min
//#define TUBE_POWER_PIN -1 //Filament or HV switch ON/OFF pin
//#define TUBE_POWER_ON HIGH //HIGH or LOW level is needed to switch ON the TUBE POWER?
//#define LIGHT_SENSOR_PIN -1 //Environment light sensor, only ADC pins are usable! ESP32 for example: 34,35,36,39... 8266: only A0
//#define REF_RESISTANCE 10000.0 // Resistor value is 10k, between LDR sensor and GND
//#define MAXIMUM_LUX 100 //Lux level for maximum tube brightness
//#define LUX_CALC_SCALAR 12518931 * 1.2 //Calibrate here the LDR sensor
//#define LUX_CALC_EXPONENT -1.405 //LDR sensor characteristic
//#define PWM1_PIN -1 //red channel if USE_PWMLEDS is defined
//#define PWM2_PIN -1 //green channel if USE_PWMLEDS is defined
//#define PWM3_PIN -1 //blue channel if USE_PWMLEDS is defined
//#define MAXBRIGHTNESS 10 //Do not change this value!
//Display temperature and date in every minute between START..END seconds
//#define ENABLE_CLOCK_DISPLAY true //false, if no clock display is needed (for example: thermometer + humidity only)
//#define SHIFT_TUBES_LEFT_BY_1 //shift the display left by 1 tube, if a thermometer is used with a special tube
//#define LEFTDECIMAL false //set true (Z574M), if decimal point is on the left side on the tube. Else set false (Z573M)!
//#define DATE_REPEAT_MIN 1 //show date only every xxx minute. If zero, the date is never displayed!
//#define DOUBLE_BLINK //both separator points are blinking (6 or 8 tubes VFD clock)
//#define TEMP_START 35 //Temperature display start..end
//#define TEMP_END 40
//#define HUMID_START 40 //Humidity% display start..end
//#define HUMID_END 45
//#define DATE_START 45 //Date is displayed start..end
//#define DATE_END 50
//#define ANIMSPEED 50 //Animation speed in millisec
//#define TEMP_CHARCODE 15 //Thermometer "C", set to -1 to disable
//#define GRAD_CHARCODE 16 //Thermometer grad, set to -1 to disable
//#define PERCENT_CHARCODE 17 //Humidity %
//#define AP_NAME "UNICLOCK" //Access Point name
//#define AP_PASSWORD "" //AP password
//#define WEBNAME "LED UniClock" //Clock's name on the web page
//#define DEFAULT_SSID "" //factoryReset default value for WiFi
//#define DEFAULT_PSW "" //factoryReset default value for WiFi password
//#define FACTORYRESET_PIN -1 //if defined, do a factory reset if the button is pushed
*/
//#define USE_MDNS
#define TIMESERVER_REFRESH 7200000 //2h, Refresh time in millisec 86400000 = 24h
unsigned long timeserverErrors = 0 ; //timeserver refresh errors
int timerON = 0 ; //for debugging
int timerOFF = 0 ;
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
//--------------------------------------------------------------------------------------------------
2026-10-25 21:23:10 +01:00
#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
2026-10-25 20:48:04 +01:00
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
// Platform-specific setup: WiFi/HTTP/filesystem headers, board defaults (web page title / AP name),
// DRAM_ATTR (only meaningful on ESP32, defined empty here so ESP8266 code using it still compiles),
// and the ASCII-art `ESPpinout[]` string used by regPin() below to sanity-check GPIO usage per pin
// (each character is one GPIO: 'O'=usable output/IO pin, 'I'=input-only pin, ' '=reserved/unusable).
#if defined(ESP8266)
#define DRAM_ATTR //https://docs.espressif.com/projects/esp-idf/en/v4.3-beta2/esp32/api-reference/storage/spi_flash_concurrency.html
#ifndef WEBNAME
#define WEBNAME "ESP32_UniClock 3.1"
#endif
#ifndef AP_NAME
#define AP_NAME "UNICLOCK"
#endif
#ifndef AP_PASSWORD
#define AP_PASSWORD ""
#endif
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClient.h>
#include "ESPAsyncTCP.h"
#include "FS.h"
#include <ESP8266httpUpdate.h>
#ifdef USE_MDNS
#include <ESP8266mDNS.h>
#endif
const char ESPpinout [] = { "OOOOOO OOOOOI" }; //GPIO 0..5, 12..16, A0) usable pins
#elif defined(ESP32)
#ifndef WEBNAME
#define WEBNAME "ESP32UniClock 3.0"
#endif
#ifndef AP_NAME
#define AP_NAME "UNICLOCK32"
#endif
#ifndef AP_PASSWORD
#define AP_PASSWORD ""
#endif
#include <WiFi.h>
#include <esp_wifi.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <ESP32httpUpdate.h>
#include "AsyncTCP.h"
#include "SPIFFS.h"
#include "FS.h"
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#ifdef USE_MDNS
#include <ESPmDNS.h>
#endif
hw_timer_t * ESP32timer = NULL ; //hardware timer handle, started by startTimer() below; drives the ISR-based multiplex drivers' writeDisplay()
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED ; //spinlock used by drivers to guard shared state accessed from both the ISR and the main loop
#define PRESCALER 15 //multiplex timer prescaler, about 80Hz for 6 tubes
const char ESPpinout [] = { "OOOOOO OOOOOOOO OOO OOO OOIII I" }; //GPIO 0..5, 12..19, 21..23, 25..27, 32..33, Input:34..36, 39) usable pins
#else
#error "Board is not supported!"
#endif
#include <DNSServer.h>
DNSServer dnsServer ;
#include "ESPAsyncWebServer.h"
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <TimeLib.h>
#include <Timezone.h>
#include <Wire.h>
#include <EEPROM.h>
#include "ArduinoJson.h"
2026-10-25 21:23:10 +01:00
#ifdef USE_ARDUINO_OTA
#include <ArduinoOTA.h>
#endif
2026-10-25 21:37:25 +01:00
#ifdef USE_MQTT
#include <ArduinoHA.h> //here and not only in mqtt.ino: PlatformIO's generated prototypes need its types early
#endif
2026-10-25 20:48:04 +01:00
#if defined(USE_NEOPIXEL) || defined(WORDCLOCK)
#include <NeoPixelBrightnessBus.h>
#endif
// Driver-interface contract: every driver .ino file must implement these regardless of which chip it
// targets (see docs/DEVELOPER_GUIDE.md section 3) - this is the entire surface the core code needs to
// drive an arbitrary physical display. writeDisplay() is the ISR-based multiplex refresh (a no-op for
// "smart" chip drivers that scan internally, e.g. PT6311/PT6355/VQC10); writeDisplaySingle() is a
// blocking, non-ISR, one-shot render of the current digit[]/digitDP[] buffers.
extern void ICACHE_RAM_ATTR writeDisplay ();
extern void writeDisplaySingle ();
extern void setup_pins ();
extern void clearTubes ();
extern int maxDigits ; //number of physical digit positions (tubes) on this clock build
extern char tubeDriver []; //human-readable driver name string, shown on the web admin page
char webName [] = WEBNAME ;
AsyncWebServer server ( 80 );
#define CACHE_MAX_AGE "max-age=31536000" //maximum is: 31536000
#ifdef USE_WIFIMANAGER
#include <ESPAsyncWiFiManager.h>
AsyncWiFiManager myWiFiManager ( & server , & dnsServer );
#endif
//______________ WiFi finding variables ____________________________________________
byte bestChn ; //the recent found best wifi AP's channel No.
uint8_t bestBssid [ 6 ]; //the recent found best wifi AP's BSSID (mac address)
uint8_t oldBssid [ 6 ]; //the current wifi AP's BSSID (mac address)
int bestRSSI = - 200 ; //the recent found best wifi AP's RSSI value
unsigned long lastWifiScan = 0 ; //last wifi scan timestamp in CPU msec millis()
boolean wifiOK = false ;
char myIp [ 20 ] = "" ;
//Display buffers - shared with every driver .ino file, indexed 0..maxDigits-1 (0 = rightmost/seconds digit).
//Value encoding (see docs/DEVELOPER_GUIDE.md section 3): 0-9 = digit to show, 10 = BLANK, 11 = dash/minus,
//12 = standalone decimal-point glyph (a few drivers only), 13+ = driver-specific extra characters
//(letters, degree sign, %, circles, etc. - each driver's own charDefinition[]/charTable[] maps these).
#define BUFSIZE 12
byte DRAM_ATTR digit [ BUFSIZE ]; //what is CURRENTLY on the physical display (what writeDisplay()/writeDisplaySingle() actually render)
byte DRAM_ATTR newDigit [ BUFSIZE ]; //the target value for the next display update, filled in by displayTime4/6/8()/displayDate()/displayTemp()/displayHumid()
byte DRAM_ATTR oldDigit [ BUFSIZE ]; //the value that was displayed before the current changeDigit() animation started - used to detect per-tube changes and drive the various animMode animations
boolean DRAM_ATTR digitDP [ BUFSIZE ]; //actual value to put to display
boolean digitsOnly = true ; //only 0..9 numbers are possible to display?
byte DRAM_ATTR animMask [ BUFSIZE ]; //0 = no animation mask is used; used by the "wipe" animation (animMode==5, see changeDigit()) - a driver-specific progress value per tube, drivers read this to fade/wipe segments in or out
volatile boolean EEPROMsaving = false ; //saving in progress - stop display refresh
// EEPROM/prm struct format version. Bump this whenever the `prm` struct below changes shape (fields
// added/removed/reordered/resized) - loadEEPROM() compares the stored value against this constant in
// setup(), and a mismatch triggers an automatic factoryReset() (old EEPROM bytes would otherwise be
// misinterpreted as the new struct layout, since EEPROM.get()/put() do a raw memcpy with no versioning
// of their own).
#define MAGIC_VALUE 304 //EEPROM version
//#define THERMOMETER_CLOCK //it means, first digit is +- sign, last digit is C/F, 4 digit is for numbers
#ifdef THERMOMETER_CLOCK //it means 6 tubes: first digit is +- sign, last digit is C/F, and only 4 digit are for numbers
boolean thermometerClock = true ;
#else
boolean thermometerClock = false ;
#endif
// 8266 internal pin registers
// https://github.com/esp8266/esp8266-wiki/wiki/gpio-registers
// example: https://github.com/mgo-tec/OLED_1351/blob/master/src/OLED_SSD1351.cpp
// Debug print macros: compile to real Serial.print calls when DEBUG is defined in clocks.h for the
// active CLOCK_NN, otherwise compile to nothing (zero code/RAM cost) - used everywhere in this file
// and in every driver .ino instead of calling Serial.* directly, so a release build can drop all
// debug logging just by commenting out one #define.
#ifdef DEBUG //Macros are usually in all capital letters.
#define DPRINT(...) Serial.print(__VA_ARGS__) //DPRINT is a macro, debug print
#define DPRINTLN(...) Serial.println(__VA_ARGS__) //DPRINT is a macro, debug print
#define DPRINTF(...) Serial.printf(__VA_ARGS__) //DPRINT is a macro, debug print
#define DPRINTBEGIN(...) Serial.begin(__VA_ARGS__) //DPRINTLN is a macro, debug print with new line
#else
#define DPRINT(...) //now defines a blank line
#define DPRINTLN(...) //now defines a blank line
#define DPRINTF(...) //now defines a blank line
#define DPRINTBEGIN(...) //now defines a blank line
#endif
#define PIN_OUT_SET PERIPHS_GPIO_BASEADDR + 4
#define PIN_OUT_CLEAR PERIPHS_GPIO_BASEADDR + 8
bool colonBlinkState = false ;
boolean radarON = true ;
boolean mqttRadarON = true ;
2026-10-25 21:37:25 +01:00
volatile boolean tubesOn = true ; //Tubes switch from Home Assistant (runtime only, not saved); honoured by nixie595.ino
2026-10-25 20:48:04 +01:00
unsigned long radarLastOn = 0 ;
boolean makeFirmwareUpdate = false ;
boolean makeCathodeProtect = false ;
int cathProtMin = 5 ;
unsigned long lastTimeUpdate = 0 ; //last time refresh from GPS or internet timeserver
boolean RTCexist = false ;
boolean GPSexist = false ;
boolean BME280exist = false ;
boolean BMP280exist = false ;
boolean AHTX0exist = false ;
boolean SHT21exist = false ;
boolean BH1750exist = false ;
boolean LDRexist = false ;
byte useDallasTemp = 0 ; //number of Dallas temperature sensors: 0,1,2
byte useTemp = 0 ; //Total number of any temperature sensors: 0..6
byte usePress = 0 ; //Total number of pressure sensors
byte useHumid = 0 ; //Total number of humidity sensors
byte useLux = 0 ;
float temperature [ 6 ] = { 0 , 0 , 0 , 0 , 0 , 0 };
float humid [ 6 ] = { 0 , 0 , 0 , 0 , 0 , 0 };
float pressur [ 6 ] = { 0 , 0 , 0 , 0 , 0 , 0 };
int lx = 0 ; //Environment LUX value, set by Light Sensor
boolean autoBrightness = false ; //Enable automatic brightness levels
//----------------- EEPROM addresses -------------------------------------------------------------------
const int EEPROM_addr = 0 ;
// `prm` - the ONE struct holding every user-configurable, persistent setting (alarm, RGB effect, WiFi
// credentials, MQTT broker, brightness/day-night schedule, date/temp/humidity display windows,
// calibration offsets, etc). Saved/loaded as a single raw block via EEPROM.put()/EEPROM.get()
// (saveEEPROM()/loadEEPROM() below) - NOT field-by-field, so struct layout changes require bumping
// MAGIC_VALUE (see above) or old EEPROM contents get reinterpreted as garbage. The web admin's
// POST /configChanged (handleConfigChanged()) is the single entry point that mutates these fields at
// runtime; handleSendConfig() serializes (most of) this struct back out to the client as JSON.
// In-struct default values (e.g. "= 7") are only the compiled-in fallback used until the first
// factoryReset()/loadEEPROM() actually runs - factoryReset() re-assigns most fields explicitly.
struct {
//Main screen settings _____________________________________________________________________________________
boolean alarmEnable = false ; //Yes or No
byte alarmHour = 7 ; //Alarm time
byte alarmMin = 0 ;
byte alarmPeriod = 15 ; //Alarm length, sec
//RGB settings ____________________________________________________________________________________________
byte rgbEffect = 1 ; //0=OFF, 1=FixColor
byte rgbBrightness = 100 ; // 0..255
unsigned int rgbFixColor = 150 ; //0..255
byte rgbSpeed = 50 ; //0..255msec / step
boolean rgbDir = false ; //false = right, true = left
//WiFi / IP settings _______________________________________________________________________________________
boolean wifiMode = true ;
char wifiSsid [ 20 ];
char wifiPsw [ 20 ];
char ApSsid [ 20 ];
char ApPsw [ 20 ];
char NtpServer [ 30 ];
char mqttBrokerAddr [ 30 ];
char mqttBrokerUser [ 20 ] = "mqtt" ;
char mqttBrokerPsw [ 20 ] = "mqtt" ;
int mqttBrokerRefresh = 10 ; //sec
boolean mqttEnable = false ;
char firmwareServer [ 78 ];
//Tube settings ______________________________________________________________________________________
int tempRepeatMin = 1 ; //temperature and humidity display repeat (min)
int utc_offset = 1 ; //time zone offset
bool enableDST = true ; // Flag to enable DST (summer time...)
bool set12_24 = true ; // Flag indicating 12 vs 24 hour time (false = 12, true = 24);
bool showZero = true ; // Flag to indicate whether to show zero in the hour ten's place
bool enableBlink = true ; // Flag to indicate whether center colon should blink
int interval = 15 ; // prm.interval in minutes, with 0 = off
bool enableAutoShutoff = true ; // Flag to enable/disable nighttime shut off
byte dayHour = 8 ; // start of daytime
byte dayMin = 0 ;
byte nightHour = 22 ; // start of night time
byte nightMin = 0 ;
byte dayBright = MAXBRIGHTNESS ; // display daytime brightness
byte nightBright = 5 ; // display night brightness
byte animMode = 2 ; //0=no anim, if 1 or 2 is used, animation, when a digit changes
byte dateMode = 2 ; // 0:dd/mm/yyyy 1:mm/dd/yyyy 2:yyyy/mm/dd
boolean tempCF = false ; //Temperature Celsius=false / Fahrenheit=true
boolean enableTimeDisplay ; //ENABLE_CLOCK_DISPLAY
byte dateStart ; //Date is displayed start..end
byte dateEnd ;
byte tempStart ; //Temperature display start..end
byte tempEnd ;
byte humidStart ; //Humidity% display start..end
byte humidEnd ;
byte dateRepeatMin ; //show date only every xxx minute. If zero, the date is never displayed!
boolean enableDoubleBlink ; //both separator points are blinking (6 or 8 tubes VFD clock)
boolean enableAutoDim = false ; //Automatic dimming by luxmeter
boolean enableRadar = false ; //Radar sensor
int radarTimeout = 5 ; //min
float corrT0 = 0 ;
float corrT1 = 0 ;
float corrH0 = 0 ;
float corrH1 = 0 ;
//____________________________________________________________________________________________________
int magic = MAGIC_VALUE ; //magic value, to check EEPROM version when starting the clock - see loadEEPROM()/factoryReset() and the MAGIC_VALUE comment above
} prm ;
#define EEPROM_SIZE sizeof(prm) //EEPROM.begin() is sized exactly to the struct - no extra reserved space for future growth, so any struct change is effectively a breaking EEPROM-layout change (mitigated only by the magic-number factory-reset above)
//-------------------------------------------------------------------------------------------------------
WiFiUDP ntpUDP ;
NTPClient timeClient ( ntpUDP , prm . NtpServer , 0 , TIMESERVER_REFRESH ); // Refresh time in millisec
IPAddress ip ;
// Set timezone rules. Offsets set to zero, since they will be loaded from EEPROM
// DST rule is HARD-CODED to the EU convention (last Sunday of March -> first Sunday of November) -
// prm.utc_offset/prm.enableDST only scale the offset (see updateTimefromTimeserver()), the actual
// 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).
2026-10-25 21:23:10 +01:00
// 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.
2026-10-25 20:48:04 +01:00
TimeChangeRule myDST = { "DST" , Last , Sun , Mar , 2 , 0 };
2026-10-25 21:23:10 +01:00
TimeChangeRule mySTD = { "STD" , Last , Sun , Oct , 3 , 0 };
2026-10-25 20:48:04 +01:00
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
bool displayON = true ; //current day/night brightness state (true=day brightness) - set by evalShutoffTime(), or forced by manualOverride via the web UI
bool manualOverride = false ; //user has manually forced displayON via the web "manualOverride" key, overriding the automatic day/night schedule until evalShutoffTime() sees the schedule naturally cross into the opposite state again
bool initProtectionTimer = false ; // Set true at the top of the hour to synchronize protection timer with clock
bool decimalpointON = false ;
bool alarmON = false ; //Alarm in progress
unsigned long alarmStarted = 0 ; //Start timestamp millis()
// Which "screen" timeProgram() decided to render this second - set once per second, then read by
// displayTime4/6/8() to pick between clock/date/temperature/humidity output. Mutually exclusive
// (the display*() dispatch chain checks them in priority order: temp1 > temp0 > humid1 > humid0 > date > clock).
boolean showClock = false ;
boolean showDate = false ;
boolean showTemp0 = false ;
boolean showTemp1 = false ;
boolean showHumid0 = false ;
boolean showHumid1 = false ;
int lastCathodeProt = - 1 ; //minute() value when the cathode-protect animation last ran, informational only (not read elsewhere in this file)
boolean cathodeProtRunning = false ; //true while any cathode-protect animation is active - other code (e.g. wordclock.ino) can check this to skip normal rendering
boolean ipShowRunning = false ; //true while showMyIp() is running at boot (IP address shown digit-by-digit on the tubes)
boolean wifiConnectRunning = false ; //true while setup() is attempting the initial WiFi connection
boolean editorRunning = false ; //true while the RTC manual date/time editor (rtc.ino) is active - read by wordclock.ino to show an "editing" indicator
#define MAX_PIN sizeof(ESPpinout)-1
#define PIN_TXT_LEN 30
char pinTxt [ MAX_PIN ][ PIN_TXT_LEN + 1 ];
// Every driver/peripheral setup call is expected to call this once per GPIO it uses (label is a
// human-readable name, e.g. "PIN_SDA"). Purpose is twofold: (1) sanity-check the pin against the
// board's ESPpinout[] map (flags reserved/input-only/nonexistent pins) and (2) detect two features
// accidentally configured onto the same physical pin (a common clocks.h copy-paste mistake) - both
// classes of problem are only reported via DPRINT, not by refusing to continue, so a build with DEBUG
// off will silently run with a bad pin assignment.
void regPin ( byte p , const char * txt ) { //register used pins
DPRINT ( "- " ); DPRINT ( txt ); DPRINT ( ": GPIO" ); DPRINT ( p );
if ( p > MAX_PIN - 1 ) {
DPRINT ( " ERROR: PIN# DOESN'T EXIST." );
}
else if ( ESPpinout [ p ] == ' ' ) {
DPRINT ( " ERROR: RESERVED PIN." );
}
else if ( ESPpinout [ p ] == 'I' ) {
DPRINT ( " Warning: input only pin" );
}
DPRINTLN ( " " );
if ( strlen ( pinTxt [ p ]) > 0 ) {
DPRINT ( "*** ERROR *** " ); DPRINT ( txt ); DPRINT ( " on PIN#" ); DPRINT ( p );
DPRINT ( " ALREADY DEFINED AS " ); DPRINTLN ( pinTxt [ p ]);
strncpy ( pinTxt [ p ], "Error:MULTI DEF" , PIN_TXT_LEN );
}
else
strncpy ( pinTxt [ p ], txt , sizeof ( pinTxt [ p ]));
}
// Builds usedPinsStr (the HTML fragment shown on the web admin "clock details" page, see
// handleSendClockDetails()) from everything regPin() recorded, and also dumps the same list to the
// debug console.
void listPins () {
DPRINTLN ( "_______________________" );
DPRINTLN ( "___ USED CLOCK PINS ___" );
#if defined(ESP32)
usedPinsStr = String ( "ESP32 used pins:<br>" );
#else
usedPinsStr = String ( "ESP8266 used pins:<br>" );
#endif
for ( byte i = 0 ; i <= MAX_PIN ; i ++ ) {
if ( strlen ( pinTxt [ i ]) > 0 ) {
usedPinsStr += String ( i ) + ": " + String ( pinTxt [ i ]) + "<br>" ;
DPRINT ( i ); DPRINT ( ": " ); DPRINTLN ( pinTxt [ i ]);
}
}
DPRINTLN ( "_______________________" );
//DPRINT("MAX_PIN"); DPRINTLN(MAX_PIN);
}
// Starts the hardware-timer interrupt that periodically calls the active driver's writeDisplay() (the
// ISR-based multiplex refresh). Called from most drivers' setup_pins() - drivers that scan their
// display internally (PT6311/PT6355/VQC10/MAX7219/etc, see docs/DEVELOPER_GUIDE.md section 3) never
// call this, since their writeDisplay() is an empty no-op and refresh instead happens cooperatively
// in writeDisplay2()/writeDisplaySingle().
void startTimer () { //ESP_INTR_FLAG_IRAM
#if defined(ESP8266)
timer1_attachInterrupt ( writeDisplay );
timer1_enable ( TIM_DIV16 , TIM_EDGE , TIM_SINGLE );
timer1_write ( 15000 );
DPRINTLN ( "Starting 8266 timer..." );
#elif defined(ESP32)
// https://techtutorialsx.com/2017/10/07/esp32-arduino-timer-interrupts/
ESP32timer = timerBegin ( 0 , PRESCALER , true ); //set prescaler to 80 -> 1MHz signal, true = edge generated signal
timerAttachInterrupt ( ESP32timer , & writeDisplay , true );
timerAlarmWrite ( ESP32timer , 1000 , true ); //100millisec, no repeat
timerAlarmEnable ( ESP32timer );
//DPRINTLN("Starting ESP32 timer...");
#else
#error "Unknown controller board!"
#endif
}
void stopTimer () {
#if defined(ESP8266)
timer1_detachInterrupt ();
#endif
}
#ifdef USE_WIFIMANAGER
void configModeCallback ( AsyncWiFiManager * myWiFiManager ) {
DPRINTLN ( "Switch to AP config mode..." );
DPRINTLN ( "To configure Wifi, " );
DPRINT ( "connect to Wifi network " ); DPRINTLN ( prm . wifiSsid );
DPRINTLN ( "and open 192.168.4.1 in web browser" );
}
#endif
// Resets all three digit buffers to BLANK (10) and clears every decimal-point flag - a full display
// clear, used at startup and around mode transitions (WiFi connect, IP display, etc).
void clearDigits () {
memset ( oldDigit , 10 , sizeof ( oldDigit ));
memset ( digit , 10 , sizeof ( digit ));
memset ( newDigit , 10 , sizeof ( newDigit ));
memset ( digitDP , 0 , sizeof ( digitDP ));
}
// A cooperative-multitasking substitute for delay(d): the firmware has no delay()-based blocking
// waits in loop() (see docs/DEVELOPER_GUIDE.md section 4.2), so any function that needs to "wait"
// (animations, WiFi connect retries, cathode-protect, IP display, etc.) calls Fdelay() instead - it
// busy-waits for `d` milliseconds but keeps servicing the DNS captive-portal server (AP mode only),
// RGB/PWM background animations, the alarm buzzer, the light sensor and (if USE_GPS) the GPS serial
// feed, so none of those subsystems stalls during a long animation/connect sequence.
void Fdelay ( unsigned long d ) {
unsigned long dStart = millis ();
doAnimationMakuna ();
while (( millis () - dStart ) < d ) {
if ( WiFi . getMode () == 2 ) dnsServer . processNextRequest ();
writeDisplay2 ();
getLightSensor ();
doAnimationMakuna ();
doAnimationPWM ();
alarmSound ();
#ifdef USE_GPS
smartDelay ( 1 ); //feed GPS
#endif
2026-10-25 21:23:10 +01:00
#ifdef USE_ARDUINO_OTA
ArduinoOTA . handle (); //also during long animations/startup waits
#endif
2026-10-25 20:48:04 +01:00
yield ();
}
}
float round1 ( float in ) {
return round ( 10.0 * in ) / 10.0 ;
}
//--------------------------------------------------------------------------------------------------------------------
// Scans for WiFi networks and, among all APs broadcasting the configured SSID (prm.wifiSsid),
// remembers the one with the best RSSI (bestChn/bestBssid/bestRSSI) - useful when multiple APs/mesh
// nodes share the same SSID, so the clock can explicitly target the strongest one via WiFi.begin(...,
// bestChn, bestBssid, true) in startNewWifiMode() below.
// NOTE: dead code in the current build - its only two call sites (in setup() and resetWiFi() further
// down) are both commented out (`//findBestWifi();`), so this function, its result variables
// (bestChn/bestBssid/bestRSSI/lastWifiScan) and its consumer startNewWifiMode()/checkWifiMode() are
// effectively unused; the live WiFi-connect path is startWifiMode() (plain WiFi.begin(ssid,psw), no
// BSSID targeting).
boolean findBestWifi () {
int n ;
int newRssi ;
boolean found = false ;
if ( WiFi . status () == WL_CONNECTED ) { //save wifi settings to prm
WiFi . disconnect ();
}
WiFi . scanNetworks ( false );
n = WiFi . scanComplete ();
DPRINT ( F ( "Scanning WiFi, found networks:" )); DPRINTLN ( n );
if ( n >= 0 ){
bestRSSI = - 200 ;
memset ( bestBssid , 0 , sizeof ( bestBssid ));
for ( int i = 0 ; i < n ; ++ i ){
if ( WiFi . SSID ( i ) == String ( prm . wifiSsid )) {
newRssi = WiFi . RSSI ( i );
DPRINT ( F ( "scan: RSSI" )); DPRINT ( newRssi );
if ( bestRSSI < newRssi ) { //better rsssi found...
bestChn = WiFi . channel ( i );
bestRSSI = newRssi ;
memcpy ( bestBssid , WiFi . BSSID ( i ), sizeof ( bestBssid ));
DPRINT ( F ( " FOUND: Channel:" )); DPRINT ( bestChn );
DPRINT ( F ( " BSSID:" )); DPRINTLN ( WiFi . BSSIDstr ( i ));
found = true ;
lastWifiScan = millis ();
}
else {
DPRINTLN ( " " );
}
}
}
WiFi . scanDelete ();
} //end else
else {
// WiFi.scanNetworks(false);
}
return ( found );
}
// Fallback when a direct WiFi.begin(prm.wifiSsid,...) connect fails (or no SSID is stored at all):
// opens the ESPAsyncWiFiManager captive-configuration portal (its own temporary AP + web form) so the
// user can pick/enter WiFi credentials from a phone/laptop. Retries autoConnect() up to 5 times before
// forcing the portal open unconditionally. On success, persists the newly-chosen SSID/password into
// prm and saves EEPROM. Compiled to an empty no-op when USE_WIFIMANAGER is not defined for this
// CLOCK_NN (the #ifdef below spans the whole function body).
void wifiManager () {
#ifdef USE_WIFIMANAGER
AsyncWiFiManager MyWifiManager ( & server , & dnsServer );
MyWifiManager . setAPCallback ( configModeCallback );
//MyWifiManager.setConfigPortalTimeout(180);
for ( int i = 0 ; i < 5 ; i ++ ) {
if ( ! MyWifiManager . autoConnect ( AP_NAME )) { //or autoConnect(AP_NAME,AP_PASSWORD))
DPRINT ( "Retry to Connect:" ); DPRINTLN ( i );
WiFi . disconnect ();
WiFi . mode ( WIFI_OFF );
delay ( 1000 );
WiFi . mode ( WIFI_STA );
#if defined(ESP32)
WiFi . setHostname ( webName );
#else
WiFi . hostname ( webName );
#endif
if ( i == 4 ) {
MyWifiManager . autoConnect ( AP_NAME ); // or autoConnect(AP_NAME,AP_PASSWORD))
}
}
else
break ;
} //end for
if ( WiFi . status () == WL_CONNECTED ) { //save wifi settings to prm
String s = WiFi . SSID ();
String p = WiFi . psk ();
memcpy ( oldBssid , WiFi . BSSID (), sizeof ( oldBssid ));
s . toCharArray ( prm . wifiSsid , sizeof ( prm . wifiSsid ));
p . toCharArray ( prm . wifiPsw , sizeof ( prm . wifiPsw ));
saveEEPROM ();
ip = WiFi . localIP ();
WiFi . setAutoReconnect ( true ); WiFi . persistent ( true );
}
#endif
}
// Alternate STA-mode connect path that targets the specific BSSID/channel found by findBestWifi()
// (WiFi.begin(ssid,psw,channel,bssid,true) instead of a plain SSID connect). See the findBestWifi()
// NOTE above - this function is currently unreachable from setup()/loop() (only called from the
// disabled checkWifiMode()), so it and its bestRSSI/lastWifiScan freshness gating are dead in practice.
void startNewWifiMode () { // Start a Wi-Fi access point, and try to connect to some given access points. Then wait for either an AP or STA connection
if ((( millis () - lastWifiScan ) < 90000 ) && ( bestRSSI > - 95 ) && ( WiFi . status () != WL_CONNECTED )) {
DPRINTLN ( F ( "_____________ Connecting WiFi ___________________" ));
if ( strlen ( prm . wifiSsid ) == 0 ) return ;
WiFi . disconnect ();
delay ( 200 );
WiFi . mode ( WIFI_OFF );
delay ( 200 );
WiFi . mode ( WIFI_STA );
WiFi . setAutoReconnect ( true );
#if defined(ESP32)
esp_wifi_set_ps ( WIFI_PS_NONE );
WiFi . setHostname ( webName );
WiFi . setSleep ( false );
#else
WiFi . hostname ( webName );
#endif
char tmp [ 20 ];
sprintf ( tmp , "%02X:%02X:%02X:%02X:%02X:%02X" , bestBssid [ 0 ], bestBssid [ 1 ], bestBssid [ 2 ], bestBssid [ 3 ], bestBssid [ 4 ], bestBssid [ 5 ]);
DPRINT ( F ( "Connecting to best WiFi AP:" )); DPRINT ( prm . wifiSsid ); DPRINT ( F ( " PSW:" )); DPRINT ( prm . wifiPsw );
DPRINT ( F ( " BSSID:" )); DPRINT ( tmp ); DPRINT ( " Chn:" ); DPRINTLN ( bestChn );
WiFi . begin ( prm . wifiSsid , prm . wifiPsw , bestChn , bestBssid , true );
Fdelay ( 500 );
int counter = 0 ;
while ( WiFi . status () != WL_CONNECTED ) {
DPRINT ( '.' );
Fdelay ( 3000 );
if ( counter ++> 3 ) {
DPRINTLN ( "Connecting to WiFi failed." );
return ;
}
}
ip = WiFi . localIP ();
sprintf ( myIp , "%d.%d.%d.%d" , ip [ 0 ], ip [ 1 ], ip [ 2 ], ip [ 3 ]);
DPRINT ( " \n Connected to " ); DPRINT ( WiFi . SSID ());
DPRINT ( ". LocalIP:" ); DPRINT ( myIp );
DPRINT ( " BSSID:" ); DPRINT ( WiFi . BSSIDstr ());
DPRINT ( " RSSI:" ); DPRINT ( WiFi . RSSI ());
DPRINT ( " CHN:" ); DPRINTLN ( WiFi . channel ());
DPRINTLN ( " \n\n Network Configuration:" );
DPRINTLN ( "----------------------" );
DPRINT ( " SSID: " ); DPRINTLN ( WiFi . SSID ());
DPRINT ( " Wifi Status: " ); DPRINTLN ( WiFi . status ());
DPRINT ( "Wifi Strength: " ); DPRINT ( WiFi . RSSI ()); DPRINTLN ( " dBm" );
DPRINT ( " MAC: " ); DPRINTLN ( WiFi . macAddress ());
DPRINT ( " IP: " ); DPRINTLN ( myIp );
DPRINT ( " Subnet: " ); DPRINTLN ( WiFi . subnetMask ());
DPRINT ( " Gateway: " ); DPRINTLN ( WiFi . gatewayIP ());
DPRINT ( " DNS 1: " ); DPRINTLN ( WiFi . dnsIP ( 0 ));
DPRINT ( " DNS 2: " ); DPRINTLN ( WiFi . dnsIP ( 1 ));
DPRINT ( " DNS 3: " ); DPRINTLN ( WiFi . dnsIP ( 2 ));
DPRINTLN ( "_____________________________________________________________________________" );
memcpy ( oldBssid , WiFi . BSSID (), sizeof ( oldBssid ));
}
DPRINTLN ( " \r\n " );
}
// The LIVE Infrastructure(STA)-mode connect path, called from setup() when prm.wifiMode==true. Plain
// WiFi.begin(ssid,psw) (no BSSID targeting, unlike startNewWifiMode() above), retries once a second
// for up to ~10s, then falls back to wifiManager() (config portal) if still not connected - and
// wifiManager() itself falls straight through here again if no SSID was stored at all.
void startWifiMode () {
DPRINTLN ( "Starting Clock in WiFi Mode!" );
if ( strlen ( prm . wifiSsid ) == 0 ) {
DPRINTLN ( "WiFi SSID not defined!" );
wifiManager ();
return ;
}
WiFi . disconnect ( true );
//WiFi.mode(WIFI_OFF);
delay ( 100 );
WiFi . mode ( WIFI_STA );
delay ( 100 );
//Fdelay(1000);
#if defined(ESP32)
WiFi . setHostname ( webName );
WiFi . setSleep ( false );
#else
WiFi . hostname ( webName );
#endif
#if defined(ESP32)
esp_wifi_set_ps ( WIFI_PS_NONE ); //power saving disable!
#endif
//if (WiFi.status() == WL_CONNECTED) return;
DPRINT ( " \n Connecting to WiFi SSID:(" ); DPRINT ( prm . wifiSsid ); DPRINT ( ") PSW:(" ); DPRINT ( prm . wifiPsw ); DPRINTLN ( ")" );
int counter = 0 ;
if ( strlen ( prm . wifiPsw ) != 0 )
WiFi . begin ( prm . wifiSsid , prm . wifiPsw );
else
WiFi . begin ( prm . wifiSsid );
while ( WiFi . status () != WL_CONNECTED ) {
DPRINT ( '.' );
//playTubes();
//Fdelay(3000);
delay ( 1000 );
if ( counter ++> 10 ) {
wifiManager ();
return ;
}
}
DPRINTLN ( " " );
ip = WiFi . localIP ();
WiFi . setAutoReconnect ( true );
}
// Primary time source (see calcTime()/docs/DEVELOPER_GUIDE.md 4.4): refreshes the system clock from
// NTP over WiFi, at most once every TIMESERVER_REFRESH (2h) - or every 5 minutes if the previous
// attempt failed (lastTimeFailure backoff), retrying the NTP request itself up to 5 times per call.
// On success, rebuilds myTZ from the current prm.utc_offset/prm.enableDST (so a live timezone/DST
// change from the web UI takes effect on the very next scheduled refresh) and calls setTime() with
// the timezone-adjusted local time. Returns true only if this call actually refreshed the clock -
// callers (calcTime()) use that to decide whether the RTC also needs updating this cycle.
boolean updateTimefromTimeserver () { //true, if successful
static unsigned long lastTimeFailure = 0 ;
boolean res = false ;
int count = 1 ;
if ((( millis () - lastTimeUpdate ) < TIMESERVER_REFRESH ) && ( lastTimeUpdate != 0 ))
return ( res );
if (( lastTimeFailure > 0 ) && (( millis () - lastTimeFailure ) < 300000 )) return ( res ); //wait for 5min to retry, if no success
if ( WiFi . status () == WL_CONNECTED ) {
while ( true ) {
DPRINT ( "Connecting to timeserver: " ); DPRINTLN ( count );
res = timeClient . forceUpdate ();
if ( res ) {
mySTD . offset = prm . utc_offset * 60 ;
myDST . offset = mySTD . offset ;
if ( prm . enableDST ) {
myDST . offset += 60 ;
}
myTZ = Timezone ( myDST , mySTD );
setTime ( myTZ . toLocal ( timeClient . getEpochTime ()));
lastTimeUpdate = millis ();
DPRINT ( "Timeserver date:" ); DPRINT ( year ()); DPRINT ( "/" ); DPRINT ( month ()); DPRINT ( "/" ); DPRINT ( day ());
DPRINT ( " time:" ); DPRINT ( hour ()); DPRINT ( ":" ); DPRINT ( minute ()); DPRINT ( ":" ); DPRINTLN ( second ());
DPRINTLN ( "Clock refreshed from timeserver." );
lastTimeFailure = 0 ;
}
count ++ ;
if ( res ) break ; //success!!!
Fdelay ( 1000 );
if ( count > 5 ) { //failure, but stop trying
lastTimeFailure = millis ();
break ;
}
} //end while
}
return ( res );
}
#ifndef MDNSNAME
#define MDNSNAME "uniclock"
#endif
// Optional mDNS responder (only compiled/started if USE_MDNS is defined - commented out by default
// near the top of this file) so the clock can be reached as e.g. "uniclock.local" instead of typing
// its IP address. Guards against re-starting an already-running responder via the static flag.
void startMDNS () {
static boolean mdnsStarted = false ;
if ( mdnsStarted ) return ; //running and not changed
#ifdef USE_MDNS
if ( ! MDNS . begin ( MDNSNAME )) { //mdns_free() switch off
DPRINTLN ( F ( "Error setting up MDNS responder!" ));
mdnsStarted = false ;
}
else {
DPRINT ( F ( "Started mDns service:" )); DPRINTLN ( MDNSNAME );
MDNS . addService ( "http" , "tcp" , 80 );
mdnsStarted = true ;
}
#endif
}
2026-10-25 21:23:10 +01:00
// 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
}
2026-10-25 20:48:04 +01:00
// 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
// phones/laptops auto-launch a captive-portal browser pointed at the admin page. In this mode there
// is no internet, so calcTime() never gets a WiFi/NTP refresh - GPS (if fitted) or the RTC are the
// only time sources.
void startStandaloneMode () {
DPRINTLN ( "Starting Clock in Standalone Mode!" );
DPRINT ( "Clock's AP SSID:" ); DPRINT ( prm . ApSsid );
DPRINT ( " PSW:" ); DPRINTLN ( prm . ApPsw );
DPRINTLN ( "IP: 192.168.4.1" );
IPAddress local_ip ( 192 , 168 , 4 , 1 );
IPAddress gateway ( 192 , 168 , 4 , 1 );
IPAddress subnet ( 255 , 255 , 255 , 0 );
WiFi . mode ( WIFI_AP );
WiFi . softAPConfig ( local_ip , gateway , subnet );
boolean nwState ;
if ( strlen ( prm . ApPsw )) {
nwState = WiFi . softAP ( prm . ApSsid , prm . ApPsw );
}
else {
nwState = WiFi . softAP ( prm . ApSsid );
}
Fdelay ( 2000 ); //info: https://github.com/espressif/arduino-esp32/issues/2025
DPRINT ( "AP status:" ); DPRINTLN ( nwState ? "Ready" : "Failed!" ); //channel
DPRINT ( "Mac address:" ); DPRINTLN ( WiFi . softAPmacAddress ());
ip = WiFi . softAPIP ();
DPRINT ( "SOFT_AP IP:" ); DPRINTLN ( WiFi . softAPIP ());
dnsServer . setErrorReplyCode ( DNSReplyCode :: NoError );
dnsServer . start ( 53 , "*" , WiFi . softAPIP ());
}
// OTA firmware+filesystem update over plain HTTP. Triggered by the web admin's POST /firmwareupdate
// (sets makeFirmwareUpdate, actually run from loop() so it doesn't block the request handler) or
// automatically whenever the "firmware" server URL is changed via /configChanged ("for testing only",
// per the comment there). Downloads and flashes two files in sequence from prm.firmwareServer:
// "<FW>.bin" (the sketch binary) then "<FW>.spiffs.bin" (the SPIFFS web-page image) - FW is the
// firmware ID string (see clocks.h). Stops the web server first (frees RAM/sockets for the update),
// and only calls doReset() to actually reboot into the new firmware if BOTH updates reported success;
// otherwise the (old) server is simply restarted so the clock keeps running on the current firmware.
void doFirmwareUpdate (){
if ( WiFi . status () != WL_CONNECTED ) {
DPRINTLN ( "Wifi disconnected. FirmwareUpdate failed." );
return ;
}
DPRINTLN ( "Webserver stopped..." );
server . reset (); //Stop server
delay ( 2000 );
yield ();
String fname = String ( prm . firmwareServer ) + "/" + String ( FW ) + ".bin" ;
DPRINT ( "Update firmware: " ); DPRINTLN ( fname );
t_httpUpdate_return ret , ret2 ;
boolean succ = false ;
WiFiClient client ;
ESPhttpUpdate . rebootOnUpdate ( false );
#if defined(ESP32) || defined(ESP8266_CORE_2xx)
ret = ESPhttpUpdate . update ( fname ); //ESP32 and old 8266 Core
#else
ret = ESPhttpUpdate . update ( client , fname ); //new 8266 core
#endif
switch ( ret ) {
case HTTP_UPDATE_FAILED :
DPRINTF ( "HTTP_UPDATE_FAILED Error (%d): %s" , ESPhttpUpdate . getLastError (), ESPhttpUpdate . getLastErrorString (). c_str ());
DPRINTLN ( " " );
break ;
case HTTP_UPDATE_NO_UPDATES :
DPRINTLN ( "HTTP_UPDATE_NO_UPDATES" );
break ;
case HTTP_UPDATE_OK :
DPRINTLN ( "HTTP_UPDATE_OK" );
succ = true ;
break ;
}
fname = String ( prm . firmwareServer ) + "/" + String ( FW ) + ".spiffs.bin" ;
DPRINT ( "Update SPIFFS: " ); DPRINTLN ( fname );
#if defined(ESP32)|| defined(ESP8266_CORE_2xx)
ret2 = ESPhttpUpdate . updateSpiffs ( fname ); //ESP32 or old 8266 Core
#else
ret2 = ESPhttpUpdate . updateFS ( client , fname ); //new 8266 core
#endif
switch ( ret2 ) {
case HTTP_UPDATE_FAILED :
DPRINTF ( "HTTP_UPDATE_FAILED Error (%d): %s" , ESPhttpUpdate . getLastError (), ESPhttpUpdate . getLastErrorString (). c_str ());
DPRINTLN ( " " );
break ;
case HTTP_UPDATE_NO_UPDATES :
DPRINTLN ( "HTTP_UPDATE_NO_UPDATES" );
break ;
case HTTP_UPDATE_OK :
DPRINTLN ( "HTTP_UPDATE_OK" );
succ = true ;
break ;
}
if ( succ ) {
DPRINTLN ( " " );
delay ( 1000 );
doReset ();
}
DPRINTLN ( " " );
startServer (); //restart webserver
}
// Simple "run all digits through 0-9" anti-cathode-poisoning/burn-in pattern, triggered by the web
// admin's POST /cathodeProtect button (via makeCathodeProtect, executed from loop()) for
// cathProtMin minutes at full brightness (temporarily forces MAXBRIGHTNESS and disables auto-dim,
// restored at the end). Distinct from the automatic, scheduled newCathodeProtect() below (which runs
// every prm.interval minutes via timeProgram() and has per-tube stop timing/direction) - this one is
// the manual, user-triggered, uniform version.
void doCathodeProtect () {
unsigned long started = millis ();
byte num = 0 ;
int db = prm . dayBright ; //save brightness values
int nb = prm . nightBright ;
boolean ab = autoBrightness ;
byte onOff = 0 ;
prm . dayBright = MAXBRIGHTNESS ;
prm . nightBright = MAXBRIGHTNESS ;
autoBrightness = false ;
cathodeProtRunning = true ;
DPRINT ( "Cathode Protect is running for " ); DPRINT ( cathProtMin ); DPRINTLN ( " minutes." );
memset ( digitDP , 0 , sizeof ( digitDP ));
memset ( animMask , 0 , sizeof ( animMask ));
while ( true ) {
for ( int i = 0 ; i < maxDigits ; i ++ ) {
if ( i % 2 ) digit [ i ] = num ;
else digit [ i ] = 9 - num ;
newDigit [ i ] = digit [ i ];
digitDP [ i ] = ( onOff % 2 == i % 2 );
}
writeDisplaySingle ();
Fdelay ( 100 );
num ++ ; if ( num > 9 ) num = 0 ;
onOff ++ ;
if (( millis () - started ) > uint32_t ( cathProtMin ) * 60000l ) break ;
} //end while
prm . dayBright = db ; //restore brightness values
prm . nightBright = nb ;
autoBrightness = ab ;
cathodeProtRunning = false ;
}
// Sets up the ESPAsyncWebServer instance (port 80) that backs the web admin UI: static file routes
// serve the SPA's HTML/CSS/JS straight out of SPIFFS (uploaded separately via the IDE's filesystem
// uploader from the repo's data/ folder - see file header), plus a handful of action routes
// (/reset, /factoryreset, /firmwareupdate, /cathodeProtect) and the JSON/form REST-ish API
// (handleConfigChanged/handleSendConfig/handleSendCurrentInfos/handleSendClockDetails, defined below).
void startServer () {
DPRINTLN ( "Starting Async Webserver..." );
server . on ( "/" , HTTP_GET , []( AsyncWebServerRequest * request ) {
DPRINTLN ( "Webserver: / -> index.html" );
if ( SPIFFS . exists ( "/index.html" )) {
AsyncWebServerResponse * response = request -> beginResponse ( SPIFFS , "/index.html" , "text/html" );
request -> send ( response );
}
else {
request -> send ( 204 , "text/html" , "File Not Found" );
DPRINTLN ( "/index.html not found" );
}
});
server . on ( "/jquery_351.js" , HTTP_GET , []( AsyncWebServerRequest * request ) {
DPRINTLN ( "Webserver: /jquery_351.js" );
if ( SPIFFS . exists ( "/jquery_351.js" )) {
AsyncWebServerResponse * response = request -> beginResponse ( SPIFFS , "/jquery_351.js" , "text/js" );
response -> addHeader ( "Cache-Control" , CACHE_MAX_AGE );
request -> send ( response );
}
else {
request -> send ( 204 , "text/html" , "File Not Found" );
DPRINTLN ( "/jquery_351.js not found" );
}
});
server . on ( "/jquery_360.js" , HTTP_GET , []( AsyncWebServerRequest * request ) {
DPRINTLN ( "Webserver: /jquery_360.js" );
if ( SPIFFS . exists ( "/jquery_360.js" )) {
AsyncWebServerResponse * response = request -> beginResponse ( SPIFFS , "/jquery_360.js" , "text/js" );
response -> addHeader ( "Cache-Control" , CACHE_MAX_AGE );
request -> send ( response );
}
else {
request -> send ( 204 , "text/html" , "File Not Found" );
DPRINTLN ( "/jquery_360.js not found" );
}
});
server . on ( "/page.js" , HTTP_GET , []( AsyncWebServerRequest * request ) {
DPRINTLN ( "Webserver: /page.js" );
if ( SPIFFS . exists ( "/page.js" )) {
AsyncWebServerResponse * response = request -> beginResponse ( SPIFFS , "/page.js" , "text/js" );
response -> addHeader ( "Cache-Control" , CACHE_MAX_AGE );
request -> send ( response );
}
else {
request -> send ( 204 , "text/html" , "File Not Found" );
DPRINTLN ( "/page.js not found" );
}
});
server . on ( "/favicon.ico" , HTTP_GET , []( AsyncWebServerRequest * request ) {
DPRINTLN ( "Webserver: /favicon.ico" );
if ( SPIFFS . exists ( "/favicon.ico" )){
AsyncWebServerResponse * response = request -> beginResponse ( SPIFFS , "/favicon.ico" , "image/png" );
response -> addHeader ( "Cache-Control" , CACHE_MAX_AGE );
request -> send ( response );
}
else {
request -> send ( 204 , "text/html" , "File Not Found" );
DPRINTLN ( "/favicon.ico not found" );
}
});
server . on ( "/generate_204" , HTTP_GET , []( AsyncWebServerRequest * request ) {
DPRINTLN ( "Webserver: /generate_204" );
AsyncWebServerResponse * response = request -> beginResponse ( 302 ); //
response -> addHeader ( "Location" , String ( "http://" ) + WiFi . softAPIP (). toString (). c_str ());
request -> send ( response );
/*
//request->send(SPIFFS, "/index.html", "text/html");
AsyncResponseStream *response = request->beginResponseStream("text/html");
response->print("<!DOCTYPE html><html><head><title>UniClock</title></head><body>");
response->print("<p><H1>"); response->print(webName);
response->print("</p><p>Please, login to network and </p>");
response->printf("<p>try opening <a href='http://%s'>%s</a> </p>", WiFi.softAPIP().toString().c_str(),WiFi.softAPIP().toString().c_str());
//response->printf("192.168.4.1");
response->print("<H1></body></html>");
request->send(response);
*/
});
//____________________________________________________________________________
server . on ( "/site.css" , HTTP_GET , []( AsyncWebServerRequest * request ) {
DPRINTLN ( "Webserver: /site.css" );
if ( SPIFFS . exists ( "/site.css" )) {
AsyncWebServerResponse * response = request -> beginResponse ( SPIFFS , "/site.css" , "text/css" );
response -> addHeader ( "Cache-Control" , CACHE_MAX_AGE );
request -> send ( response );
}
else {
request -> send ( 204 , "text/html" , "File Not Found" );
DPRINTLN ( "/site.css not found" );
}
});
//____________________________________________________________________________
server . on ( "/reset" , HTTP_POST , [] ( AsyncWebServerRequest * request ) {
DPRINTLN ( "/reset:" );
request -> send ( 200 , "text/plain" , "Reset: Restarting the Box!" );
delay ( 200 );
doReset ();
});
//____________________________________________________________________________
server . on ( "/factoryreset" , HTTP_POST , [] ( AsyncWebServerRequest * request ) {
DPRINTLN ( "/factoryreset:" );
request -> send ( 200 , "text/plain" , "Factory Reset!" );
delay ( 200 );
factoryReset ();
doReset ();
});
//____________________________________________________________________________
server . on ( "/firmwareupdate" , HTTP_POST , []( AsyncWebServerRequest * request ) {
makeFirmwareUpdate = true ;
});
//____________________________________________________________________________
server . on ( "/cathodeProtect" , HTTP_POST , []( AsyncWebServerRequest * request ) {
makeCathodeProtect = true ;
});
server . on ( "/saveSetting" , HTTP_POST , handleConfigChanged );
server . on ( "/getConfiguration" , HTTP_GET , handleSendConfig );
server . on ( "/getCurrentInfos" , HTTP_GET , handleSendCurrentInfos );
server . on ( "/getClockDetails" , HTTP_GET , handleSendClockDetails );
server . onNotFound ( handleNotFound );
server . begin ();
} //end of procedure
// Catch-all 404 handler (server.onNotFound) - also doubles as a debug request-dumper (prints every
// request param, GET/POST, to the serial console when DEBUG is on) which is handy for reverse-
// engineering what the SPA's JS is actually sending during development.
void handleNotFound ( AsyncWebServerRequest * request ) {
int params = request -> params ();
for ( int i = 0 ; i < params ; i ++ ) {
#ifdef DEBUG
AsyncWebParameter * p = request -> getParam ( i );
if ( p -> isFile ()) { //p->isPost() is also true
Serial . printf ( "FILE[%s]: %s, size: %u \n " , p -> name (). c_str (), p -> value (). c_str (), p -> size ());
} else if ( p -> isPost ()) {
Serial . printf ( "POST[%s]: %s \n " , p -> name (). c_str (), p -> value (). c_str ());
} else {
Serial . printf ( "GET[%s]: %s \n " , p -> name (). c_str (), p -> value (). c_str ());
}
#endif
}
String message = "File Not Found \n\n " ;
message += "URL: " ;
message += request -> url ();
message += " \n Method: " ;
message += ( request -> method () == HTTP_GET ) ? "GET" : "POST" ;
message += " \n Arguments: " ;
message += request -> args ();
message += " \n " ;
for ( uint8_t i = 0 ; i < request -> args (); i ++ ) {
message += " " + request -> argName ( i ) + ": " + request -> arg ( i ) + " \n " ;
}
DPRINTLN ( message );
request -> send ( 204 , "text/html" , "URL Not Found" );
}
// POST /saveSetting - the SINGLE entry point for every settings change made from the web admin UI.
// Request carries two form fields, `key` (a string identifying which setting) and `value` (its new
// value as a string) - handled as one long if/else-if chain matching `key`, converting `value` to the
// right type and writing it into the matching prm.* field (or, for a few keys, a plain global like
// c_MinBrightness/cathProtMin/autoBrightness). Any recognized key ends with saveEEPROM() so the
// change survives a reboot; an unrecognized key returns HTTP 404 without touching EEPROM. Most
// branches are simple 1:1 field assignments and aren't commented individually below - only branches
// with side effects beyond "store this value" are called out.
void handleConfigChanged ( AsyncWebServerRequest * request ) {
if ( request -> hasParam ( "key" , true ) && request -> hasParam ( "value" , true )) {
//int args = request->args();
//for(int i=0;i<args;i++){
// Serial.printf("ARG[%s]: %s\n", request->argName(i).c_str(), request->arg(i).c_str());
//}
boolean oldDST = prm . enableDST ;
int old_utc_offset = prm . utc_offset ;
String key = request -> getParam ( "key" , true ) -> value ();
String value = request -> getParam ( "value" , true ) -> value ();
DPRINT ( key ); DPRINT ( " = " ); DPRINTLN ( value );
boolean paramFound = true ;
if ( key == "utc_offset" ) {
// Changing the UTC offset immediately shifts the LIVE running clock (not just the stored
// preference) by the delta in hours, and pushes that change into the RTC right away via
// updateRTC() - so the displayed time updates instantly rather than waiting for the next NTP
// sync (which would otherwise take up to TIMESERVER_REFRESH to reflect the new zone).
prm . utc_offset = value . toInt ();
if ( old_utc_offset != prm . utc_offset ) { //Change time zone
setTime ( now () + ( prm . utc_offset - old_utc_offset ) * 3600 );
updateRTC ();
2026-10-25 21:23:10 +01:00
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 );
2026-10-25 20:48:04 +01:00
}
}
else if ( key == "set12_24" ) {
prm . set12_24 = ( value == "true" );
DPRINT ( "set12_24:" );
DPRINTLN ( prm . set12_24 );
}
else if ( key == "showZero" ) {
prm . showZero = ( value == "true" );
DPRINT ( "showZero:" );
DPRINTLN ( prm . showZero );
}
else if ( key == "enableBlink" ) {
prm . enableBlink = ( value == "true" );
}
else if ( key == "enableDST" ) {
// Same immediate-effect pattern as utc_offset above: toggling DST support live-shifts the
// 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).
2026-10-25 21:23:10 +01:00
// 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.
2026-10-25 20:48:04 +01:00
prm . enableDST = ( value == "true" );
2026-10-25 21:23:10 +01:00
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 ));
2026-10-25 20:48:04 +01:00
updateRTC ();
}
}
else if ( key == "interval" ) {
prm . interval = value . toInt ();
}
else if ( key == "enableAutoShutoff" ) {
prm . enableAutoShutoff = ( value == "true" );
}
else if ( key == "dayTimeHours" ) {
prm . dayHour = value . toInt ();
}
else if ( key == "dayTimeMinutes" ) {
prm . dayMin = value . toInt ();
}
else if ( key == "nightTimeHours" ) {
prm . nightHour = value . toInt ();
}
else if ( key == "nightTimeMinutes" ) {
prm . nightMin = value . toInt ();
}
else if ( key == "dayBright" ) {
prm . dayBright = value . toInt ();
}
else if ( key == "nightBright" ) {
prm . nightBright = value . toInt ();
}
else if ( key == "animMode" ) {
prm . animMode = value . toInt ();
}
else if ( key == "manualOverride" ) {
// Not a prm.* field - "manualOverride" is the web UI's day/night ON/OFF toggle: it flips the
// live displayON state directly and sets the manualOverride flag so evalShutoffTime() (called
// every second from timeProgram()) stops auto-driving displayON from the day/night schedule
// until the schedule naturally catches up to the state the user forced (see evalShutoffTime()).
boolean v = value == "false" ;
if ( v != displayON ) {
manualOverride = true ;
displayON = v ;
}
}
else if ( key == "wifiMode" ) {
prm . wifiMode = ( value == "true" );
}
else if ( key == "alarmEnable" ) {
prm . alarmEnable = ( value == "true" );
}
else if ( key == "alarmTimeHours" ) {
prm . alarmHour = value . toInt ();
}
else if ( key == "alarmTimeMinutes" ) {
prm . alarmMin = value . toInt ();
}
else if ( key == "alarmPeriod" ) {
prm . alarmPeriod = value . toInt ();
}
//RGB LED values
else if ( key == "rgbEffect" ) {
prm . rgbEffect = value . toInt ();
}
else if ( key == "rgbBrightness" ) {
prm . rgbBrightness = value . toInt ();
}
else if ( key == "rgbFixColor" ) {
prm . rgbFixColor = value . toInt ();
}
else if ( key == "rgbSpeed" ) {
prm . rgbSpeed = value . toInt ();
}
else if ( key == "rgbDir" ) {
prm . rgbDir = ( value == "true" );
}
else if ( key == "rgbMinBrightness" ) {
c_MinBrightness = value . toInt ();
}
else if ( key == "wifiSsid" ) {
value . toCharArray ( prm . wifiSsid , sizeof ( prm . wifiSsid ));
}
else if ( key == "wifiPsw" ) {
value . toCharArray ( prm . wifiPsw , sizeof ( prm . wifiPsw ));
}
else if ( key == "ApSsid" ) {
// Sanitizes the OLD prm.ApSsid in place (replacing any non-printable/non-ASCII byte with '_')
// BEFORE overwriting it with the new value from the web form - i.e. this cleans up a
// possibly-corrupted stored SSID from a prior session, not the incoming `value` itself (same
// repair loop also runs unconditionally in factoryReset(), see below).
for ( int i = 0 ; i < ( int ) strlen ( prm . ApSsid ); i ++ ) { //repair bad chars in AP SSID
if (( prm . ApSsid [ i ] < 32 ) || ( prm . ApSsid [ i ] > 126 )) prm . ApSsid [ i ] = '_' ;
}
value . toCharArray ( prm . ApSsid , sizeof ( prm . ApSsid ));
}
else if ( key == "ApPsw" ) {
value . toCharArray ( prm . ApPsw , sizeof ( prm . ApPsw ));
}
else if ( key == "NtpServer" ) {
value . toCharArray ( prm . NtpServer , sizeof ( prm . NtpServer ));
}
else if ( key == "firmware" ) {
// Side effect: merely changing the firmware-server URL setting also immediately kicks off an
// OTA update attempt (makeFirmwareUpdate, picked up by loop()->doFirmwareUpdate()) - per the
// "for testing only" comment this looks like a leftover dev convenience rather than intended
// production behavior (a user just correcting a typo in the URL would trigger a real update).
value . toCharArray ( prm . firmwareServer , sizeof ( prm . firmwareServer ));
makeFirmwareUpdate = true ; //for testing only
}
else if ( key == "mqttBrokerAddr" ) {
value . toCharArray ( prm . mqttBrokerAddr , sizeof ( prm . mqttBrokerAddr ));
}
else if ( key == "mqttBrokerUser" ) {
value . toCharArray ( prm . mqttBrokerUser , sizeof ( prm . mqttBrokerUser ));
}
else if ( key == "mqttBrokerPsw" ) {
value . toCharArray ( prm . mqttBrokerPsw , sizeof ( prm . mqttBrokerPsw ));
}
else if ( key == "mqttBrokerRefresh" ) {
prm . mqttBrokerRefresh = value . toInt ();
}
else if ( key == "mqttEnable" ) {
prm . mqttEnable = ( value == "true" );
}
else if ( key == "dateMode" ) {
prm . dateMode = value . toInt ();
}
else if ( key == "dateStart" ) {
prm . dateStart = value . toInt ();
}
else if ( key == "dateEnd" ) {
prm . dateEnd = value . toInt ();
}
else if ( key == "tempStart" ) {
prm . tempStart = value . toInt ();
}
else if ( key == "tempEnd" ) {
prm . tempEnd = value . toInt ();
}
else if ( key == "humidStart" ) {
prm . humidStart = value . toInt ();
}
else if ( key == "humidEnd" ) {
prm . humidEnd = value . toInt ();
}
else if ( key == "dateRepeatMin" ) {
prm . dateRepeatMin = value . toInt ();
if ( prm . dateRepeatMin > 10 ) prm . dateRepeatMin = 10 ;
}
else if ( key == "tempRepeatMin" ) {
prm . tempRepeatMin = value . toInt ();
if ( prm . tempRepeatMin > 10 ) prm . tempRepeatMin = 10 ;
}
else if ( key == "enableDoubleBlink" ) {
prm . enableDoubleBlink = ( value == "true" );
}
else if ( key == "enableTimeDisplay" ) {
prm . enableTimeDisplay = ( value == "true" );
}
else if ( key == "enableAutoDim" ) {
prm . enableAutoDim = ( value == "true" );
autoBrightness = prm . enableAutoDim ; //mirrors the flag into the live `autoBrightness` global read by getLightSensor()/the display brightness logic, so the change takes effect immediately rather than only after the next getLightSensor() poll
}
else if ( key == "enableRadar" ) {
prm . enableRadar = ( value == "true" );
radarLastOn = millis (); //resets the radar "last motion seen" timestamp so toggling the feature on doesn't immediately look timed-out and shut the tubes off
radarON = true ;
}
else if ( key == "radarTimeout" ){
prm . radarTimeout = value . toInt ();
if ( prm . radarTimeout > 60 ) prm . radarTimeout = 60 ;
if ( prm . radarTimeout < 1 ) prm . radarTimeout = 1 ;
radarLastOn = millis (); //same reasoning as enableRadar above - avoid an immediate false shutoff after changing the timeout value
radarON = true ;
}
else if ( key == "tempCF" ) {
prm . tempCF = ( value == "true" );
}
else if ( key == "corrT0" ) {
prm . corrT0 = value . toFloat ();
}
else if ( key == "corrT1" ) {
prm . corrT1 = value . toFloat ();
}
else if ( key == "corrH0" ) {
prm . corrH0 = value . toFloat ();
}
else if ( key == "corrH1" ) {
prm . corrH1 = value . toFloat ();
}
else if ( key == "cathProtMin" ) {
cathProtMin = value . toInt ();
}
else {
paramFound = false ;
}
if ( paramFound ) {
saveEEPROM ();
request -> send ( 200 , "text/plain" , "Ok" );
}
else {
request -> send ( 404 , "text/plain" , "404: Parameter not found" );
}
}
else {
request -> send ( 400 , "text/plain" , "400: Invalid Request. Parameters: key and value" );
}
}
// GET /getConfiguration - dumps (almost) the entire `prm` struct plus a handful of live/derived values
// (current date/time, sensor readings, MAC, driver name...) as one JSON object. This is what the web
// SPA fetches once on page load to populate every settings form field; a "255" sentinel value marks a
// sensor reading/feature as "not installed" (the JS treats 255 specially rather than 0, since 0 is a
// plausible real reading). Compare with handleSendCurrentInfos() below, which is the lightweight,
// frequently-polled subset used to keep the live clock/sensor display refreshed without re-sending
// the whole settings form each time.
void handleSendConfig ( AsyncWebServerRequest * request ) {
StaticJsonDocument < 2048 > doc ;
char buf [ 20 ]; //conversion buffer
DPRINTLN ( "Sending configuration to web client." );
//Global data
doc [ "version" ] = webName ;
doc [ "FW" ] = FW ;
doc [ "tubeDriver" ] = tubeDriver ;
doc [ "maxDigits" ] = maxDigits ; //number of digits (tubes)
doc [ "maxBrightness" ] = MAXBRIGHTNESS ; //Maximum tube brightness usually 10, sometimes 12
//Actual time and environment data
sprintf ( buf , "%4d.%02d.%02d" , year (), month (), day ());
doc [ "currentDate" ] = buf ;
sprintf ( buf , "%02d:%02d" , hour (), minute ());
doc [ "currentTime" ] = buf ;
//DPRINT("useTemp:"); DPRINT(useTemp); DPRINT("useHumid:"); DPRINT(useHumid);
if ( useTemp > 0 ) {
doc [ "temperature" ] = round1 ( temperature [ 0 ] + prm . corrT0 );
}
else
doc [ "temperature" ] = 255 ;
if ( useTemp > 1 )
doc [ "temperature2" ] = round1 ( temperature [ 1 ] + prm . corrT1 );
else
doc [ "temperature2" ] = 255 ;
if ( useHumid > 0 ) {
doc [ "humidity" ] = round1 ( humid [ 0 ] + prm . corrH0 );
}
else
doc [ "humidity" ] = 255 ;
if ( useHumid > 1 )
doc [ "humidity2" ] = round1 ( humid [ 1 ] + prm . corrH1 );
else
doc [ "humidity2" ] = 255 ;
if ( usePress > 0 ) {
doc [ "pressure" ] = pressur [ 0 ];
}
else
doc [ "pressure" ] = 255 ;
if ( useLux > 0 ) {
doc [ "lux" ] = lx ;
}
else
doc [ "lux" ] = 255 ;
//Clock calculation and display parameters
doc [ "utc_offset" ] = prm . utc_offset ;
doc [ "enableDST" ] = prm . enableDST ; // Flag to enable DST (summer time...)
doc [ "set12_24" ] = prm . set12_24 ; // Flag indicating 12 vs 24 hour time (false = 12, true = 24);
doc [ "showZero" ] = prm . showZero ; // Flag to indicate whether to show zero in the hour ten's place
doc [ "enableBlink" ] = prm . enableBlink ; // Flag to indicate whether center colon should blink
doc [ "interval" ] = prm . interval ; // interval in minutes, with 0 = off
//Day/Night dimmer parameters
doc [ "enableAutoShutoff" ] = prm . enableAutoShutoff ; // Flag to enable/disable nighttime shut off
sprintf ( buf , "%02d:%02d" , prm . dayHour , prm . dayMin );
doc [ "dayTime" ] = buf ;
sprintf ( buf , "%02d:%02d" , prm . nightHour , prm . nightMin );
doc [ "nightTime" ] = buf ;
doc [ "dayBright" ] = prm . dayBright ;
doc [ "nightBright" ] = prm . nightBright ;
doc [ "animMode" ] = prm . animMode ; //Tube animation
doc [ "manualOverride" ] = ! displayON ;
//Alarm values
doc [ "alarmEnable" ] = prm . alarmEnable ; //1 = ON, 0 = OFF
sprintf ( buf , "%02d:%02d" , prm . alarmHour , prm . alarmMin );
doc [ "alarmTime" ] = buf ;
doc [ "alarmPeriod" ] = prm . alarmPeriod ;
//RGB LED values
#if defined(USE_NEOPIXEL) || defined(USE_PWMLEDS)
doc [ "rgbEffect" ] = prm . rgbEffect ; // if 255, no RGB exist!
#else
doc [ "rgbEffect" ] = 255 ; //Not installed Neopixels!!!
#endif
doc [ "rgbBrightness" ] = prm . rgbBrightness ; // c_MinBrightness..255
doc [ "rgbFixColor" ] = prm . rgbFixColor ; // 0..256
doc [ "rgbSpeed" ] = prm . rgbSpeed ; // 1..255
doc [ "rgbDir" ] = prm . rgbDir ; // 0 = right direction, 1 = left direction
doc [ "rgbMinBrightness" ] = c_MinBrightness ; //minimum brightness for range check!!
doc [ "wifiMode" ] = prm . wifiMode ;
doc [ "wifiSsid" ] = prm . wifiSsid ;
doc [ "wifiPsw" ] = prm . wifiPsw ;
doc [ "ApSsid" ] = prm . ApSsid ;
doc [ "ApPsw" ] = prm . ApPsw ;
doc [ "NtpServer" ] = prm . NtpServer ;
doc [ "firmware" ] = prm . firmwareServer ;
doc [ "mqttBrokerAddr" ] = prm . mqttBrokerAddr ;
doc [ "mqttBrokerUser" ] = prm . mqttBrokerUser ;
doc [ "mqttBrokerPsw" ] = prm . mqttBrokerPsw ;
doc [ "mqttEnable" ] = prm . mqttEnable ;
#if defined(USE_MQTT)
doc [ "mqttBrokerRefresh" ] = prm . mqttBrokerRefresh ;
# else
doc [ "mqttBrokerRefresh" ] = 0 ;
# endif
doc [ "dateMode" ] = prm . dateMode ;
doc [ "dateRepeatMin" ] = prm . dateRepeatMin ;
doc [ "tempRepeatMin" ] = prm . tempRepeatMin ;
doc [ "tempCF" ] = prm . tempCF ;
doc [ "enableTimeDisplay" ] = prm . enableTimeDisplay ;
doc [ "enableDoubleBlink" ] = prm . enableDoubleBlink ;
doc [ "dateStart" ] = prm . dateStart ;
doc [ "dateEnd" ] = prm . dateEnd ;
doc [ "tempStart" ] = prm . tempStart ;
doc [ "tempEnd" ] = prm . tempEnd ;
doc [ "humidStart" ] = prm . humidStart ;
doc [ "humidEnd" ] = prm . humidEnd ;
doc [ "enableAutoDim" ] = prm . enableAutoDim ;
doc [ "enableRadar" ] = prm . enableRadar ;
# if ( RADAR_PIN >= 0 ) || defined ( USE_MQTT )
if ( prm . radarTimeout < 1 ) prm . radarTimeout = 1 ;
doc [ "radarTimeout" ] = prm . radarTimeout ;
# else
doc [ "radarTimeout" ] = 0 ;
#endif
doc [ "corrT0" ] = prm . corrT0 ;
doc [ "corrT1" ] = prm . corrT1 ;
doc [ "corrH0" ] = prm . corrH0 ;
doc [ "corrH1" ] = prm . corrH1 ;
doc [ "cathProtMin" ] = 3 ; //default value for cathProtMin slider
String json ;
serializeJson ( doc , json );
request -> send ( 200 , "application/json" , json );
}
// GET /getClockDetails - returns a pre-formatted HTML fragment (not JSON, unlike the other two
// handlers) with static/diagnostic info: firmware ID, driver name, MAC address, sensor counts, and
// the full used-GPIO listing built by listPins()/regPin() (usedPinsStr) plus any driver-supplied
// extra setup info (driverSetupStr). Shown on an "about/diagnostics" panel of the web admin UI.
void handleSendClockDetails ( AsyncWebServerRequest * request ) {
StaticJsonDocument < 200 > doc ;
//StaticJsonDocument<512> doc;
String dat ;
DPRINTLN ( "SendClockDetails" );
dat = String ( "<br><br><strong>FirmwareID:" ) + FW + "<br>" ;
dat += String ( " Tube driver:" ) + tubeDriver + "<br>" ;
dat += String ( " Mac:" ) + String ( WiFi . macAddress ()) + "</strong><br>" ;
dat += String ( "MAXBRIGHTNESS:" ) + String ( MAXBRIGHTNESS ) + "<br>" ;
if ( RTCexist ) dat += String ( "RTC exist: YES <br>" );
if ( useTemp > 0 ) dat += String ( "Temperature sensors:" ) + useTemp + "<br>" ;
if ( useHumid > 0 ) dat += String ( "Humidity sensors:" ) + useHumid + "<br>" ;
if ( usePress > 0 ) dat += String ( "Pressure sensors:" ) + usePress + "<br>" ;
dat += usedPinsStr + "<br>" ;
dat += driverSetupStr + "<br>" ;
DPRINTLN ( dat );
request -> send ( 200 , "text/html" , dat );
}
// GET /getCurrentInfos - the lightweight, frequently AJAX-polled JSON endpoint (date/time,
// temperature/humidity/pressure/lux, WiFi RSSI) used to keep the web admin page's live readouts
// current without re-fetching the whole settings form (see handleSendConfig() above). Keeps both new
// ("temperature1"/"humidity1") and legacy ("temperature"/"humidity") key names for backwards
// compatibility with older versions of the web front-end.
void handleSendCurrentInfos ( AsyncWebServerRequest * request ) {
StaticJsonDocument < 400 > doc ;
//StaticJsonDocument<512> doc;
char buf [ 20 ]; //conversion buffer
DPRINTLN ( "WebRefresh" );
//Actual time and environment data
sprintf ( buf , "%4d.%02d.%02d" , year (), month (), day ());
doc [ "currentDate" ] = buf ;
sprintf ( buf , "%02d:%02d" , hour (), minute ());
doc [ "currentTime" ] = buf ;
//DPRINT("useTemp:"); DPRINT(useTemp); DPRINT("useHumid:"); DPRINT(useHumid);
if ( useTemp > 0 ) {
doc [ "temperature1" ] = temperature [ 0 ] + prm . corrT0 ;
doc [ "temperature" ] = temperature [ 0 ] + prm . corrT0 ; //for compatibility with the old web page
}
else
doc [ "temperature1" ] = 255 ;
if ( useTemp > 1 )
doc [ "temperature2" ] = temperature [ 1 ] + prm . corrT1 ;
else
doc [ "temperature2" ] = 255 ;
if ( useHumid > 0 ) {
doc [ "humidity1" ] = humid [ 0 ] + prm . corrH0 ;
doc [ "humidity" ] = humid [ 0 ] + prm . corrH0 ; //for compatibility with the old web page
}
else
doc [ "humidity1" ] = 255 ;
if ( useHumid > 1 )
doc [ "humidity2" ] = humid [ 1 ] + prm . corrH1 ;
else
doc [ "humidity2" ] = 255 ;
if ( usePress > 0 ) {
doc [ "pressure" ] = pressur [ 0 ];
}
else
doc [ "pressure" ] = 255 ;
if ( useLux > 0 ) {
doc [ "lux" ] = lx ;
}
else
doc [ "lx" ] = 255 ;
doc [ "rssi" ] = WiFi . RSSI ();
String json ;
serializeJson ( doc , json );
request -> send ( 200 , "application/json" , json );
}
// Startup sequence (see docs/DEVELOPER_GUIDE.md section 4.1 for the full ordered summary):
// brownout detector (optional) -> EEPROM init -> setup_pins() (driver-specific GPIO setup) ->
// optional peripherals (alarm/colon/radar/tube-power/light-sensor/PWM LEDs) -> temperature/I2C/RTC/GPS
// setup -> loadEEPROM() (factoryReset() if the magic number doesn't match) -> NeoPixel setup -> tube
// test animation -> SPIFFS mount (web page files) -> WiFi connect (STA or AP mode) -> web server start
// -> MQTT setup -> show IP address on the tubes -> initial time sync. Order matters here: pins must be
// configured before any peripheral touches them, and EEPROM must be loaded before WiFi connects
// (credentials live in prm).
void setup () {
#if defined(DISABLE_BROWNOUT) && defined(ESP32)
WRITE_PERI_REG ( RTC_CNTL_BROWN_OUT_REG , 0 ); //disable brownout detector
#endif
delay ( 1000 );
WiFi . mode ( WIFI_OFF );
EEPROM . begin ( EEPROM_SIZE );
memset ( pinTxt , 0 , sizeof ( pinTxt ));
DPRINTBEGIN ( 115200 ); DPRINTLN ( " " );
DPRINTLN ( F ( "=================================================" ));
DPRINT ( "Starting " ); DPRINTLN ( webName );
DPRINT ( "Firmware code:" ); DPRINT ( FW ); DPRINT ( " Tube driver:" ); DPRINTLN ( tubeDriver );
DPRINT ( "MAXBRIGHTNESS:" ); DPRINTLN ( MAXBRIGHTNESS );
DPRINTLN ( F ( "=================================================" ));
clearDigits ();
setup_pins ();
#if ALARMSPEAKER_PIN >= 0
pinMode ( ALARMSPEAKER_PIN , OUTPUT ); regPin ( ALARMSPEAKER_PIN , "ALARMSPEAKER_PIN" );
digitalWrite ( ALARMSPEAKER_PIN , ! ALARM_ON );
DPRINT ( " - ON state:" );
if ( ALARM_ON == HIGH ) { DPRINTLN ( "HIGH" ); }
else { DPRINTLN ( "LOW" ); }
#endif
#if ALARMBUTTON_PIN >= 0
pinMode ( ALARMBUTTON_PIN , INPUT_PULLUP ); regPin ( ALARMBUTTON_PIN , "ALARMBUTTON_PIN" );
#endif
#if COLON_PIN >= 0
pinMode ( COLON_PIN , OUTPUT ); regPin ( COLON_PIN , "COLON_PIN" );
#endif
#if LED_SWITCH_PIN >= 0
pinMode ( LED_SWITCH_PIN , OUTPUT ); regPin ( LED_SWITCH_PIN , "LED_SWITCH_PIN" );
#endif
#if DECIMALPOINT_PIN >= 0
pinMode ( DECIMALPOINT_PIN , OUTPUT ); regPin ( DECIMALPOINT_PIN , "DECIMALPOINT_PIN" );
#endif
#if RADAR_PIN >= 0
pinMode ( RADAR_PIN , INPUT ); regPin ( RADAR_PIN , "RADAR_PIN" );
DPRINT ( " - RADAR Timeout:" ); DPRINTLN ( prm . radarTimeout );
#endif
#if TUBE_POWER_PIN >= 0
pinMode ( TUBE_POWER_PIN , OUTPUT ); regPin ( TUBE_POWER_PIN , "TUBE_POWER_PIN" );
digitalWrite ( TUBE_POWER_PIN , TUBE_POWER_ON );
DPRINT ( " - ON state:" );
if ( TUBE_POWER_ON == HIGH ) { DPRINTLN ( "HIGH" ); }
else { DPRINTLN ( "LOW" ); }
#endif
#if LIGHT_SENSOR_PIN >= 0
pinMode ( LIGHT_SENSOR_PIN , INPUT ); regPin ( LIGHT_SENSOR_PIN , "LIGHT_SENSOR_PIN" );
DPRINT ( " - MAXIMUM_LUX for MAXBRIGHTNESS:" ); DPRINTLN ( MAXIMUM_LUX );
useLux ++ ;
LDRexist = true ;
#endif
#ifdef USE_PWMLEDS
#if PWM1_PIN >= 0
pinMode ( PWM1_PIN , OUTPUT ); regPin ( PWM1_PIN , "PWM1_PIN" );
ledcAttachPin ( PWM1_PIN , 0 );
ledcSetup ( 0 , 200 , 8 ); // 12
#endif
#if PWM2_PIN >= 0
pinMode ( PWM2_PIN , OUTPUT ); regPin ( PWM2_PIN , "PWM2_PIN" );
ledcAttachPin ( PWM2_PIN , 1 );
ledcSetup ( 1 , 200 , 8 ); // 12
#endif
#if PWM3_PIN >= 0
pinMode ( PWM3_PIN , OUTPUT ); regPin ( PWM3_PIN , "PWM3_PIN" );
ledcAttachPin ( PWM3_PIN , 2 );
ledcSetup ( 2 , 200 , 8 ); // 12
#endif
#endif
#if defined(PIN_EXTINP1)
if ( PIN_EXTINP1 >= 0 )
pinMode ( PIN_EXTINP1 , INPUT ); regPin ( PIN_EXTINP1 , "NOT_USED_INPUT" );
#endif
#if defined(PIN_EXTINP2)
if ( PIN_EXTINP2 >= 0 )
pinMode ( PIN_EXTINP2 , INPUT ); regPin ( PIN_EXTINP2 , "NOT_USED_INPUT" );
#endif
#if TEMP_DALLAS_PIN >= 0
setupDallasTemp ();
#endif
#if TEMP_DHT_PIN >= 0
setupDHTemp ();
#endif
#ifdef USE_I2CSENSORS
setupI2Csensors ();
#endif
#ifdef USE_RTC
setupRTC ();
#endif
#ifdef USE_GPS
setupGPS ();
#endif
decimalpointON = false ;
DPRINT ( "Number of digits:" ); DPRINTLN ( maxDigits );
# ifdef FACTORYRESET_PIN
if ( FACTORYRESET_PIN >= 0 ) {
DPRINTLN ( "FactoryReset button is pushed!" );
pinMode ( FACTORYRESET_PIN , INPUT_PULLUP ); regPin ( FACTORYRESET_PIN , "FACTORYRESET_PIN" );
if ( digitalRead ( FACTORYRESET_PIN ) == LOW ) factoryReset ();
}
#endif
loadEEPROM ();
if ( prm . magic != MAGIC_VALUE ) factoryReset (); //EEPROM struct version mismatch (see MAGIC_VALUE comment) - wipe to sane defaults
setupNeopixel ();
listPins ();
writeAlarmPin ( ALARM_ON ); writeAlarmPin ( ! ALARM_ON ); //quick blip of the alarm pin at boot (on then immediately off) - a self-test/audible "I'm alive" click, not a real alarm
getDHTemp (); //get the first DHT temp+humid measure
// Temporarily force animMode to 2 (all changed tubes spin together) for the power-on tube test
// below, regardless of the user's saved animation preference, then restore it afterwards - ensures
// testTubes() always animates visibly even if the stored prm.animMode is 0 (no animation).
byte saveMode = prm . animMode ;
prm . animMode = 2 ;
doAnimationPWM ();
testTubes ( 300 );
clearDigits ();
Fdelay ( 200 );
if ( ! SPIFFS . begin ()) {
DPRINTLN ( "An Error has occurred while mounting SPIFFS" );
}
else {
#if defined(ESP32)
DPRINT ( "SPIFFS Total bytes: " ); DPRINTLN ( SPIFFS . totalBytes ());
DPRINT ( "SPIFFS Used bytes: " ); DPRINTLN ( SPIFFS . usedBytes ());
#endif
DPRINTLN ( "SPIFFS started." );
if ( ! SPIFFS . exists ( "/index.html" )) { DPRINTLN ( "/index.html not found!" );}
if ( ! SPIFFS . exists ( "/site.css" )) { DPRINTLN ( "/site.css not found!" );}
if ( ! SPIFFS . exists ( "/page.js" )) { DPRINTLN ( "/page.js not found!" );}
if ( ! SPIFFS . exists ( "/jquery_360.js" )) { DPRINTLN ( "/jquery_360.js not found!" );}
}
memset ( digit , 10 , sizeof ( digit )); //clear display
memset ( newDigit , 10 , sizeof ( newDigit ));
memset ( oldDigit , 10 , sizeof ( oldDigit ));
wifiConnectRunning = true ;
writeDisplay2 ();
if ( prm . wifiMode ) {
//findBestWifi();
startWifiMode (); //note: startWifiMode() already falls back to wifiManager() internally on failure/empty SSID - the retry loop below is an extra layer only exercised when USE_WIFIMANAGER is enabled
#ifdef USE_WIFIMANAGER
int counter = 0 ;
while ( WiFi . status () != WL_CONNECTED ) { //try to use wifiManeger, if enabled
DPRINT ( '.' );
Fdelay ( 3000 );
if ( counter ++> 2 ) {
wifiManager ();
break ;
}
}
DPRINTLN ( " " );
#endif
if ( WiFi . status () != WL_CONNECTED ) { //failed to connect to wifi - give up on infrastructure mode entirely and fall back to hosting our own AP so the clock is at least reachable
startStandaloneMode ();
}
else {
ip = WiFi . localIP ();
WiFi . setAutoReconnect ( true );
}
}
else {
startStandaloneMode ();
}
wifiConnectRunning = false ;
startMDNS ();
startServer ();
2026-10-25 21:23:10 +01:00
startArduinoOTA ();
2026-10-25 20:48:04 +01:00
#ifdef USE_MQTT
setupMqtt ();
#endif
2026-10-25 21:23:10 +01:00
2026-10-25 20:48:04 +01:00
showMyIp ();
Fdelay ( 500 );
clearDigits ();
Fdelay ( 200 );
prm . animMode = saveMode ;
timeClient . begin ();
calcTime ();
timeProgram ();
//newCathodeProtect(5000,0);
}
// Time-source priority chain (docs/DEVELOPER_GUIDE.md section 4.4): NTP-over-WiFi is always tried
// first when connected (updateTimefromTimeserver() self-throttles to once per TIMESERVER_REFRESH, so
// this is cheap to call every cycle); GPS is tried next if fitted; the DS3231 RTC (if fitted) is the
// LAST-RESORT fallback: if either of the above actually refreshed the system clock this cycle, the RTC
// is instead WRITTEN (updateRTC(), to keep it in sync as a fallback for the next power-loss/no-network
// boot) - otherwise the RTC is READ (getRTC()) to keep the clock ticking with no network/GPS at all.
// A build with none of WiFi/GPS/RTC available simply free-runs on TimeLib's own millis()-derived clock
// (set once at some point in the past) with no further correction.
void calcTime () {
boolean refreshed = false ;
boolean refreshed2 = false ;
if ( WiFi . status () == WL_CONNECTED ) { //check wifi connection
refreshed = updateTimefromTimeserver (); //update time from wifi
}
if ( GPSexist ) { //update time from GPS, if exist
refreshed2 = getGPS ();
}
if ( RTCexist ) { //update time from RTC, if exist
if ( refreshed || refreshed2 )
updateRTC (); //update RTC, if needed
else
getRTC ();
}
}
// Main per-tick clock/sensor/display driver, called unconditionally every loop() iteration but
// self-throttled to run at most every 200ms (calcTime()/sensor polling cadence) - the actual visible
// digit refresh only happens on real second boundaries (`if (now() != prevTime)` below), so most 200ms
// ticks only refresh sensor readings and return without touching the display buffers.
void timeProgram () {
static unsigned long lastRun = 0 ;
if (( millis () - lastRun ) < 200 ) return ;
lastRun = millis ();
calcTime ();
if ( useDallasTemp > 0 ) {
requestDallasTemp ( false ); //start measure
getTemp (); //get result, if ready
}
getDHTemp ();
getI2Csensors (); //check all existing I2C sensors
#ifdef SIMULATE_TEMPERATURE_SENSOR //for test only
useTemp = 2 ; temperature [ 0 ] = 20.4 ; temperature [ 1 ] = 22.8 ; //test only
useHumid = 2 ; humid [ 0 ] = 41.2 ; humid [ 1 ] = 42.7 ;
#endif
if ( now () != prevTime ) { // Update time every second
prevTime = now ();
evalShutoffTime (); // Check whether display should be turned off (Auto shutoff mode)
if ( ! displayON || ! prm . enableBlink ) colonBlinkState = false ;
else colonBlinkState = ( bool )( second () % 2 );
// Decide which "screen" to show this second, in priority order: temperature > humidity > date >
// plain clock (see the showClock/showDate/showTemp*/showHumid* globals declared near the top of
// this file). Each window is an independent [xStart,xEnd) range of seconds-within-minute, so e.g.
// temperature and date windows can be configured to overlap or not from the web UI; whichever
// display*() function actually runs is decided later in displayTime4/6/8() by checking these
// flags in the same priority order.
showClock = false ;
showDate = ( prm . enableTimeDisplay && ( prm . dateRepeatMin > 0 ) && (( second () >= prm . dateStart ) && ( second () < prm . dateEnd )));
if ( showDate && ( prm . dateRepeatMin > 1 )) {
if (( minute () % prm . dateRepeatMin ) != 0 ) {
showDate = false ;
}
}
//useTemp = 1; temperature[0] = -12.34; useHumid = 1; humid[0] = 26.7; //for test only
showTemp0 = ( useTemp > 0 ) && ( second () >= prm . tempStart ) && ( second () < prm . tempEnd );
showTemp1 = ( useTemp > 1 ) && ( second () >= prm . tempStart + ( prm . tempEnd - prm . tempStart ) / 2 ) && ( second () < prm . tempEnd );
showHumid0 = ( useHumid > 0 ) && ( second () >= prm . humidStart ) && ( second () < prm . humidEnd );
showHumid1 = ( useHumid > 1 ) && ( second () >= prm . humidStart + ( prm . humidEnd - prm . humidStart ) / 2 ) && ( second () < prm . humidEnd );
if (( prm . tempRepeatMin == 0 ) || (( minute () % prm . tempRepeatMin ) != 0 )) {
showTemp0 = false ;
showTemp1 = false ;
showHumid0 = false ;
showHumid1 = false ;
}
if ( maxDigits >= 8 ) displayTime8 ();
else if ( maxDigits == 6 ) displayTime6 ();
else displayTime4 ();
// if (COLON_PIN>=0) digitalWrite(COLON_PIN,colonBlinkState); // Blink colon pin
#if LED_SWITCH_PIN >= 0
if ( displayON ) //Switch on backlight LED only daytime
digitalWrite ( LED_SWITCH_PIN , HIGH );
else
digitalWrite ( LED_SWITCH_PIN , LOW );
#endif
changeDigit ();
printDigits ( 0 );
if ( prm . interval > 0 ) { // protection is enabled
// At the first top of the hour, initialize protection logic timer
if ( ! initProtectionTimer && ( minute () == 0 )) {
protectTimer = 0 ; // Ensures protection logic will be immediately triggered
initProtectionTimer = true ;
}
if (( now () - protectTimer ) >= 60 * prm . interval ) {
protectTimer = now ();
// The current time can drift slightly relative to the protectTimer when NIST time is updated
// Need to make a small adjustment to the timer to ensure it is triggered at the minute change
protectTimer -= (( second () + 30 ) % 60 - 30 );
// random(3)-1 picks the animation direction for this run: -1, 0 (random-order) or +1 - see
// newCathodeProtect()'s dir parameter and its POSSIBLE BUG note further down.
if ( displayON && ( millis () > 50000 )) newCathodeProtect ( maxDigits * 1500 , random ( 3 ) - 1 ); //don't play in the first 50 sec
}
}
}
}
// Raw single-block read of the whole `prm` struct from EEPROM address 0 - does NOT itself check
// prm.magic; the caller (setup()) is responsible for calling factoryReset() if the loaded magic value
// doesn't match MAGIC_VALUE.
void loadEEPROM () {
DPRINT ( "Loading setting from EEPROM. Size:" ); DPRINT ( EEPROM_SIZE );
EEPROM . get ( EEPROM_addr , prm );
DPRINT ( " version:" ); DPRINTLN ( prm . magic );
}
// Raw single-block write of the whole `prm` struct back to EEPROM (address 0) - called after every
// successful key/value change in handleConfigChanged(), so every settings tweak from the web UI does
// a full-struct EEPROM commit (flash wear consideration: frequent single-field changes each cost a
// full-struct flash write, not just the changed bytes).
void saveEEPROM () {
EEPROM . put ( EEPROM_addr , prm );
EEPROM . commit ();
DPRINTLN ( "Settings saved to EEPROM!" );
}
// Resets every prm.* field to its hard-coded default (falling back to clocks.h-provided compile-time
// defaults like DEFAULT_SSID/AP_NAME/DATE_START/etc where available), then saves and re-syncs the
// clock. Triggered by: EEPROM magic-number mismatch at boot (see setup()), the web admin's
// POST /factoryreset, or (if wired) a held FACTORYRESET_PIN button at boot.
void factoryReset () {
DPRINTLN ( "Factory Reset!!!" );
prm . alarmEnable = false ;
prm . alarmHour = 7 ;
prm . alarmMin = 0 ;
prm . alarmPeriod = 15 ;
prm . rgbEffect = 2 ;
prm . rgbBrightness = 80 ;
prm . rgbFixColor = 150 ;
prm . rgbSpeed = 50 ;
prm . rgbDir = 0 ;
prm . wifiMode = true ;
#ifdef DEFAULT_SSID
strncpy ( prm . wifiSsid , DEFAULT_SSID , sizeof ( prm . wifiSsid ));
#else
strcpy ( prm . wifiSsid , "" );
#endif
#ifdef DEFAULT_PSW
strncpy ( prm . wifiPsw , DEFAULT_PSW , sizeof ( prm . wifiPsw ));
#else
strcpy ( prm . wifiPsw , "" );
#endif
for ( int i = 0 ; i < ( int ) strlen ( prm . ApSsid ); i ++ ) { //repair bad chars in AP SSID
if (( prm . ApSsid [ i ] < 32 ) || ( prm . ApSsid [ i ] > 126 )) prm . ApSsid [ i ] = '_' ;
}
strncpy ( prm . ApSsid , AP_NAME , sizeof ( prm . ApSsid ));
strncpy ( prm . ApPsw , AP_PASSWORD , sizeof ( prm . ApPsw ));
strncpy ( prm . NtpServer , "pool.ntp.org" , sizeof ( prm . NtpServer ));
strcpy ( prm . mqttBrokerAddr , "10.10.0.202" );
strcpy ( prm . mqttBrokerUser , "mqtt" );
strcpy ( prm . mqttBrokerPsw , "mqtt" );
strncpy ( prm . firmwareServer , FIRMWARE_SERVER , sizeof ( prm . firmwareServer ));
prm . mqttEnable = false ;
prm . mqttBrokerRefresh = 10 ; //sec
prm . utc_offset = 1 ;
prm . enableDST = false ; // Flag to enable DST (summer time...)
prm . set12_24 = true ; // Flag indicating 12 vs 24 hour time (false = 12, true = 24);
prm . showZero = true ; // Flag to indicate whether to show zero in the hour ten's place
prm . enableBlink = true ; // Flag to indicate whether center colon should blink
prm . interval = 15 ; // prm.interval in minutes, with 0 = off
prm . enableAutoShutoff = true ; // Flag to enable/disable nighttime shut off
prm . dayHour = 7 ;
prm . dayMin = 0 ;
prm . nightHour = 20 ;
prm . nightMin = 0 ;
prm . dayBright = MAXBRIGHTNESS ;
prm . nightBright = 3 ;
prm . animMode = 6 ;
prm . dateMode = 2 ; // 0:dd/mm/yyyy 1:mm/dd/yyyy 2:yyyy/mm/dd
prm . tempCF = false ; //Temperature Celsius / Fahrenheit
prm . enableTimeDisplay = ENABLE_CLOCK_DISPLAY ;
prm . dateStart = DATE_START ; //Date is displayed start..end
prm . dateEnd = DATE_END ;
prm . tempStart = TEMP_START ; //Temperature display start..end
prm . tempEnd = TEMP_END ;
prm . humidStart = HUMID_START ; //Humidity% display start..end
prm . humidEnd = HUMID_END ;
prm . dateRepeatMin = DATE_REPEAT_MIN ; //show date only every xxx minute. If zero, the date is never displayed!
prm . tempRepeatMin = 1 ;
#ifdef DOUBLE_BLINK
prm . enableDoubleBlink = true ; //both separator points are blinking (6 or 8 tubes VFD clock)
#else
prm . enableDoubleBlink = false ;
# endif
prm . enableAutoDim = false ; //Automatic dimming by luxmeter
prm . enableRadar = false ; //Radar sensor
prm . radarTimeout = 5 ; //min
prm . corrT0 = 0 ;
prm . corrT1 = 0 ;
prm . corrH0 = 0 ;
prm . corrH1 = 0 ;
prm . magic = MAGIC_VALUE ; //magic value to check the EEPROM version
saveEEPROM ();
calcTime ();
}
// Scheduled/automatic anti-cathode-poisoning animation: every tube counts rapidly through 0-9, then
// tubes stop one-by-one (in the order controlled by `dir`) once they reach their real target digit
// value (`tmp[]`, the display content saved at entry). dir<0 stops digits left-to-right (index 0
// first), dir>0 stops them right-to-left (highest index first), dir==0 is meant to stop them in random
// order - see the POSSIBLE BUG note below. Called automatically every prm.interval minutes from
// timeProgram(), and also reachable via the commented-out `newCathodeProtect(5000,0);` line at the end
// of setup(). Distinct from the simpler, manually-triggered doCathodeProtect() above.
void newCathodeProtect ( unsigned long t , int dir ) { //t = time in msec, dir = direction -1,0,1 (0=random)
byte tmp [ 10 ];
boolean tmpDP [ 10 ];
unsigned long started = millis ();
boolean finish , stopThis ;
byte fin [ 10 ];
byte nextStoppedDigit ;
int sum = 0 ;
cathodeProtRunning = true ;
DPRINT ( "Cathode Protect running! dir:" ); DPRINTLN ( dir );
memcpy ( tmp , digit , sizeof ( tmp ));
memcpy ( tmpDP , digitDP , sizeof ( tmpDP ));
memset ( fin , 0 , sizeof ( fin ));
switch ( dir ) {
case - 1 :
nextStoppedDigit = 0 ;
break ;
case 0 :
nextStoppedDigit = random ( maxDigits );
break ;
case 1 :
nextStoppedDigit = maxDigits - 1 ;
break ;
}
// POSSIBLE BUG: this line unconditionally overwrites whatever the switch above just computed,
// including the `case 0` (dir==0, "random") result - so the FIRST digit to stop is never actually
// random, it's always index 0 (dir<=0) or maxDigits-1 (dir>0), identical to the dir<0/dir>0 cases.
// The random-order behavior only actually shows up LATER, in the `else if (sum<=maxDigits-2)` branch
// below that picks each SUBSEQUENT stop digit randomly - so dir==0 does look random from the second
// stopped digit onward, just not on the very first one, which is likely not what was intended.
nextStoppedDigit = dir > 0 ? 0 : maxDigits - 1 ;
while ( true ) {
for ( int i = 0 ; i < maxDigits ; i ++ ) {
finish = (( millis () - started ) >= t );
stopThis = ( i == nextStoppedDigit );
if ( finish && stopThis ) { //this digit stops
fin [ i ] = 1 ;
digitDP [ i ] = tmpDP [ i ]; //restore original DP value
digit [ i ] = tmp [ i ];
t += 1000 ;
if ( dir > 0 ) { //find next stop digit
nextStoppedDigit ++ ;
if ( nextStoppedDigit >= maxDigits ) nextStoppedDigit = 0 ;
}
else if ( dir < 0 ) {
nextStoppedDigit -- ;
if ( nextStoppedDigit == 255 ) nextStoppedDigit = maxDigits - 1 ;
}
else if ( sum <= maxDigits - 2 ) {
while ( true ) {
nextStoppedDigit = random ( maxDigits );
if ( fin [ nextStoppedDigit ] == 0 ) break ; //found a running digit
}
}
}
else if ( fin [ i ] == 0 ) { //get next number
if ( dir >= 0 )
digit [ i ] = ( digit [ i ] + 1 ) % 10 ;
else {
digit [ i ] -- ; if ( digit [ i ] == 255 ) digit [ i ] = 9 ;
}
digitDP [ i ] = (( digit [ i ] % 2 ) == 0 );
}
} //end for
//DPRINT("lastStoppedDigit:"); DPRINT(lastStoppedDigit);
//DPRINT(" nextStoppedDigit:"); DPRINTLN(nextStoppedDigit);
writeDisplaySingle ();
Fdelay ( 100 );
sum = 0 ;
for ( int i = 0 ; i < maxDigits ; i ++ ) sum += fin [ i ];
//DPRINT("Sum:"); DPRINTLN(sum);
if ( sum >= maxDigits ) //all digits ready
break ;
if (( millis () - started ) >= ( t + 5000 )) {
DPRINTLN ( "Safety exit" );
break ;
}
} //end while
memcpy ( oldDigit , digit , sizeof ( oldDigit ));
lastCathodeProt = minute ();
cathodeProtRunning = false ;
}
// NOTE: earlier/simpler cathode-protect implementation, superseded by newCathodeProtect() above
// (kept for reference/comparison - "original version"). Not called from anywhere else in this file;
// appears to be dead code left in place.
void cathodeProtect () { //original version
int hour12_24 = prm . set12_24 ? ( byte ) hour () : ( byte ) hourFormat12 ();
byte hourShow = ( byte ) hour12_24 ;
byte minShow = ( byte ) minute ();
byte secShow = ( byte ) second ();
byte dh1 = ( hourShow / 10 ), dh2 = ( hourShow % 10 );
byte dm1 = ( minShow / 10 ), dm2 = ( minShow % 10 );
byte ds1 = ( secShow / 10 ), ds2 = ( secShow % 10 );
DPRINTLN ( "Cathode Protect running!" );
// All four digits will increment up at 10 Hz.
// At T=2 sec, individual digits will stop at
// the correct time every second starting from
// the right and ending on the left
for ( int i = 0 ; i <= 70 ; i ++ ) {
if ( i >= 20 ) ds2 = ( secShow % 10 );
if ( i >= 30 ) ds1 = ( secShow / 10 );
if ( i >= 40 ) dm2 = ( minShow % 10 );
if ( i >= 50 ) dm1 = ( minShow / 10 );
if ( i >= 60 ) dh2 = ( hourShow % 10 );
if ( i == 70 ) dh1 = ( hourShow / 10 );
if ( maxDigits >= 8 ) {
digit [ 7 ] = dh1 ;
digit [ 6 ] = dh2 ;
digit [ 5 ] = 11 ;
digit [ 4 ] = dm1 ;
digit [ 3 ] = dm2 ;
digit [ 2 ] = 11 ;
digit [ 1 ] = ds1 ;
digit [ 0 ] = ds2 ;
}
else if ( maxDigits == 6 ) {
digit [ 5 ] = dh1 ;
digit [ 4 ] = dh2 ;
digit [ 3 ] = dm1 ;
digit [ 2 ] = dm2 ;
digit [ 1 ] = ds1 ;
digit [ 0 ] = ds2 ;
}
else {
digit [ 3 ] = dh1 ;
digit [ 2 ] = dh2 ;
digit [ 1 ] = dm1 ;
digit [ 0 ] = dm2 ;
}
incMod10 ( dh1 ); incMod10 ( dh2 );
incMod10 ( dm1 ); incMod10 ( dm2 );
incMod10 ( ds1 ); incMod10 ( ds2 );
for ( int d = 0 ; d < maxDigits ; d ++ ) {
digitDP [ d ] = (( digit [ d ] % 2 ) == 0 );
}
writeDisplaySingle ();
Fdelay ( 100 );
}
memcpy ( oldDigit , digit , sizeof ( oldDigit ));
lastCathodeProt = minute ();
}
inline void incMod10 ( byte & x ) {
x = ( x + 1 == 10 ? 0 : x + 1 );
};
// displayTemp()/displayHumid() render a sensor reading into newDigit[]/digitDP[] (right-aligned,
// working backwards from digPtr), applying the per-sensor calibration offset (prm.corrT0/corrT1 or
// corrH0/corrH1 - ptr selects which sensor slot, 0 or 1). Two build-time variants exist per function:
// a compact DISPLAYTEMP_ONLY_2DIGITS/DISPLAYHUMID_ONLY_2DIGITS version for clocks with too few digit
// positions to show units/sign, and the full version below (unit letter + degree/percent glyph +
// optional leading sign). `ptr==0`/`ptr==1` only ever apply corrT0/corrH0 vs corrT1/corrH1 - a 3rd or
// 4th simultaneous sensor slot (see I2Csensors.ino review notes) gets no calibration correction here.
#ifdef DISPLAYTEMP_ONLY_2DIGITS
void displayTemp ( byte ptr ) { //special version, 2 digits only
float t = temperature [ ptr ];
if ( ptr == 0 ) t += prm . corrT0 ;
if ( ptr == 1 ) t += prm . corrT1 ;
if ( prm . tempCF ) {
t = round1 (( temperature [ ptr ] * 9 / 5 ) + 32 );
}
int digPtr = 3 ; //tube number: first digit of minutes (hours:5,4 minutes:3,2 seconds: 1,0 )
#ifdef TEMP_START_POSITION
digPtr = TEMP_START_POSITION ;
# endif
for ( int i = 0 ; i < maxDigits ; i ++ ) { //clear display
digitDP [ i ] = false ;
newDigit [ i ] = 10 ;
}
t = abs ( t );
newDigit [ digPtr ] = t / 10 ;
if ( newDigit [ digPtr ] == 0 ) newDigit [ digPtr ] = 10 ; //BLANK, if zero
newDigit [ -- digPtr ] = int ( t ) % 10 ;
if ( prm . animMode == 0 ) memcpy ( oldDigit , newDigit , sizeof ( oldDigit )); //don't do animation
colonBlinkState = false ;
decimalpointON = false ;
}
#else
void displayTemp ( byte ptr ) {
float t = temperature [ ptr ];
if ( ptr == 0 ) t += prm . corrT0 ;
if ( ptr == 1 ) t += prm . corrT1 ;
if ( prm . tempCF ) {
t = round1 (( temperature [ ptr ] * 9 / 5 ) + 32 );
}
int digPtr = maxDigits - 1 ;
#ifdef TEMP_START_POSITION
digPtr = TEMP_START_POSITION ;
# endif
for ( int i = 0 ; i < maxDigits ; i ++ ) {
digitDP [ i ] = false ;
newDigit [ i ] = 10 ;
}
#if defined(PLUS_CHARCODE) || defined(MINUS_CHARCODE) //leading sign
if ( t >= 0 ) newDigit [ digPtr ] = PLUS_CHARCODE ;
if ( t < 0 ) newDigit [ digPtr ] = MINUS_CHARCODE ;
digPtr -- ;
#endif
t = abs ( t );
newDigit [ digPtr ] = t / 10 ;
if ( newDigit [ digPtr ] == 0 ) newDigit [ digPtr ] = 10 ; //BLANK!!!
newDigit [ -- digPtr ] = int ( t ) % 10 ;
digitDP [ digPtr ] = true ;
newDigit [ -- digPtr ] = int ( t * 10 ) % 10 ;
if (( maxDigits > 5 ) && ( GRAD_CHARCODE >= 0 )) {
digPtr -= 1 ;
newDigit [ digPtr ] = GRAD_CHARCODE ; //grad
}
if ( TEMP_CHARCODE >= 0 ) {
if ( prm . tempCF ) newDigit [ -- digPtr ] = TEMP_CHARCODE + 5 ; // 'F'
else newDigit [ -- digPtr ] = TEMP_CHARCODE ; // 'C'
}
if ( prm . animMode == 0 ) memcpy ( oldDigit , newDigit , sizeof ( oldDigit )); //don't do animation
colonBlinkState = true ;
decimalpointON = true ;
}
#endif
#ifdef DISPLAYHUMID_ONLY_2DIGITS
void displayHumid ( byte ptr ) { //special version, 2 digits only
float h = humid [ ptr ];
if ( ptr == 0 ) h = round ( h + prm . corrH0 );
if ( ptr == 1 ) h = round ( h + prm . corrH1 );
int digPtr = 3 ; //tube number: first digit of minutes (hours:5,4 minutes:3,2 seconds: 1,0 )
#ifdef HUMID_START_POSITION
digPtr = HUMID_START_POSITION ;
# endif
for ( int i = 0 ; i < maxDigits ; i ++ ) { //clear display
digitDP [ i ] = false ;
newDigit [ i ] = 10 ;
}
newDigit [ digPtr ] = h / 10 ;
if ( newDigit [ digPtr ] == 0 ) newDigit [ digPtr ] = 10 ; //BLANK, if zero
newDigit [ -- digPtr ] = int ( h ) % 10 ;
if ( prm . animMode == 0 ) memcpy ( oldDigit , newDigit , sizeof ( oldDigit )); //don't do animation
colonBlinkState = false ;
decimalpointON = false ;
}
#else
void displayHumid ( byte ptr ) {
int digPtr = maxDigits - 1 ;
#ifdef HUMID_START_POSITION
digPtr = HUMID_START_POSITION ;
# endif
for ( int i = 0 ; i < maxDigits ; i ++ ) {
digitDP [ i ] = false ;
newDigit [ i ] = 10 ;
}
float h = humid [ ptr ];
if ( ptr == 0 ) h += prm . corrH0 ;
if ( ptr == 1 ) h += prm . corrH1 ;
#if defined(PLUS_CHARCODE) || defined(MINUS_CHARCODE) //leading sign
digPtr -- ;
#endif
newDigit [ digPtr ] = int ( h ) / 10 ;
if ( newDigit [ digPtr ] == 0 ) newDigit [ digPtr ] = 10 ; //BLANK if zero!!!
newDigit [ -- digPtr ] = int ( h ) % 10 ;
digitDP [ digPtr ] = true ;
newDigit [ -- digPtr ] = int ( h * 10 ) % 10 ;
if ( maxDigits > 5 ) {
if ( digitsOnly ) {
newDigit [ -- digPtr ] = 10 ; //empty character
newDigit [ -- digPtr ] = PERCENT_CHARCODE ; // "%"
}
else {
newDigit [ -- digPtr ] = 10 ; //empty character
newDigit [ -- digPtr ] = UPPER_CIRCLE_CHARCODE ; //upper circle = 16
newDigit [ -- digPtr ] = LOWER_CIRCLE_CHARCODE ; // lower circle = 18
}
}
else { //4 tubes only
if ( digitsOnly ) {
newDigit [ -- digPtr ] = PERCENT_CHARCODE ; // "%"
}
else {
for ( int i = 0 ; i < maxDigits ; i ++ ) { digitDP [ i ] = false ;} //clear any decimal point
newDigit [ digPtr ] = UPPER_CIRCLE_CHARCODE ; //upper circle = 16
newDigit [ -- digPtr ] = LOWER_CIRCLE_CHARCODE ; // lower circle = 18
}
}
if ( prm . animMode == 0 ) memcpy ( oldDigit , newDigit , sizeof ( oldDigit )); //don't do animation
colonBlinkState = true ;
decimalpointON = true ;
}
#endif
// Renders the current calendar date into newDigit[] according to prm.dateMode (0=dd/mm/yyyy,
// 1=mm/dd/yyyy, 2=yyyy/mm/dd), truncating the year portion on clocks with fewer than 8 digit
// positions (>6 digits shows a 2-digit year, <=6 shows none) - the `p[m][i]` lookup table encodes
// which date component (0=day,1=month,2=year) goes in which position for each mode.
void displayDate () {
byte m = prm . dateMode ;
if ( m > 2 ) m = 2 ;
byte p [ 3 ][ 8 ] =
{ //tube# 76543210 543210 3210
{ 2 , 1 , 0 }, //ddmmyyyy ddmmyy ddmm
{ 2 , 0 , 1 }, //mmddyyyy mmddyy mmdd
{ 0 , 1 , 2 } //yyyymmdd yymmdd mmdd
};
int t = 0 ;
if ( thermometerClock ) t = 1 ;
for ( int i = 0 ; i < 3 ; i ++ ) {
if ( p [ m ][ i ] == 2 ) {
if ( ! thermometerClock ) {
if ( maxDigits > 4 ) {
newDigit [ t ++ ] = year () % 10 ;
newDigit [ t ++ ] = ( year () % 100 ) / 10 ;
}
if ( maxDigits > 6 ) {
newDigit [ t ++ ] = ( year () % 1000 ) / 100 ;
newDigit [ t ++ ] = year () / 1000 ;
}
} //!thermometerClock
}
if ( p [ m ][ i ] == 1 ) {
newDigit [ t ++ ] = month () % 10 ;
newDigit [ t ++ ] = month () / 10 ;
}
if ( p [ m ][ i ] == 0 ) {
newDigit [ t ++ ] = day () % 10 ;
newDigit [ t ++ ] = day () / 10 ;
}
} //end for
if (( maxDigits > 6 ) && ( m < 2 )) {
digitDP [ 4 ] = true ;
digitDP [ 6 ] = true ;
}
else {
if ( thermometerClock ) {
digitDP [ 3 ] = true ;
newDigit [ 0 ] = 10 ;
newDigit [ 5 ] = 10 ;
}
else {
digitDP [ 2 ] = true ;
digitDP [ 4 ] = true ;
}
}
colonBlinkState = true ;
if ( prm . animMode == 1 ) memcpy ( oldDigit , newDigit , sizeof ( oldDigit )); //don't do animation
}
// displayTime4/6/8() are the top-level "what goes on the display this second" dispatchers, selected by
// timeProgram() based on maxDigits. Each one first blanks digitDP[], sets the colon-separator DP
// positions, then dispatches by priority to temperature/humidity/date, falling through to the normal
// H:M(:S) clock rendering only if none of those "special screens" is currently active AND
// prm.enableTimeDisplay is set (a thermometer-only build can disable the clock face entirely).
// showZero controls whether a leading-zero hour (e.g. "07") shows the 0 or blanks it (digit value 10).
void displayTime4 () {
for ( int i = 0 ; i < maxDigits ; i ++ ) digitDP [ i ] = false ;
digitDP [ 4 ] = true ; digitDP [ 2 ] = true ;
int hour12_24 = prm . set12_24 ? ( byte ) hour () : ( byte ) hourFormat12 ();
if ( showTemp1 ) displayTemp ( 1 );
else if ( showTemp0 ) displayTemp ( 0 );
else if ( showHumid1 ) displayHumid ( 1 );
else if ( showHumid0 ) displayHumid ( 0 );
else if ( showDate && prm . enableTimeDisplay ) displayDate ();
else if ( prm . enableTimeDisplay ) {
showClock = true ;
newDigit [ 3 ] = hour12_24 / 10 ;
if (( ! prm . showZero ) && ( newDigit [ 3 ] == 0 )) newDigit [ 3 ] = 10 ;
newDigit [ 2 ] = hour12_24 % 10 ;
newDigit [ 1 ] = minute () / 10 ;
newDigit [ 0 ] = minute () % 10 ;
if ( prm . enableBlink && ( second () % 2 == 0 )) digitDP [ 2 ] = false ;
}
}
// 6-digit variant of displayTime4() above - has an extra branch for `thermometerClock` builds (see
// THERMOMETER_CLOCK in clocks.h: first digit reserved for +/- sign, last for C/F, middle 4 for HH:MM)
// vs the normal 6-tube H:M:S layout.
void displayTime6 () {
for ( int i = 0 ; i < maxDigits ; i ++ ) digitDP [ i ] = false ;
digitDP [ 4 ] = true ; digitDP [ 2 ] = true ;
int hour12_24 = prm . set12_24 ? ( byte ) hour () : ( byte ) hourFormat12 ();
if ( showTemp1 ) displayTemp ( 1 );
else if ( showTemp0 ) displayTemp ( 0 );
else if ( showHumid1 ) displayHumid ( 1 );
else if ( showHumid0 ) displayHumid ( 0 );
else if ( showDate && prm . enableTimeDisplay ) displayDate ();
else if ( prm . enableTimeDisplay ) {
showClock = true ;
if ( thermometerClock ) {
newDigit [ 4 ] = hour12_24 / 10 ;
if (( ! prm . showZero ) && ( newDigit [ 4 ] == 0 )) newDigit [ 4 ] = 10 ;
newDigit [ 3 ] = hour12_24 % 10 ;
newDigit [ 2 ] = minute () / 10 ;
newDigit [ 1 ] = minute () % 10 ;
newDigit [ 0 ] = 10 ;
newDigit [ 5 ] = 10 ;
if ( prm . enableBlink && ( second () % 2 == 0 )) digitDP [ 3 ] = false ;
}
else { //normal clock
newDigit [ 5 ] = hour12_24 / 10 ;
if (( ! prm . showZero ) && ( newDigit [ 5 ] == 0 )) newDigit [ 5 ] = 10 ;
newDigit [ 4 ] = hour12_24 % 10 ;
newDigit [ 3 ] = minute () / 10 ;
newDigit [ 2 ] = minute () % 10 ;
if ( prm . enableBlink && ( second () % 2 == 0 )){
digitDP [ 2 ] = false ;
if ( prm . enableDoubleBlink ) {
digitDP [ 4 ] = false ;
}
}
newDigit [ 1 ] = second () / 10 ;
newDigit [ 0 ] = second () % 10 ;
}
}
}
// 8-digit variant of displayTime4() above - full H:M:S with dash separators (digit value 11, see the
// BUFSIZE encoding comment near the digit[] declaration) instead of decimal points between the groups.
void displayTime8 () {
for ( int i = 0 ; i < maxDigits ; i ++ ) {
digitDP [ i ] = false ;
newDigit [ i ] = 10 ;
}
int hour12_24 = prm . set12_24 ? ( byte ) hour () : ( byte ) hourFormat12 ();
if ( showTemp1 ) displayTemp ( 1 );
else if ( showTemp0 ) displayTemp ( 0 );
else if ( showHumid1 ) displayHumid ( 1 );
else if ( showHumid0 ) displayHumid ( 0 );
else if ( showDate && prm . enableTimeDisplay ) displayDate ();
else if ( prm . enableTimeDisplay ) {
showClock = true ;
newDigit [ 8 ] = 10 ; //sign digit = BLANK
newDigit [ 7 ] = hour12_24 / 10 ;
if (( ! prm . showZero ) && ( newDigit [ 7 ] == 0 )) newDigit [ 7 ] = 10 ;
newDigit [ 6 ] = hour12_24 % 10 ;
newDigit [ 5 ] = 11 ; //- sign
newDigit [ 4 ] = minute () / 10 ;
newDigit [ 3 ] = minute () % 10 ;
newDigit [ 2 ] = 11 ; //- sign
if ( prm . enableBlink && ( second () % 2 == 0 )) {
newDigit [ 2 ] = 10 ; //BLANK
if ( prm . enableDoubleBlink ) {
newDigit [ 5 ] = 10 ; //BLANK
}
}
newDigit [ 1 ] = second () / 10 ;
newDigit [ 0 ] = second () % 10 ;
}
}
// The driver-independent animation engine: compares newDigit[] (the target the current displayTimeN()/
// displayDate()/displayTemp()/displayHumid() call just computed) against oldDigit[] (what was on
// screen before) and, if anything changed, plays the animation selected by prm.animMode (0-7, see
// docs/DEVELOPER_GUIDE.md section 4.5):
// 0 = no animation, instant switch
// 1 = each changed tube "rolls" through the digits from its old value up to its new one, one tube at a time
// 2 = all changed tubes spin through 0-9 together, in lockstep
// 3/4 = whole digit-row slides in from the right/left (with a "----" gap between old and new content)
// 5 = "wipe" - drives animMask[] with a ramp value per changed tube; interpretation is driver-specific
// (some multiplex-family drivers get a finer 1..19 ramp, others a coarser 1..5-then-back-down ramp)
// 6 = picks a random animation from 1-5 on every call
// 7 = picks a random animation from 1-4 on every call (excludes the wipe animation)
// Special characters (value >9, e.g. blank/dash/custom glyphs) are always applied immediately outside
// the animation (never rolled through like a numeric digit change) since there's no sensible
// "in-between" glyph to animate through. On clocks with >4 digits, the seconds tube (index 0) is
// excluded from most animations (`j=1`) so a normal ticking seconds digit doesn't animate every second.
void changeDigit () {
int j = 0 ;
byte tmp [ 50 ];
byte space = 4 ;
byte anim ;
#if defined(TUBE1CLOCK) //no tube animation possible
writeDisplaySingle ();
return ;
#endif
anim = prm . animMode ;
#if defined(DISABLE_NIGHT_ANIMATION)
if ( ! displayON ) anim = 0 ; //At NIGHT switch off animation
#endif
#if defined(WORDCLOCK)
anim = 0 ;
#endif
if ( anim == 6 ) anim = 1 + rand () % 5 ;
else if ( anim == 7 ) anim = 1 + rand () % 4 ;
if ( anim != 5 ) {
for ( int i = 0 ; i < maxDigits ; i ++ )
if (( newDigit [ i ] > 9 ) || (( oldDigit [ i ] > 9 ) && ( newDigit [ i ] <= 9 ) )) {
digit [ i ] = newDigit [ i ]; //show special characters ASAP or if special char changes to numbers
oldDigit [ i ] = newDigit [ i ];
}
if (( maxDigits > 4 ) && ( newDigit [ 0 ] != 0 )) j = 1 ; //if 6 or 8 tube clock, don't play (animate) the seconds digit
}
if ( memcmp ( newDigit + j , oldDigit + j , maxDigits - j ) != 0 ) {
switch ( anim ) {
case 1 :
for ( int tube = j ; tube < maxDigits ; tube ++ ) {
if (( newDigit [ tube ] != oldDigit [ tube ]) && ( newDigit [ tube ] <= 9 )) {
for ( int i = oldDigit [ tube ]; i <= int ( newDigit [ tube ] + 10 ); i ++ ) {
digit [ tube ] = i % 10 ;
writeDisplaySingle ();
Fdelay ( ANIMSPEED );
} //end for i
writeDisplaySingle ();
} //endif
} //end for tube
break ;
case 2 :
for ( int i = 0 ; i <= 9 ; i ++ ) {
for ( int tube = j ; tube < maxDigits ; tube ++ ) {
if (( newDigit [ tube ] != oldDigit [ tube ]) && ( newDigit [ tube ] <= 9 ))
digit [ tube ] = ( oldDigit [ tube ] + i ) % 10 ;
} //end for tube
writeDisplaySingle ();
Fdelay ( ANIMSPEED );
} //end for i
break ;
case 3 :
memcpy ( tmp , oldDigit , maxDigits );
memcpy ( tmp + maxDigits + space , newDigit , maxDigits );
memset ( tmp + maxDigits , 11 , space ); //----
for ( int i = 0 ; i < maxDigits + space ; i ++ ) {
for ( int tube = 0 ; tube < maxDigits ; tube ++ ) {
digit [ tube % maxDigits ] = tmp [ i + tube ];
} //end for tube
writeDisplaySingle ();
Fdelay ( ANIMSPEED );
} //end for i
break ;
case 4 :
memcpy ( tmp , newDigit , maxDigits );
memcpy ( tmp + maxDigits + space , oldDigit , maxDigits );
memset ( tmp + maxDigits , 11 , space ); //----
for ( int i = maxDigits - 1 + space ; i >= 0 ; i -- ) {
for ( int tube = 0 ; tube < maxDigits ; tube ++ ) {
digit [ tube % maxDigits ] = tmp [ i + tube ];
} //end for tube
writeDisplaySingle ();
Fdelay ( ANIMSPEED );
} //end for i
break ;
case 5 :
// "Wipe" animation - two different ramp shapes depending on which driver is active, since
// different segment/multiplex hardware interprets the animMask[] progress value differently:
// the SN74141-family/VQC10/HV5122 drivers get a fine 1..19 monotonic ramp (their own
// writeDisplay*() decodes finer wipe steps), everything else gets a coarser 1..5-then-5..1
// "ramp up then back down" pattern, with digit[] snapped to newDigit[] partway through.
memset ( animMask , 0 , sizeof ( animMask ));
#if defined(MULTIPLEX74141) || defined(NO_MULTIPLEX74141) || defined(MULTIPLEX74141_ESP32) || defined(MULE_V2) || defined(PCF_74141) || defined(NO_MULTIPLEX_ESP32) || defined(VQC10) || defined(NEWHV5122)
for ( int i = 1 ; i < 20 ; i ++ ) {
for ( int tube = j ; tube < maxDigits ; tube ++ ) {
if ( oldDigit [ tube ] != newDigit [ tube ]) animMask [ tube ] = i ; //digit is changed
} //end for tube
writeDisplaySingle ();
Fdelay ( ANIMSPEED );
} //end for i
#else
for ( int i = 1 ; i <= 5 ; i ++ ) {
for ( int tube = j ; tube < maxDigits ; tube ++ ) {
if ( oldDigit [ tube ] != newDigit [ tube ]) animMask [ tube ] = i ; //digit is changed
} //end for tube
writeDisplaySingle ();
Fdelay ( ANIMSPEED );
} //end for i
memcpy ( digit , newDigit , sizeof ( digit ));
for ( int i = 1 ; i <= 5 ; i ++ ) {
for ( int tube = j ; tube < maxDigits ; tube ++ ) {
if ( oldDigit [ tube ] != newDigit [ tube ]) animMask [ tube ] = 6 - i ; //digit is changed
} //end for tube
writeDisplaySingle ();
Fdelay ( ANIMSPEED );
} //end for i
#endif
memset ( animMask , 0 , sizeof ( animMask ));
break ;
}
} //endif memcmp
memcpy ( digit , newDigit , sizeof ( digit ));
memcpy ( oldDigit , newDigit , sizeof ( oldDigit ));
writeDisplaySingle ();
}
// Drives ALARMSPEAKER_PIN, but only actually calls digitalWrite() when the requested state differs
// from what's already set (oldState) - avoids redundant GPIO writes on every alarmSound() tick.
void writeAlarmPin ( boolean newState ) {
#if ALARMSPEAKER_PIN>=0
static boolean oldState = ! ALARM_ON ;
if ( oldState != newState ) {
oldState = newState ;
//if (newState == ALARM_ON) DPRINTLN("Alarm ON"); else DPRINTLN("Alarm OFF");
digitalWrite ( ALARMSPEAKER_PIN , newState );
}
#endif
}
// Called every loop() iteration. Non-blocking alarm-tone state machine: when the wall-clock time
// matches prm.alarmHour:prm.alarmMin (checked once, in the first 3 seconds of that minute, to avoid
// re-triggering all through the minute), it starts a beep pattern defined by the `t[]` timestamp table
// below (an escalating series of short beeps, in milliseconds relative to alarmStarted) that repeats
// beyond the table's end as plain 500ms on/off beeping, for prm.alarmPeriod seconds total. Can be
// silenced early via ALARMBUTTON_PIN if wired.
void alarmSound ( void ) {
static const unsigned int t [] = { 0 , 3000 , 6000 , 6200 , 9000 , 9200 , 9400 , 12000 , 12200 , 12400 , 15000 , 15200 , 15400 };
static int count = 0 ;
static unsigned long nextEvent ;
const int cMax = sizeof ( t ) / sizeof ( t [ 0 ]); //number of time steps
if ( prm . alarmEnable ) {
if ( ( ! alarmON && prm . alarmHour == hour ()) && ( prm . alarmMin == minute ()) && ( second () <= 3 )) { //switch ON alarm sound
DPRINTLN ( "Alarm started!" );
alarmON = true ;
alarmStarted = millis ();
count = 0 ;
nextEvent = alarmStarted - 1 ;
}
}
else {
alarmON = false ;
}
if ( ! alarmON ) {
writeAlarmPin ( ! ALARM_ON );
return ; //nothing to do
}
if (( millis () - alarmStarted ) > 1000 * ( long ) prm . alarmPeriod ) {
alarmON = false ; //alarm period is over
darkenNeopixels ();
DPRINTLN ( "Alarm ended." );
}
if ( ALARMBUTTON_PIN >= 0 ) { //is button installed?
if ( digitalRead ( ALARMBUTTON_PIN ) == LOW ) { //stop alarm
DPRINTLN ( "Alarm stopped by button press." );
alarmON = false ;
}
}
if ( ! prm . alarmEnable || ! alarmON ) { //no alarm, switch off
writeAlarmPin ( ! ALARM_ON );
return ;
}
//-------- Generate sound --------------------------------
if ( millis () > nextEvent ) { //go to the next event
if ( count % 2 == 0 ) {
nextEvent += 500 ;
writeAlarmPin ( ALARM_ON );
//DPRINT(" Sound ON"); DPRINT(" Next:"); DPRINTLN(nextEvent);
}
else {
writeAlarmPin ( ! ALARM_ON );
nextEvent = ( count / 2 < cMax ) ? alarmStarted + t [ count / 2 ] : nextEvent + 500 ;
//DPRINT(" OFF"); DPRINT(" Next:"); DPRINTLN(nextEvent);
}
count ++ ;
}
}
// Called once per second from timeProgram(). Decides displayON (day/night brightness state) from the
// prm.dayHour:dayMin / prm.nightHour:nightMin schedule, handling the case where the night window wraps
// past midnight (mn_off > mn_on, e.g. night starts 22:00 and ends 07:00) vs. the (unusual) case where
// it doesn't. manualOverride (set by the web UI's "manualOverride" key in handleConfigChanged()) lets
// the user force displayON one way regardless of the schedule; that override is automatically cleared
// again the moment the schedule's own computed state (prevShutoffState) naturally transitions to match
// what the override had forced - i.e. a manual override lasts until the next scheduled day/night
// boundary, not forever.
void evalShutoffTime ( void ) { // Determine whether tubes should be turned to NIGHT mode
if ( ! prm . enableAutoShutoff ) return ;
int mn = 60 * hour () + minute ();
int mn_on = prm . dayHour * 60 + prm . dayMin ;
int mn_off = prm . nightHour * 60 + prm . nightMin ;
static bool prevShutoffState = true ;
if ( ((( mn_off < mn_on ) && ( mn >= mn_off ) && ( mn < mn_on ))) || // Nighttime
(( mn_off > mn_on ) && (( mn >= mn_off ) || ( mn < mn_on )))) {
if ( ! manualOverride ) displayON = false ;
if ( prevShutoffState == true ) manualOverride = false ;
prevShutoffState = false ;
}
else { // Tubes should be on
if ( ! manualOverride ) displayON = true ;
if ( prevShutoffState == false ) manualOverride = false ;
prevShutoffState = true ;
}
return ;
// POSSIBLE BUG / NOTE: unconditional `return` above makes the debug DPRINT block below permanently
// unreachable dead code - harmless (it only ever fed DPRINT, a no-op without DEBUG), but the
// early-return means these diagnostics can never be re-enabled just by turning DEBUG on; the `return`
// itself would also need removing.
DPRINT ( "mn=" ); DPRINT ( mn );
DPRINT ( " mn_on=" ); DPRINT ( mn_on );
DPRINT ( " mn_off=" ); DPRINT ( mn_off );
DPRINT ( " manOverride:" ); DPRINT ( manualOverride );
DPRINT ( " displayON:" ); DPRINTLN ( displayON );
DPRINT ( " enableBlink:" ); DPRINTLN ( prm . enableBlink );
DPRINT ( " blinkState:" ); DPRINTLN ( colonBlinkState );
}
// Renders a single IP-address octet (0-255) as a 3-digit number, optionally preceded by "IP" glyphs
// on displays with an alphabetic character set and >=6 digit positions, and shows it once via
// changeDigit(). Called 4 times in a row by showMyIp() below, once per octet.
void writeIpTag ( byte iptag ) {
#ifdef SHIFT_TUBES_LEFT_BY_1
byte offset = 1 ;
#else
byte offset = 0 ;
#endif
memset ( newDigit , 10 , sizeof ( newDigit ));
if ( ! digitsOnly && ( maxDigits >= 6 )) {
newDigit [ 5 ] = 19 ; //'I'
newDigit [ 4 ] = 14 ; //'P'
}
//if (iptag >= 100)
newDigit [ 2 + offset ] = iptag / 100 ;
newDigit [ 1 + offset ] = ( iptag % 100 ) / 10 ;
newDigit [ 0 + offset ] = iptag % 10 ;
memcpy ( digit , newDigit , sizeof ( digit ));
printDigits ( 0 );
changeDigit ();
}
// Called once at the end of setup(): displays the clock's IP address on the tubes, one octet at a
// time (each shown for SPEED=1500ms via writeIpTag()), so the user knows what address to browse to
// for the web admin UI without needing a router's client list or a phone app.
void showMyIp ( void ) { //at startup, show the web page internet address
clearDigits ();
#define SPEED 1500
for ( int i = 0 ; i < 4 ; i ++ ) {
ipShowRunning = true ;
writeIpTag ( ip [ i ]);
Fdelay ( SPEED );
}
ipShowRunning = false ;
}
// NOTE: converts a "number of 15-minute steps since midnight" value into hours+minutes - not called
// anywhere in this file or elsewhere in the project; dead/unused helper.
void fifteenMinToHM ( int & hours , int & minutes , int fifteenMin )
{
hours = fifteenMin / 4 ;
minutes = ( fifteenMin % 4 ) * 15 ;
}
// Called from loop() whenever prm.wifiMode is true but WiFi.status()!=WL_CONNECTED. Self-throttled to
// try at most once every 10 minutes: first attempts a lightweight WiFi.disconnect()+WiFi.reconnect()
// (reuses previously stored credentials), and only falls back to the heavier startWifiMode() (which
// can itself open the wifiManager() config portal) if that quick reconnect didn't succeed.
void resetWiFi ( void ) {
static unsigned long lastTest = millis ();
static unsigned int counter = 0 ;
if (( millis () - lastTest ) < 600000 ) return ; //check in every 10 min
lastTest = millis ();
DPRINTLN ( "Lost WiFi. Reconnect." );
//#if defined(ESP8266)
WiFi . disconnect ();
WiFi . reconnect ();
//#endif
if ( WiFi . status () == WL_CONNECTED ) return ;
//findBestWifi();
startWifiMode ();
return ;
// POSSIBLE BUG / NOTE: unconditional `return` above makes everything below permanently unreachable -
// including a 3-hour "give up and force a full reboot" counter/message. As written, resetWiFi() will
// retry forever (every 10 minutes) without ever calling doReset(), no matter how long WiFi stays
// down. Likely intentional (a full reboot loop might have been judged riskier than an endless retry),
// but flagged as dead code worth reviewing - see docs/CODE_REVIEW.md.
counter ++ ;
DPRINT ( "Wifi lost! Minutes to restart:" ); DPRINTLN ( 180 - counter );
if ( counter < 180 ) return ; //3 hours passed
DPRINTLN ( "Restart Clock..." );
delay ( 1000 );
doReset ();
}
// NOTE: proper (always-non-negative) modulo helper - not called anywhere in this file or elsewhere in
// the project; dead/unused helper.
inline int mod ( int a , int b ) {
int r = a % b ;
return r < 0 ? r + b : r ;
}
// Power-on self-test animation: sweeps every tube through 0-9 in unison (called once from setup()).
void testTubes ( int dely ) {
Fdelay ( dely );
cathodeProtRunning = true ;
DPRINT ( "Testing tubes: " );
for ( int i = 0 ; i < 10 ; i ++ ) {
DPRINT ( i ); DPRINT ( " " );
for ( int j = 0 ; j < maxDigits ; j ++ ) {
newDigit [ j ] = i ;
// POSSIBLE BUG: likely meant to be `digit[j] = i;` (j is the tube-index loop variable here; i is
// the 0..9 character being swept through). As written this indexes digit[] by the CHARACTER
// value i (0..9) instead of the tube position j, so it writes into at most the first 10 entries
// of digit[] regardless of which tube is being iterated - harmless in practice only because
// changeDigit() (called right below, every outer-loop iteration) immediately overwrites the
// whole digit[] buffer from newDigit[] anyway, so this line's result is never actually rendered.
digit [ i ] = i ;
digitDP [ j ] = i % 2 ;
}
changeDigit ();
Fdelay ( dely );
}
DPRINTLN ( " " );
Fdelay ( 1000 );
memset ( digitDP , 0 , sizeof ( digitDP ));
cathodeProtRunning = false ;
}
// NOTE: cycles a rotating "walking digit" pattern across the tubes - not called from setup()/loop() or
// anywhere else in this file; dead/unused (a leftover manual test helper, presumably invoked ad hoc
// during development).
void playTubes () {
static byte tube = 0 ;
memset ( digitDP , 0 , sizeof ( digitDP ));
for ( int j = 0 ; j < maxDigits ; j ++ ) {
newDigit [ j ] = tube ++ % maxDigits ;
}
changeDigit ();
}
// Debug helper: dumps all temperature/humidity sensor readings to the serial console, throttled to
// once every 30s. Called from printDigits() below (itself a no-op unless DEBUG is defined).
void printSensors ( void ) {
static unsigned long lastRun = millis ();
if (( millis () - lastRun ) < 30000 ) return ;
lastRun = millis ();
if ( useTemp > 0 ) {
DPRINT ( "Temperature (" ); DPRINT ( useTemp ); DPRINT ( "): " );
for ( int i = 0 ; i < useTemp ; i ++ ) {
DPRINT ( temperature [ i ]); DPRINT ( ", " );
}
DPRINTLN ( " " );
}
if ( useHumid > 0 ) {
DPRINT ( "Humidity (" ); DPRINT ( useHumid ); DPRINT ( "): " );
for ( int i = 0 ; i < useHumid ; i ++ ) {
DPRINT ( humid [ i ]); DPRINT ( ", " );
}
DPRINTLN ( " " );
}
}
// Debug helper: prints one digit[]/newDigit[] buffer value (see the BUFSIZE encoding comment near the
// digit[] declaration near the top of this file) as its human-readable glyph to the serial console.
// NOTE: only decodes a handful of the possible 13+ "special character" codes by hardcoded value/macro
// (TEMP_CHARCODE, GRAD_CHARCODE, PERCENT_CHARCODE, plus literal 18/19/14) - any other special glyph
// code prints as a generic '-', so this debug view doesn't fully reflect every driver's char set.
void printChar ( int i ) {
if ( i < 10 ) { DPRINT ( i );}
else if ( i == 10 ) { DPRINT ( " " );}
else if ( i == TEMP_CHARCODE ) { DPRINT ( "C" );}
else if ( i == GRAD_CHARCODE ) { DPRINT ( "°" );}
else if ( i == 18 ) { DPRINT ( "." );}
else if ( i == PERCENT_CHARCODE ) { DPRINT ( "%" ); }
else if ( i == 19 ) { DPRINT ( "I" );}
else if ( i == 14 ) { DPRINT ( "P" );}
else DPRINT ( "-" );
if ( digitDP [ i ]) { DPRINT ( "." );} else { DPRINT ( " " );}
}
// Debug console status line: current digit[] content (rendered via printChar()), colon-blink state,
// lux reading, WiFi status, plus periodic sensor dump (printSensors()) - entire body compiles to
// nothing when DEBUG is not defined. `timeout` throttles how often it actually prints (0 = every call).
void printDigits ( unsigned long timeout ) {
static unsigned long lastRun = millis ();
if (( millis () - lastRun ) < timeout ) return ;
lastRun = millis ();
#ifdef DEBUG
DPRINT ( " digit: [" ); for ( int i = maxDigits - 1 ; i >= 0 ; i -- ) { printChar ( digit [ i ]);} DPRINT ( "]" );
DPRINT ( colonBlinkState ? " * " : " " );
//DPRINT("\noldDigit: "); for (int i = maxDigits - 1; i >= 0; i--) {printChar(oldDigit[i]);}
//DPRINT("\nnewDigit: "); for (int i = maxDigits - 1; i >= 0; i--) {printChar(newDigit[i]);}
/*
if ((millis()/1000%10) == 1) { //show free memory for debugging memory leak
DPRINT("Heap:"); DPRINT(ESP.getFreeHeap()); DPRINT(" byte");
#if defined(ESP8266)
DPRINT(" Fragmentation:"); DPRINT(100 - ESP.getMaxFreeBlockSize() * 100.0 / ESP.getFreeHeap()); DPRINT("%");
#endif
}
*/
//DPRINT("INT:"); DPRINT(intCounter); intCounter = 0; //show multiplex interrupt counter
//DPRINT(" ESaving:"); DPRINT(EEPROMsaving);
if ( useLux > 0 ) {
DPRINT ( " Lux:" ); DPRINT ( lx );
}
if ( RADAR_PIN >= 0 ) {
//DPRINT(" RadarPin:"); DPRINT(digitalRead(RADAR_PIN)); DPRINT(" lastOn:"); DPRINT(radarLastOn); DPRINT(" sec:"); DPRINTLN((millis()-radarLastOn)/1000);
}
//DPRINT(" tON:"); DPRINT(timerON); DPRINT(" tOFF:"); DPRINT(timerOFF); //Multiplex timing values for testing
if ( WiFi . status () != WL_CONNECTED ) DPRINT ( " no WIFI" );
DPRINTLN ( " " );
printSensors ();
#endif
}
// Called every loop() iteration (self-throttled to 500ms). Drives TUBE_POWER_PIN (filament/HV supply
// on/off) based on radar-detected presence: motion resets a timeout (radarLastOn), and if no motion is
// seen for prm.radarTimeout minutes the tubes' power supply is switched off entirely (a harder cutoff
// than just dimming brightness). The radar signal can come from a LOCAL RADAR_PIN sensor (if wired -
// takes priority) or from mqttRadarON (a remote/MQTT-published radar state, see mqtt.ino's master/
// slave radar sharing). If prm.enableRadar is off, radarON is forced true (always powered) regardless
// of any radar input.
void checkTubePowerOnOff ( void ) {
static unsigned long lastRun = 0 ;
if (( millis () - lastRun ) < 500 ) return ;
lastRun = millis ();
boolean newP = mqttRadarON ;
if ( RADAR_PIN >= 0 ) { //if local RADAR is defined, it has priority
newP = digitalRead ( RADAR_PIN );
}
if ( ! radarON && newP ) { //check, is switching ON?
DPRINTLN ( "RADAR: Switching ON tubes." );
}
if ( newP ) { //restart timeout
radarLastOn = millis ();
radarON = true ;
}
if ( radarON && ( millis () - radarLastOn ) > 60000ul * uint32_t ( prm . radarTimeout )) { //check if should switch off?
radarON = false ;
DPRINTLN ( "RADAR: Switching OFF tubes." );
}
//DPRINT("RadarON:"); DPRINT(radarON); DPRINT(" timeout:"); DPRINTLN((millis()-radarLastOn));
if ( ! prm . enableRadar ) radarON = true ; //If Radar is disabled, always ON !
#if TUBE_POWER_PIN >=0
if ((( displayON ? prm . dayBright : prm . nightBright ) == 0 ) || ! radarON ) {
digitalWrite ( TUBE_POWER_PIN , ! TUBE_POWER_ON ); //Switch OFF
//DPRINTLN("POWER OFF");
}
else {
digitalWrite ( TUBE_POWER_PIN , TUBE_POWER_ON ); //Switch ON
//DPRINTLN("POWER ON");
}
#endif
}
//Calculation parameters are defined in clocks.h
//https://www.pcboard.ca/ldr-light-dependent-resistor
// Converts a raw ADC reading from an LDR (light-dependent resistor) voltage divider into an
// approximate lux value, using the LUX_CALC_SCALAR/LUX_CALC_EXPONENT calibration constants from
// clocks.h (LDR characteristic curve) and a simple exponential smoothing filter (oldLux) to avoid
// jumpy brightness changes from ADC noise. Returns 0 (a build with no LIGHT_SENSOR_PIN configured)
// when there's no LDR wired at all - see getLightSensor() below for the BH1750 (digital lux sensor)
// alternative, which takes priority over this analog LDR path when both are present.
int luxMeter ( void ) {
#if LIGHT_SENSOR_PIN >=0
static float oldLux = MAXIMUM_LUX ;
float ADCdata = analogRead ( LIGHT_SENSOR_PIN ); //DPRINT("ADC:"); DPRINTLN(ADCdata);
float ldrResistance = ( MAX_ADC_READING - ADCdata ) / ADCdata * REF_RESISTANCE ;
float ldrLux = LUX_CALC_SCALAR * pow ( ldrResistance , LUX_CALC_EXPONENT );
if (( ldrLux >= MAXIMUM_LUX ) || ( ldrLux < 0 )) ldrLux = MAXIMUM_LUX ; //Limit lux value to maximum
oldLux = oldLux + ( ldrLux - oldLux ) / 10 ; //slow down Lux change
return ( int ) oldLux ;
#else
return ( 0 );
#endif
}
// Called from loop()/Fdelay() (self-throttled to 500ms) to refresh the global `lx` (ambient lux) and
// `autoBrightness` (whether auto-dim is actually active) values. Prefers a BH1750 digital sensor if
// present, falls back to an analog LDR (luxMeter()) if not, and if neither exists forces a fixed
// MAXIMUM_LUX reading with autoBrightness permanently off (i.e. "auto-dim" silently has no hardware to
// act on). autoBrightness itself is only ever actually true when prm.enableAutoDim is also set by the
// user - this function just gates it on hardware presence.
void getLightSensor ( void ) {
static unsigned long lastRun = 0 ;
static int oldLx = 0 ;
int tmp ;
if (( millis () - lastRun ) < 500 ) return ;
lastRun = millis ();
if ( BH1750exist ) {
tmp = getBH1750 ();
if (( abs ( tmp - oldLx ) > 1 ) || ( tmp >= MAXIMUM_LUX )) {
lx = tmp ; oldLx = lx ;
}
if ( lx >= MAXIMUM_LUX - 2 ) lx = MAXIMUM_LUX ;
autoBrightness = prm . enableAutoDim ;
}
else if ( LDRexist ) {
tmp = luxMeter ();
if (( abs ( tmp - oldLx ) > 1 ) || ( tmp >= MAXIMUM_LUX )) {
lx = tmp ; oldLx = lx ;
}
if ( lx >= MAXIMUM_LUX - 2 ) lx = MAXIMUM_LUX ;
autoBrightness = prm . enableAutoDim ;
}
else {
lx = MAXIMUM_LUX ;
autoBrightness = false ;
}
}
// NOTE: intended to let the web UI's "wifiMode" (STA vs AP) toggle switch the radio mode live, without
// a reboot - would re-run startNewWifiMode()/startStandaloneMode() whenever prm.wifiMode changes. Its
// only call site in loop() is commented out (`//checkWifiMode();`), so this is currently dead code;
// changing prm.wifiMode from the web UI today only takes effect after the next reboot/doReset().
void checkWifiMode () {
static boolean oldMode = prm . wifiMode ;
if ( oldMode != prm . wifiMode ) {
if ( prm . wifiMode ) {
startNewWifiMode ();
}
else {
startStandaloneMode ();
}
oldMode = prm . wifiMode ;
}
}
// Debug helper: logs to serial whenever any byte of `prm` differs from the last time this was called -
// a crude way to spot unexpected/unintended settings changes during development. Its only call site in
// loop() is commented out (`//checkPrm();`), so it is currently dead code / not part of the live build.
void checkPrm () {
static byte oldPrm [ sizeof ( prm )];
int tmp = memcmp ( oldPrm , & prm , sizeof ( prm ));
if ( tmp != 0 ){
DPRINT ( "Prm mismatch!" ); DPRINTLN ( tmp );
memcpy ( oldPrm , & prm , sizeof ( prm ));
}
}
// Main loop. Deliberately delay()-free (see docs/DEVELOPER_GUIDE.md section 4.2 and the Fdelay()
// comment above) - every subsystem gets serviced once per iteration: cooperative display refresh
// (writeDisplay2(), only does anything for non-ISR/"smart chip" drivers), DNS captive-portal service
// (AP mode only), the clock/sensor/display tick (timeProgram(), itself throttled to ~200ms), RGB/PWM
// backlight animations, the alarm buzzer, radar-driven tube power, the light sensor, MQTT publish (STA
// mode + WiFi connected) or WiFi reconnect attempts (STA mode + WiFi down), the RTC manual-editor
// button scan, and finally any pending OTA-update/cathode-protect request queued by the web server
// (makeFirmwareUpdate/makeCathodeProtect flags, set from server route handlers which must return
// quickly and can't run these long operations inline).
void loop ( void ) {
//checkPrm();
writeDisplay2 ();
if ( WiFi . status () != WL_CONNECTED )
dnsServer . processNextRequest ();
timeProgram ();
//writeDisplaySingle();
writeDisplay2 ();
doAnimationMakuna ();
doAnimationPWM ();
alarmSound ();
checkTubePowerOnOff ();
getLightSensor ();
//checkWifiMode();
if ( prm . wifiMode ) { //WiFi Clock Mode
if ( WiFi . status () == WL_CONNECTED ) {
if ( prm . mqttEnable ) mqttSend ();
}
else {
resetWiFi ();
}
}
editor ();
2026-10-25 21:23:10 +01:00
#ifdef USE_ARDUINO_OTA
ArduinoOTA . handle ();
#endif
2026-10-25 20:48:04 +01:00
if ( makeFirmwareUpdate ) {
makeFirmwareUpdate = false ;
doFirmwareUpdate ();
}
if ( makeCathodeProtect ) {
makeCathodeProtect = false ;
doCathodeProtect ();
}
yield ();
}
// Clean reboot: drops the WiFi radio (and, on ESP8266, its TX power) before calling ESP.restart(),
// which avoids leaving the radio associated to the AP mid-reset. Called after a successful OTA update,
// from the web admin's POST /reset and /factoryreset routes, and (dead-code path only, see the
// resetWiFi() NOTE above) from the unreachable end of resetWiFi().
void doReset ( void ) {
DPRINTLN ( "Restart Clock..." );
#if defined(ESP8266)
WiFi . setOutputPower ( 0 );
#endif
WiFi . disconnect ();
delay ( 1000 );
ESP . restart ();
}
// Default empty stand-in for writeDisplay2() - the cooperative (non-ISR) "give the display a chance to
// refresh" hook called from loop()/Fdelay(). Only VQC10/TUBE1CLOCK/WORDCLOCK actually define a real
// writeDisplay2() (in their own driver .ino files) because their hardware needs cooperative,
// non-interrupt-safe timing (e.g. delayMicroseconds() during row scanning) that a hardware-timer ISR
// can't safely provide; every other driver relies purely on the ISR-based writeDisplay(), so this
// no-op keeps the call sites unconditional without needing per-driver #ifdef guards everywhere.
#if defined(VQC10) || defined(TUBE1CLOCK) || defined(WORDCLOCK)
#else
void writeDisplay2 ( void ){} //not interrupt driven display handler
#endif