feat(wifi): v1.9.2 - WiFi resilience and credential persistence (#1)

## Problem
Previous versions would clear WiFi credentials on connection failure,
requiring manual reconfiguration via captive portal after each WiFi outage.

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

## Changes

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

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

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

Co-authored-by: Alex Petrochenko <petrochenko@life-pay.ru>
This commit is contained in:
Alex Petrochenko
2026-01-06 20:51:42 +00:00
committed by GitHub
co-authored by Alex Petrochenko
parent 0f2860c25e
commit c3bfaa26bc
4 changed files with 2487 additions and 230 deletions
-73
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
build/
*.bak
*.bak2
File diff suppressed because it is too large Load Diff
+273 -157
View File
@@ -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): * Version 1.9.1 Changes (HYBRID ASYNC FIX):
* - FIXED STARTUP: WiFi now synchronous in setup() for proper initialization * - FIXED STARTUP: WiFi now synchronous in setup() for proper initialization
@@ -69,7 +76,7 @@
// Configuration structure with validation // Configuration structure with validation
#define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data #define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data
#define FIRMWARE_VERSION "1.9.1" #define FIRMWARE_VERSION "1.9.2"
struct Config { struct Config {
uint32_t magic = CONFIG_MAGIC; // Magic number for validation 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 setupWebServer();
void ICACHE_FLASH_ATTR clearDisplay(); void ICACHE_FLASH_ATTR clearDisplay();
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros); 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 showStartupAnimation();
void ICACHE_FLASH_ATTR showIP(); void ICACHE_FLASH_ATTR showIP();
void ICACHE_FLASH_ATTR handleRoot(); void ICACHE_FLASH_ATTR handleRoot();
@@ -200,12 +208,14 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
// Exponential backoff retry configuration // Exponential backoff retry configuration
struct RetryConfig { struct RetryConfig {
uint8_t maxRetries = 3; uint8_t maxRetries = 3; // For NTP/Weather: give up after 3 tries
uint8_t currentRetry = 0; uint8_t currentRetry = 0;
unsigned long nextRetryTime = 0; unsigned long nextRetryTime = 0;
unsigned long maxBackoffMs = 8000; // Max backoff 8 seconds for NTP/Weather
unsigned long getBackoffDelay() { 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() { 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 // Async HTTP client for non-blocking weather fetch
AsyncHTTPRequest weatherRequest; 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 // Retry configurations with exponential backoff
RetryConfig ntpRetry; RetryConfig ntpRetry;
RetryConfig weatherRetry; RetryConfig weatherRetry;
WiFiRetryConfig wifiRetry;
// Web server // Web server
ESP8266WebServer server(80); ESP8266WebServer server(80);
@@ -398,20 +436,45 @@ void loop() {
server.handleClient(); server.handleClient();
MDNS.update(); MDNS.update();
// WiFi reconnection logic (async, non-blocking) // WiFi reconnection logic (async, non-blocking with exponential backoff)
// If WiFi disconnects during operation, try to reconnect asynchronously
static unsigned long lastWiFiCheck = 0; 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) { if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
Serial.println("⚠️ WiFi disconnected, attempting async reconnect..."); Serial.println("⚠️ WiFi disconnected!");
WiFi.begin(); // Try to reconnect with saved credentials 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; wifiConnState = WIFI_CONN_CONNECTING;
wifiConnectStart = millis(); wifiConnectStart = millis();
} }
lastWiFiCheck = millis();
} }
// Process async WiFi reconnection (only if reconnecting, not during initial setup) // Process async WiFi reconnection
processWiFiConnection(); processWiFiConnection();
// Process async NTP response (non-blocking check) // Process async NTP response (non-blocking check)
@@ -635,7 +698,152 @@ void processWiFiConnection() {
// Check connection status // Check connection status
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
wifiConnState = WIFI_CONN_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.print("SSID: ");
Serial.println(WiFi.SSID()); Serial.println(WiFi.SSID());
Serial.print("IP: "); Serial.print("IP: ");
@@ -647,147 +855,15 @@ void processWiFiConnection() {
// Show IP on display // Show IP on display
showIP(); showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return; return;
} }
// Check timeout // We have credentials but WiFi is not available - will retry in loop()
if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) { Serial.println("\n⚠️ WiFi not available. Will retry in background.");
wifiConnState = WIFI_CONN_FAILED; wifiConnState = WIFI_CONN_FAILED;
Serial.println("\n⚠️ WiFi async connection timeout"); wifiRetry.scheduleRetry();
Serial.println("Falling back to WiFiManager..."); showNoWiFi(wifiRetry.getBackoffDelay() / 1000);
// 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
} }
// OTA setup // OTA setup
@@ -866,7 +942,7 @@ void ICACHE_FLASH_ATTR setupWebServer() {
// Update OLED display with current time // Update OLED display with current time
// BLUE zone (top 48px): Large time // BLUE zone (top 48px): Large time
// YELLOW zone (bottom 16px): Date // YELLOW zone (bottom 16px): Date + WiFi status
void updateDisplay() { void updateDisplay() {
static unsigned long lastUpdate = 0; static unsigned long lastUpdate = 0;
@@ -876,17 +952,29 @@ void updateDisplay() {
display.clearDisplay(); 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.setTextSize(3);
display.setTextColor(SSD1306_WHITE); display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 24); display.setCursor(20, 24);
display.println("--:--"); 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(); display.display();
return; return;
} }
// Calculate local time with DST // Get epoch from best available source
unsigned long epochTime = timeClient.getEpochTime(); unsigned long epochTime = timeIsSynced ? getAsyncEpoch() : timeClient.getEpochTime();
unsigned long localTime = epochTime + getTotalOffset(epochTime); unsigned long localTime = epochTime + getTotalOffset(epochTime);
int hours = (localTime / 3600) % 24; int hours = (localTime / 3600) % 24;
@@ -903,10 +991,16 @@ void updateDisplay() {
time_t t = epochTime; time_t t = epochTime;
struct tm *ptm = gmtime(&t); 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"}; const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
char dateStr[16]; char dateStr[20];
sprintf(dateStr, "%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
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) // Center in yellow zone (Y: 48-63)
int dateWidth = strlen(dateStr) * 12; // Size 2 = ~12px per char 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(); 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) // Dummy function for compatibility (not needed for OLED)
void displaySegments(const uint8_t segments[]) { void displaySegments(const uint8_t segments[]) {
// Not used with OLED - kept for compatibility // Not used with OLED - kept for compatibility