4 Commits
Author SHA1 Message Date
Alex Petrochenko e9dc158a4e chore: bump version to 1.9.6, update CHANGELOG 2026-05-18 16:02:53 +01:00
Alex Petrochenko 7ebe341c06 fix(web): clamp lastError before snprintf to prevent malformed JSON (Gemini review) 2026-05-18 15:57:41 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 78bbd96f45 fix: C1 C2 H1 H2 H3 — stability fixes for long-running operation
C1 (critical): Add 15s watchdog for WEATHER_REQUESTING state — resets to
IDLE if TCP connection hangs silently, preventing permanent weather death

C2 (critical): Fix millis() rollover in all retry timers — replace unsafe
`millis() >= nextRetryTime` with subtraction-safe `(millis() - nextRetryTime)
< 0x80000000UL` in RetryConfig and WiFiRetryConfig; fix boot guard with
static flag instead of raw millis() comparison

H1 (high): Fix DST last-Sunday formula — was using incorrect year-only
heuristic; now derives weekday of the 31st from current day's tm_wday:
`weekdayOf31 = (tm_wday + (31 - day)) % 7`. Verified: March 2026 = 29th ✓

H2 (high): Replace String+= with snprintf+sendContent in handleAPITime,
handleAPIStatus, handleAPIDebug, handleAPIWeather — eliminates permanent
heap fragmentation from JS polling every second

H3 (high): Add volatile to weatherState and ntpState — shared between
ESPAsyncTCP callbacks and main loop; prevents stale register-cached reads

