Files
esp8266-weather-clock-opens…/firmware/clock_ntp_ota_v1.9/clock_ntp_ota_v1.9.ino
T
Alex PetrochenkoandAlex Petrochenko c3bfaa26bc feat(wifi): v1.9.2 - WiFi resilience and credential persistence (#1)
## Problem
Previous versions would clear WiFi credentials on connection failure,
requiring manual reconfiguration via captive portal after each WiFi outage.

## Solution
- Never clear credentials on connection failure
- Infinite retry with exponential backoff (5s → 10s → 20s → ... → 5min max)
- Fallback AP ("TJ56654-Setup") enabled after ~5 min of failed attempts
- Dual STA+AP mode allows configuration while still retrying connection
- AP automatically disabled when WiFi reconnects

## Changes

### WiFi Credential Handling
- Try SDK-stored credentials first (from WiFiManager)
- Fall back to EEPROM config if SDK credentials fail
- Fixes OTA update credential loss issue

### User Experience
- Display shows "No WiFi" with retry countdown instead of cryptic numbers
- "!" indicator in date line when WiFi is disconnected
- Clock continues running with last synced time during outage

### Network Activity (~50 requests/day)
| Service | Interval | Endpoint |
|---------|----------|----------|
| NTP | 1 hour | pool.ntp.org:123 (UDP) |
| Weather | 30 min | api.open-meteo.com (HTTP) |
| mDNS | continuous | 224.0.0.251 (multicast) |

Co-authored-by: Alex Petrochenko <petrochenko@life-pay.ru>
2026-01-06 20:51:42 +00:00

2212 lines
72 KiB
Arduino

/*
* TJ-56-654 Weather Clock - Custom NTP Firmware with OTA v1.9.2
*
* Version 1.9.2 Changes (WIFI RESILIENCE):
* - FIXED: No longer clears WiFi credentials on connection failure
* - IMPROVED: Infinite retry with exponential backoff (up to 5 min between attempts)
* - IMPROVED: Shows "No WiFi" status on display instead of cryptic numbers
* - IMPROVED: AP mode only on first boot (empty credentials) or manual trigger
* - IMPROVED: Clock continues running with last synced time during WiFi outage
*
* Version 1.9.1 Changes (HYBRID ASYNC FIX):
* - FIXED STARTUP: WiFi now synchronous in setup() for proper initialization
* - FIXED DNS: testInternetConnectivity() now runs AFTER WiFi is connected
* - HYBRID MODEL: Sync WiFi in setup(), async reconnect in loop()
* - RESULT: No more "DNS resolution failed" on startup, proper init order
*
* Version 1.9 Changes (ASYNC PERFORMANCE):
* - ASYNC HTTP: Non-blocking weather API calls (was 1-10 sec freeze → 0ms)
* - ASYNC NTP: Non-blocking time sync (was 5-20 sec freeze → 0ms)
* - ASYNC WiFi RECONNECT: Non-blocking reconnection in loop()
* - REMOVED DELAYS: All blocking delay() calls from loop() eliminated
* - RETRY LOGIC: Exponential backoff for failed network operations
* - RESULT: Device always responsive during operation
*
* Version 1.8 Changes (CRITICAL STABILITY FIXES):
* - IRAM CRISIS FIX: Added ICACHE_FLASH_ATTR to 26 functions
* - SECURITY FIX: WiFiManager integration (removed hardcoded credentials)
* - BUG FIXES: NTP interval config, boolean parsing, infinite loop protection
* - MEMORY FIX: Chunked HTTP responses, reduced String concatenation
* - SAFETY: Input validation, buffer overflow prevention
*
* Version 1.7 Changes:
* - FINALLY FIXED: Display is 0.96" OLED 128x64 with SSD1306/SH1106!
* - GM009605v4.3 module (not TM1650, not TM1637)
* - Using Adafruit_SSD1306 library
* - Graphical display with large time digits
*
* Hardware:
* - ESP-01S (ESP8266)
* - GM009605v4.3 OLED 128x64 display (SSD1306 I2C)
*
* Connections:
* - GPIO0 → OLED SDA (I2C Data) - SWAPPED!
* - GPIO2 → OLED SCL (I2C Clock) - SWAPPED!
*
* OLED I2C Address: 0x3C
*/
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266HTTPUpdateServer.h>
#include <ESP8266mDNS.h>
#include <ArduinoOTA.h>
#include <WiFiUdp.h>
#include <NTPClient.h>
#include <EEPROM.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <WiFiManager.h> // WiFiManager for captive portal configuration
// Async libraries for non-blocking operations
#include <ESPAsyncTCP.h> // Async TCP for ESP8266
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN_TARGET "AsyncHTTPRequest_Generic v1.13.0"
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN 1013000
#include <AsyncHTTPRequest_Generic.h> // Async HTTP requests
// OLED I2C Configuration - TRY SWAPPED!
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
#define I2C_SCL 2 // GPIO2 (I2C Clock) - SWAPPED!
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1 // No reset pin
#define OLED_ADDRESS 0x3C
// Configuration structure with validation
#define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data
#define FIRMWARE_VERSION "1.9.2"
struct Config {
uint32_t magic = CONFIG_MAGIC; // Magic number for validation
char ssid[32] = ""; // Empty - configured via WiFiManager captive portal
char password[64] = ""; // Empty - configured via WiFiManager captive portal
long timezone_offset = 0; // Base UTC offset in seconds (0=Lisbon/London, 3600=Paris/Berlin)
bool dst_enabled = true; // Auto DST: +1 hour during summer (European rules: last Sun Mar-Oct)
int brightness = 4; // 0-7
char ntp_server[64] = "pool.ntp.org";
unsigned long ntp_interval = 3600; // NTP update interval in seconds (default: 1 hour)
bool hour_format_24 = true; // true=24h, false=12h
char hostname[32] = "tj56654-clock";
// Weather settings
float latitude = 37.19; // Portimao, Portugal
float longitude = -8.54;
char city_name[32] = "Portimao";
bool weather_enabled = true;
unsigned long weather_interval = 1800; // 30 minutes in seconds
// Display settings
uint8_t display_rotation_sec = 5; // Seconds per screen
bool show_weather = true;
bool show_sunrise_sunset = true;
uint8_t display_orientation = 2; // 0=0°, 1=90°, 2=180°, 3=270°
};
Config config;
// OLED Display object
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// Forward declarations
class NTPClient;
extern NTPClient timeClient;
void updateDisplayRotation();
void fetchWeatherAsync();
void onWeatherResponse(void* optParm, AsyncHTTPRequest* request, int readyState);
void sendNTPRequestAsync();
void processNTPResponse();
void processWiFiConnection();
void calculateSunTimes();
bool ICACHE_FLASH_ATTR isModeEnabled(uint8_t mode);
// Forward declarations for ICACHE_FLASH_ATTR functions
void ICACHE_FLASH_ATTR testInternetConnectivity();
void ICACHE_FLASH_ATTR updateNTPTime();
void ICACHE_FLASH_ATTR setupWiFi();
void ICACHE_FLASH_ATTR setupOTA();
void ICACHE_FLASH_ATTR setupWebServer();
void ICACHE_FLASH_ATTR clearDisplay();
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros);
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds);
void ICACHE_FLASH_ATTR showStartupAnimation();
void ICACHE_FLASH_ATTR showIP();
void ICACHE_FLASH_ATTR handleRoot();
void ICACHE_FLASH_ATTR handleDebug();
void ICACHE_FLASH_ATTR handleTestNTP();
void ICACHE_FLASH_ATTR handleTestDisplay();
void ICACHE_FLASH_ATTR handleConfig();
void ICACHE_FLASH_ATTR handleConfigSave();
void ICACHE_FLASH_ATTR handleAPITime();
void ICACHE_FLASH_ATTR handleAPIStatus();
void ICACHE_FLASH_ATTR handleAPIDebug();
void ICACHE_FLASH_ATTR handleAPIWeather();
void ICACHE_FLASH_ATTR handleAPIConfigExport();
void ICACHE_FLASH_ATTR handleAPIConfigImport();
void ICACHE_FLASH_ATTR handleEEPROMClear();
void ICACHE_FLASH_ATTR handleReboot();
void ICACHE_FLASH_ATTR handleI2CScan();
void ICACHE_FLASH_ATTR loadConfig();
void ICACHE_FLASH_ATTR saveConfig();
// DST calculation for European rules
// DST starts: last Sunday of March at 01:00 UTC
// DST ends: last Sunday of October at 01:00 UTC
bool isDST(unsigned long epochTime) {
if (!config.dst_enabled) return false;
time_t t = epochTime;
struct tm *timeinfo = gmtime(&t);
int month = timeinfo->tm_mon + 1; // 1-12
int day = timeinfo->tm_mday; // 1-31
int weekday = timeinfo->tm_wday; // 0=Sunday
int hour = timeinfo->tm_hour;
// Not DST: November - February
if (month < 3 || month > 10) return false;
// Always DST: April - September
if (month > 3 && month < 10) return true;
// March: DST starts last Sunday at 01:00 UTC
if (month == 3) {
// Find last Sunday of March
int lastSunday = 31 - ((5 + timeinfo->tm_year) % 7);
if (day < lastSunday) return false;
if (day > lastSunday) return true;
if (hour < 1) return false;
return true;
}
// October: DST ends last Sunday at 01:00 UTC
if (month == 10) {
// Find last Sunday of October
int lastSunday = 31 - ((1 + timeinfo->tm_year) % 7);
if (day < lastSunday) return true;
if (day > lastSunday) return false;
if (hour < 1) return true;
return false;
}
return false;
}
// Get total timezone offset including DST (pass epochTime to avoid dependency)
long getTotalOffset(unsigned long epochTime) {
long offset = config.timezone_offset;
if (isDST(epochTime)) {
offset += 3600; // Add 1 hour for DST
}
return offset;
}
// NTP Client - will be reinitialized in setup() with config values
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
// Exponential backoff retry configuration
struct RetryConfig {
uint8_t maxRetries = 3; // For NTP/Weather: give up after 3 tries
uint8_t currentRetry = 0;
unsigned long nextRetryTime = 0;
unsigned long maxBackoffMs = 8000; // Max backoff 8 seconds for NTP/Weather
unsigned long getBackoffDelay() {
unsigned long delay = 1000UL * (1UL << currentRetry); // 1s, 2s, 4s, 8s...
return (delay > maxBackoffMs) ? maxBackoffMs : delay;
}
void scheduleRetry() {
if (currentRetry < maxRetries) {
nextRetryTime = millis() + getBackoffDelay();
currentRetry++;
} else {
nextRetryTime = 0; // Max retries reached, stop
}
}
bool isRetryTime() {
return nextRetryTime > 0 && millis() >= nextRetryTime;
}
void reset() {
currentRetry = 0;
nextRetryTime = 0;
}
bool maxRetriesReached() {
return currentRetry >= maxRetries;
}
};
// WiFi retry configuration - infinite retries with longer backoff
struct WiFiRetryConfig {
uint8_t currentRetry = 0;
unsigned long nextRetryTime = 0;
static const unsigned long MAX_BACKOFF_MS = 300000; // Max 5 minutes between retries
unsigned long getBackoffDelay() {
// 5s, 10s, 20s, 40s, 80s, 160s, 300s (max)
unsigned long delay = 5000UL * (1UL << currentRetry);
return (delay > MAX_BACKOFF_MS) ? MAX_BACKOFF_MS : delay;
}
void scheduleRetry() {
nextRetryTime = millis() + getBackoffDelay();
if (currentRetry < 10) currentRetry++; // Cap at 10 to prevent overflow
}
bool isRetryTime() {
return nextRetryTime > 0 && millis() >= nextRetryTime;
}
void reset() {
currentRetry = 0;
nextRetryTime = 0;
}
};
// Async HTTP client for non-blocking weather fetch
AsyncHTTPRequest weatherRequest;
// Weather fetch state machine
enum WeatherState {
WEATHER_IDLE,
WEATHER_REQUESTING,
WEATHER_SUCCESS,
WEATHER_FAILED
};
WeatherState weatherState = WEATHER_IDLE;
// Async NTP state machine
enum NTPState {
NTP_IDLE,
NTP_REQUEST_SENT,
NTP_WAITING,
NTP_SUCCESS,
NTP_FAILED
};
NTPState ntpState = NTP_IDLE;
// NTP packet buffer and timing
byte ntpPacketBuffer[48];
unsigned long ntpRequestTime = 0;
const unsigned long NTP_TIMEOUT_MS = 5000; // 5 second timeout
// Independent epoch tracking for async NTP
unsigned long syncedEpoch = 0;
unsigned long syncedMillis = 0;
bool timeIsSynced = false;
// Async WiFi state machine
enum WiFiConnectionState {
WIFI_CONN_IDLE,
WIFI_CONN_CONNECTING,
WIFI_CONN_CONNECTED,
WIFI_CONN_FAILED,
WIFI_CONN_SKIP_ASYNC // Skip async, go straight to WiFiManager
};
WiFiConnectionState wifiConnState = WIFI_CONN_IDLE;
unsigned long wifiConnectStart = 0;
const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second timeout for v1.7 migration
// Retry configurations with exponential backoff
RetryConfig ntpRetry;
RetryConfig weatherRetry;
WiFiRetryConfig wifiRetry;
// Web server
ESP8266WebServer server(80);
ESP8266HTTPUpdateServer httpUpdater;
// State variables
bool colonBlink = false;
unsigned long lastBlinkTime = 0;
unsigned long lastNTPUpdate = 0;
unsigned long ipDisplayUntil = 0; // Non-blocking IP display timer
// NTP_UPDATE_INTERVAL removed - now using config.ntp_interval dynamically
// Debug variables
String lastError = "";
int ntpAttempts = 0;
int ntpSuccesses = 0;
bool internetConnected = false;
// Weather data cache
struct WeatherData {
float temperature = 0.0;
int weathercode = -1; // WMO weather code
int humidity = 0;
float windspeed = 0.0;
unsigned long lastUpdate = 0;
bool valid = false;
};
WeatherData weather;
// Sunrise/Sunset cache
struct SunTimes {
int sunriseMinutes = 0; // Minutes since midnight
int sunsetMinutes = 0;
int lastDay = -1; // Day of year
char sunrise[6] = "--:--"; // HH:MM format
char sunset[6] = "--:--";
};
SunTimes sunTimes;
// Display rotation state
uint8_t displayMode = 0; // 0=time, 1=weather, 2=sunrise/sunset
unsigned long lastModeSwitch = 0;
unsigned long lastWeatherUpdate = 0;
// Helper function: Safe string copy with truncation warning
void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxLen) {
if (src.length() >= maxLen) {
Serial.printf("WARNING: String truncated from %d to %d chars\n", src.length(), maxLen - 1);
}
src.toCharArray(dest, maxLen);
dest[maxLen - 1] = '\0'; // Ensure null termination
}
// Setup function
void setup() {
Serial.begin(115200);
delay(100); // Wait for serial
Serial.println("\n\nTJ-56-654 NTP Clock with OTA v" FIRMWARE_VERSION);
Serial.println("==========================================");
Serial.println("Display: GM009605v4.3 OLED 128x64 (SSD1306 I2C)");
// Initialize I2C
Wire.begin(I2C_SDA, I2C_SCL);
// Initialize OLED display
Serial.print("Initializing OLED at 0x");
Serial.println(OLED_ADDRESS, HEX);
if(!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
Serial.println("✗ OLED initialization FAILED!");
// Try alternate address 0x3D
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3D)) {
Serial.println("✗ OLED not found at 0x3C or 0x3D!");
} else {
Serial.println("✓ OLED found at 0x3D");
}
} else {
Serial.println("✓ OLED initialized successfully!");
}
// Set display rotation from config (0=0°, 1=90°, 2=180°, 3=270°)
display.setRotation(config.display_orientation);
Serial.printf("Display rotation: %d (180°)\n", config.display_orientation);
// Show startup animation
Serial.println("Showing startup animation...");
showStartupAnimation();
// Load configuration from EEPROM
loadConfig();
// Setup WiFi
setupWiFi();
// Setup OTA
setupOTA();
// Setup web server
setupWebServer();
// Setup NTP with config parameters
// Note: NTP server and interval from config
// Reinitialize timeClient with config values
timeClient = NTPClient(ntpUDP, config.ntp_server, 0, config.ntp_interval * 1000);
timeClient.begin();
// Test internet connectivity
testInternetConnectivity();
Serial.println("Setup complete!");
}
void loop() {
// Handle OTA updates
ArduinoOTA.handle();
// Handle web server
server.handleClient();
MDNS.update();
// WiFi reconnection logic (async, non-blocking with exponential backoff)
static unsigned long lastWiFiCheck = 0;
if (millis() - lastWiFiCheck > 1000) { // Check every second
lastWiFiCheck = millis();
// If WiFi was connected but now disconnected
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
Serial.println("⚠️ WiFi disconnected!");
wifiConnState = WIFI_CONN_FAILED;
internetConnected = false;
wifiRetry.reset(); // Start fresh retry sequence
wifiRetry.scheduleRetry(); // Schedule first retry
}
// If it's time to retry
if (wifiConnState == WIFI_CONN_FAILED && wifiRetry.isRetryTime()) {
Serial.printf("🔄 WiFi retry attempt (backoff level %d)...\n", wifiRetry.currentRetry);
// After 5+ failed attempts (~5 min), enable fallback AP in dual mode
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
Serial.println("📡 Enabling fallback AP (dual mode) for manual configuration");
WiFi.mode(WIFI_AP_STA);
WiFi.softAP("TJ56654-Setup", "12345678");
Serial.print("Fallback AP IP: ");
Serial.println(WiFi.softAPIP());
}
// Try SDK-stored credentials first, then EEPROM
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
} else {
WiFi.begin(); // Use SDK-stored credentials from WiFiManager
}
wifiConnState = WIFI_CONN_CONNECTING;
wifiConnectStart = millis();
}
}
// Process async WiFi reconnection
processWiFiConnection();
// Process async NTP response (non-blocking check)
processNTPResponse();
// Check for NTP retry (exponential backoff)
if (ntpRetry.isRetryTime() && ntpState == NTP_IDLE) {
Serial.println("⏰ NTP retry time reached, attempting retry...");
sendNTPRequestAsync();
}
// Trigger async NTP update periodically (using config.ntp_interval in seconds)
unsigned long ntpInterval = config.ntp_interval * 1000UL; // Convert seconds to milliseconds
if (millis() - lastNTPUpdate > ntpInterval || lastNTPUpdate == 0) {
if (ntpState == NTP_IDLE && !ntpRetry.isRetryTime()) { // Only if not already in progress or waiting for retry
sendNTPRequestAsync(); // Non-blocking!
lastNTPUpdate = millis();
}
// Also recalculate sun times when time is synced
if (timeIsSynced || timeClient.isTimeSet()) {
calculateSunTimes();
}
}
// Check for weather retry (exponential backoff)
if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) {
Serial.println("⏰ Weather retry time reached, attempting retry...");
fetchWeatherAsync();
}
// Update weather periodically (but wait at least 10 seconds after boot)
// Async weather fetch - non-blocking!
if (config.weather_enabled && millis() > 10000) {
unsigned long weatherInterval = config.weather_interval * 1000UL;
if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) {
if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) {
fetchWeatherAsync(); // Non-blocking! Also updates sunrise/sunset from API
lastWeatherUpdate = millis();
}
}
}
// Check if IP display should be cleared (non-blocking timer)
if (ipDisplayUntil > 0 && millis() >= ipDisplayUntil) {
clearDisplay();
ipDisplayUntil = 0; // Reset timer
}
// Update display with rotation (Time → Weather → Sunrise/Sunset)
updateDisplayRotation();
// Blink colon every second
if (millis() - lastBlinkTime > 500) {
colonBlink = !colonBlink;
lastBlinkTime = millis();
}
// No delay needed - loop() runs as fast as possible for responsiveness
}
// Test internet connectivity
void ICACHE_FLASH_ATTR testInternetConnectivity() {
Serial.println("\n=== Testing Internet Connectivity ===");
// Test DNS resolution
IPAddress ntpIP;
if (WiFi.hostByName(config.ntp_server, ntpIP)) {
Serial.print("✓ DNS works: ");
Serial.print(config.ntp_server);
Serial.print(" → ");
Serial.println(ntpIP);
} else {
Serial.print("✗ DNS failed: cannot resolve ");
Serial.println(config.ntp_server);
lastError = "DNS resolution failed";
return;
}
// Test ping to Google DNS
if (WiFi.hostByName("google.com", ntpIP)) {
Serial.println("✓ Can resolve google.com");
internetConnected = true;
} else {
Serial.println("✗ Cannot resolve google.com - no internet?");
lastError = "No internet connectivity";
internetConnected = false;
}
}
// Update NTP time with better error handling
void ICACHE_FLASH_ATTR updateNTPTime() {
Serial.println("\n=== Updating NTP Time ===");
ntpAttempts++;
if (!internetConnected) {
Serial.println("✗ Skipping NTP update - no internet");
lastError = "No internet connection";
return;
}
bool success = timeClient.update();
if (success) {
ntpSuccesses++;
Serial.print("✓ NTP sync successful: ");
Serial.println(timeClient.getFormattedTime());
lastError = "";
} else {
Serial.println("✗ NTP sync failed");
lastError = "NTP sync failed (timeout or no response)";
// Try force update
Serial.println(" Trying force update...");
if (timeClient.forceUpdate()) {
ntpSuccesses++;
Serial.print("✓ Force update successful: ");
Serial.println(timeClient.getFormattedTime());
lastError = "";
} else {
Serial.println("✗ Force update also failed");
// Test connectivity again
testInternetConnectivity();
}
}
Serial.print("NTP Stats: ");
Serial.print(ntpSuccesses);
Serial.print(" / ");
Serial.print(ntpAttempts);
Serial.println(" successful");
}
// Get current epoch (async NTP independent tracking)
unsigned long getAsyncEpoch() {
if (!timeIsSynced) return timeClient.getEpochTime();
unsigned long elapsed = (millis() - syncedMillis) / 1000;
return syncedEpoch + elapsed;
}
// Async NTP - Send request (non-blocking)
void sendNTPRequestAsync() {
if (ntpState != NTP_IDLE) return;
if (!internetConnected) {
Serial.println(F("✗ Skip NTP - no internet"));
return;
}
Serial.println(F("⬇️ NTP request (async)..."));
ntpAttempts++;
memset(ntpPacketBuffer, 0, 48);
ntpPacketBuffer[0] = 0b11100011;
ntpPacketBuffer[1] = 0;
ntpPacketBuffer[2] = 6;
ntpPacketBuffer[3] = 0xEC;
ntpUDP.beginPacket(config.ntp_server, 123);
ntpUDP.write(ntpPacketBuffer, 48);
ntpUDP.endPacket();
ntpState = NTP_REQUEST_SENT;
ntpRequestTime = millis();
Serial.println("✓ NTP sent (non-blocking)");
}
// Async NTP - Process response (call in loop)
void processNTPResponse() {
if (ntpState == NTP_IDLE) return;
// Timeout check with exponential backoff retry
if (millis() - ntpRequestTime > NTP_TIMEOUT_MS) {
ntpState = NTP_IDLE;
Serial.printf("✗ NTP timeout (attempt %d/%d)\n", ntpRetry.currentRetry + 1, ntpRetry.maxRetries);
// Schedule retry with exponential backoff
ntpRetry.scheduleRetry();
if (ntpRetry.maxRetriesReached()) {
Serial.println("✗ NTP max retries reached, will try again later");
lastError = "NTP timeout - max retries";
} else {
unsigned long backoff = ntpRetry.getBackoffDelay() / 1000;
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
}
return;
}
// Check for response packet
if (ntpUDP.parsePacket() >= 48) {
ntpUDP.read(ntpPacketBuffer, 48);
unsigned long high = word(ntpPacketBuffer[40], ntpPacketBuffer[41]);
unsigned long low = word(ntpPacketBuffer[42], ntpPacketBuffer[43]);
unsigned long epoch = (high << 16 | low) - 2208988800UL;
syncedEpoch = epoch;
syncedMillis = millis();
timeIsSynced = true;
timeClient = NTPClient(ntpUDP, config.ntp_server, 0, config.ntp_interval * 1000);
timeClient.begin();
timeClient.update();
ntpState = NTP_IDLE;
ntpSuccesses++;
ntpRetry.reset(); // Success! Reset retry counter
lastError = "";
unsigned long h = (epoch % 86400L) / 3600;
unsigned long m = (epoch % 3600) / 60;
Serial.printf("✓ NTP synced (async): %02lu:%02lu UTC\n", h, m);
}
}
// Async WiFi - Process connection (call in loop)
void processWiFiConnection() {
if (wifiConnState != WIFI_CONN_CONNECTING) return;
// Check connection status
if (WiFi.status() == WL_CONNECTED) {
wifiConnState = WIFI_CONN_CONNECTED;
wifiRetry.reset(); // Success! Reset retry counter
internetConnected = true;
Serial.println("\n✅ WiFi connected!");
Serial.print("SSID: ");
Serial.println(WiFi.SSID());
Serial.print("IP: ");
Serial.println(WiFi.localIP());
// Disable fallback AP if it was enabled (switch back to STA only)
if (WiFi.getMode() == WIFI_AP_STA) {
Serial.println("📡 Disabling fallback AP (back to STA mode)");
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
}
// Sync connected SSID to config for display purposes (don't clear on failure!)
if (strlen(config.ssid) == 0) {
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
saveConfig();
}
// Show IP on display
showIP();
return;
}
// Check timeout for this attempt
if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) {
// Connection attempt failed - schedule retry with backoff
// DO NOT clear credentials!
wifiRetry.scheduleRetry();
unsigned long nextRetryMs = wifiRetry.getBackoffDelay();
Serial.printf("\n⚠️ WiFi connection failed. Retry in %lu seconds\n", nextRetryMs / 1000);
wifiConnState = WIFI_CONN_FAILED;
internetConnected = false;
// Show "No WiFi" status on display
showNoWiFi(nextRetryMs / 1000);
return;
}
// Still connecting, show progress dots in serial only (no display spam)
static unsigned long lastDot = 0;
if (millis() - lastDot > 500) {
Serial.print(".");
lastDot = millis();
}
}
// WiFi setup (SYNCHRONOUS in setup(), async reconnect in loop())
void ICACHE_FLASH_ATTR setupWiFi() {
Serial.println("WiFi Setup - Synchronous for initial connection");
// Set hostname before connecting
WiFi.hostname(config.hostname);
WiFi.mode(WIFI_STA);
// STRATEGY: First try SDK-stored credentials (from WiFiManager), then EEPROM config
// WiFiManager stores credentials in ESP flash separately from our EEPROM
// Try 1: Use WiFi.begin() without params - uses SDK stored credentials
Serial.println("Trying SDK-stored credentials...");
WiFi.begin(); // Uses credentials stored by WiFiManager/SDK
// SYNCHRONOUS wait for connection (max 10 seconds)
Serial.print("Connecting to WiFi");
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
showNumber(attempts, false);
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\n✅ WiFi connected!");
Serial.print("SSID: ");
Serial.println(WiFi.SSID());
Serial.print("IP: ");
Serial.println(WiFi.localIP());
Serial.print("Gateway: ");
Serial.println(WiFi.gatewayIP());
Serial.print("DNS: ");
Serial.println(WiFi.dnsIP());
// Sync connected SSID to our config
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
// Note: password stays in SDK storage, we don't have access to it
saveConfig();
// Show IP on display
showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return; // Success!
}
// Try 2: If we have EEPROM credentials, try those
if (strlen(config.ssid) > 0 && strlen(config.password) > 0) {
Serial.println("\nTrying EEPROM credentials...");
WiFi.begin(config.ssid, config.password);
attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
showNumber(attempts, false);
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\n✅ WiFi connected via EEPROM credentials!");
showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return;
}
}
// Both attempts failed - check if we have ANY stored credentials
// If not, use WiFiManager for initial setup
if (strlen(config.ssid) == 0) {
Serial.println("\nNo saved credentials, using WiFiManager...");
WiFiManager wifiManager;
wifiManager.setConfigPortalTimeout(180); // 3 minutes timeout
// Display AP mode indication
showNumber(0xAF, false);
// Auto-connect: Portal SSID "TJ56654-Setup", Password "12345678"
Serial.println("Attempting WiFiManager auto-connect...");
if (!wifiManager.autoConnect("TJ56654-Setup", "12345678")) {
// Connection failed after timeout
Serial.println("WiFi connection failed. Starting fallback AP...");
WiFi.mode(WIFI_AP);
WiFi.softAP("TJ56654-Clock", "12345678");
Serial.print("Fallback AP IP: ");
Serial.println(WiFi.softAPIP());
wifiConnState = WIFI_CONN_CONNECTED; // Mark as handled
return;
}
// Connected successfully via WiFiManager!
Serial.println("WiFi connected via WiFiManager!");
Serial.print("SSID: ");
Serial.println(WiFi.SSID());
Serial.print("IP: ");
Serial.println(WiFi.localIP());
// Sync connected SSID to config for display purposes
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
saveConfig();
// Show IP on display
showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return;
}
// We have credentials but WiFi is not available - will retry in loop()
Serial.println("\n⚠️ WiFi not available. Will retry in background.");
wifiConnState = WIFI_CONN_FAILED;
wifiRetry.scheduleRetry();
showNoWiFi(wifiRetry.getBackoffDelay() / 1000);
}
// OTA setup
void ICACHE_FLASH_ATTR setupOTA() {
ArduinoOTA.setHostname(config.hostname);
ArduinoOTA.onStart([]() {
String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem";
Serial.println("Start OTA updating " + type);
clearDisplay();
showNumber(0, false);
});
ArduinoOTA.onEnd([]() {
Serial.println("\nOTA Update complete!");
showNumber(100, false);
});
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
int percent = (progress / (total / 100));
Serial.printf("Progress: %u%%\r", percent);
showNumber(percent, false);
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("Error[%u]: ", error);
if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
else if (error == OTA_END_ERROR) Serial.println("End Failed");
});
ArduinoOTA.begin();
Serial.println("OTA ready");
}
// Web server setup
void ICACHE_FLASH_ATTR setupWebServer() {
// Setup web OTA updater at /update
httpUpdater.setup(&server, "/update", "admin", "admin");
// Root page
server.on("/", HTTP_GET, handleRoot);
// Config page
server.on("/config", HTTP_GET, handleConfig);
server.on("/config", HTTP_POST, handleConfigSave);
// Debug page
server.on("/debug", HTTP_GET, handleDebug);
server.on("/test-ntp", HTTP_GET, handleTestNTP);
server.on("/test-display", HTTP_GET, handleTestDisplay);
// API endpoints
server.on("/api/time", HTTP_GET, handleAPITime);
server.on("/api/status", HTTP_GET, handleAPIStatus);
server.on("/api/debug", HTTP_GET, handleAPIDebug);
server.on("/api/weather", HTTP_GET, handleAPIWeather);
server.on("/api/config", HTTP_GET, handleAPIConfigExport);
server.on("/api/config", HTTP_POST, handleAPIConfigImport);
server.on("/api/eeprom-clear", HTTP_POST, handleEEPROMClear);
server.on("/api/reboot", HTTP_POST, handleReboot);
server.on("/api/i2c-scan", HTTP_GET, handleI2CScan);
server.begin();
Serial.println("Web server started");
// Start mDNS
if (MDNS.begin(config.hostname)) {
Serial.printf("mDNS started: http://%s.local\n", config.hostname);
MDNS.addService("http", "tcp", 80);
MDNS.addService("arduino", "tcp", 8266);
}
}
// Update OLED display with current time
// BLUE zone (top 48px): Large time
// YELLOW zone (bottom 16px): Date + WiFi status
void updateDisplay() {
static unsigned long lastUpdate = 0;
// Update display only every 500ms (not every loop cycle)
if (millis() - lastUpdate < 500) return;
lastUpdate = millis();
display.clearDisplay();
// Check if we have ANY time source (NTPClient or async sync)
bool hasTime = timeClient.isTimeSet() || timeIsSynced;
if (!hasTime) {
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 24);
display.println("--:--");
// Show WiFi status in yellow zone
display.setTextSize(1);
display.setCursor(25, 52);
if (wifiConnState == WIFI_CONN_CONNECTED) {
display.print("Syncing NTP...");
} else {
display.print("No WiFi");
}
display.display();
return;
}
// Get epoch from best available source
unsigned long epochTime = timeIsSynced ? getAsyncEpoch() : timeClient.getEpochTime();
unsigned long localTime = epochTime + getTotalOffset(epochTime);
int hours = (localTime / 3600) % 24;
int minutes = (localTime / 60) % 60;
// Convert to 12h format if needed
if (!config.hour_format_24) {
if (hours == 0) hours = 12;
else if (hours > 12) hours -= 12;
}
// === YELLOW ZONE (Y: 48-63): Date (size 2 = 16px height) ===
display.setTextSize(2);
time_t t = epochTime;
struct tm *ptm = gmtime(&t);
// Format: "Thu 02.01" or "! Thu 02.01" if no WiFi
const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
char dateStr[20];
if (wifiConnState != WIFI_CONN_CONNECTED) {
// Show "!" indicator when WiFi is down
sprintf(dateStr, "!%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
} else {
sprintf(dateStr, "%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
}
// Center in yellow zone (Y: 48-63)
int dateWidth = strlen(dateStr) * 12; // Size 2 = ~12px per char
int dateX = (128 - dateWidth) / 2;
display.setCursor(dateX, 48);
display.print(dateStr);
// === BLUE ZONE (Y: 0-47): Large time (size 3 = 24px height) ===
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
// Calculate center position for HH:MM
// Each char size 3 = 18px width
display.setCursor(10, 12); // Y=12 centers in blue zone (0-47)
display.printf("%02d", hours);
// Blinking colon
if (colonBlink) {
display.print(":");
} else {
display.print(" ");
}
display.printf("%02d", minutes);
display.display();
}
// Weather display
// BLUE zone (top 48px): Large temperature
// YELLOW zone (bottom 16px): City name
void ICACHE_FLASH_ATTR displayWeather() {
display.clearDisplay();
if (!weather.valid) {
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 24);
display.println("No Data");
display.display();
return;
}
// === YELLOW ZONE (Y: 48-63): City name (size 2 = 16px) ===
display.setTextSize(2);
int cityWidth = strlen(config.city_name) * 12;
int cityX = (128 - cityWidth) / 2;
display.setCursor(cityX, 48);
display.print(config.city_name);
// === BLUE ZONE (Y: 0-47): Temperature (size 3 = 24px height) ===
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
// Format temperature value only (no degree symbol yet)
char tempStr[16];
sprintf(tempStr, "%.1f", weather.temperature); // Just the number
// Center temperature + degree symbol
int tempValueWidth = strlen(tempStr) * 18; // Size 3 = ~18px per char
int degreeSymbolWidth = 6; // Size 1 = 6px per char
int totalWidth = tempValueWidth + degreeSymbolWidth + 6; // +6 for small "c"
int startX = (128 - totalWidth) / 2;
// Print temperature value
display.setCursor(startX, 12); // Center in blue zone
display.print(tempStr);
// Print degree symbol and C (small, raised)
display.setTextSize(1);
int degreeX = startX + tempValueWidth;
display.setCursor(degreeX, 12); // Raised position (same Y as temp)
display.print("\xF8" "c"); // °c (lowercase, smaller)
display.display();
}
// Sunrise/Sunset display
// BLUE zone (top 48px): Times
// YELLOW zone (bottom 16px): Next event
void ICACHE_FLASH_ATTR displaySunTimes() {
display.clearDisplay();
if (sunTimes.lastDay == -1) {
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
display.setCursor(30, 24);
display.println("----");
display.display();
return;
}
// === YELLOW ZONE (Y: 48-63): Daylight duration ===
// Calculate daylight duration
int daylightMinutes = sunTimes.sunsetMinutes - sunTimes.sunriseMinutes;
int daylightHours = daylightMinutes / 60;
int daylightMins = daylightMinutes % 60;
// Format: "Day 9h 41m" or "9h 41m"
char daylightStr[16];
sprintf(daylightStr, "Day %dh %dm", daylightHours, daylightMins);
display.setTextSize(1); // Size 1 to fit more text
int textWidth = strlen(daylightStr) * 6; // Size 1 = 6px per char
int textX = (128 - textWidth) / 2; // Center
display.setCursor(textX, 52); // Y=52 centers in yellow zone
display.print(daylightStr);
// === BLUE ZONE (Y: 0-47): Sunrise and Sunset times ===
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
// Line 1: Sunrise (arrow + space + time)
display.setCursor(5, 4);
display.print("\x18 "); // Up arrow + SPACE
display.print(sunTimes.sunrise);
// Line 2: Sunset (arrow + space + time)
display.setCursor(5, 28);
display.print("\x19 "); // Down arrow + SPACE
display.print(sunTimes.sunset);
// No labels needed - arrows are self-explanatory
// ↑ = sunrise (sun going up)
// ↓ = sunset (sun going down)
display.display();
}
void ICACHE_FLASH_ATTR updateDisplayRotation() {
unsigned long now = millis();
unsigned long interval = config.display_rotation_sec * 1000UL;
// Switch mode if interval elapsed
if (now - lastModeSwitch > interval) {
// Cycle through modes with protection against infinite loop
uint8_t attempts = 0;
do {
displayMode = (displayMode + 1) % 3;
attempts++;
if (attempts >= 3) {
// Safety: if no mode is enabled after 3 attempts, force time mode
displayMode = 0;
Serial.println("WARNING: No display mode enabled, forcing time mode");
break;
}
} while (!isModeEnabled(displayMode));
lastModeSwitch = now;
Serial.printf("Display mode: %d\n", displayMode);
}
// Display based on current mode
switch(displayMode) {
case 0:
updateDisplay(); // Show time
break;
case 1:
displayWeather(); // Show weather
break;
case 2:
displaySunTimes(); // Show sunrise/sunset
break;
}
}
bool ICACHE_FLASH_ATTR isModeEnabled(uint8_t mode) {
switch(mode) {
case 0:
return true; // Time always enabled
case 1:
return config.show_weather && weather.valid;
case 2:
return config.show_sunrise_sunset && sunTimes.lastDay != -1;
}
return false;
}
// Clear OLED display
void ICACHE_FLASH_ATTR clearDisplay() {
display.clearDisplay();
display.display();
}
// Show number on OLED (centered, large font)
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros) {
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(SSD1306_WHITE);
// Center text
display.setCursor(20, 20);
if (leadingZeros) {
display.printf("%04d", num);
} else {
display.print(num);
}
display.display();
}
// Show "No WiFi" status on OLED with retry countdown
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds) {
display.clearDisplay();
// === BLUE ZONE: "No WiFi" message ===
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 8);
display.println("No WiFi");
// === YELLOW ZONE: Retry info ===
display.setTextSize(1);
display.setCursor(10, 52);
if (nextRetrySeconds < 60) {
display.printf("Retry in %lu sec", nextRetrySeconds);
} else {
display.printf("Retry in %lu min", nextRetrySeconds / 60);
}
display.display();
}
// Dummy function for compatibility (not needed for OLED)
void displaySegments(const uint8_t segments[]) {
// Not used with OLED - kept for compatibility
}
// Startup animation for OLED
void ICACHE_FLASH_ATTR showStartupAnimation() {
Serial.println(" Animation: Show logo");
// Show "TJ-56" text
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 10);
display.println("TJ-56");
display.setTextSize(1);
display.setCursor(15, 35);
display.println("Weather Clock");
display.setCursor(30, 50);
display.print("v");
display.print(FIRMWARE_VERSION);
display.display();
delay(1000);
// Blink
display.clearDisplay();
display.display();
delay(200);
// Show again
display.clearDisplay();
display.setTextSize(2);
display.setCursor(30, 20);
display.println("READY");
display.display();
delay(500);
clearDisplay();
Serial.println(" Animation complete!");
}
// Show IP address on OLED (non-blocking)
void ICACHE_FLASH_ATTR showIP() {
IPAddress ip = WiFi.localIP();
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 20);
display.print("IP: ");
display.println(ip);
display.display();
// Schedule display clear after 3 seconds (non-blocking)
ipDisplayUntil = millis() + 3000;
}
// PROGMEM templates for handleRoot() - chunked response
const char ROOT_HTML_HEADER[] PROGMEM =
"<!DOCTYPE html><html><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>TJ-56-654 Clock v" FIRMWARE_VERSION "</title>"
"<style>"
"body{font-family:Arial;margin:20px;background:#f0f0f0;}"
".container{max-width:600px;margin:0 auto;background:white;padding:20px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,0.1);}"
"h1{color:#333;}.time{font-size:48px;text-align:center;margin:20px 0;font-weight:bold;color:#0066cc;}"
".info{margin:10px 0;padding:10px;background:#f9f9f9;border-radius:5px;}"
".error{background:#ffebee;color:#c62828;}"
"a.button{display:inline-block;background:#0066cc;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;margin:10px 5px;}"
"a.button:hover{background:#0052a3;}"
".debug{background:#fff3cd;color:#856404;padding:10px;border-radius:5px;margin:10px 0;}"
"</style>"
"<script>"
"function updateTime(){fetch('/api/time').then(r=>r.json()).then(d=>{document.getElementById('time').innerText=d.time;});}"
"setInterval(updateTime,1000);updateTime();"
"</script>"
"</head><body>"
"<div class='container'>"
"<h1>TJ-56-654 NTP Clock v" FIRMWARE_VERSION "</h1>"
"<div class='time' id='time'>--:--:--</div>";
const char ROOT_HTML_FOOTER[] PROGMEM =
"<a href='/config' class='button'>Configuration</a>"
"<a href='/debug' class='button'>Debug Info</a>"
"<a href='/update' class='button'>Firmware Update</a>"
"<a href='/api/status' class='button'>Status (JSON)</a>"
"<button class='button' onclick=\"if(confirm('Reboot device?')) fetch('/api/reboot', {method:'POST'}).then(()=>alert('Rebooting...'))\">Reboot</button>"
"</div></body></html>";
void ICACHE_FLASH_ATTR handleRoot() {
char buf[150];
// Start chunked transfer
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
// Header
server.sendContent_P(ROOT_HTML_HEADER);
// Error message if present
if (lastError != "") {
snprintf_P(buf, sizeof(buf), PSTR("<div class='info error'><strong>⚠ Error:</strong> %s</div>"), lastError.c_str());
server.sendContent(buf);
}
// WiFi info
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>WiFi:</strong> %s</div>"), WiFi.SSID().c_str());
server.sendContent(buf);
// IP info
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>IP:</strong> %s</div>"), WiFi.localIP().toString().c_str());
server.sendContent(buf);
// Hostname
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>Hostname:</strong> %s.local</div>"), config.hostname);
server.sendContent(buf);
// Uptime
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>Uptime:</strong> %lu seconds</div>"), millis()/1000);
server.sendContent(buf);
// NTP sync warning
if (!timeClient.isTimeSet()) {
snprintf_P(buf, sizeof(buf), PSTR("<div class='debug'><strong>⚠ NTP not synced yet</strong><br>Attempts: %d | Success: %d</div>"),
ntpAttempts, ntpSuccesses);
server.sendContent(buf);
}
// Footer
server.sendContent_P(ROOT_HTML_FOOTER);
// End chunked transfer
server.sendContent("");
}
// PROGMEM templates for handleDebug() - chunked response to eliminate String concatenation
const char DEBUG_HTML_HEADER[] PROGMEM =
"<!DOCTYPE html><html><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Debug Info</title>"
"<style>"
"body{font-family:monospace;margin:20px;background:#f0f0f0;}"
".container{max-width:800px;margin:0 auto;background:white;padding:20px;border-radius:10px;}"
".ok{color:green;}.fail{color:red;}"
"pre{background:#f5f5f5;padding:10px;border-radius:5px;overflow-x:auto;}"
"button{background:#0066cc;color:white;padding:10px 20px;border:none;border-radius:5px;cursor:pointer;margin:5px;}"
"</style>"
"</head><body>"
"<div class='container'>"
"<h1>Debug Information</h1>"
"<h2>Network</h2><pre>";
const char DEBUG_HTML_FOOTER[] PROGMEM =
"<h2>Actions</h2>"
"<button onclick=\"location.href='/test-ntp'\">Test NTP Now</button>"
"<button onclick=\"location.href='/test-display'\">Test Display (8888)</button>"
"<button onclick=\"location.reload()\">Refresh</button>"
"<button onclick=\"location.href='/api/config'\">Download Config (JSON)</button>"
"<button onclick=\"if(confirm('Clear EEPROM and reboot?')) fetch('/api/eeprom-clear', {method:'POST'}).then(()=>alert('Rebooting...'))\">Clear EEPROM</button>"
"<button onclick=\"location.href='/'\">Back</button>"
"</div></body></html>";
void ICACHE_FLASH_ATTR handleDebug() {
char buf[200]; // Buffer for dynamic content
// Start chunked transfer
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
// Header
server.sendContent_P(DEBUG_HTML_HEADER);
// Network info
snprintf_P(buf, sizeof(buf), PSTR("SSID: %s\nIP: %s\nGateway: %s\nDNS: %s\nRSSI: %d dBm\nHostname: %s\n</pre>"),
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.gatewayIP().toString().c_str(),
WiFi.dnsIP().toString().c_str(), WiFi.RSSI(), config.hostname);
server.sendContent(buf);
// Internet connectivity
server.sendContent_P(PSTR("<h2>Internet Connectivity</h2><pre>Status: "));
server.sendContent_P(internetConnected ? PSTR("<span class='ok'>✓ Connected</span>\n</pre>") : PSTR("<span class='fail'>✗ Not connected</span>\n</pre>"));
// NTP info
server.sendContent_P(PSTR("<h2>NTP</h2><pre>"));
snprintf_P(buf, sizeof(buf), PSTR("Server: %s\nUpdate interval: %u seconds\nSynced: "), config.ntp_server, config.ntp_interval);
server.sendContent(buf);
server.sendContent_P(timeClient.isTimeSet() ? PSTR("<span class='ok'>✓ Yes</span>\n") : PSTR("<span class='fail'>✗ No</span>\n"));
snprintf_P(buf, sizeof(buf), PSTR("UTC time: %s\n"), timeClient.getFormattedTime().c_str());
server.sendContent(buf);
if (timeClient.isTimeSet()) {
unsigned long epochTime = timeClient.getEpochTime();
unsigned long localTime = epochTime + getTotalOffset(epochTime);
snprintf_P(buf, sizeof(buf), PSTR("Local time: %02d:%02d:%02d\n"), (int)((localTime/3600)%24), (int)((localTime/60)%60), (int)(localTime%60));
server.sendContent(buf);
}
snprintf_P(buf, sizeof(buf), PSTR("Attempts: %d\nSuccesses: %d\nLast error: %s\n</pre>"), ntpAttempts, ntpSuccesses, lastError.c_str());
server.sendContent(buf);
// Timezone & DST
server.sendContent_P(PSTR("<h2>Timezone & DST</h2><pre>"));
snprintf_P(buf, sizeof(buf), PSTR("Base offset: %.1f hours (%ld seconds)\nDST enabled: %s\n"),
config.timezone_offset/3600.0, config.timezone_offset, config.dst_enabled ? "Yes" : "No");
server.sendContent(buf);
if (config.dst_enabled && timeClient.isTimeSet()) {
unsigned long epochTime = timeClient.getEpochTime();
bool inDST = isDST(epochTime);
snprintf_P(buf, sizeof(buf), PSTR("DST active now: %s\nTotal offset: %.1f hours\n"),
inDST ? "<span class='ok'>✓ Yes (+1 hour)</span>" : "✗ No", getTotalOffset(epochTime)/3600.0);
server.sendContent(buf);
}
snprintf_P(buf, sizeof(buf), PSTR("Time format: %s\n</pre>"), config.hour_format_24 ? "24-hour" : "12-hour (AM/PM)");
server.sendContent(buf);
// Weather
server.sendContent_P(PSTR("<h2>Weather</h2><pre>"));
snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\nValid data: %s\n"),
config.weather_enabled ? "Yes" : "No",
weather.valid ? "<span class='ok'>✓ Yes</span>" : "<span class='fail'>✗ No</span>");
server.sendContent(buf);
if (weather.valid) {
snprintf_P(buf, sizeof(buf), PSTR("Temperature: %.1f°C\nWeather code: %d\nWind speed: %.1f km/h\nLast update: %lu sec ago\n"),
weather.temperature, weather.weathercode, weather.windspeed, weather.lastUpdate/1000);
server.sendContent(buf);
}
snprintf_P(buf, sizeof(buf), PSTR("City: %s\nLocation: %.6f, %.6f\nUpdate interval: %u seconds\n</pre>"),
config.city_name, config.latitude, config.longitude, config.weather_interval);
server.sendContent(buf);
// Sun times
server.sendContent_P(PSTR("<h2>Sunrise/Sunset</h2><pre>"));
snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\n"), config.show_sunrise_sunset ? "Yes" : "No");
server.sendContent(buf);
if (sunTimes.lastDay != -1) {
snprintf_P(buf, sizeof(buf), PSTR("<span class='ok'>✓ Data available</span>\nSunrise: %s (%d min)\nSunset: %s (%d min)\nLast update day: %d\n</pre>"),
sunTimes.sunrise, sunTimes.sunriseMinutes, sunTimes.sunset, sunTimes.sunsetMinutes, sunTimes.lastDay);
} else {
snprintf_P(buf, sizeof(buf), PSTR("<span class='fail'>✗ No data</span>\n</pre>"));
}
server.sendContent(buf);
// Display
server.sendContent_P(PSTR("<h2>Display</h2><pre>"));
const char* modeStr = (displayMode == 0) ? " (Time)" : (displayMode == 1) ? " (Weather)" : " (Sun times)";
snprintf_P(buf, sizeof(buf), PSTR("Current mode: %d%s\nRotation interval: %u seconds\nBrightness: %d (0-7)\nShow weather: %s\nShow sun times: %s\nNTP synced: %s\n</pre>"),
displayMode, modeStr, config.display_rotation_sec, config.brightness,
config.show_weather ? "Yes" : "No", config.show_sunrise_sunset ? "Yes" : "No",
timeClient.isTimeSet() ? "<span class='ok'>✓ Yes</span>" : "<span class='fail'>✗ No</span>");
server.sendContent(buf);
// System
server.sendContent_P(PSTR("<h2>System</h2><pre>"));
snprintf_P(buf, sizeof(buf), PSTR("Uptime: %lu seconds\nFree heap: %u bytes\nChip ID: %X\nFlash size: %u bytes\nSDK version: %s\n</pre>"),
millis()/1000, ESP.getFreeHeap(), ESP.getChipId(), ESP.getFlashChipSize(), ESP.getSdkVersion());
server.sendContent(buf);
// Footer
server.sendContent_P(DEBUG_HTML_FOOTER);
// End chunked transfer
server.sendContent("");
}
void ICACHE_FLASH_ATTR handleTestNTP() {
// Force NTP update
testInternetConnectivity();
updateNTPTime();
// Redirect back to debug page
server.sendHeader("Location", "/debug");
server.send(303);
}
void ICACHE_FLASH_ATTR handleTestDisplay() {
// Test display by showing "8888" for 3 seconds
uint8_t data[] = {0xFF, 0xFF, 0xFF, 0xFF}; // All segments on = "8888"
displaySegments(data);
delay(3000);
// Redirect back to debug page
server.sendHeader("Location", "/debug");
server.send(303);
}
// PROGMEM templates for handleConfig() - chunked response
const char CONFIG_HTML_HEADER[] PROGMEM =
"<!DOCTYPE html><html><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Configuration</title>"
"<style>"
"body{font-family:Arial;margin:20px;background:#f0f0f0;}"
".container{max-width:600px;margin:0 auto;background:white;padding:20px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,0.1);}"
"input,select{width:100%;padding:8px;margin:5px 0 15px 0;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;}"
"button{background:#0066cc;color:white;padding:12px 20px;border:none;border-radius:5px;cursor:pointer;width:100%;}"
"button:hover{background:#0052a3;}"
"label{font-weight:bold;}"
"</style>"
"</head><body>"
"<div class='container'>"
"<h1>Configuration</h1>"
"<form method='POST' action='/config'>";
const char CONFIG_HTML_FOOTER[] PROGMEM =
"<button type='submit'>Save & Reboot</button>"
"</form>"
"<p><a href='/'>Back to Home</a></p>"
"</div></body></html>";
void ICACHE_FLASH_ATTR handleConfig() {
char buf[150];
// Start chunked transfer
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
// Header
server.sendContent_P(CONFIG_HTML_HEADER);
// WiFi settings
snprintf_P(buf, sizeof(buf), PSTR("<label>WiFi SSID:</label><input type='text' name='ssid' value='%s' required>"), config.ssid);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf), PSTR("<label>WiFi Password:</label><input type='password' name='password' value='%s'>"), config.password);
server.sendContent(buf);
// System settings
snprintf_P(buf, sizeof(buf), PSTR("<label>Timezone Offset (seconds):</label><input type='number' name='timezone' value='%ld'>"), config.timezone_offset);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf), PSTR("<label>Brightness (0-7):</label><input type='number' name='brightness' min='0' max='7' value='%d'>"), config.brightness);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf), PSTR("<label>Hostname:</label><input type='text' name='hostname' value='%s'>"), config.hostname);
server.sendContent(buf);
// Weather settings
server.sendContent_P(PSTR("<h2 style='margin-top:20px;'>Weather Settings</h2>"));
snprintf_P(buf, sizeof(buf), PSTR("<label>City Name:</label><input type='text' name='city_name' value='%s'>"), config.city_name);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf), PSTR("<label>Latitude:</label><input type='number' step='0.000001' name='latitude' value='%.6f'>"), config.latitude);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf), PSTR("<label>Longitude:</label><input type='number' step='0.000001' name='longitude' value='%.6f'>"), config.longitude);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf), PSTR("<label>Weather Update Interval (seconds):</label><input type='number' name='weather_interval' value='%u'>"), config.weather_interval);
server.sendContent(buf);
// Display settings
server.sendContent_P(PSTR("<h2 style='margin-top:20px;'>Display Settings</h2>"));
snprintf_P(buf, sizeof(buf), PSTR("<label>Screen Rotation Interval (seconds):</label><input type='number' name='display_rotation_sec' value='%u'>"), config.display_rotation_sec);
server.sendContent(buf);
// Footer
server.sendContent_P(CONFIG_HTML_FOOTER);
// End chunked transfer
server.sendContent("");
}
void ICACHE_FLASH_ATTR handleConfigSave() {
if (server.hasArg("ssid")) {
safeStringCopy(server.arg("ssid"), config.ssid, sizeof(config.ssid));
}
if (server.hasArg("password")) {
safeStringCopy(server.arg("password"), config.password, sizeof(config.password));
}
if (server.hasArg("timezone")) {
config.timezone_offset = server.arg("timezone").toInt();
}
if (server.hasArg("brightness")) {
config.brightness = server.arg("brightness").toInt();
// OLED brightness controlled by hardware (no software control with Adafruit lib)
}
if (server.hasArg("hostname")) {
safeStringCopy(server.arg("hostname"), config.hostname, sizeof(config.hostname));
}
if (server.hasArg("city_name")) {
safeStringCopy(server.arg("city_name"), config.city_name, sizeof(config.city_name));
}
if (server.hasArg("latitude")) {
config.latitude = server.arg("latitude").toFloat();
}
if (server.hasArg("longitude")) {
config.longitude = server.arg("longitude").toFloat();
}
if (server.hasArg("weather_interval")) {
config.weather_interval = server.arg("weather_interval").toInt();
}
if (server.hasArg("display_rotation_sec")) {
config.display_rotation_sec = server.arg("display_rotation_sec").toInt();
}
if (server.hasArg("display_orientation")) {
config.display_orientation = server.arg("display_orientation").toInt();
display.setRotation(config.display_orientation);
}
saveConfig();
String html = F("<!DOCTYPE html><html><head>");
html += F("<meta charset='UTF-8'>");
html += F("<meta http-equiv='refresh' content='5;url=/'>");
html += F("<style>body{font-family:Arial;text-align:center;margin-top:50px;}</style>");
html += F("</head><body>");
html += F("<h1>Configuration Saved!</h1>");
html += F("<p>Device will reboot in 5 seconds...</p>");
html += F("</body></html>");
server.send(200, "text/html", html);
delay(1000);
ESP.restart();
}
void ICACHE_FLASH_ATTR handleAPITime() {
String json = "{";
json += "\"time\":\"" + timeClient.getFormattedTime() + "\",";
json += "\"hours\":" + String(timeClient.getHours()) + ",";
json += "\"minutes\":" + String(timeClient.getMinutes()) + ",";
json += "\"seconds\":" + String(timeClient.getSeconds()) + ",";
json += "\"epoch\":" + String(timeClient.getEpochTime());
json += "}";
server.send(200, "application/json", json);
}
void ICACHE_FLASH_ATTR handleAPIStatus() {
String json = "{";
json += "\"wifi\":{";
json += "\"ssid\":\"" + String(WiFi.SSID()) + "\",";
json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
json += "\"hostname\":\"" + String(config.hostname) + "\"";
json += "},";
json += "\"time\":{";
json += "\"current\":\"" + timeClient.getFormattedTime() + "\",";
json += "\"timezone_offset\":" + String(config.timezone_offset) + ",";
json += "\"ntp_synced\":" + String(timeClient.isTimeSet() ? "true" : "false");
json += "},";
json += "\"system\":{";
json += "\"uptime\":" + String(millis() / 1000) + ",";
json += "\"free_heap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"chip_id\":\"" + String(ESP.getChipId(), HEX) + "\"";
json += "}";
json += "}";
server.send(200, "application/json", json);
}
void ICACHE_FLASH_ATTR handleAPIDebug() {
String json = "{";
json += "\"internet_connected\":" + String(internetConnected ? "true" : "false") + ",";
json += "\"ntp_attempts\":" + String(ntpAttempts) + ",";
json += "\"ntp_successes\":" + String(ntpSuccesses) + ",";
json += "\"last_error\":\"" + lastError + "\",";
json += "\"gateway\":\"" + WiFi.gatewayIP().toString() + "\",";
json += "\"dns\":\"" + WiFi.dnsIP().toString() + "\"";
json += "}";
server.send(200, "application/json", json);
}
void ICACHE_FLASH_ATTR handleAPIWeather() {
String json = "{";
json += "\"enabled\":" + String(config.weather_enabled ? "true" : "false") + ",";
json += "\"valid\":" + String(weather.valid ? "true" : "false") + ",";
json += "\"temperature\":" + String(weather.temperature, 1) + ",";
json += "\"weathercode\":" + String(weather.weathercode) + ",";
json += "\"windspeed\":" + String(weather.windspeed, 1) + ",";
json += "\"last_update\":" + String(weather.lastUpdate) + ",";
json += "\"sunrise\":\"" + String(sunTimes.sunrise) + "\",";
json += "\"sunset\":\"" + String(sunTimes.sunset) + "\",";
json += "\"sunrise_minutes\":" + String(sunTimes.sunriseMinutes) + ",";
json += "\"sunset_minutes\":" + String(sunTimes.sunsetMinutes);
json += "}";
server.send(200, "application/json", json);
}
void ICACHE_FLASH_ATTR handleAPIConfigExport() {
String json = "{";
json += "\"firmware_version\":\"" FIRMWARE_VERSION "\",";
json += "\"magic\":\"0x" + String(config.magic, HEX) + "\",";
json += "\"ssid\":\"" + String(config.ssid) + "\",";
json += "\"password\":\"" + String(config.password) + "\",";
json += "\"timezone_offset\":" + String(config.timezone_offset) + ",";
json += "\"dst_enabled\":" + String(config.dst_enabled ? "true" : "false") + ",";
json += "\"brightness\":" + String(config.brightness) + ",";
json += "\"ntp_server\":\"" + String(config.ntp_server) + "\",";
json += "\"ntp_interval\":" + String(config.ntp_interval) + ",";
json += "\"hour_format_24\":" + String(config.hour_format_24 ? "true" : "false") + ",";
json += "\"hostname\":\"" + String(config.hostname) + "\",";
// Weather settings
json += "\"latitude\":" + String(config.latitude, 6) + ",";
json += "\"longitude\":" + String(config.longitude, 6) + ",";
json += "\"city_name\":\"" + String(config.city_name) + "\",";
json += "\"weather_enabled\":" + String(config.weather_enabled ? "true" : "false") + ",";
json += "\"weather_interval\":" + String(config.weather_interval) + ",";
// Display settings
json += "\"display_rotation_sec\":" + String(config.display_rotation_sec) + ",";
json += "\"show_weather\":" + String(config.show_weather ? "true" : "false") + ",";
json += "\"show_sunrise_sunset\":" + String(config.show_sunrise_sunset ? "true" : "false");
json += "}";
server.sendHeader("Content-Disposition", "attachment; filename=clock-config.json");
server.send(200, "application/json", json);
}
void ICACHE_FLASH_ATTR handleAPIConfigImport() {
if (!server.hasArg("plain")) {
server.send(400, "text/plain", "No config data received");
return;
}
String body = server.arg("plain");
Serial.println("Received config: " + body);
// Simple JSON parsing (for production, consider ArduinoJson library)
// This is basic parsing - just extracts values between quotes and colons
int pos;
// Parse SSID
pos = body.indexOf("\"ssid\":\"");
if (pos >= 0) {
int start = pos + 8;
int end = body.indexOf("\"", start);
if (end > start) {
String ssid = body.substring(start, end);
safeStringCopy(ssid, config.ssid, sizeof(config.ssid));
}
}
// Parse password
pos = body.indexOf("\"password\":\"");
if (pos >= 0) {
int start = pos + 12;
int end = body.indexOf("\"", start);
if (end > start) {
String password = body.substring(start, end);
safeStringCopy(password, config.password, sizeof(config.password));
}
}
// Parse timezone_offset
pos = body.indexOf("\"timezone_offset\":");
if (pos >= 0) {
int start = pos + 18;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
config.timezone_offset = body.substring(start, end).toInt();
}
}
// Parse brightness
pos = body.indexOf("\"brightness\":");
if (pos >= 0) {
int start = pos + 13;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
config.brightness = body.substring(start, end).toInt();
}
}
// Parse hostname
pos = body.indexOf("\"hostname\":\"");
if (pos >= 0) {
int start = pos + 12;
int end = body.indexOf("\"", start);
if (end > start) {
String hostname = body.substring(start, end);
safeStringCopy(hostname, config.hostname, sizeof(config.hostname));
}
}
// Parse dst_enabled
pos = body.indexOf("\"dst_enabled\":");
if (pos >= 0) {
int start = pos + 14;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
String value = body.substring(start, end);
value.trim();
config.dst_enabled = (value == "true");
}
}
// Parse ntp_server
pos = body.indexOf("\"ntp_server\":\"");
if (pos >= 0) {
int start = pos + 14;
int end = body.indexOf("\"", start);
if (end > start) {
String ntp_server = body.substring(start, end);
safeStringCopy(ntp_server, config.ntp_server, sizeof(config.ntp_server));
}
}
// Parse ntp_interval
pos = body.indexOf("\"ntp_interval\":");
if (pos >= 0) {
int start = pos + 15;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
config.ntp_interval = body.substring(start, end).toInt();
}
}
// Parse hour_format_24
pos = body.indexOf("\"hour_format_24\":");
if (pos >= 0) {
int start = pos + 17;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
String value = body.substring(start, end);
value.trim();
config.hour_format_24 = (value == "true");
}
}
// Parse weather settings
pos = body.indexOf("\"latitude\":");
if (pos >= 0) {
int start = pos + 11;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
config.latitude = body.substring(start, end).toFloat();
}
}
pos = body.indexOf("\"longitude\":");
if (pos >= 0) {
int start = pos + 12;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
config.longitude = body.substring(start, end).toFloat();
}
}
pos = body.indexOf("\"city_name\":\"");
if (pos >= 0) {
int start = pos + 13;
int end = body.indexOf("\"", start);
if (end > start) {
String city_name = body.substring(start, end);
safeStringCopy(city_name, config.city_name, sizeof(config.city_name));
}
}
pos = body.indexOf("\"weather_enabled\":");
if (pos >= 0) {
int start = pos + 18;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
String value = body.substring(start, end);
value.trim();
config.weather_enabled = (value == "true");
}
}
pos = body.indexOf("\"weather_interval\":");
if (pos >= 0) {
int start = pos + 19;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
config.weather_interval = body.substring(start, end).toInt();
}
}
// Parse display settings
pos = body.indexOf("\"display_rotation_sec\":");
if (pos >= 0) {
int start = pos + 23;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
config.display_rotation_sec = body.substring(start, end).toInt();
}
}
pos = body.indexOf("\"show_weather\":");
if (pos >= 0) {
int start = pos + 15;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
String value = body.substring(start, end);
value.trim();
config.show_weather = (value == "true");
}
}
pos = body.indexOf("\"show_sunrise_sunset\":");
if (pos >= 0) {
int start = pos + 22;
int end = body.indexOf(",", start);
if (end < 0) end = body.indexOf("}", start);
if (end > start) {
String value = body.substring(start, end);
value.trim();
config.show_sunrise_sunset = (value == "true");
}
}
config.magic = CONFIG_MAGIC;
saveConfig();
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Config imported and saved. Reboot recommended.\"}");
}
void ICACHE_FLASH_ATTR handleEEPROMClear() {
EEPROM.begin(512);
for (int i = 0; i < 512; i++) {
EEPROM.write(i, 0xFF);
}
EEPROM.commit();
EEPROM.end();
Serial.println("EEPROM cleared!");
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"EEPROM cleared, device will reboot\"}");
delay(1000);
ESP.restart();
}
void ICACHE_FLASH_ATTR handleReboot() {
Serial.println("Reboot requested via web interface");
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Device rebooting...\"}");
delay(1000);
ESP.restart();
}
void ICACHE_FLASH_ATTR handleI2CScan() {
String json = "{\"i2c_scan\":{\"devices\":[";
int deviceCount = 0;
for (uint8_t address = 0x08; address <= 0x77; address++) {
Wire.beginTransmission(address);
uint8_t error = Wire.endTransmission();
if (error == 0) {
if (deviceCount > 0) json += ",";
json += "{\"address\":\"0x";
if (address < 16) json += "0";
json += String(address, HEX);
json += "\",\"decimal\":" + String(address) + "}";
deviceCount++;
}
delay(1);
}
json += "],\"count\":" + String(deviceCount);
// Try OLED addresses specifically
json += ",\"oled_test\":{";
Wire.beginTransmission(0x3C);
json += "\"0x3C\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\",";
Wire.beginTransmission(0x3D);
json += "\"0x3D\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\"";
json += "}}}";
Serial.println("I2C Scan results: " + json);
server.send(200, "application/json", json);
}
// Weather and Sun Functions
// Async weather response callback
void onWeatherResponse(void* optParm, AsyncHTTPRequest* request, int readyState) {
(void)optParm; // Unused
if (readyState == 4) { // Request complete
weatherState = WEATHER_IDLE;
int httpCode = request->responseHTTPcode();
if (httpCode == 200) {
String payload = request->responseText();
Serial.printf("✓ Weather response: %d bytes\n", payload.length());
// Parse JSON response
StaticJsonDocument<1536> doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
// Extract current weather
JsonObject current = doc["current_weather"];
weather.temperature = current["temperature"] | 0.0f;
weather.weathercode = current["weathercode"] | -1;
weather.windspeed = current["windspeed"] | 0.0f;
weather.lastUpdate = millis();
weather.valid = true;
// Extract sunrise/sunset
JsonArray daily_sunrise = doc["daily"]["sunrise"];
JsonArray daily_sunset = doc["daily"]["sunset"];
if (daily_sunrise.size() > 0 && daily_sunset.size() > 0) {
const char* sunrise_str = daily_sunrise[0];
const char* sunset_str = daily_sunset[0];
// Parse ISO time (2026-01-02T07:52) -> HH:MM
if (sunrise_str && strlen(sunrise_str) >= 16) {
sunTimes.sunrise[0] = sunrise_str[11];
sunTimes.sunrise[1] = sunrise_str[12];
sunTimes.sunrise[2] = ':';
sunTimes.sunrise[3] = sunrise_str[14];
sunTimes.sunrise[4] = sunrise_str[15];
sunTimes.sunrise[5] = '\0';
sunTimes.sunriseMinutes = (sunrise_str[11] - '0') * 600 +
(sunrise_str[12] - '0') * 60 +
(sunrise_str[14] - '0') * 10 +
(sunrise_str[15] - '0');
}
if (sunset_str && strlen(sunset_str) >= 16) {
sunTimes.sunset[0] = sunset_str[11];
sunTimes.sunset[1] = sunset_str[12];
sunTimes.sunset[2] = ':';
sunTimes.sunset[3] = sunset_str[14];
sunTimes.sunset[4] = sunset_str[15];
sunTimes.sunset[5] = '\0';
sunTimes.sunsetMinutes = (sunset_str[11] - '0') * 600 +
(sunset_str[12] - '0') * 60 +
(sunset_str[14] - '0') * 10 +
(sunset_str[15] - '0');
}
// Update lastDay
time_t epochTime = timeClient.getEpochTime();
struct tm *ptm = gmtime(&epochTime);
sunTimes.lastDay = ptm->tm_yday;
}
weatherState = WEATHER_SUCCESS;
weatherRetry.reset(); // Success! Reset retry counter
Serial.printf("✓ Weather: %.1f°C, code %d, wind %.1f km/h\n",
weather.temperature, weather.weathercode, weather.windspeed);
} else {
weatherState = WEATHER_FAILED;
weather.valid = false;
lastError = String("JSON: ") + error.c_str();
Serial.printf("✗ JSON parse error (attempt %d/%d): %s\n",
weatherRetry.currentRetry + 1, weatherRetry.maxRetries, error.c_str());
// Schedule retry with exponential backoff
weatherRetry.scheduleRetry();
if (weatherRetry.maxRetriesReached()) {
Serial.println("✗ Weather max retries reached, will try again later");
} else {
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
}
}
doc.clear();
} else {
weatherState = WEATHER_FAILED;
weather.valid = false;
lastError = "Weather API: " + String(httpCode);
Serial.printf("✗ HTTP error %d (attempt %d/%d)\n",
httpCode, weatherRetry.currentRetry + 1, weatherRetry.maxRetries);
// Schedule retry with exponential backoff
weatherRetry.scheduleRetry();
if (weatherRetry.maxRetriesReached()) {
Serial.println("✗ Weather max retries reached, will try again later");
} else {
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
}
}
}
}
// Async weather fetch - non-blocking!
void fetchWeatherAsync() {
if (!config.weather_enabled) {
Serial.println(F("Weather disabled"));
return;
}
if (weatherState != WEATHER_IDLE) {
Serial.println(F("Weather request already in progress"));
return;
}
// Build URL
String url = "http://api.open-meteo.com/v1/forecast?";
url += "latitude=" + String(config.latitude, 2);
url += "&longitude=" + String(config.longitude, 2);
url += "&current_weather=true";
url += "&daily=sunrise,sunset";
url += "&timezone=auto";
url += "&forecast_days=1";
Serial.println(F("⬇️ Fetching weather (async)..."));
// Open async request
if (weatherRequest.open("GET", url.c_str())) {
weatherRequest.onReadyStateChange(onWeatherResponse);
weatherRequest.setTimeout(10); // 10 seconds
weatherRequest.send();
weatherState = WEATHER_REQUESTING;
Serial.println("✓ Weather request sent (non-blocking)");
} else {
weatherState = WEATHER_FAILED;
Serial.println("✗ Failed to open weather request");
}
}
void ICACHE_FLASH_ATTR calculateSunTimes() {
// Sunrise/sunset are fetched from Open-Meteo API in fetchWeather()
// This function is kept as placeholder for future local calculation if needed
if (!config.show_sunrise_sunset) return;
// Data already provided by fetchWeather() API call
if (sunTimes.lastDay != -1) {
Serial.println(F("Sun times already available from API"));
return;
}
Serial.println(F("⚠ Sun times not available yet - will be fetched with weather"));
}
// EEPROM functions
void ICACHE_FLASH_ATTR loadConfig() {
EEPROM.begin(512);
Config tempConfig;
EEPROM.get(0, tempConfig);
// Validate magic number
if (tempConfig.magic == CONFIG_MAGIC) {
config = tempConfig;
Serial.println("✓ Valid configuration loaded from EEPROM");
} else {
Serial.println("⚠ Invalid EEPROM data detected, using defaults");
config.magic = CONFIG_MAGIC; // Set magic number
saveConfig(); // Save defaults to EEPROM
}
EEPROM.end();
Serial.println("Configuration:");
Serial.printf(" Magic: 0x%08X %s\n", config.magic,
config.magic == CONFIG_MAGIC ? "✓" : "✗");
Serial.printf(" SSID: %s\n", config.ssid);
Serial.printf(" Timezone: %ld\n", config.timezone_offset);
Serial.printf(" Brightness: %d\n", config.brightness);
Serial.printf(" Hostname: %s\n", config.hostname);
}
void ICACHE_FLASH_ATTR saveConfig() {
EEPROM.begin(512);
EEPROM.put(0, config);
EEPROM.commit();
EEPROM.end();
Serial.println("Configuration saved!");
}