diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index ec8cb5c..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Build Firmware - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - release: - types: [ created ] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Arduino CLI - uses: arduino/setup-arduino-cli@v1 - - - name: Install ESP8266 platform - run: | - arduino-cli core update-index - arduino-cli core install esp8266:esp8266 - - - name: Install libraries - run: | - arduino-cli lib install "Adafruit GFX Library" - arduino-cli lib install "Adafruit SSD1306" - arduino-cli lib install "NTPClient" - arduino-cli lib install "WiFiManager" - arduino-cli lib install "AsyncHTTPRequest_Generic" - arduino-cli lib install "ESPAsyncTCP" - - - name: Compile firmware - run: | - arduino-cli compile --fqbn esp8266:esp8266:generic \ - --build-property "build.flash_size=1M64" \ - --build-property "build.flash_mode=dio" \ - --output-dir build \ - src/clock_ntp_ota_v1.9.ino - - - name: Check firmware size - run: | - SIZE=$(stat -c%s "build/clock_ntp_ota_v1.9.ino.bin") - echo "Firmware size: $SIZE bytes" - MAX_SIZE=470000 # 470KB max for OTA - if [ $SIZE -gt $MAX_SIZE ]; then - echo "ERROR: Firmware too large ($SIZE > $MAX_SIZE)" - exit 1 - fi - - - name: Upload build artifacts - uses: actions/upload-artifact@v3 - with: - name: firmware - path: | - build/clock_ntp_ota_v1.9.ino.bin - build/clock_ntp_ota_v1.9.ino.elf - build/clock_ntp_ota_v1.9.ino.map - retention-days: 30 - - - name: Upload release assets - if: github.event_name == 'release' - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: build/clock_ntp_ota_v1.9.ino.bin - asset_name: esp8266-weather-clock-${{ github.event.release.tag_name }}.bin - asset_content_type: application/octet-stream diff --git a/firmware/clock_ntp_ota_v1.9/.gitignore b/firmware/clock_ntp_ota_v1.9/.gitignore new file mode 100644 index 0000000..bfb591d --- /dev/null +++ b/firmware/clock_ntp_ota_v1.9/.gitignore @@ -0,0 +1,3 @@ +build/ +*.bak +*.bak2 diff --git a/firmware/clock_ntp_ota_v1.9/clock_ntp_ota_v1.9.ino b/firmware/clock_ntp_ota_v1.9/clock_ntp_ota_v1.9.ino new file mode 100644 index 0000000..2e50da8 --- /dev/null +++ b/firmware/clock_ntp_ota_v1.9/clock_ntp_ota_v1.9.ino @@ -0,0 +1,2211 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // WiFiManager for captive portal configuration + +// Async libraries for non-blocking operations +#include // Async TCP for ESP8266 +#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN_TARGET "AsyncHTTPRequest_Generic v1.13.0" +#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN 1013000 +#include // 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 = + "" + "" + "" + "TJ-56-654 Clock v" FIRMWARE_VERSION "" + "" + "" + "" + "
" + "

TJ-56-654 NTP Clock v" FIRMWARE_VERSION "

" + "
--:--:--
"; + +const char ROOT_HTML_FOOTER[] PROGMEM = + "Configuration" + "Debug Info" + "Firmware Update" + "Status (JSON)" + "" + "
"; + +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("
⚠ Error: %s
"), lastError.c_str()); + server.sendContent(buf); + } + + // WiFi info + snprintf_P(buf, sizeof(buf), PSTR("
WiFi: %s
"), WiFi.SSID().c_str()); + server.sendContent(buf); + + // IP info + snprintf_P(buf, sizeof(buf), PSTR("
IP: %s
"), WiFi.localIP().toString().c_str()); + server.sendContent(buf); + + // Hostname + snprintf_P(buf, sizeof(buf), PSTR("
Hostname: %s.local
"), config.hostname); + server.sendContent(buf); + + // Uptime + snprintf_P(buf, sizeof(buf), PSTR("
Uptime: %lu seconds
"), millis()/1000); + server.sendContent(buf); + + // NTP sync warning + if (!timeClient.isTimeSet()) { + snprintf_P(buf, sizeof(buf), PSTR("
⚠ NTP not synced yet
Attempts: %d | Success: %d
"), + 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 = + "" + "" + "" + "Debug Info" + "" + "" + "
" + "

Debug Information

" + "

Network

";
+
+const char DEBUG_HTML_FOOTER[] PROGMEM =
+  "

Actions

