Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b9285aeb9 | ||
|
|
02834bada1 | ||
|
|
11aab761c3 | ||
|
|
5b8a58715e | ||
|
|
e9dc158a4e | ||
|
|
7ebe341c06 | ||
|
|
78bbd96f45 | ||
|
|
eeec0155c3 | ||
|
|
d5d2b23129 | ||
|
|
2f3814c225 |
@@ -25,3 +25,6 @@ build/
|
||||
# Secrets (in case someone accidentally commits credentials)
|
||||
secrets.h
|
||||
config_local.h
|
||||
|
||||
# Internal backlogs (not for public repo)
|
||||
BACKLOG_*.md
|
||||
|
||||
@@ -5,6 +5,49 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.9.7] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Config endpoint accepted garbage values, bricking device** (M1): `/config` form handler
|
||||
now validates all inputs. SSID rejected if empty, >31 chars, non-printable, or all-same-char
|
||||
(fuzz garbage like "AAAA..."). Numeric fields are `constrain()`-ed to safe ranges
|
||||
(`ntp_interval`/`weather_interval`: 60–86400, `brightness`: 0–7, `timezone`: ±12h,
|
||||
`latitude`/`longitude`: physical ranges, `display_orientation`: 0–3). Invalid input
|
||||
returns HTTP 400 instead of silently saving and rebooting.
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Temperature disappears permanently** (#9): After 3 consecutive API failures,
|
||||
the weather state machine was permanently locked — `weatherState` stayed `WEATHER_FAILED`
|
||||
and no new requests were ever made even after the API recovered. Fix: reset retry
|
||||
counter and state after max retries so periodic refresh resumes after next interval (30 min).
|
||||
|
||||
## [1.9.4] - 2026-05-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
// Firmware version
|
||||
#define FIRMWARE_VERSION "1.9.4"
|
||||
#define FIRMWARE_VERSION "1.9.7"
|
||||
|
||||
// OLED I2C Configuration
|
||||
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
|
||||
@@ -70,7 +70,8 @@ struct RetryConfig {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -101,7 +102,8 @@ struct WiFiRetryConfig {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -165,4 +167,16 @@ const unsigned long NTP_TIMEOUT_MS = 5000; // 5 second timeout
|
||||
// WiFi timeout
|
||||
const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second timeout
|
||||
|
||||
// Triple power-cycle factory reset
|
||||
// Counter stored at EEPROM offset 480, well past Config (~260 bytes)
|
||||
#define RESET_COUNTER_ADDR 480
|
||||
#define RESET_COUNTER_MAGIC 0xA5
|
||||
#define RESET_COUNTER_WINDOW 10000UL // 10s: if device runs longer, counter clears
|
||||
#define RESET_COUNTER_TRIPS 3 // 3 quick power cycles = factory reset
|
||||
|
||||
struct ResetCounter {
|
||||
uint8_t magic;
|
||||
uint8_t count;
|
||||
};
|
||||
|
||||
#endif // CONFIG_H
|
||||
|
||||
@@ -29,9 +29,9 @@ extern NTPClient timeClient;
|
||||
extern ESP8266WebServer server;
|
||||
extern ESP8266HTTPUpdateServer httpUpdater;
|
||||
|
||||
// State machines
|
||||
extern WeatherState weatherState;
|
||||
extern NTPState ntpState;
|
||||
// State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
|
||||
extern volatile WeatherState weatherState;
|
||||
extern volatile NTPState ntpState;
|
||||
extern WiFiConnectionState wifiConnState;
|
||||
|
||||
// Retry configurations
|
||||
@@ -71,6 +71,7 @@ extern SunTimes sunTimes;
|
||||
extern uint8_t displayMode;
|
||||
extern unsigned long lastModeSwitch;
|
||||
extern unsigned long lastWeatherUpdate;
|
||||
extern unsigned long weatherRequestStart;
|
||||
|
||||
// Dissolve transition state
|
||||
extern bool inTransition;
|
||||
|
||||
@@ -26,7 +26,9 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
|
||||
|
||||
// March: DST starts last Sunday at 01:00 UTC
|
||||
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 true;
|
||||
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
|
||||
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 false;
|
||||
if (hour < 1) return true;
|
||||
|
||||
@@ -96,7 +96,10 @@ void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* reques
|
||||
|
||||
weatherRetry.scheduleRetry();
|
||||
if (weatherRetry.maxRetriesReached()) {
|
||||
Serial.println("Weather max retries reached");
|
||||
// Reset so periodic refresh can retry after weatherInterval — prevents permanent lockup
|
||||
Serial.println("Weather max retries reached, resetting for next interval");
|
||||
weatherRetry.reset();
|
||||
weatherState = WEATHER_IDLE;
|
||||
} else {
|
||||
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
|
||||
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
||||
@@ -113,7 +116,10 @@ void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* reques
|
||||
|
||||
weatherRetry.scheduleRetry();
|
||||
if (weatherRetry.maxRetriesReached()) {
|
||||
Serial.println("Weather max retries reached");
|
||||
// Reset so periodic refresh can retry after weatherInterval — prevents permanent lockup
|
||||
Serial.println("Weather max retries reached, resetting for next interval");
|
||||
weatherRetry.reset();
|
||||
weatherState = WEATHER_IDLE;
|
||||
} else {
|
||||
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
|
||||
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
||||
@@ -150,6 +156,7 @@ void ICACHE_FLASH_ATTR fetchWeatherAsync() {
|
||||
weatherRequest.setTimeout(10); // 10 seconds
|
||||
weatherRequest.send();
|
||||
weatherState = WEATHER_REQUESTING;
|
||||
weatherRequestStart = millis();
|
||||
Serial.println("Weather request sent (non-blocking)");
|
||||
} else {
|
||||
weatherState = WEATHER_FAILED;
|
||||
|
||||
@@ -50,9 +50,9 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
|
||||
ESP8266WebServer server(80);
|
||||
ESP8266HTTPUpdateServer httpUpdater;
|
||||
|
||||
// State machines
|
||||
WeatherState weatherState = WEATHER_IDLE;
|
||||
NTPState ntpState = NTP_IDLE;
|
||||
// State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
|
||||
volatile WeatherState weatherState = WEATHER_IDLE;
|
||||
volatile NTPState ntpState = NTP_IDLE;
|
||||
WiFiConnectionState wifiConnState = WIFI_CONN_IDLE;
|
||||
|
||||
// Retry configurations
|
||||
@@ -92,6 +92,7 @@ SunTimes sunTimes;
|
||||
uint8_t displayMode = 0;
|
||||
unsigned long lastModeSwitch = 0;
|
||||
unsigned long lastWeatherUpdate = 0;
|
||||
unsigned long weatherRequestStart = 0; // Tracks when WEATHER_REQUESTING began (TCP hang watchdog)
|
||||
|
||||
// Dissolve transition state
|
||||
bool inTransition = false;
|
||||
@@ -109,6 +110,64 @@ void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxL
|
||||
dest[maxLen - 1] = '\0';
|
||||
}
|
||||
|
||||
// ============ Triple power-cycle factory reset ============
|
||||
//
|
||||
// How it works: on each boot we increment a counter in EEPROM.
|
||||
// If the device runs for >10s the counter is cleared back to 0.
|
||||
// 3 quick power cycles before the 10s window = factory reset:
|
||||
// clears WiFi credentials, reboots into WiFiManager AP mode.
|
||||
|
||||
void ICACHE_FLASH_ATTR checkFactoryReset() {
|
||||
EEPROM.begin(512);
|
||||
ResetCounter rc;
|
||||
EEPROM.get(RESET_COUNTER_ADDR, rc);
|
||||
|
||||
if (rc.magic != RESET_COUNTER_MAGIC) {
|
||||
rc.magic = RESET_COUNTER_MAGIC;
|
||||
rc.count = 0;
|
||||
}
|
||||
|
||||
rc.count++;
|
||||
Serial.printf("Boot counter: %d/%d (power-cycle %d more times within 10s to factory reset)\n",
|
||||
rc.count, RESET_COUNTER_TRIPS, RESET_COUNTER_TRIPS - rc.count);
|
||||
|
||||
if (rc.count >= RESET_COUNTER_TRIPS) {
|
||||
Serial.println("!!! FACTORY RESET triggered !!!");
|
||||
rc.count = 0;
|
||||
EEPROM.put(RESET_COUNTER_ADDR, rc);
|
||||
EEPROM.commit();
|
||||
EEPROM.end();
|
||||
|
||||
// Clear WiFi credentials only — keep other settings
|
||||
memset(config.ssid, 0, sizeof(config.ssid));
|
||||
memset(config.password, 0, sizeof(config.password));
|
||||
saveConfig();
|
||||
|
||||
// Show reset screen
|
||||
display.clearDisplay();
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setTextSize(2);
|
||||
display.setCursor(8, 4);
|
||||
display.println("FACTORY");
|
||||
display.setCursor(8, 24);
|
||||
display.println("RESET!");
|
||||
display.setTextSize(1);
|
||||
display.setCursor(2, 48);
|
||||
display.println("WiFi: TJ56654-Setup");
|
||||
display.setCursor(2, 57);
|
||||
display.println("Pass: 12345678");
|
||||
display.display();
|
||||
|
||||
delay(4000);
|
||||
ESP.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
EEPROM.put(RESET_COUNTER_ADDR, rc);
|
||||
EEPROM.commit();
|
||||
EEPROM.end();
|
||||
}
|
||||
|
||||
// ============ EEPROM functions ============
|
||||
|
||||
void ICACHE_FLASH_ATTR loadConfig() {
|
||||
@@ -218,6 +277,9 @@ void setup() {
|
||||
// Load configuration
|
||||
loadConfig();
|
||||
|
||||
// Check for triple power-cycle factory reset (must be after display+config init)
|
||||
checkFactoryReset();
|
||||
|
||||
// Setup WiFi
|
||||
setupWiFi();
|
||||
|
||||
@@ -270,16 +332,29 @@ void loop() {
|
||||
|
||||
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
|
||||
Serial.println("Enabling fallback AP (dual mode)");
|
||||
// Must set mode BEFORE WiFi.begin() — begin() resets mode to STA killing the AP
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
WiFi.softAP("TJ56654-Setup", "12345678");
|
||||
Serial.print("Fallback AP IP: ");
|
||||
Serial.println(WiFi.softAPIP());
|
||||
}
|
||||
|
||||
// Reconnect STA side without changing mode (preserves AP_STA if active)
|
||||
if (WiFi.getMode() == WIFI_AP_STA) {
|
||||
// Use low-level reconnect to keep AP alive
|
||||
WiFi.disconnect(false);
|
||||
if (strlen(config.password) > 0) {
|
||||
WiFi.begin(config.ssid, config.password);
|
||||
} else {
|
||||
WiFi.begin();
|
||||
WiFi.begin(config.ssid);
|
||||
}
|
||||
WiFi.mode(WIFI_AP_STA); // Restore AP_STA after begin() may have reset it
|
||||
} else {
|
||||
if (strlen(config.password) > 0) {
|
||||
WiFi.begin(config.ssid, config.password);
|
||||
} else {
|
||||
WiFi.begin(config.ssid);
|
||||
}
|
||||
}
|
||||
wifiConnState = WIFI_CONN_CONNECTING;
|
||||
wifiConnectStart = millis();
|
||||
@@ -311,14 +386,35 @@ 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
|
||||
if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) {
|
||||
Serial.println("Weather retry time reached, attempting retry...");
|
||||
fetchWeatherAsync();
|
||||
}
|
||||
|
||||
// Update weather periodically
|
||||
if (config.weather_enabled && millis() > 10000) {
|
||||
// Clear factory-reset boot counter after 10s of normal operation
|
||||
static bool resetCounterCleared = false;
|
||||
if (!resetCounterCleared && millis() > RESET_COUNTER_WINDOW) {
|
||||
EEPROM.begin(512);
|
||||
ResetCounter rc = { RESET_COUNTER_MAGIC, 0 };
|
||||
EEPROM.put(RESET_COUNTER_ADDR, rc);
|
||||
EEPROM.commit();
|
||||
EEPROM.end();
|
||||
resetCounterCleared = true;
|
||||
Serial.println("Boot counter cleared — stable operation confirmed");
|
||||
}
|
||||
|
||||
// Update weather periodically (static flag avoids millis() > 10000 rollover trap)
|
||||
static bool weatherBootReady = false;
|
||||
if (!weatherBootReady && millis() > 10000UL) weatherBootReady = true;
|
||||
if (config.weather_enabled && weatherBootReady) {
|
||||
unsigned long weatherInterval = config.weather_interval * 1000UL;
|
||||
if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) {
|
||||
if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) {
|
||||
|
||||
@@ -283,39 +283,90 @@ void ICACHE_FLASH_ATTR handleConfig() {
|
||||
server.sendContent("");
|
||||
}
|
||||
|
||||
// Validate SSID: 1-31 printable ASCII chars, not all-same-char (likely fuzz garbage)
|
||||
static bool isValidSSID(const String& s) {
|
||||
size_t n = s.length();
|
||||
if (n == 0 || n > 31) return false;
|
||||
char first = s[0];
|
||||
bool allSame = true;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
char c = s[i];
|
||||
if (c < 0x20 || c > 0x7E) return false; // non-printable
|
||||
if (c != first) allSame = false;
|
||||
}
|
||||
return !allSame; // reject "AAAAA...", "BBBBB...", etc.
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleConfigSave() {
|
||||
// Validate before saving — reject obviously bad input rather than brick the device
|
||||
if (server.hasArg("ssid")) {
|
||||
safeStringCopy(server.arg("ssid"), config.ssid, sizeof(config.ssid));
|
||||
String s = server.arg("ssid");
|
||||
if (!isValidSSID(s)) {
|
||||
server.send(400, "text/plain", "Invalid SSID (1-31 printable chars, not all-same)");
|
||||
return;
|
||||
}
|
||||
safeStringCopy(s, config.ssid, sizeof(config.ssid));
|
||||
}
|
||||
if (server.hasArg("password")) {
|
||||
safeStringCopy(server.arg("password"), config.password, sizeof(config.password));
|
||||
String p = server.arg("password");
|
||||
if (p.length() > 63) {
|
||||
server.send(400, "text/plain", "Password too long (max 63 chars)");
|
||||
return;
|
||||
}
|
||||
safeStringCopy(p, config.password, sizeof(config.password));
|
||||
}
|
||||
if (server.hasArg("timezone")) {
|
||||
config.timezone_offset = server.arg("timezone").toInt();
|
||||
long tz = server.arg("timezone").toInt();
|
||||
config.timezone_offset = constrain(tz, -43200L, 43200L); // ±12h
|
||||
}
|
||||
if (server.hasArg("brightness")) {
|
||||
config.brightness = server.arg("brightness").toInt();
|
||||
config.brightness = constrain(server.arg("brightness").toInt(), 0, 7);
|
||||
}
|
||||
if (server.hasArg("hostname")) {
|
||||
safeStringCopy(server.arg("hostname"), config.hostname, sizeof(config.hostname));
|
||||
String h = server.arg("hostname");
|
||||
if (h.length() == 0 || h.length() > 31) {
|
||||
server.send(400, "text/plain", "Invalid hostname length (1-31)");
|
||||
return;
|
||||
}
|
||||
safeStringCopy(h, config.hostname, sizeof(config.hostname));
|
||||
}
|
||||
if (server.hasArg("city_name")) {
|
||||
safeStringCopy(server.arg("city_name"), config.city_name, sizeof(config.city_name));
|
||||
String c = server.arg("city_name");
|
||||
if (c.length() > 31) {
|
||||
server.send(400, "text/plain", "City name too long (max 31)");
|
||||
return;
|
||||
}
|
||||
safeStringCopy(c, config.city_name, sizeof(config.city_name));
|
||||
}
|
||||
if (server.hasArg("latitude")) {
|
||||
config.latitude = server.arg("latitude").toFloat();
|
||||
float lat = server.arg("latitude").toFloat();
|
||||
config.latitude = constrain(lat, -90.0f, 90.0f);
|
||||
}
|
||||
if (server.hasArg("longitude")) {
|
||||
config.longitude = server.arg("longitude").toFloat();
|
||||
float lon = server.arg("longitude").toFloat();
|
||||
config.longitude = constrain(lon, -180.0f, 180.0f);
|
||||
}
|
||||
if (server.hasArg("weather_interval")) {
|
||||
config.weather_interval = server.arg("weather_interval").toInt();
|
||||
long wi = server.arg("weather_interval").toInt();
|
||||
config.weather_interval = constrain(wi, 60L, 86400L); // 1min to 1day
|
||||
}
|
||||
if (server.hasArg("ntp_interval")) {
|
||||
long ni = server.arg("ntp_interval").toInt();
|
||||
config.ntp_interval = constrain(ni, 60L, 86400L);
|
||||
}
|
||||
if (server.hasArg("ntp_server")) {
|
||||
String n = server.arg("ntp_server");
|
||||
if (n.length() == 0 || n.length() > 63) {
|
||||
server.send(400, "text/plain", "Invalid NTP server (1-63 chars)");
|
||||
return;
|
||||
}
|
||||
safeStringCopy(n, config.ntp_server, sizeof(config.ntp_server));
|
||||
}
|
||||
if (server.hasArg("display_rotation_sec")) {
|
||||
config.display_rotation_sec = server.arg("display_rotation_sec").toInt();
|
||||
config.display_rotation_sec = constrain(server.arg("display_rotation_sec").toInt(), 1, 60);
|
||||
}
|
||||
if (server.hasArg("display_orientation")) {
|
||||
config.display_orientation = server.arg("display_orientation").toInt();
|
||||
config.display_orientation = constrain(server.arg("display_orientation").toInt(), 0, 3);
|
||||
display.setRotation(config.display_orientation);
|
||||
}
|
||||
|
||||
@@ -337,68 +388,103 @@ void ICACHE_FLASH_ATTR handleConfigSave() {
|
||||
}
|
||||
|
||||
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 += "}";
|
||||
char buf[256];
|
||||
|
||||
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() {
|
||||
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 += "}";
|
||||
char buf[256];
|
||||
|
||||
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() {
|
||||
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 += "}";
|
||||
char buf[256];
|
||||
char errBuf[80];
|
||||
|
||||
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() {
|
||||
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 += "}";
|
||||
char buf[256];
|
||||
|
||||
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() {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hardware-in-the-loop test suite for ESP8266 Weather Clock firmware.
|
||||
Runs against a live device via HTTP API.
|
||||
|
||||
Usage:
|
||||
python3 tests/test_device.py [device_ip]
|
||||
python3 tests/test_device.py 192.168.2.47
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
DEVICE_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.2.47"
|
||||
BASE_URL = f"http://{DEVICE_IP}"
|
||||
TIMEOUT = 10
|
||||
|
||||
# Safe config values to restore after fuzz tests
|
||||
SAFE_CONFIG = {
|
||||
"ssid": "", # keep empty — don't overwrite real credentials
|
||||
"ntp_interval": "3600",
|
||||
"weather_interval": "1800",
|
||||
"brightness": "4",
|
||||
"timezone_offset": "0",
|
||||
"latitude": "37.19",
|
||||
"longitude": "-8.54",
|
||||
"hostname": "tj56654-clock",
|
||||
"ntp_server": "pool.ntp.org",
|
||||
"city_name": "Portimao",
|
||||
}
|
||||
|
||||
# Saved before fuzz, restored after
|
||||
_config_backup: dict = {}
|
||||
|
||||
# ─── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def get(path) -> tuple[int, str]:
|
||||
try:
|
||||
r = urllib.request.urlopen(f"{BASE_URL}{path}", timeout=TIMEOUT)
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
def get_json(path) -> tuple[int, dict | None]:
|
||||
status, body = get(path)
|
||||
try:
|
||||
return status, json.loads(body)
|
||||
except Exception:
|
||||
return status, None
|
||||
|
||||
def post_form(path, data: dict) -> tuple[int, str]:
|
||||
encoded = urllib.parse.urlencode(data).encode()
|
||||
req = urllib.request.Request(f"{BASE_URL}{path}", data=encoded, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=TIMEOUT)
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
def post_raw(path, body: bytes, content_type="application/json") -> tuple[int, str]:
|
||||
req = urllib.request.Request(f"{BASE_URL}{path}", data=body, method="POST")
|
||||
req.add_header("Content-Type", content_type)
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=TIMEOUT)
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
# ─── Test runner ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str = ""
|
||||
|
||||
results: list[Result] = []
|
||||
|
||||
def test(name: str, passed: bool, detail: str = ""):
|
||||
r = Result(name, passed, detail)
|
||||
results.append(r)
|
||||
icon = "✅" if passed else "❌"
|
||||
print(f" {icon} {name}" + (f" — {detail}" if detail else ""))
|
||||
return passed
|
||||
|
||||
# ─── Test suites ─────────────────────────────────────────────────────────────
|
||||
|
||||
def suite_connectivity():
|
||||
print("\n📡 Connectivity")
|
||||
status, body = get("/")
|
||||
test("Device reachable", status == 200, f"HTTP {status}")
|
||||
test("Returns HTML", "<html" in body.lower() or "<!DOCTYPE" in body.lower(),
|
||||
f"{len(body)} bytes")
|
||||
test("Version present", "v1.9" in body, body[:80] if body else "empty")
|
||||
|
||||
def suite_api_time():
|
||||
print("\n🕐 /api/time")
|
||||
status, data = get_json("/api/time")
|
||||
test("HTTP 200", status == 200, f"got {status}")
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
test("Has 'current' field", "current" in data, str(data.keys()))
|
||||
if "current" in data:
|
||||
t = data["current"]
|
||||
test("Time format HH:MM:SS", len(t) == 8 and t[2] == ":" and t[5] == ":",
|
||||
f"got '{t}'")
|
||||
h, m, s = int(t[:2]), int(t[3:5]), int(t[6:])
|
||||
test("Hour in 0-23", 0 <= h <= 23, f"h={h}")
|
||||
test("Minute in 0-59", 0 <= m <= 59, f"m={m}")
|
||||
test("Second in 0-59", 0 <= s <= 59, f"s={s}")
|
||||
test("Has 'timezone_offset'", "timezone_offset" in data)
|
||||
test("Has 'ntp_synced'", "ntp_synced" in data)
|
||||
|
||||
def suite_api_weather():
|
||||
print("\n🌤 /api/weather")
|
||||
status, data = get_json("/api/weather")
|
||||
test("HTTP 200", status == 200, f"got {status}")
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
test("enabled=true", data.get("enabled") == True)
|
||||
test("valid=true", data.get("valid") == True, "weather data may not be fetched yet")
|
||||
if data.get("valid"):
|
||||
t = data.get("temperature", 0)
|
||||
test("Temperature sane [-50, 70]", -50 <= t <= 70, f"{t}°C")
|
||||
w = data.get("windspeed", 0)
|
||||
test("Windspeed ≥ 0", w >= 0, f"{w} km/h")
|
||||
wc = data.get("weathercode", -1)
|
||||
test("Weathercode ≥ 0", wc >= 0, f"code={wc}")
|
||||
test("Has sunrise", "sunrise" in data, str(data.get("sunrise")))
|
||||
test("Has sunset", "sunset" in data, str(data.get("sunset")))
|
||||
|
||||
def suite_api_status():
|
||||
print("\n📊 /api/status")
|
||||
status, data = get_json("/api/status")
|
||||
test("HTTP 200", status == 200)
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
|
||||
wifi = data.get("wifi", {})
|
||||
test("Has wifi.ssid", "ssid" in wifi, str(wifi))
|
||||
test("Has wifi.ip", "ip" in wifi)
|
||||
test("Has wifi.rssi", "rssi" in wifi)
|
||||
if "rssi" in wifi:
|
||||
test("RSSI in realistic range [-100, 0]", -100 <= wifi["rssi"] <= 0,
|
||||
f"{wifi['rssi']} dBm")
|
||||
|
||||
sys_ = data.get("system", {})
|
||||
test("Has system.uptime", "uptime" in sys_)
|
||||
test("Has system.free_heap", "free_heap" in sys_)
|
||||
if "free_heap" in sys_:
|
||||
heap = sys_["free_heap"]
|
||||
test("Heap > 8KB (not fragmented)", heap > 8192, f"{heap} bytes free")
|
||||
test("Heap > 20KB (healthy)", heap > 20480, f"{heap} bytes free")
|
||||
|
||||
def suite_api_debug():
|
||||
print("\n🔍 /api/debug")
|
||||
status, data = get_json("/api/debug")
|
||||
test("HTTP 200", status == 200)
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
test("Has internet_connected", "internet_connected" in data)
|
||||
test("internet_connected=true", data.get("internet_connected") == True)
|
||||
test("Has ntp_attempts", "ntp_attempts" in data)
|
||||
test("Has ntp_successes", "ntp_successes" in data)
|
||||
attempts = data.get("ntp_attempts", 0)
|
||||
successes = data.get("ntp_successes", 0)
|
||||
test("NTP attempted at least once", attempts >= 1, f"{attempts} attempts")
|
||||
test("NTP success rate > 0", successes >= 1, f"{successes}/{attempts}")
|
||||
test("last_error field present", "last_error" in data)
|
||||
test("last_error is string", isinstance(data.get("last_error"), str))
|
||||
|
||||
def backup_config():
|
||||
"""Save safe fields before fuzzing."""
|
||||
global _config_backup
|
||||
_, data = get_json("/api/status")
|
||||
_config_backup = {
|
||||
"ntp_interval": "3600",
|
||||
"weather_interval": "1800",
|
||||
"brightness": "4",
|
||||
"timezone_offset": "0",
|
||||
"ntp_server": "pool.ntp.org",
|
||||
"hostname": "tj56654-clock",
|
||||
}
|
||||
print(" 💾 Config backup saved")
|
||||
|
||||
def restore_config():
|
||||
"""Restore safe config after fuzzing — prevents bricking on bad values."""
|
||||
status, _ = post_form("/config", SAFE_CONFIG)
|
||||
time.sleep(1)
|
||||
ok = status in (200, 302)
|
||||
print(f" 🔄 Config restored {'✅' if ok else '❌'} (HTTP {status})")
|
||||
return ok
|
||||
|
||||
def suite_fuzz_config():
|
||||
print("\n🧨 Fuzz: /config boundary values")
|
||||
backup_config()
|
||||
|
||||
# Save current config to restore later
|
||||
_, before = get_json("/api/status")
|
||||
|
||||
cases = [
|
||||
# (description, field, value, expect_no_crash)
|
||||
("ntp_interval=0 (DoS risk)", "ntp_interval", "0", True),
|
||||
("ntp_interval=86401 (over max day)", "ntp_interval", "86401", True),
|
||||
("weather_interval=0", "weather_interval", "0", True),
|
||||
("brightness=255 (uint8 overflow)", "brightness", "255", True),
|
||||
("brightness=-1", "brightness", "-1", True),
|
||||
("timezone_offset=99999999", "timezone_offset", "99999999", True),
|
||||
("timezone_offset=-99999999", "timezone_offset", "-99999999", True),
|
||||
("latitude=999 (invalid)", "latitude", "999", True),
|
||||
("latitude=-999", "latitude", "-999", True),
|
||||
("longitude=999", "longitude", "999", True),
|
||||
("ssid=A*100 (overflow char[32])", "ssid", "A" * 100, True),
|
||||
("password=B*200 (overflow char[64])","password", "B" * 200, True),
|
||||
("hostname=C*100 (overflow char[32])","hostname", "C" * 100, True),
|
||||
("ntp_server=D*200 (overflow char[64])","ntp_server", "D" * 200, True),
|
||||
]
|
||||
|
||||
for desc, field_name, value, expect_survive in cases:
|
||||
status, body = post_form("/config", {field_name: value})
|
||||
survived = status in (200, 302, 400) # any response = not crashed
|
||||
test(desc, survived, f"HTTP {status}")
|
||||
|
||||
# Always restore safe config after fuzzing — critical to prevent bricking
|
||||
restore_config()
|
||||
|
||||
# Verify device still alive after fuzzing
|
||||
time.sleep(1)
|
||||
status, data = get_json("/api/status")
|
||||
test("Device alive after fuzz", status == 200 and data is not None,
|
||||
f"HTTP {status}")
|
||||
|
||||
def suite_fuzz_config_import():
|
||||
print("\n🧨 Fuzz: /api/config import (JSON endpoint)")
|
||||
|
||||
cases = [
|
||||
("Empty body", b""),
|
||||
("Not JSON", b"this is not json at all!!!"),
|
||||
("Partial JSON", b'{"ssid": "test"'),
|
||||
("Null JSON", b"null"),
|
||||
("Array JSON", b"[]"),
|
||||
("Nested bomb", b'{"a":{"b":{"c":{"d":{"e":"f"}}}}}'),
|
||||
("Very large string", json.dumps({"ssid": "X" * 5000}).encode()),
|
||||
("Unicode", '{"ssid": "тест-сеть"}'.encode("utf-8")),
|
||||
("Zero bytes field", json.dumps({"ntp_interval": 0}).encode()),
|
||||
("Negative interval", json.dumps({"weather_interval": -1}).encode()),
|
||||
("NaN float", b'{"latitude": "NaN"}'),
|
||||
("Inf float", b'{"latitude": "Infinity"}'),
|
||||
("SQL-like injection", b'{"ssid": "\'; DROP TABLE config; --"}'),
|
||||
("HTML injection", b'{"ssid": "<script>alert(1)</script>"}'),
|
||||
("Null bytes", b'{"ssid": "test\x00hidden"}'),
|
||||
]
|
||||
|
||||
for desc, body in cases:
|
||||
status, resp = post_raw("/api/config", body)
|
||||
survived = status != 0 # got any response = not crashed/hung
|
||||
test(desc, survived, f"HTTP {status}")
|
||||
|
||||
restore_config()
|
||||
|
||||
time.sleep(1)
|
||||
status, _ = get_json("/api/status")
|
||||
test("Device alive after import fuzz", status == 200, f"HTTP {status}")
|
||||
|
||||
def suite_stability():
|
||||
print("\n⏱ Stability: heap trend over 5 requests")
|
||||
heaps = []
|
||||
for i in range(5):
|
||||
_, data = get_json("/api/status")
|
||||
if data and "system" in data:
|
||||
heaps.append(data["system"].get("free_heap", 0))
|
||||
time.sleep(0.5)
|
||||
|
||||
if heaps:
|
||||
test("Got heap samples", len(heaps) == 5, f"{len(heaps)}/5")
|
||||
min_heap = min(heaps)
|
||||
max_heap = max(heaps)
|
||||
drift = max_heap - min_heap
|
||||
test("Heap stable (drift < 2KB)", drift < 2048,
|
||||
f"min={min_heap} max={max_heap} drift={drift}")
|
||||
test("Min heap > 20KB", min_heap > 20480, f"min={min_heap}")
|
||||
print(f" Heap samples: {heaps}")
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
print(f"🔌 Testing device at {BASE_URL}")
|
||||
print("=" * 55)
|
||||
|
||||
suite_connectivity()
|
||||
suite_api_time()
|
||||
suite_api_weather()
|
||||
suite_api_status()
|
||||
suite_api_debug()
|
||||
suite_fuzz_config()
|
||||
suite_fuzz_config_import()
|
||||
suite_stability()
|
||||
|
||||
print("\n" + "=" * 55)
|
||||
passed = sum(1 for r in results if r.passed)
|
||||
failed = sum(1 for r in results if not r.passed)
|
||||
total = len(results)
|
||||
print(f"Results: {passed}/{total} passed", end="")
|
||||
if failed:
|
||||
print(f" ({failed} failed)")
|
||||
print("\nFailed tests:")
|
||||
for r in results:
|
||||
if not r.passed:
|
||||
print(f" ❌ {r.name}" + (f" — {r.detail}" if r.detail else ""))
|
||||
else:
|
||||
print(" ✅ All passed!")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user