/* * 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!"); }