/* 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. // LOOP_DIAG (temporary, clocks.h): finds what stalls the main loop. Every step in loop() and Fdelay() // reports via DIAG() when it is done; a gap of LOOP_DIAG_MS or more since the previous report is // recorded with the step's name. The last stalls and the longest one are in /getCurrentInfos ("stalls"). #ifdef LOOP_DIAG #define LOOP_DIAG_MS 250 #define DIAG(step, call) do { call; diagTick(step); } while (0) struct DiagStall { const char* step; uint16_t ms; uint32_t at; }; DiagStall diagStalls[8]; byte diagStallPtr = 0; DiagStall diagWorst = {"", 0, 0}; char diagText[260]; void diagTick(const char* step) { static unsigned long last = 0; unsigned long now = millis(); unsigned long gap = now - last; last = now; if (gap < LOOP_DIAG_MS) return; DiagStall s = {step, (uint16_t)min(gap, 65535ul), now / 1000}; diagStalls[diagStallPtr] = s; diagStallPtr = (diagStallPtr + 1) % 8; if (s.ms > diagWorst.ms) diagWorst = s; } //"worst: step ms @uptime s | step ms @s, ..." (newest first) const char* diagReport() { int n = snprintf(diagText, sizeof(diagText), "worst: %s %ums @%lus |", diagWorst.step, diagWorst.ms, (unsigned long)diagWorst.at); for (int i = 1; i <= 8 && n < (int)sizeof(diagText); i++) { DiagStall& s = diagStalls[(diagStallPtr + 8 - i) % 8]; if (s.ms == 0) break; n += snprintf(diagText + n, sizeof(diagText) - n, " %s %u @%lu,", s.step, s.ms, (unsigned long)s.at); } return diagText; } #else #define DIAG(step, call) call #endif void Fdelay(unsigned long d) { unsigned long dStart = millis(); doAnimationMakuna(); while ((millis() - dStart) < d) { if (WiFi.getMode() == 2) DIAG("F:dns", dnsServer.processNextRequest()); DIAG("F:writeDisplay2", writeDisplay2()); DIAG("F:lightSensor", getLightSensor()); DIAG("F:neopixel", doAnimationMakuna()); DIAG("F:pwmLeds", doAnimationPWM()); DIAG("F:alarm", alarmSound()); #ifdef USE_GPS smartDelay(1); //feed GPS #endif #ifdef USE_ARDUINO_OTA DIAG("F:ota", ArduinoOTA.handle()); //also during long animations/startup waits #endif DIAG("F:yield", 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 (strlen(prm.firmwareServer) == 0) { DPRINTLN("No firmware server set. FirmwareUpdate skipped."); return; } 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). // Optional password for the web UI (WEB_PASSWORD, and WEB_USER, default "admin", in secrets.h). // Everything except /generate_204 (captive-portal check) and /getCurrentInfos (time and sensor // values only) needs it; /getConfiguration returns the stored WiFi and MQTT passwords. #ifdef WEB_PASSWORD #ifndef WEB_USER #define WEB_USER "admin" #endif #define WEB_AUTH .setAuthentication(WEB_USER, WEB_PASSWORD) #else #define WEB_AUTH #endif 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"); } }) WEB_AUTH; 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"); } }) WEB_AUTH; 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"); } }) WEB_AUTH; 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"); } }) WEB_AUTH; 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"); } }) WEB_AUTH; 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"); } }) WEB_AUTH; //____________________________________________________________________________ server.on("/reset", HTTP_POST, [] (AsyncWebServerRequest *request) { DPRINTLN("/reset:"); request->send(200, "text/plain", "Reset: Restarting the Box!"); delay(200); doReset(); }) WEB_AUTH; //____________________________________________________________________________ server.on("/factoryreset", HTTP_POST, [] (AsyncWebServerRequest *request) { DPRINTLN("/factoryreset:"); request->send(200, "text/plain", "Factory Reset!"); delay(200); factoryReset(); doReset(); }) WEB_AUTH; //____________________________________________________________________________ server.on("/firmwareupdate", HTTP_POST, [](AsyncWebServerRequest * request) { makeFirmwareUpdate = true; }) WEB_AUTH; //____________________________________________________________________________ server.on("/cathodeProtect", HTTP_POST, [](AsyncWebServerRequest * request) { makeCathodeProtect = true; }) WEB_AUTH; server.on("/saveSetting", HTTP_POST, handleConfigChanged) WEB_AUTH; server.on("/getConfiguration", HTTP_GET, handleSendConfig) WEB_AUTH; server.on("/getCurrentInfos", HTTP_GET, handleSendCurrentInfos); server.on("/getClockDetails", HTTP_GET, handleSendClockDetails) WEB_AUTH; 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") { // FIXED: saving the URL used to start an update right away ("makeFirmwareUpdate = true; //for // testing only"), so clearing or correcting the field flashed whatever that URL served. Updates // now only start from the explicit POST /firmwareupdate. value.toCharArray(prm.firmwareServer,sizeof(prm.firmwareServer)); } 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(); doc["uptime"] = millis() / 1000; doc["heap"] = ESP.getFreeHeap(); #ifdef LOOP_DIAG doc["stalls"] = diagReport(); #endif 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)); // FIXED: the first stop digit was overwritten by "dir>0 ? 0 : maxDigits-1" after this switch, so a // random run (dir==0) never started at a random digit. The +-1 cases below were swapped too (the // overwrite hid that): dir>0 counts up from 0, dir<0 counts down from the last digit (see further down). switch (dir) { case -1: nextStoppedDigit = maxDigits-1; break; case 0: nextStoppedDigit = random(maxDigits); break; case 1: default: nextStoppedDigit = 0; break; } 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(); boolean wasAP = (WiFi.getMode() & WIFI_AP); startWifiMode(); // First connection after running as an access point (e.g. WiFi just configured on 192.168.4.1): // restart, so everything set up at boot (ArduinoOTA, mDNS, MQTT) starts on the real network. // Without this ArduinoOTA did not answer until the next power cycle. if (wasAP && (WiFi.status() == WL_CONNECTED)) { DPRINTLN("Connected to WiFi after AP mode. Restarting..."); delay(1000); doReset(); } 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(); DIAG("writeDisplay2", writeDisplay2()); if (WiFi.status() != WL_CONNECTED) DIAG("dns", dnsServer.processNextRequest()); DIAG("timeProgram", timeProgram()); //writeDisplaySingle(); DIAG("writeDisplay2", writeDisplay2()); DIAG("neopixel", doAnimationMakuna()); DIAG("pwmLeds", doAnimationPWM()); DIAG("alarm", alarmSound()); DIAG("tubePower", checkTubePowerOnOff()); DIAG("lightSensor", getLightSensor()); //checkWifiMode(); if (prm.wifiMode) { //WiFi Clock Mode if (WiFi.status() == WL_CONNECTED) { if (prm.mqttEnable) DIAG("mqtt", mqttSend()); } else { DIAG("resetWiFi", resetWiFi()); } } DIAG("editor", editor()); #ifdef USE_ARDUINO_OTA DIAG("ota", ArduinoOTA.handle()); #endif if (makeFirmwareUpdate) { makeFirmwareUpdate = false; doFirmwareUpdate(); } if (makeCathodeProtect) { makeCathodeProtect = false; DIAG("cathodeProtect", doCathodeProtect()); } DIAG("yield", 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