The clock answers like a WLED device (mDNS _wled._tcp, /json/info, /json/state, realtime UDP on 21324 and DDP on 4048), so SignalRGB can drive the 4 backlight LEDs, one per tube, LED 0 = left tube. Frames are shown at most 30 times a second (WLED_MAX_FPS); the normal effect returns 2.5 s after they stop, and the alarm flash still wins. Three new settings on the RGB page: enable (also a Home Assistant switch), stay dark while the tubes are off, and scale by the RGB brightness. They are stored in EEPROM after prm with their own magic value, so the update does not factory-reset the clock. page.js and site.css are now sent with no-cache: browsers kept them for a year and ran the old script after a web UI update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3433 lines
140 KiB
Arduino
3433 lines
140 KiB
Arduino
/*
|
|
Universal Clock v3.xx (Nixie, VFD, LED, Numitron) for ESP8266 or ESP32
|
|
with optional Dallas Thermometer and DS3231 RTC, Neopixel strip, GPS and more...
|
|
03/04/2022
|
|
Copyright (C) 2020 Peter Gautier
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License <http://www.gnu.org/licenses/> for more details.
|
|
|
|
Always check the usable pins of 8266: https://randomnerdtutorials.com/esp8266-pinout-reference-gpios/
|
|
and usable pins for ESP32: https://randomnerdtutorials.com/esp32-pinout-reference-gpios/
|
|
|
|
//https://lastminuteengineers.com/esp32-arduino-ide-tutorial/
|
|
|
|
Web page files, which are found in the "dat" subfolder, must be uploaded to SPIFFS !
|
|
ESP8266: https://randomnerdtutorials.com/install-esp8266-filesystem-uploader-arduino-ide/
|
|
ESP32: https://randomnerdtutorials.com/install-esp32-filesystem-uploader-arduino-ide/
|
|
*/
|
|
//====================================================================================================
|
|
// ESP32_UniClock2.ino - CORE file of the project. This is the ONE file shared by all ~65 physical
|
|
// clock builds (see clocks.h) - it contains everything that is NOT specific to how a digit gets
|
|
// written to the hardware: setup()/loop(), WiFi connection management + web admin server (REST-ish
|
|
// /configChanged API + JSON status endpoints), EEPROM-persisted settings (`prm` struct), time-source
|
|
// selection (NTP/GPS/RTC priority chain in calcTime()), the display/animation pipeline
|
|
// (displayTimeN()->changeDigit()->writeDisplaySingle()), alarm buzzer, day/night brightness,
|
|
// radar-based auto shutoff, light-sensor auto-dim, and cathode/element "protect" (anti burn-in) test
|
|
// patterns. The Arduino build concatenates every .ino file in this sketch folder into one compilation
|
|
// unit, and exactly one of the ~25 driver .ino files (e.g. MAX6921.ino, ZM1500.ino, wordclock.ino)
|
|
// actually compiles in per build, selected by whichever driver macro the active CLOCK_NN block in
|
|
// clocks.h defines - this core file calls that driver only through a small set of `extern` functions
|
|
// (setup_pins/writeDisplay/writeDisplaySingle/clearTubes) and shared global buffers (digit[]/newDigit[]
|
|
// /oldDigit[]/digitDP[]/animMask[]), so it never needs to know which physical driver is active.
|
|
// See docs/DEVELOPER_GUIDE.md for the full architecture writeup (this file is section 4) and
|
|
// docs/CODE_REVIEW.md for the list of known bugs/dead-code spots (also marked in-place below with
|
|
// "// POSSIBLE BUG:" / "// NOTE:").
|
|
//====================================================================================================
|
|
#include <stddef.h>
|
|
//#define byte uint8_t
|
|
#include "clocks.h" //DEFINE YOUR CLOCKS SETUP IN THIS FILE!!! - clocks.h picks the single active CLOCK_NN block, which #defines the driver macro, pins and feature flags read throughout this file
|
|
// 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 <ESP8266WiFi.h>
|
|
#include <ESP8266HTTPClient.h>
|
|
#include <WiFiClient.h>
|
|
#include "ESPAsyncTCP.h"
|
|
#include "FS.h"
|
|
#include <ESP8266httpUpdate.h>
|
|
#ifdef USE_MDNS
|
|
#include <ESP8266mDNS.h>
|
|
#endif
|
|
|
|
|
|
const char ESPpinout[] = {"OOOOOO OOOOOI"}; //GPIO 0..5, 12..16, A0) usable pins
|
|
|
|
#elif defined(ESP32)
|
|
#ifndef WEBNAME
|
|
#define WEBNAME "ESP32UniClock 3.0"
|
|
#endif
|
|
#ifndef AP_NAME
|
|
#define AP_NAME "UNICLOCK32"
|
|
#endif
|
|
#ifndef AP_PASSWORD
|
|
#define AP_PASSWORD ""
|
|
#endif
|
|
#include <WiFi.h>
|
|
#include <esp_wifi.h>
|
|
#include <WiFiClient.h>
|
|
#include <HTTPClient.h>
|
|
#include <ESP32httpUpdate.h>
|
|
|
|
#include "AsyncTCP.h"
|
|
#include "SPIFFS.h"
|
|
#include "FS.h"
|
|
#include "soc/soc.h"
|
|
#include "soc/rtc_cntl_reg.h"
|
|
#ifdef USE_MDNS
|
|
#include <ESPmDNS.h>
|
|
#endif
|
|
hw_timer_t * ESP32timer = NULL; //hardware timer handle, started by startTimer() below; drives the ISR-based multiplex drivers' writeDisplay()
|
|
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED; //spinlock used by drivers to guard shared state accessed from both the ISR and the main loop
|
|
#define PRESCALER 15 //multiplex timer prescaler, about 80Hz for 6 tubes
|
|
|
|
const char ESPpinout[] = {"OOOOOO OOOOOOOO OOO OOO OOIII I"}; //GPIO 0..5, 12..19, 21..23, 25..27, 32..33, Input:34..36, 39) usable pins
|
|
#else
|
|
#error "Board is not supported!"
|
|
#endif
|
|
|
|
#include <DNSServer.h>
|
|
DNSServer dnsServer;
|
|
#include "ESPAsyncWebServer.h"
|
|
#include <NTPClient.h>
|
|
#include <WiFiUdp.h>
|
|
|
|
#include <TimeLib.h>
|
|
#include <Timezone.h>
|
|
#include <Wire.h>
|
|
#include <EEPROM.h>
|
|
#include "ArduinoJson.h"
|
|
#ifdef USE_ARDUINO_OTA
|
|
#include <ArduinoOTA.h>
|
|
#endif
|
|
#ifdef USE_MQTT
|
|
#include <ArduinoHA.h> //here and not only in mqtt.ino: PlatformIO's generated prototypes need its types early
|
|
#endif
|
|
|
|
#if defined(USE_NEOPIXEL) || defined(WORDCLOCK)
|
|
#include <NeoPixelBrightnessBus.h>
|
|
#endif
|
|
|
|
// Driver-interface contract: every driver .ino file must implement these regardless of which chip it
|
|
// targets (see docs/DEVELOPER_GUIDE.md section 3) - this is the entire surface the core code needs to
|
|
// drive an arbitrary physical display. writeDisplay() is the ISR-based multiplex refresh (a no-op for
|
|
// "smart" chip drivers that scan internally, e.g. PT6311/PT6355/VQC10); writeDisplaySingle() is a
|
|
// blocking, non-ISR, one-shot render of the current digit[]/digitDP[] buffers.
|
|
extern void ICACHE_RAM_ATTR writeDisplay();
|
|
extern void writeDisplaySingle();
|
|
extern void setup_pins();
|
|
extern void clearTubes();
|
|
extern int maxDigits; //number of physical digit positions (tubes) on this clock build
|
|
extern char tubeDriver[]; //human-readable driver name string, shown on the web admin page
|
|
|
|
char webName[] = WEBNAME;
|
|
AsyncWebServer server(80);
|
|
#define CACHE_MAX_AGE "max-age=31536000" //maximum is: 31536000
|
|
#define CACHE_REVALIDATE "no-cache" //page.js and site.css change with web UI updates (uploadfs): don't let browsers keep an old copy
|
|
|
|
#ifdef USE_WIFIMANAGER
|
|
#include <ESPAsyncWiFiManager.h>
|
|
AsyncWiFiManager myWiFiManager(&server,&dnsServer);
|
|
#endif
|
|
|
|
//______________ WiFi finding variables ____________________________________________
|
|
byte bestChn; //the recent found best wifi AP's channel No.
|
|
uint8_t bestBssid[6]; //the recent found best wifi AP's BSSID (mac address)
|
|
uint8_t oldBssid[6]; //the current wifi AP's BSSID (mac address)
|
|
int bestRSSI = -200; //the recent found best wifi AP's RSSI value
|
|
unsigned long lastWifiScan = 0; //last wifi scan timestamp in CPU msec millis()
|
|
boolean wifiOK = false;
|
|
char myIp[20]="";
|
|
|
|
//Display buffers - shared with every driver .ino file, indexed 0..maxDigits-1 (0 = rightmost/seconds digit).
|
|
//Value encoding (see docs/DEVELOPER_GUIDE.md section 3): 0-9 = digit to show, 10 = BLANK, 11 = dash/minus,
|
|
//12 = standalone decimal-point glyph (a few drivers only), 13+ = driver-specific extra characters
|
|
//(letters, degree sign, %, circles, etc. - each driver's own charDefinition[]/charTable[] maps these).
|
|
#define BUFSIZE 12
|
|
byte DRAM_ATTR digit[BUFSIZE]; //what is CURRENTLY on the physical display (what writeDisplay()/writeDisplaySingle() actually render)
|
|
byte DRAM_ATTR newDigit[BUFSIZE]; //the target value for the next display update, filled in by displayTime4/6/8()/displayDate()/displayTemp()/displayHumid()
|
|
byte DRAM_ATTR oldDigit[BUFSIZE]; //the value that was displayed before the current changeDigit() animation started - used to detect per-tube changes and drive the various animMode animations
|
|
boolean DRAM_ATTR digitDP[BUFSIZE]; //actual value to put to display
|
|
boolean digitsOnly = true; //only 0..9 numbers are possible to display?
|
|
byte DRAM_ATTR animMask[BUFSIZE]; //0 = no animation mask is used; used by the "wipe" animation (animMode==5, see changeDigit()) - a driver-specific progress value per tube, drivers read this to fade/wipe segments in or out
|
|
|
|
volatile boolean EEPROMsaving = false; //saving in progress - stop display refresh
|
|
|
|
// EEPROM/prm struct format version. Bump this whenever the `prm` struct below changes shape (fields
|
|
// added/removed/reordered/resized) - loadEEPROM() compares the stored value against this constant in
|
|
// setup(), and a mismatch triggers an automatic factoryReset() (old EEPROM bytes would otherwise be
|
|
// misinterpreted as the new struct layout, since EEPROM.get()/put() do a raw memcpy with no versioning
|
|
// of their own).
|
|
#define MAGIC_VALUE 304 //EEPROM version
|
|
|
|
|
|
//#define THERMOMETER_CLOCK //it means, first digit is +- sign, last digit is C/F, 4 digit is for numbers
|
|
#ifdef THERMOMETER_CLOCK //it means 6 tubes: first digit is +- sign, last digit is C/F, and only 4 digit are for numbers
|
|
boolean thermometerClock = true;
|
|
#else
|
|
boolean thermometerClock = false;
|
|
#endif
|
|
|
|
// 8266 internal pin registers
|
|
// https://github.com/esp8266/esp8266-wiki/wiki/gpio-registers
|
|
// example: https://github.com/mgo-tec/OLED_1351/blob/master/src/OLED_SSD1351.cpp
|
|
|
|
|
|
// Debug print macros: compile to real Serial.print calls when DEBUG is defined in clocks.h for the
|
|
// active CLOCK_NN, otherwise compile to nothing (zero code/RAM cost) - used everywhere in this file
|
|
// and in every driver .ino instead of calling Serial.* directly, so a release build can drop all
|
|
// debug logging just by commenting out one #define.
|
|
#ifdef DEBUG //Macros are usually in all capital letters.
|
|
#define DPRINT(...) Serial.print(__VA_ARGS__) //DPRINT is a macro, debug print
|
|
#define DPRINTLN(...) Serial.println(__VA_ARGS__) //DPRINT is a macro, debug print
|
|
#define DPRINTF(...) Serial.printf(__VA_ARGS__) //DPRINT is a macro, debug print
|
|
#define DPRINTBEGIN(...) Serial.begin(__VA_ARGS__) //DPRINTLN is a macro, debug print with new line
|
|
#else
|
|
#define DPRINT(...) //now defines a blank line
|
|
#define DPRINTLN(...) //now defines a blank line
|
|
#define DPRINTF(...) //now defines a blank line
|
|
#define DPRINTBEGIN(...) //now defines a blank line
|
|
#endif
|
|
|
|
#define PIN_OUT_SET PERIPHS_GPIO_BASEADDR + 4
|
|
#define PIN_OUT_CLEAR PERIPHS_GPIO_BASEADDR + 8
|
|
|
|
bool colonBlinkState = false;
|
|
boolean radarON = true;
|
|
boolean mqttRadarON = true;
|
|
volatile boolean tubesOn = true; //Tubes switch from Home Assistant (runtime only, not saved); honoured by nixie595.ino
|
|
boolean wledLive = false; //SignalRGB/WLED frames are arriving and drive the backlight (wled.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;
|
|
|
|
// Settings added by this port, stored in EEPROM right after `prm` with a magic value of their own.
|
|
// Adding them to `prm` would change its layout, and the MAGIC_VALUE check would then factory-reset
|
|
// the clock (WiFi settings included). If the magic value doesn't match (first boot after the update),
|
|
// loadEEPROM() sets these defaults and leaves `prm` alone.
|
|
#define MAGIC_VALUE_EXT 0x5701
|
|
struct {
|
|
boolean wledEnable = false; //SignalRGB/WLED realtime control of the backlight (wled.ino)
|
|
boolean wledDarkWithTubes = true; //WLED frames stay dark while the tubes/backlight are off
|
|
boolean wledUseBrightness = true; //WLED frames are scaled by the RGB brightness (and auto dim)
|
|
uint16_t magic = MAGIC_VALUE_EXT;
|
|
} prmExt;
|
|
|
|
#define EEPROM_SIZE (sizeof(prm) + sizeof(prmExt)) //no extra reserved space for future growth: a change to either struct needs its own magic value bumped
|
|
|
|
//-------------------------------------------------------------------------------------------------------
|
|
|
|
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:<br>");
|
|
#else
|
|
usedPinsStr = String("ESP8266 used pins:<br>");
|
|
#endif
|
|
for (byte i=0;i<=MAX_PIN;i++) {
|
|
if (strlen(pinTxt[i])>0) {
|
|
usedPinsStr += String(i) + ": " + String(pinTxt[i]) + "<br>";
|
|
DPRINT(i); DPRINT(": "); DPRINTLN(pinTxt[i]);
|
|
}
|
|
}
|
|
DPRINTLN("_______________________");
|
|
//DPRINT("MAX_PIN"); DPRINTLN(MAX_PIN);
|
|
}
|
|
|
|
// Starts the hardware-timer interrupt that periodically calls the active driver's writeDisplay() (the
|
|
// ISR-based multiplex refresh). Called from most drivers' setup_pins() - drivers that scan their
|
|
// display internally (PT6311/PT6355/VQC10/MAX7219/etc, see docs/DEVELOPER_GUIDE.md section 3) never
|
|
// call this, since their writeDisplay() is an empty no-op and refresh instead happens cooperatively
|
|
// in writeDisplay2()/writeDisplaySingle().
|
|
void startTimer() { //ESP_INTR_FLAG_IRAM
|
|
#if defined(ESP8266)
|
|
timer1_attachInterrupt(writeDisplay);
|
|
timer1_enable(TIM_DIV16, TIM_EDGE, TIM_SINGLE);
|
|
timer1_write(15000);
|
|
DPRINTLN("Starting 8266 timer...");
|
|
|
|
#elif defined(ESP32)
|
|
// https://techtutorialsx.com/2017/10/07/esp32-arduino-timer-interrupts/
|
|
ESP32timer = timerBegin(0, PRESCALER, true); //set prescaler to 80 -> 1MHz signal, true = edge generated signal
|
|
timerAttachInterrupt(ESP32timer, &writeDisplay, true);
|
|
timerAlarmWrite(ESP32timer, 1000, true); //100millisec, no repeat
|
|
timerAlarmEnable(ESP32timer);
|
|
//DPRINTLN("Starting ESP32 timer...");
|
|
#else
|
|
#error "Unknown controller board!"
|
|
#endif
|
|
}
|
|
|
|
void stopTimer() {
|
|
#if defined(ESP8266)
|
|
timer1_detachInterrupt();
|
|
#endif
|
|
}
|
|
|
|
#ifdef USE_WIFIMANAGER
|
|
void configModeCallback (AsyncWiFiManager *myWiFiManager) {
|
|
DPRINTLN("Switch to AP config mode...");
|
|
DPRINTLN("To configure Wifi, ");
|
|
DPRINT("connect to Wifi network "); DPRINTLN(prm.wifiSsid);
|
|
DPRINTLN("and open 192.168.4.1 in web browser");
|
|
}
|
|
#endif
|
|
|
|
// Resets all three digit buffers to BLANK (10) and clears every decimal-point flag - a full display
|
|
// clear, used at startup and around mode transitions (WiFi connect, IP display, etc).
|
|
void clearDigits() {
|
|
memset(oldDigit, 10, sizeof(oldDigit));
|
|
memset(digit, 10, sizeof(digit));
|
|
memset(newDigit, 10, sizeof(newDigit));
|
|
memset(digitDP, 0, sizeof(digitDP));
|
|
}
|
|
|
|
// A cooperative-multitasking substitute for delay(d): the firmware has no delay()-based blocking
|
|
// waits in loop() (see docs/DEVELOPER_GUIDE.md section 4.2), so any function that needs to "wait"
|
|
// (animations, WiFi connect retries, cathode-protect, IP display, etc.) calls Fdelay() instead - it
|
|
// busy-waits for `d` milliseconds but keeps servicing the DNS captive-portal server (AP mode only),
|
|
// RGB/PWM background animations, the alarm buzzer, the light sensor and (if USE_GPS) the GPS serial
|
|
// feed, so none of those subsystems stalls during a long animation/connect sequence.
|
|
// 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:wled", wledHandle());
|
|
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<newRssi) { //better rsssi found...
|
|
bestChn = WiFi.channel(i);
|
|
bestRSSI = newRssi;
|
|
memcpy(bestBssid,WiFi.BSSID(i),sizeof(bestBssid));
|
|
DPRINT(F(" FOUND: Channel:")); DPRINT(bestChn);
|
|
DPRINT(F(" BSSID:")); DPRINTLN(WiFi.BSSIDstr(i));
|
|
found = true;
|
|
lastWifiScan = millis();
|
|
}
|
|
else {
|
|
DPRINTLN(" ");
|
|
}
|
|
}
|
|
}
|
|
WiFi.scanDelete();
|
|
} //end else
|
|
else {
|
|
// WiFi.scanNetworks(false);
|
|
}
|
|
return(found);
|
|
}
|
|
|
|
// Fallback when a direct WiFi.begin(prm.wifiSsid,...) connect fails (or no SSID is stored at all):
|
|
// opens the ESPAsyncWiFiManager captive-configuration portal (its own temporary AP + web form) so the
|
|
// user can pick/enter WiFi credentials from a phone/laptop. Retries autoConnect() up to 5 times before
|
|
// forcing the portal open unconditionally. On success, persists the newly-chosen SSID/password into
|
|
// prm and saves EEPROM. Compiled to an empty no-op when USE_WIFIMANAGER is not defined for this
|
|
// CLOCK_NN (the #ifdef below spans the whole function body).
|
|
void wifiManager() {
|
|
#ifdef USE_WIFIMANAGER
|
|
AsyncWiFiManager MyWifiManager(&server, &dnsServer);
|
|
MyWifiManager.setAPCallback(configModeCallback);
|
|
//MyWifiManager.setConfigPortalTimeout(180);
|
|
for (int i = 0; i < 5; i++) {
|
|
if (!MyWifiManager.autoConnect(AP_NAME)) { //or autoConnect(AP_NAME,AP_PASSWORD))
|
|
DPRINT("Retry to Connect:"); DPRINTLN(i);
|
|
WiFi.disconnect();
|
|
WiFi.mode(WIFI_OFF);
|
|
delay(1000);
|
|
WiFi.mode(WIFI_STA);
|
|
#if defined(ESP32)
|
|
WiFi.setHostname(webName);
|
|
#else
|
|
WiFi.hostname(webName);
|
|
#endif
|
|
if (i == 4) {
|
|
MyWifiManager.autoConnect(AP_NAME); // or autoConnect(AP_NAME,AP_PASSWORD))
|
|
}
|
|
}
|
|
else
|
|
break;
|
|
} //end for
|
|
|
|
if (WiFi.status() == WL_CONNECTED) { //save wifi settings to prm
|
|
String s = WiFi.SSID();
|
|
String p = WiFi.psk();
|
|
memcpy(oldBssid,WiFi.BSSID(),sizeof(oldBssid));
|
|
s.toCharArray(prm.wifiSsid,sizeof(prm.wifiSsid));
|
|
p.toCharArray(prm.wifiPsw,sizeof(prm.wifiPsw));
|
|
saveEEPROM();
|
|
ip = WiFi.localIP();
|
|
WiFi.setAutoReconnect(true); WiFi.persistent(true);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// Alternate STA-mode connect path that targets the specific BSSID/channel found by findBestWifi()
|
|
// (WiFi.begin(ssid,psw,channel,bssid,true) instead of a plain SSID connect). See the findBestWifi()
|
|
// NOTE above - this function is currently unreachable from setup()/loop() (only called from the
|
|
// disabled checkWifiMode()), so it and its bestRSSI/lastWifiScan freshness gating are dead in practice.
|
|
void startNewWifiMode() { // Start a Wi-Fi access point, and try to connect to some given access points. Then wait for either an AP or STA connection
|
|
if (((millis()-lastWifiScan)<90000) && (bestRSSI > -95) && (WiFi.status() != WL_CONNECTED)) {
|
|
DPRINTLN(F("_____________ Connecting WiFi ___________________"));
|
|
if (strlen(prm.wifiSsid)==0) return;
|
|
WiFi.disconnect();
|
|
delay(200);
|
|
WiFi.mode(WIFI_OFF);
|
|
delay(200);
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.setAutoReconnect(true);
|
|
#if defined(ESP32)
|
|
esp_wifi_set_ps(WIFI_PS_NONE);
|
|
WiFi.setHostname(webName);
|
|
WiFi.setSleep(false);
|
|
#else
|
|
WiFi.hostname(webName);
|
|
#endif
|
|
|
|
char tmp[20];
|
|
sprintf(tmp,"%02X:%02X:%02X:%02X:%02X:%02X",bestBssid[0],bestBssid[1],bestBssid[2],bestBssid[3],bestBssid[4],bestBssid[5]);
|
|
DPRINT(F("Connecting to best WiFi AP:")); DPRINT(prm.wifiSsid); DPRINT(F(" PSW:")); DPRINT(prm.wifiPsw);
|
|
DPRINT(F(" BSSID:")); DPRINT(tmp); DPRINT(" Chn:"); DPRINTLN(bestChn);
|
|
WiFi.begin(prm.wifiSsid, prm.wifiPsw, bestChn, bestBssid, true);
|
|
|
|
Fdelay(500);
|
|
int counter = 0;
|
|
while (WiFi.status() != WL_CONNECTED) {
|
|
DPRINT('.');
|
|
Fdelay(3000);
|
|
if (counter++>3) {
|
|
DPRINTLN("Connecting to WiFi failed.");
|
|
return;
|
|
}
|
|
}
|
|
ip = WiFi.localIP();
|
|
sprintf(myIp,"%d.%d.%d.%d",ip[0],ip[1],ip[2],ip[3]);
|
|
DPRINT("\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)<TIMESERVER_REFRESH) && (lastTimeUpdate!=0))
|
|
return(res);
|
|
|
|
if ((lastTimeFailure>0) && ((millis()-lastTimeFailure)<300000)) return(res); //wait for 5min to retry, if no success
|
|
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
while (true) {
|
|
DPRINT("Connecting to timeserver: "); DPRINTLN(count);
|
|
res = timeClient.forceUpdate();
|
|
if (res) {
|
|
mySTD.offset = prm.utc_offset * 60;
|
|
myDST.offset = mySTD.offset;
|
|
if (prm.enableDST) {
|
|
myDST.offset += 60;
|
|
}
|
|
myTZ = Timezone(myDST, mySTD);
|
|
setTime(myTZ.toLocal(timeClient.getEpochTime()));
|
|
lastTimeUpdate = millis();
|
|
DPRINT("Timeserver date:"); DPRINT(year()); DPRINT("/"); DPRINT(month()); DPRINT("/"); DPRINT(day());
|
|
DPRINT(" time:"); DPRINT(hour()); DPRINT(":"); DPRINT(minute()); DPRINT(":"); DPRINTLN(second());
|
|
DPRINTLN("Clock refreshed from timeserver.");
|
|
lastTimeFailure = 0;
|
|
}
|
|
count ++;
|
|
if (res) break; //success!!!
|
|
Fdelay(1000);
|
|
if (count > 5) { //failure, but stop trying
|
|
lastTimeFailure = millis();
|
|
break;
|
|
}
|
|
} //end while
|
|
}
|
|
return (res);
|
|
}
|
|
|
|
#ifndef MDNSNAME
|
|
#define MDNSNAME "uniclock"
|
|
#endif
|
|
|
|
// Optional mDNS responder (only compiled/started if USE_MDNS is defined - commented out by default
|
|
// near the top of this file) so the clock can be reached as e.g. "uniclock.local" instead of typing
|
|
// its IP address. Guards against re-starting an already-running responder via the static flag.
|
|
void startMDNS() {
|
|
static boolean mdnsStarted = false;
|
|
|
|
if (mdnsStarted) return; //running and not changed
|
|
#ifdef USE_MDNS
|
|
if (!MDNS.begin(MDNSNAME)) { //mdns_free() switch off
|
|
DPRINTLN(F("Error setting up MDNS responder!"));
|
|
mdnsStarted = false;
|
|
}
|
|
else {
|
|
DPRINT(F("Started mDns service:")); DPRINTLN(MDNSNAME);
|
|
MDNS.addService("http", "tcp", 80);
|
|
mdnsStarted = true;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// Optional ArduinoOTA (USE_ARDUINO_OTA in clocks.h): push a new firmware from PlatformIO/Arduino IDE
|
|
// over WiFi ("pio run -t upload --upload-port <ip>"). Shows the progress in percent on the two right
|
|
// tubes. Password from OTA_PASSWORD (secrets.h) if defined.
|
|
void startArduinoOTA() {
|
|
#ifdef USE_ARDUINO_OTA
|
|
static boolean otaStarted = false;
|
|
if (otaStarted) return;
|
|
ArduinoOTA.setHostname(OTA_HOSTNAME);
|
|
#ifdef OTA_PASSWORD
|
|
ArduinoOTA.setPassword(OTA_PASSWORD);
|
|
#endif
|
|
ArduinoOTA.onStart([]() {
|
|
DPRINTLN(F("ArduinoOTA: update started"));
|
|
clearDigits();
|
|
});
|
|
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
|
|
int p = (int)(progress / (total / 100 + 1));
|
|
digit[1] = (p / 10) % 10;
|
|
digit[0] = p % 10;
|
|
});
|
|
ArduinoOTA.onEnd([]() {
|
|
DPRINTLN(F("ArduinoOTA: done, rebooting"));
|
|
});
|
|
ArduinoOTA.onError([](ota_error_t error) {
|
|
DPRINTF("ArduinoOTA: error %u\n", error);
|
|
clearDigits();
|
|
});
|
|
ArduinoOTA.begin();
|
|
otaStarted = true;
|
|
DPRINT(F("ArduinoOTA ready: ")); DPRINTLN(OTA_HOSTNAME);
|
|
#endif
|
|
}
|
|
|
|
// The Standalone(AP)-mode path (prm.wifiMode==false, or a fallback from a fully-failed STA connect):
|
|
// the clock hosts its own WiFi hotspot at the fixed address 192.168.4.1 (prm.ApSsid/prm.ApPsw) and
|
|
// runs a DNS server that answers every query with its own IP (dnsServer.start(53,"*",...)) so most
|
|
// phones/laptops auto-launch a captive-portal browser pointed at the admin page. In this mode there
|
|
// is no internet, so calcTime() never gets a WiFi/NTP refresh - GPS (if fitted) or the RTC are the
|
|
// only time sources.
|
|
void startStandaloneMode() {
|
|
DPRINTLN("Starting Clock in Standalone Mode!");
|
|
DPRINT("Clock's AP SSID:"); DPRINT(prm.ApSsid);
|
|
DPRINT(" PSW:"); DPRINTLN(prm.ApPsw);
|
|
DPRINTLN("IP: 192.168.4.1");
|
|
IPAddress local_ip(192, 168, 4, 1);
|
|
IPAddress gateway(192, 168, 4, 1);
|
|
IPAddress subnet(255, 255, 255, 0);
|
|
WiFi.mode(WIFI_AP);
|
|
WiFi.softAPConfig(local_ip, gateway, subnet);
|
|
boolean nwState;
|
|
if (strlen(prm.ApPsw)) {
|
|
nwState = WiFi.softAP(prm.ApSsid, prm.ApPsw);
|
|
}
|
|
else {
|
|
nwState = WiFi.softAP(prm.ApSsid);
|
|
}
|
|
Fdelay(2000); //info: https://github.com/espressif/arduino-esp32/issues/2025
|
|
|
|
DPRINT("AP status:"); DPRINTLN(nwState ? "Ready" : "Failed!"); //channel
|
|
DPRINT("Mac address:"); DPRINTLN(WiFi.softAPmacAddress());
|
|
ip = WiFi.softAPIP();
|
|
DPRINT("SOFT_AP IP:"); DPRINTLN(WiFi.softAPIP());
|
|
|
|
dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
|
|
dnsServer.start(53, "*", WiFi.softAPIP());
|
|
|
|
}
|
|
|
|
// OTA firmware+filesystem update over plain HTTP. Triggered by the web admin's POST /firmwareupdate
|
|
// (sets makeFirmwareUpdate, actually run from loop() so it doesn't block the request handler) or
|
|
// automatically whenever the "firmware" server URL is changed via /configChanged ("for testing only",
|
|
// per the comment there). Downloads and flashes two files in sequence from prm.firmwareServer:
|
|
// "<FW>.bin" (the sketch binary) then "<FW>.spiffs.bin" (the SPIFFS web-page image) - FW is the
|
|
// firmware ID string (see clocks.h). Stops the web server first (frees RAM/sockets for the update),
|
|
// and only calls doReset() to actually reboot into the new firmware if BOTH updates reported success;
|
|
// otherwise the (old) server is simply restarted so the clock keeps running on the current firmware.
|
|
void doFirmwareUpdate(){
|
|
if (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;i<maxDigits;i++) {
|
|
if (i%2) digit[i] = num;
|
|
else digit[i] = 9-num;
|
|
newDigit[i] = digit[i];
|
|
digitDP[i] = (onOff%2 == i%2);
|
|
}
|
|
writeDisplaySingle();
|
|
Fdelay(100);
|
|
num++; if (num>9) num = 0;
|
|
onOff++;
|
|
if ((millis()-started)>uint32_t(cathProtMin)*60000l) break;
|
|
} //end while
|
|
|
|
prm.dayBright = db; //restore brightness values
|
|
prm.nightBright = nb;
|
|
autoBrightness = ab;
|
|
cathodeProtRunning = false;
|
|
}
|
|
|
|
// Sets up the ESPAsyncWebServer instance (port 80) that backs the web admin UI: static file routes
|
|
// serve the SPA's HTML/CSS/JS straight out of SPIFFS (uploaded separately via the IDE's filesystem
|
|
// uploader from the repo's data/ folder - see file header), plus a handful of action routes
|
|
// (/reset, /factoryreset, /firmwareupdate, /cathodeProtect) and the JSON/form REST-ish API
|
|
// (handleConfigChanged/handleSendConfig/handleSendCurrentInfos/handleSendClockDetails, defined below).
|
|
// 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_REVALIDATE);
|
|
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("<!DOCTYPE html><html><head><title>UniClock</title></head><body>");
|
|
response->print("<p><H1>"); response->print(webName);
|
|
response->print("</p><p>Please, login to network and </p>");
|
|
response->printf("<p>try opening <a href='http://%s'>%s</a> </p>", WiFi.softAPIP().toString().c_str(),WiFi.softAPIP().toString().c_str());
|
|
//response->printf("192.168.4.1");
|
|
response->print("<H1></body></html>");
|
|
request->send(response);
|
|
*/
|
|
});
|
|
|
|
//____________________________________________________________________________
|
|
server.on("/site.css", HTTP_GET, [](AsyncWebServerRequest * request) {
|
|
DPRINTLN("Webserver: /site.css");
|
|
if (SPIFFS.exists("/site.css")) {
|
|
AsyncWebServerResponse *response = request->beginResponse(SPIFFS, "/site.css", "text/css");
|
|
response->addHeader("Cache-Control", CACHE_REVALIDATE);
|
|
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;
|
|
wledRoutes(); //WLED JSON API for SignalRGB, without password (wled.ino)
|
|
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;i<args;i++){
|
|
// Serial.printf("ARG[%s]: %s\n", request->argName(i).c_str(), request->arg(i).c_str());
|
|
//}
|
|
boolean oldDST = prm.enableDST;
|
|
int old_utc_offset = prm.utc_offset;
|
|
|
|
String key = request->getParam("key", true)->value();
|
|
String value = request->getParam("value", true)->value();
|
|
DPRINT(key); DPRINT(" = "); DPRINTLN(value);
|
|
|
|
boolean paramFound = true;
|
|
|
|
if (key == "utc_offset") {
|
|
// Changing the UTC offset immediately shifts the LIVE running clock (not just the stored
|
|
// preference) by the delta in hours, and pushes that change into the RTC right away via
|
|
// updateRTC() - so the displayed time updates instantly rather than waiting for the next NTP
|
|
// sync (which would otherwise take up to TIMESERVER_REFRESH to reflect the new zone).
|
|
prm.utc_offset = value.toInt();
|
|
if (old_utc_offset != prm.utc_offset) { //Change time zone
|
|
setTime(now()+(prm.utc_offset-old_utc_offset)*3600);
|
|
updateRTC();
|
|
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();
|
|
}
|
|
#ifdef USE_WLED_REALTIME
|
|
else if (key == "wledEnable") { //wledHandle() starts/stops the listeners
|
|
prmExt.wledEnable = (value == "true");
|
|
}
|
|
else if (key == "wledDarkWithTubes") {
|
|
prmExt.wledDarkWithTubes = (value == "true");
|
|
}
|
|
else if (key == "wledUseBrightness") {
|
|
prmExt.wledUseBrightness = (value == "true");
|
|
}
|
|
#endif
|
|
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!!
|
|
#ifdef USE_WLED_REALTIME //the web page hides these switches when they are missing
|
|
doc["wledEnable"] = prmExt.wledEnable;
|
|
doc["wledDarkWithTubes"] = prmExt.wledDarkWithTubes;
|
|
doc["wledUseBrightness"] = prmExt.wledUseBrightness;
|
|
#endif
|
|
doc["wifiMode"] = prm.wifiMode;
|
|
doc["wifiSsid"] = prm.wifiSsid;
|
|
doc["wifiPsw"] = prm.wifiPsw;
|
|
doc["ApSsid"] = prm.ApSsid;
|
|
doc["ApPsw"] = prm.ApPsw;
|
|
doc["NtpServer"] = prm.NtpServer;
|
|
doc["firmware"] = prm.firmwareServer;
|
|
|
|
doc["mqttBrokerAddr"] = prm.mqttBrokerAddr;
|
|
doc["mqttBrokerUser"] = prm.mqttBrokerUser;
|
|
doc["mqttBrokerPsw"] = prm.mqttBrokerPsw;
|
|
doc["mqttEnable"] = prm.mqttEnable;
|
|
#if defined(USE_MQTT)
|
|
doc["mqttBrokerRefresh"] = prm.mqttBrokerRefresh;
|
|
#else
|
|
doc["mqttBrokerRefresh"] = 0;
|
|
#endif
|
|
doc["dateMode"] = prm.dateMode;
|
|
doc["dateRepeatMin"] = prm.dateRepeatMin;
|
|
doc["tempRepeatMin"] = prm.tempRepeatMin;
|
|
doc["tempCF"] = prm.tempCF;
|
|
doc["enableTimeDisplay"] = prm.enableTimeDisplay;
|
|
doc["enableDoubleBlink"] = prm.enableDoubleBlink;
|
|
doc["dateStart"] = prm.dateStart;
|
|
doc["dateEnd"] = prm.dateEnd;
|
|
doc["tempStart"] = prm.tempStart;
|
|
doc["tempEnd"] = prm.tempEnd;
|
|
doc["humidStart"] = prm.humidStart;
|
|
doc["humidEnd"] = prm.humidEnd;
|
|
doc["enableAutoDim"] = prm.enableAutoDim;
|
|
doc["enableRadar"] = prm.enableRadar;
|
|
#if (RADAR_PIN >= 0) || defined(USE_MQTT)
|
|
if (prm.radarTimeout<1) prm.radarTimeout = 1;
|
|
doc["radarTimeout"] = prm.radarTimeout;
|
|
#else
|
|
doc["radarTimeout"] = 0;
|
|
#endif
|
|
doc["corrT0"] = prm.corrT0;
|
|
doc["corrT1"] = prm.corrT1;
|
|
doc["corrH0"] = prm.corrH0;
|
|
doc["corrH1"] = prm.corrH1;
|
|
doc["cathProtMin"] = 3; //default value for cathProtMin slider
|
|
String json;
|
|
serializeJson(doc, json);
|
|
request->send(200, "application/json", json);
|
|
}
|
|
|
|
// GET /getClockDetails - returns a pre-formatted HTML fragment (not JSON, unlike the other two
|
|
// handlers) with static/diagnostic info: firmware ID, driver name, MAC address, sensor counts, and
|
|
// the full used-GPIO listing built by listPins()/regPin() (usedPinsStr) plus any driver-supplied
|
|
// extra setup info (driverSetupStr). Shown on an "about/diagnostics" panel of the web admin UI.
|
|
void handleSendClockDetails(AsyncWebServerRequest *request) {
|
|
StaticJsonDocument<200> doc;
|
|
//StaticJsonDocument<512> doc;
|
|
String dat;
|
|
|
|
DPRINTLN("SendClockDetails");
|
|
dat = String("<br><br><strong>FirmwareID:") + FW + "<br>";
|
|
dat += String(" Tube driver:") + tubeDriver + "<br>";
|
|
dat += String(" Mac:") + String(WiFi.macAddress()) + "</strong><br>";
|
|
dat += String("MAXBRIGHTNESS:") + String(MAXBRIGHTNESS) + "<br>";
|
|
if (RTCexist) dat += String("RTC exist: YES <br>");
|
|
if (useTemp>0) dat += String("Temperature sensors:") + useTemp + "<br>";
|
|
if (useHumid>0) dat += String("Humidity sensors:") + useHumid + "<br>";
|
|
if (usePress>0) dat += String("Pressure sensors:") + usePress + "<br>";
|
|
dat += usedPinsStr + "<br>";
|
|
dat += driverSetupStr + "<br>";
|
|
DPRINTLN(dat);
|
|
request->send(200, "text/html", dat);
|
|
}
|
|
|
|
// GET /getCurrentInfos - the lightweight, frequently AJAX-polled JSON endpoint (date/time,
|
|
// temperature/humidity/pressure/lux, WiFi RSSI) used to keep the web admin page's live readouts
|
|
// current without re-fetching the whole settings form (see handleSendConfig() above). Keeps both new
|
|
// ("temperature1"/"humidity1") and legacy ("temperature"/"humidity") key names for backwards
|
|
// compatibility with older versions of the web front-end.
|
|
void handleSendCurrentInfos(AsyncWebServerRequest *request) {
|
|
StaticJsonDocument<400> doc;
|
|
//StaticJsonDocument<512> doc;
|
|
char buf[20]; //conversion buffer
|
|
|
|
DPRINTLN("WebRefresh");
|
|
//Actual time and environment data
|
|
sprintf(buf, "%4d.%02d.%02d", year(), month(), day());
|
|
doc["currentDate"] = buf;
|
|
sprintf(buf, "%02d:%02d", hour(), minute());
|
|
doc["currentTime"] = buf;
|
|
//DPRINT("useTemp:"); DPRINT(useTemp); DPRINT("useHumid:"); DPRINT(useHumid);
|
|
if (useTemp > 0) {
|
|
doc["temperature1"] = temperature[0] + prm.corrT0;
|
|
doc["temperature"] = temperature[0] + prm.corrT0; //for compatibility with the old web page
|
|
}
|
|
else
|
|
doc["temperature1"] = 255;
|
|
|
|
if (useTemp > 1)
|
|
doc["temperature2"] = temperature[1] + prm.corrT1;
|
|
else
|
|
doc["temperature2"] = 255;
|
|
|
|
if (useHumid>0) {
|
|
doc["humidity1"] = humid[0] + prm.corrH0;
|
|
doc["humidity"] = humid[0] + prm.corrH0; //for compatibility with the old web page
|
|
}
|
|
else
|
|
doc["humidity1"] = 255;
|
|
|
|
if (useHumid>1)
|
|
doc["humidity2"] = humid[1] + prm.corrH1;
|
|
else
|
|
doc["humidity2"] = 255;
|
|
|
|
if (usePress>0) {
|
|
doc["pressure"] = pressur[0];
|
|
}
|
|
else
|
|
doc["pressure"] = 255;
|
|
|
|
if (useLux>0) {
|
|
doc["lux"] = lx;
|
|
}
|
|
else
|
|
doc["lx"] = 255;
|
|
doc["rssi"] = WiFi.RSSI();
|
|
doc["uptime"] = millis() / 1000;
|
|
doc["heap"] = ESP.getFreeHeap();
|
|
#ifdef USE_WLED_REALTIME
|
|
doc["wledLive"] = wledLive; //SignalRGB is sending frames
|
|
#endif
|
|
#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();
|
|
wledSetup(); //after startArduinoOTA(): uses its mDNS responder
|
|
#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);
|
|
EEPROM.get(EEPROM_addr + sizeof(prm), prmExt);
|
|
if (prmExt.magic != MAGIC_VALUE_EXT) resetPrmExt();
|
|
}
|
|
|
|
// Defaults for prmExt (not saved here: the caller saves when it needs to).
|
|
void resetPrmExt() {
|
|
prmExt.wledEnable = false;
|
|
prmExt.wledDarkWithTubes = true;
|
|
prmExt.wledUseBrightness = true;
|
|
prmExt.magic = MAGIC_VALUE_EXT;
|
|
}
|
|
|
|
// 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.put(EEPROM_addr + sizeof(prm), prmExt);
|
|
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
|
|
resetPrmExt();
|
|
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<maxDigits;i++) {
|
|
finish = ((millis()-started) >= t);
|
|
stopThis = (i == nextStoppedDigit);
|
|
|
|
if (finish && stopThis) { //this digit stops
|
|
fin[i] = 1;
|
|
digitDP[i] = tmpDP[i]; //restore original DP value
|
|
digit[i] = tmp[i];
|
|
t +=1000;
|
|
if (dir>0) { //find next stop digit
|
|
nextStoppedDigit++;
|
|
if (nextStoppedDigit>=maxDigits) nextStoppedDigit =0;
|
|
}
|
|
else if (dir<0) {
|
|
nextStoppedDigit--;
|
|
if (nextStoppedDigit==255) nextStoppedDigit = maxDigits-1;
|
|
}
|
|
else if (sum<=maxDigits-2) {
|
|
while(true) {
|
|
nextStoppedDigit = random(maxDigits);
|
|
if (fin[nextStoppedDigit] ==0) break; //found a running digit
|
|
}
|
|
}
|
|
}
|
|
else if (fin[i]==0) { //get next number
|
|
if (dir>=0)
|
|
digit[i] = (digit[i]+1) % 10;
|
|
else {
|
|
digit[i]--; if (digit[i] == 255) digit[i] = 9;
|
|
}
|
|
digitDP[i] = ((digit[i]%2) == 0);
|
|
}
|
|
} //end for
|
|
//DPRINT("lastStoppedDigit:"); DPRINT(lastStoppedDigit);
|
|
//DPRINT(" nextStoppedDigit:"); DPRINTLN(nextStoppedDigit);
|
|
|
|
writeDisplaySingle();
|
|
Fdelay(100);
|
|
sum = 0;
|
|
for (int i=0;i<maxDigits;i++) sum += fin[i];
|
|
//DPRINT("Sum:"); DPRINTLN(sum);
|
|
if (sum >= maxDigits) //all digits ready
|
|
break;
|
|
if ((millis()-started) >= (t+5000)) {
|
|
DPRINTLN("Safety exit");
|
|
break;
|
|
}
|
|
} //end while
|
|
|
|
memcpy(oldDigit, digit, sizeof(oldDigit));
|
|
lastCathodeProt = minute();
|
|
cathodeProtRunning = false;
|
|
}
|
|
|
|
// NOTE: earlier/simpler cathode-protect implementation, superseded by newCathodeProtect() above
|
|
// (kept for reference/comparison - "original version"). Not called from anywhere else in this file;
|
|
// appears to be dead code left in place.
|
|
void cathodeProtect() { //original version
|
|
int hour12_24 = prm.set12_24 ? (byte)hour() : (byte)hourFormat12();
|
|
byte hourShow = (byte)hour12_24;
|
|
byte minShow = (byte)minute();
|
|
byte secShow = (byte)second();
|
|
byte dh1 = (hourShow / 10), dh2 = (hourShow % 10);
|
|
byte dm1 = (minShow / 10), dm2 = (minShow % 10);
|
|
byte ds1 = (secShow / 10), ds2 = (secShow % 10);
|
|
|
|
DPRINTLN("Cathode Protect running!");
|
|
// All four digits will increment up at 10 Hz.
|
|
// At T=2 sec, individual digits will stop at
|
|
// the correct time every second starting from
|
|
// the right and ending on the left
|
|
for (int i = 0; i <= 70; i++) {
|
|
if (i >= 20) ds2 = (secShow % 10);
|
|
if (i >= 30) ds1 = (secShow / 10);
|
|
if (i >= 40) dm2 = (minShow % 10);
|
|
if (i >= 50) dm1 = (minShow / 10);
|
|
if (i >= 60) dh2 = (hourShow % 10);
|
|
if (i == 70) dh1 = (hourShow / 10);
|
|
if (maxDigits >= 8) {
|
|
digit[7] = dh1;
|
|
digit[6] = dh2;
|
|
digit[5] = 11;
|
|
digit[4] = dm1;
|
|
digit[3] = dm2;
|
|
digit[2] = 11;
|
|
digit[1] = ds1;
|
|
digit[0] = ds2;
|
|
}
|
|
else if (maxDigits == 6) {
|
|
digit[5] = dh1;
|
|
digit[4] = dh2;
|
|
digit[3] = dm1;
|
|
digit[2] = dm2;
|
|
digit[1] = ds1;
|
|
digit[0] = ds2;
|
|
}
|
|
else {
|
|
digit[3] = dh1;
|
|
digit[2] = dh2;
|
|
digit[1] = dm1;
|
|
digit[0] = dm2;
|
|
}
|
|
incMod10(dh1); incMod10(dh2);
|
|
incMod10(dm1); incMod10(dm2);
|
|
incMod10(ds1); incMod10(ds2);
|
|
for (int d=0;d<maxDigits;d++) {
|
|
digitDP[d] = ((digit[d]%2) == 0);
|
|
}
|
|
writeDisplaySingle();
|
|
Fdelay(100);
|
|
}
|
|
memcpy(oldDigit, digit, sizeof(oldDigit));
|
|
lastCathodeProt = minute();
|
|
}
|
|
|
|
inline void incMod10(byte &x) {
|
|
x = (x + 1 == 10 ? 0 : x + 1);
|
|
};
|
|
|
|
|
|
// displayTemp()/displayHumid() render a sensor reading into newDigit[]/digitDP[] (right-aligned,
|
|
// working backwards from digPtr), applying the per-sensor calibration offset (prm.corrT0/corrT1 or
|
|
// corrH0/corrH1 - ptr selects which sensor slot, 0 or 1). Two build-time variants exist per function:
|
|
// a compact DISPLAYTEMP_ONLY_2DIGITS/DISPLAYHUMID_ONLY_2DIGITS version for clocks with too few digit
|
|
// positions to show units/sign, and the full version below (unit letter + degree/percent glyph +
|
|
// optional leading sign). `ptr==0`/`ptr==1` only ever apply corrT0/corrH0 vs corrT1/corrH1 - a 3rd or
|
|
// 4th simultaneous sensor slot (see I2Csensors.ino review notes) gets no calibration correction here.
|
|
#ifdef DISPLAYTEMP_ONLY_2DIGITS
|
|
void displayTemp(byte ptr) { //special version, 2 digits only
|
|
float t = temperature[ptr];
|
|
if (ptr==0) t += prm.corrT0;
|
|
if (ptr==1) t += prm.corrT1;
|
|
if (prm.tempCF) {
|
|
t = round1((temperature[ptr] * 9/5)+32);
|
|
}
|
|
int digPtr = 3; //tube number: first digit of minutes (hours:5,4 minutes:3,2 seconds: 1,0 )
|
|
#ifdef TEMP_START_POSITION
|
|
digPtr = TEMP_START_POSITION;
|
|
#endif
|
|
for (int i = 0; i < maxDigits; i++) { //clear display
|
|
digitDP[i] = false;
|
|
newDigit[i] = 10;
|
|
}
|
|
|
|
t = abs(t);
|
|
newDigit[digPtr] = t / 10;
|
|
if (newDigit[digPtr] == 0) newDigit[digPtr] = 10; //BLANK, if zero
|
|
newDigit[--digPtr] = int(t) % 10;
|
|
|
|
if (prm.animMode == 0) memcpy(oldDigit, newDigit, sizeof(oldDigit)); //don't do animation
|
|
colonBlinkState = false;
|
|
decimalpointON = false;
|
|
}
|
|
|
|
#else
|
|
void displayTemp(byte ptr) {
|
|
float t = temperature[ptr];
|
|
if (ptr==0) t += prm.corrT0;
|
|
if (ptr==1) t += prm.corrT1;
|
|
if (prm.tempCF) {
|
|
t = round1((temperature[ptr] * 9/5)+32);
|
|
}
|
|
int digPtr = maxDigits-1;
|
|
#ifdef TEMP_START_POSITION
|
|
digPtr = TEMP_START_POSITION;
|
|
#endif
|
|
for (int i = 0; i < maxDigits; i++) {
|
|
digitDP[i] = false;
|
|
newDigit[i] = 10;
|
|
}
|
|
#if defined(PLUS_CHARCODE) || defined(MINUS_CHARCODE) //leading sign
|
|
if (t>=0) newDigit[digPtr] = PLUS_CHARCODE;
|
|
if (t<0) newDigit[digPtr] = MINUS_CHARCODE;
|
|
digPtr--;
|
|
#endif
|
|
t = abs(t);
|
|
newDigit[digPtr] = t / 10;
|
|
if (newDigit[digPtr] == 0) newDigit[digPtr] = 10; //BLANK!!!
|
|
newDigit[--digPtr] = int(t) % 10;
|
|
digitDP[digPtr] = true;
|
|
newDigit[--digPtr] = int(t * 10) % 10;
|
|
if ((maxDigits > 5) && (GRAD_CHARCODE >= 0)) {
|
|
digPtr -=1;
|
|
newDigit[digPtr] = GRAD_CHARCODE; //grad
|
|
}
|
|
if (TEMP_CHARCODE >= 0) {
|
|
if (prm.tempCF) newDigit[--digPtr] = TEMP_CHARCODE+5; // 'F'
|
|
else newDigit[--digPtr] = TEMP_CHARCODE; // 'C'
|
|
}
|
|
|
|
if (prm.animMode == 0) memcpy(oldDigit, newDigit, sizeof(oldDigit)); //don't do animation
|
|
colonBlinkState = true;
|
|
decimalpointON = true;
|
|
}
|
|
#endif
|
|
|
|
#ifdef DISPLAYHUMID_ONLY_2DIGITS
|
|
void displayHumid(byte ptr) { //special version, 2 digits only
|
|
float h = humid[ptr];
|
|
if (ptr==0) h = round(h + prm.corrH0);
|
|
if (ptr==1) h = round(h + prm.corrH1);
|
|
|
|
int digPtr = 3; //tube number: first digit of minutes (hours:5,4 minutes:3,2 seconds: 1,0 )
|
|
#ifdef HUMID_START_POSITION
|
|
digPtr = HUMID_START_POSITION;
|
|
#endif
|
|
for (int i = 0; i < maxDigits; i++) { //clear display
|
|
digitDP[i] = false;
|
|
newDigit[i] = 10;
|
|
}
|
|
|
|
newDigit[digPtr] = h / 10;
|
|
if (newDigit[digPtr] == 0) newDigit[digPtr] = 10; //BLANK, if zero
|
|
newDigit[--digPtr] = int(h) % 10;
|
|
|
|
if (prm.animMode == 0) memcpy(oldDigit, newDigit, sizeof(oldDigit)); //don't do animation
|
|
colonBlinkState = false;
|
|
decimalpointON = false;
|
|
}
|
|
|
|
#else
|
|
void displayHumid(byte ptr) {
|
|
int digPtr = maxDigits-1;
|
|
#ifdef HUMID_START_POSITION
|
|
digPtr = HUMID_START_POSITION;
|
|
#endif
|
|
for (int i = 0; i < maxDigits; i++) {
|
|
digitDP[i] = false;
|
|
newDigit[i] = 10;
|
|
}
|
|
float h = humid[ptr];
|
|
if (ptr==0) h += prm.corrH0;
|
|
if (ptr==1) h += prm.corrH1;
|
|
#if defined(PLUS_CHARCODE) || defined(MINUS_CHARCODE) //leading sign
|
|
digPtr--;
|
|
#endif
|
|
newDigit[digPtr] = int(h) / 10;
|
|
if (newDigit[digPtr] == 0) newDigit[digPtr] = 10; //BLANK if zero!!!
|
|
newDigit[--digPtr] = int(h) % 10;
|
|
digitDP[digPtr] = true;
|
|
newDigit[--digPtr] = int(h * 10) % 10;
|
|
if (maxDigits > 5) {
|
|
if (digitsOnly) {
|
|
newDigit[--digPtr] = 10; //empty character
|
|
newDigit[--digPtr] = PERCENT_CHARCODE; // "%"
|
|
}
|
|
else {
|
|
newDigit[--digPtr] = 10; //empty character
|
|
newDigit[--digPtr] = UPPER_CIRCLE_CHARCODE; //upper circle = 16
|
|
newDigit[--digPtr] = LOWER_CIRCLE_CHARCODE; // lower circle = 18
|
|
}
|
|
}
|
|
else { //4 tubes only
|
|
if (digitsOnly) {
|
|
newDigit[--digPtr] = PERCENT_CHARCODE; // "%"
|
|
}
|
|
else {
|
|
for (int i = 0; i < maxDigits; i++) {digitDP[i] = false;} //clear any decimal point
|
|
newDigit[digPtr] = UPPER_CIRCLE_CHARCODE; //upper circle = 16
|
|
newDigit[--digPtr] = LOWER_CIRCLE_CHARCODE; // lower circle = 18
|
|
}
|
|
}
|
|
|
|
if (prm.animMode == 0) memcpy(oldDigit, newDigit, sizeof(oldDigit)); //don't do animation
|
|
colonBlinkState = true;
|
|
decimalpointON = true;
|
|
}
|
|
#endif
|
|
|
|
// Renders the current calendar date into newDigit[] according to prm.dateMode (0=dd/mm/yyyy,
|
|
// 1=mm/dd/yyyy, 2=yyyy/mm/dd), truncating the year portion on clocks with fewer than 8 digit
|
|
// positions (>6 digits shows a 2-digit year, <=6 shows none) - the `p[m][i]` lookup table encodes
|
|
// which date component (0=day,1=month,2=year) goes in which position for each mode.
|
|
void displayDate() {
|
|
byte m = prm.dateMode;
|
|
if (m>2) m=2;
|
|
byte p[3][8] =
|
|
{ //tube# 76543210 543210 3210
|
|
{2,1,0}, //ddmmyyyy ddmmyy ddmm
|
|
{2,0,1}, //mmddyyyy mmddyy mmdd
|
|
{0,1,2} //yyyymmdd yymmdd mmdd
|
|
};
|
|
|
|
int t = 0;
|
|
if (thermometerClock) t = 1;
|
|
for (int i=0;i<3;i++) {
|
|
if (p[m][i] == 2) {
|
|
if (!thermometerClock) {
|
|
if (maxDigits>4) {
|
|
newDigit[t++] = year() % 10;
|
|
newDigit[t++] = (year() % 100) / 10;
|
|
}
|
|
if (maxDigits>6) {
|
|
newDigit[t++] = (year() % 1000) / 100;
|
|
newDigit[t++] = year() / 1000;
|
|
}
|
|
} //!thermometerClock
|
|
}
|
|
if (p[m][i] == 1) {
|
|
newDigit[t++] = month() % 10;
|
|
newDigit[t++] = month() / 10;
|
|
}
|
|
if (p[m][i] == 0) {
|
|
newDigit[t++] = day() % 10;
|
|
newDigit[t++] = day() / 10;
|
|
}
|
|
} //end for
|
|
if ((maxDigits>6) && (m<2)) {
|
|
digitDP[4] = true;
|
|
digitDP[6] = true;
|
|
}
|
|
else {
|
|
if (thermometerClock) {
|
|
digitDP[3] = true;
|
|
newDigit[0] = 10;
|
|
newDigit[5] = 10;
|
|
}
|
|
else {
|
|
digitDP[2] = true;
|
|
digitDP[4] = true;
|
|
}
|
|
}
|
|
colonBlinkState = true;
|
|
if (prm.animMode == 1) memcpy(oldDigit, newDigit, sizeof(oldDigit)); //don't do animation
|
|
}
|
|
|
|
// displayTime4/6/8() are the top-level "what goes on the display this second" dispatchers, selected by
|
|
// timeProgram() based on maxDigits. Each one first blanks digitDP[], sets the colon-separator DP
|
|
// positions, then dispatches by priority to temperature/humidity/date, falling through to the normal
|
|
// H:M(:S) clock rendering only if none of those "special screens" is currently active AND
|
|
// prm.enableTimeDisplay is set (a thermometer-only build can disable the clock face entirely).
|
|
// showZero controls whether a leading-zero hour (e.g. "07") shows the 0 or blanks it (digit value 10).
|
|
void displayTime4() {
|
|
for (int i = 0; i < maxDigits; i++) digitDP[i] = false;
|
|
digitDP[4] = true; digitDP[2] = true;
|
|
int hour12_24 = prm.set12_24 ? (byte)hour() : (byte)hourFormat12();
|
|
if (showTemp1) displayTemp(1);
|
|
else if (showTemp0) displayTemp(0);
|
|
else if (showHumid1) displayHumid(1);
|
|
else if (showHumid0) displayHumid(0);
|
|
else if (showDate && prm.enableTimeDisplay) displayDate();
|
|
else if (prm.enableTimeDisplay) {
|
|
showClock = true;
|
|
newDigit[3] = hour12_24 / 10;
|
|
if ((!prm.showZero) && (newDigit[3] == 0)) newDigit[3] = 10;
|
|
newDigit[2] = hour12_24 % 10;
|
|
newDigit[1] = minute() / 10;
|
|
newDigit[0] = minute() % 10;
|
|
if (prm.enableBlink && (second() % 2 == 0)) digitDP[2] = false;
|
|
}
|
|
}
|
|
|
|
|
|
// 6-digit variant of displayTime4() above - has an extra branch for `thermometerClock` builds (see
|
|
// THERMOMETER_CLOCK in clocks.h: first digit reserved for +/- sign, last for C/F, middle 4 for HH:MM)
|
|
// vs the normal 6-tube H:M:S layout.
|
|
void displayTime6() {
|
|
for (int i = 0; i < maxDigits; i++) digitDP[i] = false;
|
|
digitDP[4] = true; digitDP[2] = true;
|
|
int hour12_24 = prm.set12_24 ? (byte)hour() : (byte)hourFormat12();
|
|
if (showTemp1) displayTemp(1);
|
|
else if (showTemp0) displayTemp(0);
|
|
else if (showHumid1) displayHumid(1);
|
|
else if (showHumid0) displayHumid(0);
|
|
else if (showDate && prm.enableTimeDisplay) displayDate();
|
|
else if (prm.enableTimeDisplay) {
|
|
showClock = true;
|
|
if (thermometerClock) {
|
|
newDigit[4] = hour12_24 / 10;
|
|
if ((!prm.showZero) && (newDigit[4] == 0)) newDigit[4] = 10;
|
|
newDigit[3] = hour12_24 % 10;
|
|
newDigit[2] = minute() / 10;
|
|
newDigit[1] = minute() % 10;
|
|
newDigit[0] = 10;
|
|
newDigit[5] = 10;
|
|
if (prm.enableBlink && (second() % 2 == 0)) digitDP[3] = false;
|
|
}
|
|
else { //normal clock
|
|
newDigit[5] = hour12_24 / 10;
|
|
if ((!prm.showZero) && (newDigit[5] == 0)) newDigit[5] = 10;
|
|
newDigit[4] = hour12_24 % 10;
|
|
newDigit[3] = minute() / 10;
|
|
newDigit[2] = minute() % 10;
|
|
if (prm.enableBlink && (second() % 2 == 0)){
|
|
digitDP[2] = false;
|
|
if (prm.enableDoubleBlink) {
|
|
digitDP[4] = false;
|
|
}
|
|
}
|
|
newDigit[1] = second() / 10;
|
|
newDigit[0] = second() % 10;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 8-digit variant of displayTime4() above - full H:M:S with dash separators (digit value 11, see the
|
|
// BUFSIZE encoding comment near the digit[] declaration) instead of decimal points between the groups.
|
|
void displayTime8() {
|
|
for (int i = 0; i < maxDigits; i++) {
|
|
digitDP[i] = false;
|
|
newDigit[i] = 10;
|
|
}
|
|
int hour12_24 = prm.set12_24 ? (byte)hour() : (byte)hourFormat12();
|
|
if (showTemp1) displayTemp(1);
|
|
else if (showTemp0) displayTemp(0);
|
|
else if (showHumid1) displayHumid(1);
|
|
else if (showHumid0) displayHumid(0);
|
|
else if (showDate && prm.enableTimeDisplay) displayDate();
|
|
else if (prm.enableTimeDisplay) {
|
|
showClock = true;
|
|
newDigit[8] = 10; //sign digit = BLANK
|
|
newDigit[7] = hour12_24 / 10;
|
|
if ((!prm.showZero) && (newDigit[7] == 0)) newDigit[7] = 10;
|
|
newDigit[6] = hour12_24 % 10;
|
|
newDigit[5] = 11; //- sign
|
|
newDigit[4] = minute() / 10;
|
|
newDigit[3] = minute() % 10;
|
|
newDigit[2] = 11; //- sign
|
|
if (prm.enableBlink && (second() % 2 == 0)) {
|
|
newDigit[2] = 10; //BLANK
|
|
if (prm.enableDoubleBlink) {
|
|
newDigit[5] = 10; //BLANK
|
|
}
|
|
}
|
|
newDigit[1] = second() / 10;
|
|
newDigit[0] = second() % 10;
|
|
}
|
|
}
|
|
|
|
|
|
// The driver-independent animation engine: compares newDigit[] (the target the current displayTimeN()/
|
|
// displayDate()/displayTemp()/displayHumid() call just computed) against oldDigit[] (what was on
|
|
// screen before) and, if anything changed, plays the animation selected by prm.animMode (0-7, see
|
|
// docs/DEVELOPER_GUIDE.md section 4.5):
|
|
// 0 = no animation, instant switch
|
|
// 1 = each changed tube "rolls" through the digits from its old value up to its new one, one tube at a time
|
|
// 2 = all changed tubes spin through 0-9 together, in lockstep
|
|
// 3/4 = whole digit-row slides in from the right/left (with a "----" gap between old and new content)
|
|
// 5 = "wipe" - drives animMask[] with a ramp value per changed tube; interpretation is driver-specific
|
|
// (some multiplex-family drivers get a finer 1..19 ramp, others a coarser 1..5-then-back-down ramp)
|
|
// 6 = picks a random animation from 1-5 on every call
|
|
// 7 = picks a random animation from 1-4 on every call (excludes the wipe animation)
|
|
// Special characters (value >9, e.g. blank/dash/custom glyphs) are always applied immediately outside
|
|
// the animation (never rolled through like a numeric digit change) since there's no sensible
|
|
// "in-between" glyph to animate through. On clocks with >4 digits, the seconds tube (index 0) is
|
|
// excluded from most animations (`j=1`) so a normal ticking seconds digit doesn't animate every second.
|
|
void changeDigit() {
|
|
int j = 0;
|
|
byte tmp[50];
|
|
byte space = 4;
|
|
byte anim;
|
|
|
|
#if defined(TUBE1CLOCK) //no tube animation possible
|
|
writeDisplaySingle();
|
|
return;
|
|
#endif
|
|
anim = prm.animMode;
|
|
#if defined(DISABLE_NIGHT_ANIMATION)
|
|
if (!displayON) anim = 0; //At NIGHT switch off animation
|
|
#endif
|
|
#if defined(WORDCLOCK)
|
|
anim = 0;
|
|
#endif
|
|
|
|
if (anim == 6) anim = 1 + rand() % 5;
|
|
else if (anim == 7) anim = 1 + rand() % 4;
|
|
|
|
if (anim != 5) {
|
|
for (int i = 0; i < maxDigits; i++)
|
|
if ((newDigit[i] > 9) || ((oldDigit[i] > 9) && (newDigit[i] <= 9) )) {
|
|
digit[i] = newDigit[i]; //show special characters ASAP or if special char changes to numbers
|
|
oldDigit[i] = newDigit[i];
|
|
}
|
|
if ((maxDigits > 4) && (newDigit[0] != 0)) j = 1; //if 6 or 8 tube clock, don't play (animate) the seconds digit
|
|
}
|
|
|
|
if (memcmp(newDigit + j, oldDigit + j, maxDigits - j) != 0) {
|
|
switch (anim) {
|
|
case 1:
|
|
for (int tube = j; tube < maxDigits; tube++) {
|
|
if ((newDigit[tube] != oldDigit[tube]) && (newDigit[tube] <= 9)) {
|
|
for (int i = oldDigit[tube]; i <= int(newDigit[tube] + 10); i++) {
|
|
digit[tube] = i % 10;
|
|
writeDisplaySingle();
|
|
Fdelay(ANIMSPEED);
|
|
} //end for i
|
|
writeDisplaySingle();
|
|
} //endif
|
|
} //end for tube
|
|
break;
|
|
case 2:
|
|
for (int i = 0; i <= 9; i++) {
|
|
for (int tube = j; tube < maxDigits; tube++) {
|
|
if ((newDigit[tube] != oldDigit[tube]) && (newDigit[tube] <= 9))
|
|
digit[tube] = (oldDigit[tube] + i) % 10;
|
|
} //end for tube
|
|
writeDisplaySingle();
|
|
Fdelay(ANIMSPEED);
|
|
} //end for i
|
|
break;
|
|
case 3:
|
|
memcpy(tmp, oldDigit, maxDigits);
|
|
memcpy(tmp + maxDigits + space, newDigit, maxDigits);
|
|
memset(tmp + maxDigits, 11, space); //----
|
|
for (int i = 0; i < maxDigits + space; i++) {
|
|
for (int tube = 0; tube < maxDigits; tube++) {
|
|
digit[tube % maxDigits] = tmp[i + tube];
|
|
} //end for tube
|
|
writeDisplaySingle();
|
|
Fdelay(ANIMSPEED);
|
|
} //end for i
|
|
break;
|
|
case 4:
|
|
memcpy(tmp, newDigit, maxDigits);
|
|
memcpy(tmp + maxDigits + space, oldDigit, maxDigits);
|
|
memset(tmp + maxDigits, 11, space); //----
|
|
for (int i = maxDigits - 1 + space; i >= 0; i--) {
|
|
for (int tube = 0; tube < maxDigits; tube++) {
|
|
digit[tube % maxDigits] = tmp[i + tube];
|
|
} //end for tube
|
|
writeDisplaySingle();
|
|
Fdelay(ANIMSPEED);
|
|
} //end for i
|
|
break;
|
|
case 5:
|
|
// "Wipe" animation - two different ramp shapes depending on which driver is active, since
|
|
// different segment/multiplex hardware interprets the animMask[] progress value differently:
|
|
// the SN74141-family/VQC10/HV5122 drivers get a fine 1..19 monotonic ramp (their own
|
|
// writeDisplay*() decodes finer wipe steps), everything else gets a coarser 1..5-then-5..1
|
|
// "ramp up then back down" pattern, with digit[] snapped to newDigit[] partway through.
|
|
memset(animMask, 0, sizeof(animMask));
|
|
#if defined(MULTIPLEX74141) || defined(NO_MULTIPLEX74141) || defined(MULTIPLEX74141_ESP32) || defined(MULE_V2) || defined(PCF_74141) || defined(NO_MULTIPLEX_ESP32) || defined(VQC10) || defined(NEWHV5122)
|
|
for (int i = 1; i < 20; i++) {
|
|
for (int tube = j; tube < maxDigits; tube++) {
|
|
if (oldDigit[tube] != newDigit[tube]) animMask[tube] = i; //digit is changed
|
|
} //end for tube
|
|
writeDisplaySingle();
|
|
Fdelay(ANIMSPEED);
|
|
} //end for i
|
|
#else
|
|
for (int i = 1; i <= 5; i++) {
|
|
for (int tube = j; tube < maxDigits; tube++) {
|
|
if (oldDigit[tube] != newDigit[tube]) animMask[tube] = i; //digit is changed
|
|
} //end for tube
|
|
writeDisplaySingle();
|
|
Fdelay(ANIMSPEED);
|
|
} //end for i
|
|
memcpy(digit, newDigit, sizeof(digit));
|
|
for (int i = 1; i <= 5; i++) {
|
|
for (int tube = j; tube < maxDigits; tube++) {
|
|
if (oldDigit[tube] != newDigit[tube]) animMask[tube] = 6 - i; //digit is changed
|
|
} //end for tube
|
|
writeDisplaySingle();
|
|
Fdelay(ANIMSPEED);
|
|
} //end for i
|
|
#endif
|
|
memset(animMask, 0, sizeof(animMask));
|
|
break;
|
|
}
|
|
} //endif memcmp
|
|
|
|
memcpy(digit, newDigit, sizeof(digit));
|
|
memcpy(oldDigit, newDigit, sizeof(oldDigit));
|
|
writeDisplaySingle();
|
|
}
|
|
|
|
// Drives ALARMSPEAKER_PIN, but only actually calls digitalWrite() when the requested state differs
|
|
// from what's already set (oldState) - avoids redundant GPIO writes on every alarmSound() tick.
|
|
void writeAlarmPin(boolean newState) {
|
|
#if ALARMSPEAKER_PIN>=0
|
|
static boolean oldState = !ALARM_ON;
|
|
if (oldState != newState) {
|
|
oldState = newState;
|
|
//if (newState == ALARM_ON) DPRINTLN("Alarm ON"); else DPRINTLN("Alarm OFF");
|
|
digitalWrite(ALARMSPEAKER_PIN, newState);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// Called every loop() iteration. Non-blocking alarm-tone state machine: when the wall-clock time
|
|
// matches prm.alarmHour:prm.alarmMin (checked once, in the first 3 seconds of that minute, to avoid
|
|
// re-triggering all through the minute), it starts a beep pattern defined by the `t[]` timestamp table
|
|
// below (an escalating series of short beeps, in milliseconds relative to alarmStarted) that repeats
|
|
// beyond the table's end as plain 500ms on/off beeping, for prm.alarmPeriod seconds total. Can be
|
|
// silenced early via ALARMBUTTON_PIN if wired.
|
|
void alarmSound(void) {
|
|
static const unsigned int t[] = {0, 3000, 6000, 6200, 9000, 9200, 9400, 12000, 12200, 12400, 15000, 15200, 15400};
|
|
static int count = 0;
|
|
static unsigned long nextEvent;
|
|
const int cMax = sizeof(t) / sizeof(t[0]); //number of time steps
|
|
|
|
if (prm.alarmEnable) {
|
|
if ( (!alarmON && prm.alarmHour == hour()) && (prm.alarmMin == minute()) && (second() <= 3)) { //switch ON alarm sound
|
|
DPRINTLN("Alarm started!");
|
|
alarmON = true;
|
|
alarmStarted = millis();
|
|
count = 0;
|
|
nextEvent = alarmStarted - 1;
|
|
}
|
|
}
|
|
else {
|
|
alarmON = false;
|
|
}
|
|
|
|
if (!alarmON) {
|
|
writeAlarmPin(!ALARM_ON);
|
|
return; //nothing to do
|
|
}
|
|
|
|
if ((millis() - alarmStarted) > 1000 * (long)prm.alarmPeriod) {
|
|
alarmON = false; //alarm period is over
|
|
darkenNeopixels();
|
|
DPRINTLN("Alarm ended.");
|
|
}
|
|
|
|
if (ALARMBUTTON_PIN >= 0) { //is button installed?
|
|
if (digitalRead(ALARMBUTTON_PIN) == LOW) { //stop alarm
|
|
DPRINTLN("Alarm stopped by button press.");
|
|
alarmON = false;
|
|
}
|
|
}
|
|
|
|
if (!prm.alarmEnable || !alarmON) { //no alarm, switch off
|
|
writeAlarmPin(!ALARM_ON);
|
|
return;
|
|
}
|
|
|
|
//-------- Generate sound --------------------------------
|
|
|
|
if (millis() > nextEvent) { //go to the next event
|
|
if (count % 2 == 0) {
|
|
nextEvent += 500;
|
|
writeAlarmPin(ALARM_ON);
|
|
//DPRINT(" Sound ON"); DPRINT(" Next:"); DPRINTLN(nextEvent);
|
|
}
|
|
else {
|
|
writeAlarmPin(!ALARM_ON);
|
|
nextEvent = (count / 2 < cMax) ? alarmStarted + t[count / 2] : nextEvent + 500;
|
|
//DPRINT(" OFF"); DPRINT(" Next:"); DPRINTLN(nextEvent);
|
|
}
|
|
count++;
|
|
}
|
|
}
|
|
|
|
|
|
// Called once per second from timeProgram(). Decides displayON (day/night brightness state) from the
|
|
// prm.dayHour:dayMin / prm.nightHour:nightMin schedule, handling the case where the night window wraps
|
|
// past midnight (mn_off > mn_on, e.g. night starts 22:00 and ends 07:00) vs. the (unusual) case where
|
|
// it doesn't. manualOverride (set by the web UI's "manualOverride" key in handleConfigChanged()) lets
|
|
// the user force displayON one way regardless of the schedule; that override is automatically cleared
|
|
// again the moment the schedule's own computed state (prevShutoffState) naturally transitions to match
|
|
// what the override had forced - i.e. a manual override lasts until the next scheduled day/night
|
|
// boundary, not forever.
|
|
void evalShutoffTime(void) { // Determine whether tubes should be turned to NIGHT mode
|
|
|
|
if (!prm.enableAutoShutoff) return;
|
|
|
|
int mn = 60 * hour() + minute();
|
|
int mn_on = prm.dayHour * 60 + prm.dayMin;
|
|
int mn_off = prm.nightHour * 60 + prm.nightMin;
|
|
|
|
static bool prevShutoffState = true;
|
|
if ( (((mn_off < mn_on) && (mn >= mn_off) && (mn < mn_on))) || // Nighttime
|
|
((mn_off > mn_on) && ((mn >= mn_off) || (mn < mn_on)))) {
|
|
if (!manualOverride) displayON = false;
|
|
if (prevShutoffState == true) manualOverride = false;
|
|
prevShutoffState = false;
|
|
}
|
|
else { // Tubes should be on
|
|
if (!manualOverride) displayON = true;
|
|
if (prevShutoffState == false) manualOverride = false;
|
|
prevShutoffState = true;
|
|
}
|
|
return;
|
|
// POSSIBLE BUG / NOTE: unconditional `return` above makes the debug DPRINT block below permanently
|
|
// unreachable dead code - harmless (it only ever fed DPRINT, a no-op without DEBUG), but the
|
|
// early-return means these diagnostics can never be re-enabled just by turning DEBUG on; the `return`
|
|
// itself would also need removing.
|
|
DPRINT("mn="); DPRINT(mn);
|
|
DPRINT(" mn_on="); DPRINT(mn_on);
|
|
DPRINT(" mn_off="); DPRINT(mn_off);
|
|
DPRINT(" manOverride:"); DPRINT(manualOverride);
|
|
DPRINT(" displayON:"); DPRINTLN(displayON);
|
|
DPRINT(" enableBlink:"); DPRINTLN(prm.enableBlink);
|
|
DPRINT(" blinkState:"); DPRINTLN(colonBlinkState);
|
|
}
|
|
|
|
|
|
// Renders a single IP-address octet (0-255) as a 3-digit number, optionally preceded by "IP" glyphs
|
|
// on displays with an alphabetic character set and >=6 digit positions, and shows it once via
|
|
// changeDigit(). Called 4 times in a row by showMyIp() below, once per octet.
|
|
void writeIpTag(byte iptag) {
|
|
|
|
#ifdef SHIFT_TUBES_LEFT_BY_1
|
|
byte offset = 1;
|
|
#else
|
|
byte offset = 0;
|
|
#endif
|
|
|
|
memset(newDigit, 10, sizeof(newDigit));
|
|
if (!digitsOnly && (maxDigits >= 6)) {
|
|
newDigit[5] = 19; //'I'
|
|
newDigit[4] = 14; //'P'
|
|
}
|
|
//if (iptag >= 100)
|
|
newDigit[2 + offset] = iptag / 100;
|
|
newDigit[1 + offset] = (iptag % 100) / 10;
|
|
newDigit[0 + offset] = iptag % 10;
|
|
memcpy(digit,newDigit,sizeof(digit));
|
|
printDigits(0);
|
|
changeDigit();
|
|
}
|
|
|
|
// Called once at the end of setup(): displays the clock's IP address on the tubes, one octet at a
|
|
// time (each shown for SPEED=1500ms via writeIpTag()), so the user knows what address to browse to
|
|
// for the web admin UI without needing a router's client list or a phone app.
|
|
void showMyIp(void) { //at startup, show the web page internet address
|
|
clearDigits();
|
|
#define SPEED 1500
|
|
for (int i=0;i<4;i++) {
|
|
ipShowRunning = true;
|
|
writeIpTag(ip[i]);
|
|
Fdelay(SPEED);
|
|
}
|
|
ipShowRunning = false;
|
|
}
|
|
|
|
|
|
// NOTE: converts a "number of 15-minute steps since midnight" value into hours+minutes - not called
|
|
// anywhere in this file or elsewhere in the project; dead/unused helper.
|
|
void fifteenMinToHM(int &hours, int &minutes, int fifteenMin)
|
|
{
|
|
hours = fifteenMin / 4;
|
|
minutes = (fifteenMin % 4) * 15;
|
|
}
|
|
|
|
// Called from loop() whenever prm.wifiMode is true but WiFi.status()!=WL_CONNECTED. Self-throttled to
|
|
// try at most once every 10 minutes: first attempts a lightweight WiFi.disconnect()+WiFi.reconnect()
|
|
// (reuses previously stored credentials), and only falls back to the heavier startWifiMode() (which
|
|
// can itself open the wifiManager() config portal) if that quick reconnect didn't succeed.
|
|
void resetWiFi(void) {
|
|
static unsigned long lastTest = millis();
|
|
static unsigned int counter = 0;
|
|
|
|
if ((millis() - lastTest) < 600000) return; //check in every 10 min
|
|
lastTest = millis();
|
|
DPRINTLN("Lost WiFi. Reconnect.");
|
|
//#if defined(ESP8266)
|
|
WiFi.disconnect();
|
|
WiFi.reconnect();
|
|
//#endif
|
|
if (WiFi.status() == WL_CONNECTED) return;
|
|
|
|
//findBestWifi();
|
|
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;i<useTemp;i++) {
|
|
DPRINT(temperature[i]); DPRINT(", ");
|
|
}
|
|
DPRINTLN(" ");
|
|
}
|
|
if (useHumid>0) {
|
|
DPRINT("Humidity ("); DPRINT(useHumid); DPRINT("): ");
|
|
for (int i=0;i<useHumid;i++) {
|
|
DPRINT(humid[i]); DPRINT(", ");
|
|
}
|
|
DPRINTLN(" ");
|
|
}
|
|
}
|
|
|
|
// Debug helper: prints one digit[]/newDigit[] buffer value (see the BUFSIZE encoding comment near the
|
|
// digit[] declaration near the top of this file) as its human-readable glyph to the serial console.
|
|
// NOTE: only decodes a handful of the possible 13+ "special character" codes by hardcoded value/macro
|
|
// (TEMP_CHARCODE, GRAD_CHARCODE, PERCENT_CHARCODE, plus literal 18/19/14) - any other special glyph
|
|
// code prints as a generic '-', so this debug view doesn't fully reflect every driver's char set.
|
|
void printChar(int i) {
|
|
if (i < 10) {DPRINT(i);}
|
|
else if (i==10) {DPRINT(" ");}
|
|
else if (i==TEMP_CHARCODE) {DPRINT("C");}
|
|
else if (i==GRAD_CHARCODE) {DPRINT("°");}
|
|
else if (i==18) {DPRINT(".");}
|
|
else if (i==PERCENT_CHARCODE) {DPRINT("%"); }
|
|
else if (i==19) {DPRINT("I");}
|
|
else if (i==14) {DPRINT("P");}
|
|
else DPRINT("-");
|
|
|
|
if (digitDP[i]) {DPRINT(".");} else {DPRINT(" ");}
|
|
}
|
|
|
|
// Debug console status line: current digit[] content (rendered via printChar()), colon-blink state,
|
|
// lux reading, WiFi status, plus periodic sensor dump (printSensors()) - entire body compiles to
|
|
// nothing when DEBUG is not defined. `timeout` throttles how often it actually prints (0 = every call).
|
|
void printDigits(unsigned long timeout) {
|
|
static unsigned long lastRun = millis();
|
|
|
|
if ((millis() - lastRun) < timeout) return;
|
|
lastRun = millis();
|
|
|
|
#ifdef DEBUG
|
|
DPRINT(" digit: ["); for (int i = maxDigits - 1; i >= 0; i--) {printChar(digit[i]);} DPRINT("]");
|
|
DPRINT(colonBlinkState ? " * " : " ");
|
|
//DPRINT("\noldDigit: "); for (int i = maxDigits - 1; i >= 0; i--) {printChar(oldDigit[i]);}
|
|
//DPRINT("\nnewDigit: "); for (int i = maxDigits - 1; i >= 0; i--) {printChar(newDigit[i]);}
|
|
|
|
/*
|
|
if ((millis()/1000%10) == 1) { //show free memory for debugging memory leak
|
|
DPRINT("Heap:"); DPRINT(ESP.getFreeHeap()); DPRINT(" byte");
|
|
#if defined(ESP8266)
|
|
DPRINT(" Fragmentation:"); DPRINT(100 - ESP.getMaxFreeBlockSize() * 100.0 / ESP.getFreeHeap()); DPRINT("%");
|
|
#endif
|
|
}
|
|
*/
|
|
//DPRINT("INT:"); DPRINT(intCounter); intCounter = 0; //show multiplex interrupt counter
|
|
//DPRINT(" ESaving:"); DPRINT(EEPROMsaving);
|
|
if (useLux>0) {
|
|
DPRINT(" Lux:"); DPRINT(lx);
|
|
}
|
|
if (RADAR_PIN>=0) {
|
|
//DPRINT(" RadarPin:"); DPRINT(digitalRead(RADAR_PIN)); DPRINT(" lastOn:"); DPRINT(radarLastOn); DPRINT(" sec:"); DPRINTLN((millis()-radarLastOn)/1000);
|
|
}
|
|
//DPRINT(" tON:"); DPRINT(timerON); DPRINT(" tOFF:"); DPRINT(timerOFF); //Multiplex timing values for testing
|
|
if (WiFi.status() != WL_CONNECTED) DPRINT(" no WIFI");
|
|
DPRINTLN(" ");
|
|
printSensors();
|
|
#endif
|
|
}
|
|
|
|
|
|
// Called every loop() iteration (self-throttled to 500ms). Drives TUBE_POWER_PIN (filament/HV supply
|
|
// on/off) based on radar-detected presence: motion resets a timeout (radarLastOn), and if no motion is
|
|
// seen for prm.radarTimeout minutes the tubes' power supply is switched off entirely (a harder cutoff
|
|
// than just dimming brightness). The radar signal can come from a LOCAL RADAR_PIN sensor (if wired -
|
|
// takes priority) or from mqttRadarON (a remote/MQTT-published radar state, see mqtt.ino's master/
|
|
// slave radar sharing). If prm.enableRadar is off, radarON is forced true (always powered) regardless
|
|
// of any radar input.
|
|
void checkTubePowerOnOff(void) {
|
|
static unsigned long lastRun = 0;
|
|
|
|
if ((millis()-lastRun)<500) return;
|
|
lastRun = millis();
|
|
|
|
boolean newP = mqttRadarON;
|
|
if (RADAR_PIN >=0) { //if local RADAR is defined, it has priority
|
|
newP = digitalRead(RADAR_PIN);
|
|
}
|
|
if (!radarON && newP) { //check, is switching ON?
|
|
DPRINTLN("RADAR: Switching ON tubes.");
|
|
}
|
|
if (newP) { //restart timeout
|
|
radarLastOn = millis();
|
|
radarON = true;
|
|
}
|
|
if (radarON && (millis()-radarLastOn)>60000ul*uint32_t(prm.radarTimeout)) { //check if should switch off?
|
|
radarON = false;
|
|
DPRINTLN("RADAR: Switching OFF tubes.");
|
|
}
|
|
//DPRINT("RadarON:"); DPRINT(radarON); DPRINT(" timeout:"); DPRINTLN((millis()-radarLastOn));
|
|
|
|
if (!prm.enableRadar) radarON = true; //If Radar is disabled, always ON !
|
|
#if TUBE_POWER_PIN >=0
|
|
if (((displayON ? prm.dayBright : prm.nightBright) == 0) || !radarON) {
|
|
digitalWrite(TUBE_POWER_PIN,!TUBE_POWER_ON); //Switch OFF
|
|
//DPRINTLN("POWER OFF");
|
|
}
|
|
else {
|
|
digitalWrite(TUBE_POWER_PIN,TUBE_POWER_ON); //Switch ON
|
|
//DPRINTLN("POWER ON");
|
|
}
|
|
#endif
|
|
}
|
|
|
|
|
|
//Calculation parameters are defined in clocks.h
|
|
//https://www.pcboard.ca/ldr-light-dependent-resistor
|
|
|
|
// Converts a raw ADC reading from an LDR (light-dependent resistor) voltage divider into an
|
|
// approximate lux value, using the LUX_CALC_SCALAR/LUX_CALC_EXPONENT calibration constants from
|
|
// clocks.h (LDR characteristic curve) and a simple exponential smoothing filter (oldLux) to avoid
|
|
// jumpy brightness changes from ADC noise. Returns 0 (a build with no LIGHT_SENSOR_PIN configured)
|
|
// when there's no LDR wired at all - see getLightSensor() below for the BH1750 (digital lux sensor)
|
|
// alternative, which takes priority over this analog LDR path when both are present.
|
|
int luxMeter(void) {
|
|
#if LIGHT_SENSOR_PIN >=0
|
|
static float oldLux = MAXIMUM_LUX;
|
|
float ADCdata = analogRead(LIGHT_SENSOR_PIN); //DPRINT("ADC:"); DPRINTLN(ADCdata);
|
|
float ldrResistance = (MAX_ADC_READING - ADCdata) / ADCdata * REF_RESISTANCE;
|
|
float ldrLux = LUX_CALC_SCALAR * pow(ldrResistance, LUX_CALC_EXPONENT);
|
|
if ((ldrLux>=MAXIMUM_LUX)||(ldrLux <0)) ldrLux = MAXIMUM_LUX; //Limit lux value to maximum
|
|
oldLux = oldLux + (ldrLux-oldLux)/10; //slow down Lux change
|
|
return (int)oldLux;
|
|
#else
|
|
return (0);
|
|
#endif
|
|
}
|
|
|
|
// Called from loop()/Fdelay() (self-throttled to 500ms) to refresh the global `lx` (ambient lux) and
|
|
// `autoBrightness` (whether auto-dim is actually active) values. Prefers a BH1750 digital sensor if
|
|
// present, falls back to an analog LDR (luxMeter()) if not, and if neither exists forces a fixed
|
|
// MAXIMUM_LUX reading with autoBrightness permanently off (i.e. "auto-dim" silently has no hardware to
|
|
// act on). autoBrightness itself is only ever actually true when prm.enableAutoDim is also set by the
|
|
// user - this function just gates it on hardware presence.
|
|
void getLightSensor(void) {
|
|
static unsigned long lastRun = 0;
|
|
static int oldLx = 0;
|
|
int tmp;
|
|
if ((millis()-lastRun)<500) return;
|
|
lastRun = millis();
|
|
|
|
if (BH1750exist) {
|
|
tmp = getBH1750();
|
|
if ((abs(tmp-oldLx)>1) || (tmp >= MAXIMUM_LUX)) {
|
|
lx=tmp; oldLx = lx;
|
|
}
|
|
if (lx>=MAXIMUM_LUX-2) lx = MAXIMUM_LUX;
|
|
autoBrightness = prm.enableAutoDim;
|
|
}
|
|
else if (LDRexist) {
|
|
tmp = luxMeter();
|
|
if ((abs(tmp-oldLx)>1) || (tmp >= MAXIMUM_LUX)) {
|
|
lx=tmp; oldLx = lx;
|
|
}
|
|
if (lx>=MAXIMUM_LUX-2) lx = MAXIMUM_LUX;
|
|
autoBrightness = prm.enableAutoDim;
|
|
}
|
|
else {
|
|
lx = MAXIMUM_LUX;
|
|
autoBrightness = false;
|
|
}
|
|
}
|
|
|
|
// NOTE: intended to let the web UI's "wifiMode" (STA vs AP) toggle switch the radio mode live, without
|
|
// a reboot - would re-run startNewWifiMode()/startStandaloneMode() whenever prm.wifiMode changes. Its
|
|
// only call site in loop() is commented out (`//checkWifiMode();`), so this is currently dead code;
|
|
// changing prm.wifiMode from the web UI today only takes effect after the next reboot/doReset().
|
|
void checkWifiMode() {
|
|
static boolean oldMode = prm.wifiMode;
|
|
|
|
if (oldMode != prm.wifiMode) {
|
|
if (prm.wifiMode) {
|
|
startNewWifiMode();
|
|
}
|
|
else {
|
|
startStandaloneMode();
|
|
}
|
|
oldMode = prm.wifiMode;
|
|
}
|
|
}
|
|
|
|
// Debug helper: logs to serial whenever any byte of `prm` differs from the last time this was called -
|
|
// a crude way to spot unexpected/unintended settings changes during development. Its only call site in
|
|
// loop() is commented out (`//checkPrm();`), so it is currently dead code / not part of the live build.
|
|
void checkPrm() {
|
|
static byte oldPrm[sizeof(prm)];
|
|
int tmp = memcmp(oldPrm,&prm,sizeof(prm));
|
|
if (tmp!=0){
|
|
DPRINT("Prm mismatch!"); DPRINTLN(tmp);
|
|
memcpy(oldPrm,&prm,sizeof(prm));
|
|
}
|
|
}
|
|
|
|
|
|
// Main loop. Deliberately delay()-free (see docs/DEVELOPER_GUIDE.md section 4.2 and the Fdelay()
|
|
// comment above) - every subsystem gets serviced once per iteration: cooperative display refresh
|
|
// (writeDisplay2(), only does anything for non-ISR/"smart chip" drivers), DNS captive-portal service
|
|
// (AP mode only), the clock/sensor/display tick (timeProgram(), itself throttled to ~200ms), RGB/PWM
|
|
// backlight animations, the alarm buzzer, radar-driven tube power, the light sensor, MQTT publish (STA
|
|
// mode + WiFi connected) or WiFi reconnect attempts (STA mode + WiFi down), the RTC manual-editor
|
|
// button scan, and finally any pending OTA-update/cathode-protect request queued by the web server
|
|
// (makeFirmwareUpdate/makeCathodeProtect flags, set from server route handlers which must return
|
|
// quickly and can't run these long operations inline).
|
|
void loop(void) {
|
|
//checkPrm();
|
|
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
|
|
DIAG("wled", wledHandle());
|
|
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
|