Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9dc158a4e | ||
|
|
7ebe341c06 | ||
|
|
78bbd96f45 | ||
|
|
eeec0155c3 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user