" + "" + "" + "" + "" + "" + "" + "
"; + +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"), + 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("

Internet Connectivity

Status: "));
+  server.sendContent_P(internetConnected ? PSTR("✓ Connected\n
") : PSTR("✗ Not connected\n")); + + // NTP info + server.sendContent_P(PSTR("

NTP

"));
+  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("✓ Yes\n") : PSTR("✗ No\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
"), ntpAttempts, ntpSuccesses, lastError.c_str()); + server.sendContent(buf); + + // Timezone & DST + server.sendContent_P(PSTR("

Timezone & DST

"));
+  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 ? "✓ Yes (+1 hour)" : "✗ No", getTotalOffset(epochTime)/3600.0);
+    server.sendContent(buf);
+  }
+
+  snprintf_P(buf, sizeof(buf), PSTR("Time format: %s\n
"), config.hour_format_24 ? "24-hour" : "12-hour (AM/PM)"); + server.sendContent(buf); + + // Weather + server.sendContent_P(PSTR("

Weather

"));
+  snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\nValid data: %s\n"),
+    config.weather_enabled ? "Yes" : "No",
+    weather.valid ? "✓ Yes" : "✗ No");
+  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
"), + config.city_name, config.latitude, config.longitude, config.weather_interval); + server.sendContent(buf); + + // Sun times + server.sendContent_P(PSTR("

Sunrise/Sunset

"));
+  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("✓ Data available\nSunrise: %s (%d min)\nSunset: %s (%d min)\nLast update day: %d\n
"), + sunTimes.sunrise, sunTimes.sunriseMinutes, sunTimes.sunset, sunTimes.sunsetMinutes, sunTimes.lastDay); + } else { + snprintf_P(buf, sizeof(buf), PSTR("✗ No data\n")); + } + server.sendContent(buf); + + // Display + server.sendContent_P(PSTR("

Display

"));
+  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
"), + displayMode, modeStr, config.display_rotation_sec, config.brightness, + config.show_weather ? "Yes" : "No", config.show_sunrise_sunset ? "Yes" : "No", + timeClient.isTimeSet() ? "✓ Yes" : "✗ No"); + server.sendContent(buf); + + // System + server.sendContent_P(PSTR("

System

"));
+  snprintf_P(buf, sizeof(buf), PSTR("Uptime: %lu seconds\nFree heap: %u bytes\nChip ID: %X\nFlash size: %u bytes\nSDK version: %s\n
"), + 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 = + "" + "" + "" + "Configuration" + "" + "" + "
" + "

Configuration

" + "
"; + +const char CONFIG_HTML_FOOTER[] PROGMEM = + "" + "
" + "

Back to Home

" + "
"; + +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(""), config.ssid); + server.sendContent(buf); + snprintf_P(buf, sizeof(buf), PSTR(""), config.password); + server.sendContent(buf); + + // System settings + snprintf_P(buf, sizeof(buf), PSTR(""), config.timezone_offset); + server.sendContent(buf); + snprintf_P(buf, sizeof(buf), PSTR(""), config.brightness); + server.sendContent(buf); + snprintf_P(buf, sizeof(buf), PSTR(""), config.hostname); + server.sendContent(buf); + + // Weather settings + server.sendContent_P(PSTR("

Weather Settings

")); + snprintf_P(buf, sizeof(buf), PSTR(""), config.city_name); + server.sendContent(buf); + snprintf_P(buf, sizeof(buf), PSTR(""), config.latitude); + server.sendContent(buf); + snprintf_P(buf, sizeof(buf), PSTR(""), config.longitude); + server.sendContent(buf); + snprintf_P(buf, sizeof(buf), PSTR(""), config.weather_interval); + server.sendContent(buf); + + // Display settings + server.sendContent_P(PSTR("

Display Settings

")); + snprintf_P(buf, sizeof(buf), PSTR(""), 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(""); + html += F(""); + html += F(""); + html += F(""); + html += F(""); + html += F("

Configuration Saved!

"); + html += F("

Device will reboot in 5 seconds...

"); + html += F(""); + + 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 += "¤t_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!"); +} diff --git a/src/clock_ntp_ota_v1.9.ino b/src/clock_ntp_ota_v1.9.ino index 5de39dd..2e50da8 100644 --- a/src/clock_ntp_ota_v1.9.ino +++ b/src/clock_ntp_ota_v1.9.ino @@ -1,5 +1,12 @@ /* - * TJ-56-654 Weather Clock - Custom NTP Firmware with OTA v1.9.1 + * 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 @@ -69,7 +76,7 @@ // Configuration structure with validation #define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data -#define FIRMWARE_VERSION "1.9.1" +#define FIRMWARE_VERSION "1.9.2" struct Config { uint32_t magic = CONFIG_MAGIC; // Magic number for validation @@ -122,6 +129,7 @@ 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(); @@ -200,12 +208,14 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000); // Exponential backoff retry configuration struct RetryConfig { - uint8_t maxRetries = 3; + 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() { - return 1000UL * (1UL << currentRetry); // 1s, 2s, 4s, 8s... + unsigned long delay = 1000UL * (1UL << currentRetry); // 1s, 2s, 4s, 8s... + return (delay > maxBackoffMs) ? maxBackoffMs : delay; } void scheduleRetry() { @@ -231,6 +241,33 @@ struct RetryConfig { } }; +// 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; @@ -278,6 +315,7 @@ const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second timeout for v1.7 migr // Retry configurations with exponential backoff RetryConfig ntpRetry; RetryConfig weatherRetry; +WiFiRetryConfig wifiRetry; // Web server ESP8266WebServer server(80); @@ -398,20 +436,45 @@ void loop() { server.handleClient(); MDNS.update(); - // WiFi reconnection logic (async, non-blocking) - // If WiFi disconnects during operation, try to reconnect asynchronously + // WiFi reconnection logic (async, non-blocking with exponential backoff) static unsigned long lastWiFiCheck = 0; - if (millis() - lastWiFiCheck > 5000) { // Check every 5 seconds + 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, attempting async reconnect..."); - WiFi.begin(); // Try to reconnect with saved credentials + 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(); } - lastWiFiCheck = millis(); } - // Process async WiFi reconnection (only if reconnecting, not during initial setup) + // Process async WiFi reconnection processWiFiConnection(); // Process async NTP response (non-blocking check) @@ -635,7 +698,152 @@ void processWiFiConnection() { // Check connection status if (WiFi.status() == WL_CONNECTED) { wifiConnState = WIFI_CONN_CONNECTED; - Serial.println("\n✅ WiFi connected (async)!"); + 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: "); @@ -647,147 +855,15 @@ void processWiFiConnection() { // Show IP on display showIP(); + wifiConnState = WIFI_CONN_CONNECTED; return; } - // Check timeout - if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) { - wifiConnState = WIFI_CONN_FAILED; - Serial.println("\n⚠️ WiFi async connection timeout"); - Serial.println("Falling back to WiFiManager..."); - - // Clear old credentials from config - config.ssid[0] = '\0'; - config.password[0] = '\0'; - saveConfig(); - - // Fall back to WiFiManager - WiFiManager wifiManager; - wifiManager.setConfigPortalTimeout(180); - showNumber(0xAF, false); - - Serial.println("Starting WiFiManager captive portal..."); - if (!wifiManager.autoConnect("TJ56654-Setup", "12345678")) { - Serial.println("WiFiManager failed. Starting fallback AP..."); - WiFi.mode(WIFI_AP); - WiFi.softAP("TJ56654-Clock", "12345678"); - Serial.print("Fallback AP IP: "); - Serial.println(WiFi.softAPIP()); - } else { - Serial.println("WiFiManager connected!"); - safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid)); - saveConfig(); - showIP(); - } - - wifiConnState = WIFI_CONN_CONNECTED; // Mark as handled - return; - } - - // Still connecting, update display - static uint8_t connectAttempts = 0; - static unsigned long lastAttemptDisplay = 0; - if (millis() - lastAttemptDisplay > 500) { - Serial.print("."); - showNumber(connectAttempts, false); - connectAttempts++; - lastAttemptDisplay = 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); - - // MIGRATION FROM v1.7: If config has old credentials, try them SYNCHRONOUSLY - // This ensures OTA/web/NTP can initialize properly after WiFi is connected - if (strlen(config.ssid) > 0) { - Serial.println("Found saved credentials, connecting synchronously..."); - WiFi.mode(WIFI_STA); - WiFi.begin(config.ssid, config.password); - - // 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 config - safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid)); - saveConfig(); - - // Show IP on display - showIP(); - - wifiConnState = WIFI_CONN_CONNECTED; - return; // Success! - } else { - Serial.println("\n⚠️ WiFi connection failed after 10 seconds"); - // Clear old credentials and fall through to WiFiManager - config.ssid[0] = '\0'; - config.password[0] = '\0'; - saveConfig(); - } - } - - // No v1.7 credentials - use WiFiManager (blocking, but only on first boot) - Serial.println("No 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()); - Serial.print("Gateway: "); - Serial.println(WiFi.gatewayIP()); - Serial.print("DNS: "); - Serial.println(WiFi.dnsIP()); - - // 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; // Mark as handled + // 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 @@ -866,7 +942,7 @@ void ICACHE_FLASH_ATTR setupWebServer() { // Update OLED display with current time // BLUE zone (top 48px): Large time -// YELLOW zone (bottom 16px): Date +// YELLOW zone (bottom 16px): Date + WiFi status void updateDisplay() { static unsigned long lastUpdate = 0; @@ -876,17 +952,29 @@ void updateDisplay() { display.clearDisplay(); - if (!timeClient.isTimeSet()) { + // 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; } - // Calculate local time with DST - unsigned long epochTime = timeClient.getEpochTime(); + // Get epoch from best available source + unsigned long epochTime = timeIsSynced ? getAsyncEpoch() : timeClient.getEpochTime(); unsigned long localTime = epochTime + getTotalOffset(epochTime); int hours = (localTime / 3600) % 24; @@ -903,10 +991,16 @@ void updateDisplay() { time_t t = epochTime; struct tm *ptm = gmtime(&t); - // Format: "Thu 02.01" + // Format: "Thu 02.01" or "! Thu 02.01" if no WiFi const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; - char dateStr[16]; - sprintf(dateStr, "%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1); + 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 @@ -1109,6 +1203,28 @@ void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros) { 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