/*
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 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
//#define byte uint8_t
#include "clocks.h" //DEFINE YOUR CLOCKS SETUP IN THIS FILE!!! - clocks.h picks the single active CLOCK_NN block, which #defines the driver macro, pins and feature flags read throughout this file
// PlatformIO turns the .ino files into one .cpp and puts prototypes for every function near the top,
// without evaluating #ifdefs. Declare the types of optional libraries here so those prototypes compile
// when the feature (and its #include) is switched off.
class AsyncWiFiManager;
struct RgbColor;
// Detect which generation of the ESP8266 Arduino core is in use: older cores (2.6.x/2.7.x) name the
// RAM-resident-ISR attribute ICACHE_RAM_ATTR, while newer cores renamed it to IRAM_ATTR (shared with
// 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
//--------------------------------------------------------------------------------------------------
#ifndef FIRMWARE_SERVER
#define FIRMWARE_SERVER "http://c.landventure.hu/store" //default OTA update server (see doFirmwareUpdate()); overridable per-clock via prm.firmwareServer / the web admin "firmware" field
#endif
String usedPinsStr; //human-readable list of registered GPIOs, built by listPins(), shown on the web admin "clock details" page
String driverSetupStr; //driver-specific extra setup info string (set by some drivers), also shown on the web admin page
// 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
#include
#include
#include "ESPAsyncTCP.h"
#include "FS.h"
#include
#ifdef USE_MDNS
#include
#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
#include
#include
#include
#include
#include "AsyncTCP.h"
#include "SPIFFS.h"
#include "FS.h"
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#ifdef USE_MDNS
#include
#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 dnsServer;
#include "ESPAsyncWebServer.h"
#include
#include
#include
#include
#include
#include
#include "ArduinoJson.h"
#ifdef USE_ARDUINO_OTA
#include
#endif
#ifdef USE_MQTT
#include //here and not only in mqtt.ino: PlatformIO's generated prototypes need its types early
#endif
#if defined(USE_NEOPIXEL) || defined(WORDCLOCK)
#include
#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
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;
volatile boolean tubesOn = true; //Tubes switch from Home Assistant (runtime only, not saved); honoured by nixie595.ino
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).
// NOTE: upstream ended DST on the first Sunday of November (the US date). The EU switches at 01:00 UTC:
// last Sunday of March 02:00 standard time, last Sunday of October 03:00 summer time.
TimeChangeRule myDST = {"DST", Last, Sun, Mar, 2, 0};
TimeChangeRule mySTD = {"STD", Last, Sun, Oct, 3, 0};
Timezone myTZ(myDST, mySTD);
time_t prevTime = 0; //last second() value that was actually rendered to the display - timeProgram() only redraws when now()!=prevTime, i.e. at most once per real second
time_t protectTimer = 0; //timestamp of the last cathode-protect animation, used by timeProgram()'s prm.interval-minute scheduler
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:
");
#else
usedPinsStr = String("ESP8266 used pins:
");
#endif
for (byte i=0;i<=MAX_PIN;i++) {
if (strlen(pinTxt[i])>0) {
usedPinsStr += String(i) + ": " + String(pinTxt[i]) + "
";
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
#ifdef USE_ARDUINO_OTA
ArduinoOTA.handle(); //also during long animations/startup waits
#endif
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 -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("\nConnected 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\nNetwork 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("\nConnecting 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)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
}
// Optional ArduinoOTA (USE_ARDUINO_OTA in clocks.h): push a new firmware from PlatformIO/Arduino IDE
// over WiFi ("pio run -t upload --upload-port "). Shows the progress in percent on the two right
// tubes. Password from OTA_PASSWORD (secrets.h) if defined.
void startArduinoOTA() {
#ifdef USE_ARDUINO_OTA
static boolean otaStarted = false;
if (otaStarted) return;
ArduinoOTA.setHostname(OTA_HOSTNAME);
#ifdef OTA_PASSWORD
ArduinoOTA.setPassword(OTA_PASSWORD);
#endif
ArduinoOTA.onStart([]() {
DPRINTLN(F("ArduinoOTA: update started"));
clearDigits();
});
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
int p = (int)(progress / (total / 100 + 1));
digit[1] = (p / 10) % 10;
digit[0] = p % 10;
});
ArduinoOTA.onEnd([]() {
DPRINTLN(F("ArduinoOTA: done, rebooting"));
});
ArduinoOTA.onError([](ota_error_t error) {
DPRINTF("ArduinoOTA: error %u\n", error);
clearDigits();
});
ArduinoOTA.begin();
otaStarted = true;
DPRINT(F("ArduinoOTA ready: ")); DPRINTLN(OTA_HOSTNAME);
#endif
}
// The Standalone(AP)-mode path (prm.wifiMode==false, or a fallback from a fully-failed STA connect):
// the clock hosts its own WiFi hotspot at the fixed address 192.168.4.1 (prm.ApSsid/prm.ApPsw) and
// runs a DNS server that answers every query with its own IP (dnsServer.start(53,"*",...)) so most
// 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:
// ".bin" (the sketch binary) then ".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;i9) 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("UniClock");
response->print(""); response->print(webName);
response->print("
Please, login to network and
");
response->printf("try opening %s
", WiFi.softAPIP().toString().c_str(),WiFi.softAPIP().toString().c_str());
//response->printf("192.168.4.1");
response->print("");
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 += "\nMethod: ";
message += ( request->method() == HTTP_GET ) ? "GET" : "POST";
message += "\nArguments: ";
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;iargName(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();
mySTD.offset = prm.utc_offset * 60; //keep myTZ current for a later enableDST change
myDST.offset = mySTD.offset + (prm.enableDST ? 60 : 0);
myTZ = Timezone(myDST, mySTD);
}
}
else if (key == "set12_24") {
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).
// FIXED: this used to shift the clock by a blind +-1h, which is wrong outside the summer period
// (switching DST on in winter put the clock 1h ahead until the next NTP sync). Now the local
// time is converted back to UTC with the old rules and forward with the new ones.
prm.enableDST = (value == "true");
if (oldDST != prm.enableDST) {
time_t utc = myTZ.toUTC(now());
myDST.offset = mySTD.offset + (prm.enableDST ? 60 : 0);
myTZ = Timezone(myDST, mySTD);
setTime(myTZ.toLocal(utc));
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("
FirmwareID:") + FW + "
";
dat += String(" Tube driver:") + tubeDriver + "
";
dat += String(" Mac:") + String(WiFi.macAddress()) + "
";
dat += String("MAXBRIGHTNESS:") + String(MAXBRIGHTNESS) + "
";
if (RTCexist) dat += String("RTC exist: YES
");
if (useTemp>0) dat += String("Temperature sensors:") + useTemp + "
";
if (useHumid>0) dat += String("Humidity sensors:") + useHumid + "
";
if (usePress>0) dat += String("Pressure sensors:") + usePress + "
";
dat += usedPinsStr + "
";
dat += driverSetupStr + "
";
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();
startArduinoOTA();
#ifdef USE_MQTT
setupMqtt();
#endif
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= 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) //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=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;i0) {
DPRINT("Humidity ("); DPRINT(useHumid); DPRINT("): ");
for (int i=0;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();
#ifdef USE_ARDUINO_OTA
ArduinoOTA.handle();
#endif
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