RAM: 37,268 bytes (46%) — reduced from 37,560 due to String elimination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:53:39 +01:00
Alex Petrochenko eeec0155c3 chore: ignore internal BACKLOG files 2026-05-18 15:25:34 +01:00
8 changed files with 140 additions and 62 deletions
+3
View File
@@ -25,3 +25,6 @@ build/
# Secrets (in case someone accidentally commits credentials) # Secrets (in case someone accidentally commits credentials)
secrets.h secrets.h
config_local.h config_local.h
# Internal backlogs (not for public repo)
BACKLOG_*.md
+23
View File
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.9.6] - 2026-05-18
### Fixed
- **Weather hangs permanently after TCP timeout** (C1): `WEATHER_REQUESTING` state now has a
15-second watchdog — if the HTTP callback never fires (NAT timeout, server half-close),
state resets to IDLE and retry logic resumes
- **All retry timers freeze at ~49 days** (C2): Replaced unsafe `millis() >= nextRetryTime`
with subtraction-safe `(millis() - nextRetryTime) < 0x80000000UL` in RetryConfig and
WiFiRetryConfig; fixed boot guard with static flag
- **DST switches on wrong day** (H1): Last-Sunday formula was using year-only heuristic;
now computes weekday of the 31st from current `tm_wday`: verified correct for 2026–2030
- **Heap fragmentation from web API polling** (H2): Replaced String+= concatenation with
`snprintf`+`sendContent` in `handleAPITime`, `handleAPIStatus`, `handleAPIDebug`,
`handleAPIWeather` — eliminates permanent heap fragmentation from 1s JS polling
- **Race condition on shared state** (H3): Added `volatile` to `weatherState` and `ntpState`
— prevents compiler from caching stale values across ESPAsyncTCP callback boundaries
- **Malformed JSON on long error messages**: `lastError` clamped to 79 chars before snprintf
### Changed
- RAM usage: 37,268 bytes (46%) — down from 37,560 due to String elimination in API handlers
## [1.9.5] - 2026-05-18 ## [1.9.5] - 2026-05-18
### Fixed ### Fixed
+5 -3
View File
@@ -9,7 +9,7 @@
#include <Arduino.h> #include <Arduino.h>
// Firmware version // Firmware version
#define FIRMWARE_VERSION "1.9.5" #define FIRMWARE_VERSION "1.9.6"
// OLED I2C Configuration // OLED I2C Configuration
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED! #define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
@@ -70,7 +70,8 @@ struct RetryConfig {
} }
bool isRetryTime() { bool isRetryTime() {
return nextRetryTime > 0 && millis() >= nextRetryTime; // Subtraction-safe: works correctly across millis() rollover at ~49.7 days
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
} }
void reset() { void reset() {
@@ -101,7 +102,8 @@ struct WiFiRetryConfig {
} }
bool isRetryTime() { bool isRetryTime() {
return nextRetryTime > 0 && millis() >= nextRetryTime; // Subtraction-safe: works correctly across millis() rollover at ~49.7 days
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
} }
void reset() { void reset() {
+4 -3
View File
@@ -29,9 +29,9 @@ extern NTPClient timeClient;
extern ESP8266WebServer server; extern ESP8266WebServer server;
extern ESP8266HTTPUpdateServer httpUpdater; extern ESP8266HTTPUpdateServer httpUpdater;
// State machines // State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
extern WeatherState weatherState; extern volatile WeatherState weatherState;
extern NTPState ntpState; extern volatile NTPState ntpState;
extern WiFiConnectionState wifiConnState; extern WiFiConnectionState wifiConnState;
// Retry configurations // Retry configurations
@@ -71,6 +71,7 @@ extern SunTimes sunTimes;
extern uint8_t displayMode; extern uint8_t displayMode;
extern unsigned long lastModeSwitch; extern unsigned long lastModeSwitch;
extern unsigned long lastWeatherUpdate; extern unsigned long lastWeatherUpdate;
extern unsigned long weatherRequestStart;
// Dissolve transition state // Dissolve transition state
extern bool inTransition; extern bool inTransition;
+5 -2
View File
@@ -26,7 +26,9 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
// March: DST starts last Sunday at 01:00 UTC // March: DST starts last Sunday at 01:00 UTC
if (month == 3) { if (month == 3) {
int lastSunday = 31 - ((5 + timeinfo->tm_year) % 7); // Compute weekday of the 31st from current day's weekday (tm_wday: 0=Sun)
int weekdayOf31 = (timeinfo->tm_wday + (31 - day)) % 7;
int lastSunday = 31 - weekdayOf31;
if (day < lastSunday) return false; if (day < lastSunday) return false;
if (day > lastSunday) return true; if (day > lastSunday) return true;
if (hour < 1) return false; if (hour < 1) return false;
@@ -35,7 +37,8 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
// October: DST ends last Sunday at 01:00 UTC // October: DST ends last Sunday at 01:00 UTC
if (month == 10) { if (month == 10) {
int lastSunday = 31 - ((1 + timeinfo->tm_year) % 7); int weekdayOf31 = (timeinfo->tm_wday + (31 - day)) % 7;
int lastSunday = 31 - weekdayOf31;
if (day < lastSunday) return true; if (day < lastSunday) return true;
if (day > lastSunday) return false; if (day > lastSunday) return false;
if (hour < 1) return true; if (hour < 1) return true;
+1
View File
@@ -156,6 +156,7 @@ void ICACHE_FLASH_ATTR fetchWeatherAsync() {
weatherRequest.setTimeout(10); // 10 seconds weatherRequest.setTimeout(10); // 10 seconds
weatherRequest.send(); weatherRequest.send();
weatherState = WEATHER_REQUESTING; weatherState = WEATHER_REQUESTING;
weatherRequestStart = millis();
Serial.println("Weather request sent (non-blocking)"); Serial.println("Weather request sent (non-blocking)");
} else { } else {
weatherState = WEATHER_FAILED; weatherState = WEATHER_FAILED;
+15 -5
View File
@@ -50,9 +50,9 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
ESP8266WebServer server(80); ESP8266WebServer server(80);
ESP8266HTTPUpdateServer httpUpdater; ESP8266HTTPUpdateServer httpUpdater;
// State machines // State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
WeatherState weatherState = WEATHER_IDLE; volatile WeatherState weatherState = WEATHER_IDLE;
NTPState ntpState = NTP_IDLE; volatile NTPState ntpState = NTP_IDLE;
WiFiConnectionState wifiConnState = WIFI_CONN_IDLE; WiFiConnectionState wifiConnState = WIFI_CONN_IDLE;
// Retry configurations // Retry configurations
@@ -92,6 +92,7 @@ SunTimes sunTimes;
uint8_t displayMode = 0; uint8_t displayMode = 0;
unsigned long lastModeSwitch = 0; unsigned long lastModeSwitch = 0;
unsigned long lastWeatherUpdate = 0; unsigned long lastWeatherUpdate = 0;
unsigned long weatherRequestStart = 0; // Tracks when WEATHER_REQUESTING began (TCP hang watchdog)
// Dissolve transition state // Dissolve transition state
bool inTransition = false; bool inTransition = false;
@@ -311,14 +312,23 @@ void loop() {
} }
} }
// Watchdog: reset if WEATHER_REQUESTING stuck >15s (TCP hang / half-open connection)
if (weatherState == WEATHER_REQUESTING && (millis() - weatherRequestStart) > 15000UL) {
Serial.println("Weather request timeout (TCP hang) — resetting state");
weatherState = WEATHER_IDLE;
weatherRetry.scheduleRetry();
}
// Check for weather retry // Check for weather retry
if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) { if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) {
Serial.println("Weather retry time reached, attempting retry..."); Serial.println("Weather retry time reached, attempting retry...");
fetchWeatherAsync(); fetchWeatherAsync();
} }
// Update weather periodically // Update weather periodically (static flag avoids millis() > 10000 rollover trap)
if (config.weather_enabled && millis() > 10000) { static bool weatherBootReady = false;
if (!weatherBootReady && millis() > 10000UL) weatherBootReady = true;
if (config.weather_enabled && weatherBootReady) {
unsigned long weatherInterval = config.weather_interval * 1000UL; unsigned long weatherInterval = config.weather_interval * 1000UL;
if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) { if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) {
if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) { if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) {
+84 -49
View File
@@ -337,68 +337,103 @@ void ICACHE_FLASH_ATTR handleConfigSave() {
} }
void ICACHE_FLASH_ATTR handleAPITime() { void ICACHE_FLASH_ATTR handleAPITime() {
String json = "{"; char buf[256];
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); server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf),
PSTR("{\"time\":\"%s\",\"hours\":%d,\"minutes\":%d,\"seconds\":%d,\"epoch\":%lu}"),
timeClient.getFormattedTime().c_str(),
timeClient.getHours(),
timeClient.getMinutes(),
timeClient.getSeconds(),
timeClient.getEpochTime());
server.sendContent(buf);
server.sendContent("");
} }
void ICACHE_FLASH_ATTR handleAPIStatus() { void ICACHE_FLASH_ATTR handleAPIStatus() {
String json = "{"; char buf[256];
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); server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf),
PSTR("{\"wifi\":{\"ssid\":\"%s\",\"ip\":\"%s\",\"rssi\":%d,\"hostname\":\"%s\"},"),
WiFi.SSID().c_str(),
WiFi.localIP().toString().c_str(),
WiFi.RSSI(),
config.hostname);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf),
PSTR("\"time\":{\"current\":\"%s\",\"timezone_offset\":%ld,\"ntp_synced\":%s},"),
timeClient.getFormattedTime().c_str(),
config.timezone_offset,
timeClient.isTimeSet() ? "true" : "false");
server.sendContent(buf);
snprintf_P(buf, sizeof(buf),
PSTR("\"system\":{\"uptime\":%lu,\"free_heap\":%u,\"chip_id\":\"%x\"}}"),
millis() / 1000,
ESP.getFreeHeap(),
ESP.getChipId());
server.sendContent(buf);
server.sendContent("");
} }
void ICACHE_FLASH_ATTR handleAPIDebug() { void ICACHE_FLASH_ATTR handleAPIDebug() {
String json = "{"; char buf[256];
json += "\"internet_connected\":" + String(internetConnected ? "true" : "false") + ","; char errBuf[80];
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); server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
// Clamp lastError to prevent snprintf truncation causing malformed JSON
strncpy(errBuf, lastError.c_str(), sizeof(errBuf) - 1);
errBuf[sizeof(errBuf) - 1] = '\0';
snprintf_P(buf, sizeof(buf),
PSTR("{\"internet_connected\":%s,\"ntp_attempts\":%d,\"ntp_successes\":%d,\"last_error\":\"%s\",\"gateway\":\"%s\",\"dns\":\"%s\"}"),
internetConnected ? "true" : "false",
ntpAttempts,
ntpSuccesses,
errBuf,
WiFi.gatewayIP().toString().c_str(),
WiFi.dnsIP().toString().c_str());
server.sendContent(buf);
server.sendContent("");
} }
void ICACHE_FLASH_ATTR handleAPIWeather() { void ICACHE_FLASH_ATTR handleAPIWeather() {
String json = "{"; char buf[256];
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); server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf),
PSTR("{\"enabled\":%s,\"valid\":%s,\"temperature\":%.1f,\"weathercode\":%d,\"windspeed\":%.1f,\"last_update\":%lu,"),
config.weather_enabled ? "true" : "false",
weather.valid ? "true" : "false",
weather.temperature,
weather.weathercode,
weather.windspeed,
weather.lastUpdate);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf),
PSTR("\"sunrise\":\"%s\",\"sunset\":\"%s\",\"sunrise_minutes\":%d,\"sunset_minutes\":%d}"),
sunTimes.sunrise,
sunTimes.sunset,
sunTimes.sunriseMinutes,
sunTimes.sunsetMinutes);
server.sendContent(buf);
server.sendContent("");
} }
void ICACHE_FLASH_ATTR handleAPIConfigExport() { void ICACHE_FLASH_ATTR handleAPIConfigExport() {