Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
259eda6663 | ||
|
|
f42ede5fa4 |
@@ -5,6 +5,30 @@ 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.9] - 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Factory reset atomicity**: cleared WiFi credentials are now committed BEFORE
|
||||||
|
the reset counter is zeroed. If power fails between the two writes, the device
|
||||||
|
still boots into WiFiManager AP on next start (cleared creds win over stale counter)
|
||||||
|
instead of being stuck in an inconsistent state
|
||||||
|
- **`isValidSSID` rejected single-char SSIDs**: length-1 networks like "A" were
|
||||||
|
incorrectly flagged as all-same garbage. Now allowed (per 802.11 spec)
|
||||||
|
- **`ntp_interval` config changes ignored until reboot**: `NTPClient` is constructed
|
||||||
|
in `setup()` with this value and never re-initialized. Now triggers `needsRestart`
|
||||||
|
when the interval actually changes
|
||||||
|
- **Open WiFi (no password) failed to connect at setup** (M2): Try-2 block in
|
||||||
|
`setupWiFi()` required both ssid and password; now falls back to `WiFi.begin(ssid)`
|
||||||
|
when password is empty
|
||||||
|
- **Unseeded PRNG, identical dissolve pattern every boot** (M4): `randomSeed()`
|
||||||
|
now called with `ESP.getChipId() ^ micros()` — varies between devices and boots
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Dead `NTPState` enum values** (M3): `NTP_WAITING`, `NTP_SUCCESS`, `NTP_FAILED`
|
||||||
|
were defined but never assigned. Removed to reduce code surface area
|
||||||
|
|
||||||
## [1.9.8] - 2026-05-19
|
## [1.9.8] - 2026-05-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+33
-5
@@ -73,14 +73,42 @@ Feature requests are welcome! Please include:
|
|||||||
arduino-cli compile --fqbn esp8266:esp8266:generic firmware/weather_clock
|
arduino-cli compile --fqbn esp8266:esp8266:generic firmware/weather_clock
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Flashing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Upload via FTDI (first time)
|
# OTA upload (preferred, when device is on the network)
|
||||||
arduino-cli upload -p /dev/cu.usbserial* --fqbn esp8266:esp8266:generic
|
|
||||||
|
|
||||||
# Upload via OTA (subsequent)
|
|
||||||
curl -u admin:admin -F "file=@build/*.bin" http://192.168.x.x/update
|
curl -u admin:admin -F "file=@build/*.bin" http://192.168.x.x/update
|
||||||
|
|
||||||
|
# Initial flash via FTDI (3.3V! ESP-01S in socket — no soldering)
|
||||||
|
# 1. Pull ESP-01S from socket on TJ-56-654 PCB
|
||||||
|
# 2. Connect: FTDI 3V3→3V3, GND→GND, TX↔RX crossed, GND→GPIO0
|
||||||
|
# 3. Power on with GPIO0 grounded → bootloader mode
|
||||||
|
esptool.py --port /dev/cu.usbserial-0001 --baud 115200 write_flash \
|
||||||
|
--flash_size 1MB --flash_mode dout 0x0 firmware.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the test suite
|
||||||
|
|
||||||
|
Hardware-in-the-loop tests verify functionality and resilience:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tests/test_device.py <device-ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
73 test cases cover REST API, config validation, fuzz testing, and heap stability.
|
||||||
|
Safe to run repeatedly — validation rejects garbage, only WiFi changes reboot the device.
|
||||||
|
|
||||||
|
### Recovery (bricked device)
|
||||||
|
|
||||||
|
Triple power-cycle (≤10s apart, 3 times) triggers factory reset — clears WiFi
|
||||||
|
credentials and shows AP info on the OLED. Connect to `TJ56654-Setup` /
|
||||||
|
`12345678` and reconfigure via `http://192.168.4.1/config`.
|
||||||
|
|
||||||
|
If that fails, full reset via FTDI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
esptool.py --port /dev/cu.usbserial-0001 erase_flash
|
||||||
|
esptool.py --port /dev/cu.usbserial-0001 write_flash 0x0 firmware.bin
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ I bought a cute weather clock kit from AliExpress ([TJ-56-654](https://pt.aliexp
|
|||||||
- [The Investigation](#the-investigation)
|
- [The Investigation](#the-investigation)
|
||||||
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
|
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
|
||||||
- [Technical Deep Dive](#technical-deep-dive)
|
- [Technical Deep Dive](#technical-deep-dive)
|
||||||
- [The Journey: v1.7 → v1.9.4](#the-journey-v17--v194)
|
- [The Journey: v1.7 → v1.9.8](#the-journey-v17--v198)
|
||||||
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
|
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
|
||||||
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
||||||
- [Web Interface](#web-interface)
|
- [Web Interface](#web-interface)
|
||||||
@@ -395,7 +395,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Journey: v1.7 → v1.9.4
|
## The Journey: v1.7 → v1.9.8
|
||||||
|
|
||||||
### v1.7: Display Discovery ✅
|
### v1.7: Display Discovery ✅
|
||||||
|
|
||||||
@@ -558,7 +558,7 @@ Split monolithic 2,100-line `.ino` into focused modules:
|
|||||||
| `web_server.cpp` | Web UI and REST API |
|
| `web_server.cpp` | Web UI and REST API |
|
||||||
| `wifi_manager.cpp` | WiFi connection and resilience |
|
| `wifi_manager.cpp` | WiFi connection and resilience |
|
||||||
|
|
||||||
### v1.9.4: Bug Fixes & Cleanup ✅ (Current)
|
### v1.9.4: Community Bug Fixes 🐛
|
||||||
|
|
||||||
Community-reported bugs fixed:
|
Community-reported bugs fixed:
|
||||||
|
|
||||||
@@ -568,6 +568,33 @@ Community-reported bugs fixed:
|
|||||||
- **ArduinoJson v7**: Updated `StaticJsonDocument` → `JsonDocument` for library compatibility
|
- **ArduinoJson v7**: Updated `StaticJsonDocument` → `JsonDocument` for library compatibility
|
||||||
- **Compiler warnings**: Removed unused variable, fixed sprintf buffer size
|
- **Compiler warnings**: Removed unused variable, fixed sprintf buffer size
|
||||||
|
|
||||||
|
### v1.9.5: Weather Recovery 🌦
|
||||||
|
|
||||||
|
- **Permanent weather lockup after 3 API failures** (#9): state machine no longer
|
||||||
|
deadlocks; resets to IDLE after max retries so periodic refresh resumes
|
||||||
|
|
||||||
|
### v1.9.6: Long-Running Stability ⏱
|
||||||
|
|
||||||
|
Six bugs found via dual AI code review (Sonnet + Gemini):
|
||||||
|
|
||||||
|
- **WEATHER_REQUESTING timeout watchdog** (C1): 15s timeout prevents TCP-hang deadlock
|
||||||
|
- **millis() rollover at 49.7 days** (C2): all retry timers now use subtraction-safe comparison
|
||||||
|
- **DST formula mathematically wrong** (H1): now uses tm_wday-based last-Sunday calculation
|
||||||
|
- **String heap fragmentation** (H2): API handlers use `snprintf+sendContent` instead of `String +=`
|
||||||
|
- **Volatile shared state** (H3): `weatherState`/`ntpState` written from callbacks, read in loop
|
||||||
|
- **Triple power-cycle factory reset**: 3 quick power cycles within 10s clears WiFi creds (no FTDI needed)
|
||||||
|
|
||||||
|
### v1.9.7: Config Validation 🛡
|
||||||
|
|
||||||
|
- **`/config` accepted any input, could brick device**: now rejects empty/all-same/oversized
|
||||||
|
SSIDs (HTTP 400), clamps numeric fields to safe ranges. Closes M1 from internal backlog.
|
||||||
|
|
||||||
|
### v1.9.8: Live Config Updates ⚡ (Current)
|
||||||
|
|
||||||
|
- **Restart only on WiFi/network changes**: brightness, timezone, intervals, coordinates,
|
||||||
|
display options apply live. Eliminates display blackouts on minor config tweaks.
|
||||||
|
- **73/73 automated tests pass** on hardware including full fuzz suite
|
||||||
|
|
||||||
### Memory Evolution
|
### Memory Evolution
|
||||||
|
|
||||||
| Version | RAM Usage | IRAM Usage | Flash Usage | Notes |
|
| Version | RAM Usage | IRAM Usage | Flash Usage | Notes |
|
||||||
@@ -577,6 +604,8 @@ Community-reported bugs fixed:
|
|||||||
| v1.9.0 | 37,516 (46%) | **61,987 (94%)** | 408,540 (38%) | Async libs added |
|
| v1.9.0 | 37,516 (46%) | **61,987 (94%)** | 408,540 (38%) | Async libs added |
|
||||||
| v1.9.1 | 37,644 (46%) | **61,987 (94%)** | 408,844 (38%) | Hybrid WiFi fix |
|
| v1.9.1 | 37,644 (46%) | **61,987 (94%)** | 408,844 (38%) | Hybrid WiFi fix |
|
||||||
| v1.9.2 | 37,800 (47%) | **61,987 (94%)** | 409,100 (39%) | WiFi resilience |
|
| v1.9.2 | 37,800 (47%) | **61,987 (94%)** | 409,100 (39%) | WiFi resilience |
|
||||||
|
| v1.9.6 | 37,268 (46%) | **61,987 (94%)** | 410,436 (39%) | String → snprintf |
|
||||||
|
| v1.9.8 | 37,664 (46%) | **61,987 (94%)** | 411,380 (39%) | Live config updates |
|
||||||
|
|
||||||
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
|
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
|
||||||
|
|
||||||
@@ -996,8 +1025,17 @@ Device reboots immediately.
|
|||||||
```
|
```
|
||||||
esp8266-weather-clock/
|
esp8266-weather-clock/
|
||||||
├── firmware/
|
├── firmware/
|
||||||
│ └── weather_clock/
|
│ └── weather_clock/ # Modular firmware
|
||||||
│ └── weather_clock.ino # Main firmware (~2,100 lines)
|
│ ├── weather_clock.ino # setup() and loop()
|
||||||
|
│ ├── config.h # Config struct, EEPROM layout
|
||||||
|
│ ├── globals.h # Shared state
|
||||||
|
│ ├── display.cpp # OLED rendering
|
||||||
|
│ ├── ntp_client.cpp # Async NTP + DST
|
||||||
|
│ ├── weather.cpp # Open-Meteo API
|
||||||
|
│ ├── web_server.cpp # HTTP UI + REST API
|
||||||
|
│ └── wifi_manager.cpp # WiFi resilience
|
||||||
|
├── tests/
|
||||||
|
│ └── test_device.py # HW-in-the-loop test suite (73 cases)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── HARDWARE.md # Hardware specifications
|
│ ├── HARDWARE.md # Hardware specifications
|
||||||
│ └── INSTALLATION.md # Flashing guide
|
│ └── INSTALLATION.md # Flashing guide
|
||||||
@@ -1005,6 +1043,21 @@ esp8266-weather-clock/
|
|||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Hardware-in-the-loop test suite — verifies API, fuzzes config endpoint,
|
||||||
|
# checks heap stability over 5 samples
|
||||||
|
python3 tests/test_device.py 192.168.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite covers:
|
||||||
|
|
||||||
|
- All REST API endpoints (functional validation)
|
||||||
|
- Boundary fuzz against `/config` (oversized SSID, invalid intervals, etc.)
|
||||||
|
- Malformed JSON fuzz against `/api/config` import
|
||||||
|
- Heap stability check (must stay >20KB, drift <2KB across runs)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
@@ -1038,4 +1091,4 @@ Now go make something cool. 🚀
|
|||||||
|
|
||||||
**Author**: apetrochenko
|
**Author**: apetrochenko
|
||||||
**Date**: 2026-01-06
|
**Date**: 2026-01-06
|
||||||
**Firmware Version**: v1.9.4 (Production Ready)
|
**Firmware Version**: v1.9.8 (Production Ready)
|
||||||
|
|||||||
@@ -499,6 +499,34 @@ To save memory, disable unused features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Factory Reset (no tools needed)
|
||||||
|
|
||||||
|
Since v1.9.6, three quick power cycles within 10 seconds trigger a factory reset:
|
||||||
|
|
||||||
|
1. Power off the device
|
||||||
|
2. Power on (briefly, < 10 sec)
|
||||||
|
3. Power off, power on
|
||||||
|
4. Power off, power on
|
||||||
|
|
||||||
|
On the third boot the OLED shows `FACTORY RESET! WiFi: TJ56654-Setup Pass: 12345678`.
|
||||||
|
Connect your phone to that AP and reconfigure WiFi at `http://192.168.4.1/config`.
|
||||||
|
|
||||||
|
Use this if:
|
||||||
|
|
||||||
|
- Forgot configured WiFi credentials
|
||||||
|
- Device stuck in "No WiFi" loop after router change
|
||||||
|
- Tests/fuzzing corrupted config
|
||||||
|
|
||||||
|
## Verifying Installation
|
||||||
|
|
||||||
|
Run the test suite against your device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tests/test_device.py 192.168.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
A healthy device passes all 73 tests, with heap drift under 1 KB across the run.
|
||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
If you're still stuck:
|
If you're still stuck:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
// Firmware version
|
// Firmware version
|
||||||
#define FIRMWARE_VERSION "1.9.8"
|
#define FIRMWARE_VERSION "1.9.9"
|
||||||
|
|
||||||
// OLED I2C Configuration
|
// OLED I2C Configuration
|
||||||
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
|
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
|
||||||
@@ -120,13 +120,11 @@ enum WeatherState {
|
|||||||
WEATHER_FAILED
|
WEATHER_FAILED
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async NTP state machine
|
// Async NTP state machine — only IDLE and REQUEST_SENT are used
|
||||||
|
// (response handler transitions back to IDLE directly on success or timeout)
|
||||||
enum NTPState {
|
enum NTPState {
|
||||||
NTP_IDLE,
|
NTP_IDLE,
|
||||||
NTP_REQUEST_SENT,
|
NTP_REQUEST_SENT
|
||||||
NTP_WAITING,
|
|
||||||
NTP_SUCCESS,
|
|
||||||
NTP_FAILED
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async WiFi state machine
|
// Async WiFi state machine
|
||||||
|
|||||||
@@ -133,16 +133,22 @@ void ICACHE_FLASH_ATTR checkFactoryReset() {
|
|||||||
|
|
||||||
if (rc.count >= RESET_COUNTER_TRIPS) {
|
if (rc.count >= RESET_COUNTER_TRIPS) {
|
||||||
Serial.println("!!! FACTORY RESET triggered !!!");
|
Serial.println("!!! FACTORY RESET triggered !!!");
|
||||||
|
|
||||||
|
// Atomicity: clear credentials FIRST (saveConfig commits), then zero counter.
|
||||||
|
// If power fails between the two commits, the device boots into AP mode next
|
||||||
|
// time (creds are already cleared) — instead of being in a "counter zeroed but
|
||||||
|
// creds still valid" inconsistent state.
|
||||||
|
memset(config.ssid, 0, sizeof(config.ssid));
|
||||||
|
memset(config.password, 0, sizeof(config.password));
|
||||||
|
EEPROM.end(); // close current handle before saveConfig opens its own
|
||||||
|
saveConfig(); // commits cleared credentials to flash
|
||||||
|
|
||||||
|
EEPROM.begin(512);
|
||||||
rc.count = 0;
|
rc.count = 0;
|
||||||
EEPROM.put(RESET_COUNTER_ADDR, rc);
|
EEPROM.put(RESET_COUNTER_ADDR, rc);
|
||||||
EEPROM.commit();
|
EEPROM.commit();
|
||||||
EEPROM.end();
|
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
|
// Show reset screen
|
||||||
display.clearDisplay();
|
display.clearDisplay();
|
||||||
display.setTextColor(SSD1306_WHITE);
|
display.setTextColor(SSD1306_WHITE);
|
||||||
@@ -244,6 +250,11 @@ void ICACHE_FLASH_ATTR setupOTA() {
|
|||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(100);
|
delay(100);
|
||||||
|
|
||||||
|
// Seed PRNG with hardware entropy: chip ID is unique per device,
|
||||||
|
// micros() varies on each boot due to power-on timing jitter
|
||||||
|
randomSeed(ESP.getChipId() ^ micros());
|
||||||
|
|
||||||
Serial.println("\n\nTJ-56-654 NTP Clock with OTA v" FIRMWARE_VERSION);
|
Serial.println("\n\nTJ-56-654 NTP Clock with OTA v" FIRMWARE_VERSION);
|
||||||
Serial.println("==========================================");
|
Serial.println("==========================================");
|
||||||
Serial.println("Display: GM009605v4.3 OLED 128x64 (SSD1306 I2C)");
|
Serial.println("Display: GM009605v4.3 OLED 128x64 (SSD1306 I2C)");
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ static bool isValidSSID(const String& s) {
|
|||||||
if (c < 0x20 || c > 0x7E) return false; // non-printable
|
if (c < 0x20 || c > 0x7E) return false; // non-printable
|
||||||
if (c != first) allSame = false;
|
if (c != first) allSame = false;
|
||||||
}
|
}
|
||||||
return !allSame; // reject "AAAAA...", "BBBBB...", etc.
|
// Single-char SSIDs ("A") are valid per 802.11. Only reject all-same for length>1.
|
||||||
|
return n == 1 || !allSame;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ICACHE_FLASH_ATTR handleConfigSave() {
|
void ICACHE_FLASH_ATTR handleConfigSave() {
|
||||||
@@ -358,7 +359,9 @@ void ICACHE_FLASH_ATTR handleConfigSave() {
|
|||||||
}
|
}
|
||||||
if (server.hasArg("ntp_interval")) {
|
if (server.hasArg("ntp_interval")) {
|
||||||
long ni = server.arg("ntp_interval").toInt();
|
long ni = server.arg("ntp_interval").toInt();
|
||||||
config.ntp_interval = constrain(ni, 60L, 86400L);
|
unsigned long newInterval = constrain(ni, 60L, 86400L);
|
||||||
|
if (newInterval != config.ntp_interval) needsRestart = true; // NTPClient constructed at boot with this
|
||||||
|
config.ntp_interval = newInterval;
|
||||||
}
|
}
|
||||||
if (server.hasArg("ntp_server")) {
|
if (server.hasArg("ntp_server")) {
|
||||||
String n = server.arg("ntp_server");
|
String n = server.arg("ntp_server");
|
||||||
|
|||||||
@@ -105,10 +105,14 @@ void ICACHE_FLASH_ATTR setupWiFi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try 2: If we have EEPROM credentials, try those
|
// Try 2: If we have EEPROM credentials, try those (M2: support open networks)
|
||||||
if (strlen(config.ssid) > 0 && strlen(config.password) > 0) {
|
if (strlen(config.ssid) > 0) {
|
||||||
Serial.println("\nTrying EEPROM credentials...");
|
Serial.println("\nTrying EEPROM credentials...");
|
||||||
WiFi.begin(config.ssid, config.password);
|
if (strlen(config.password) > 0) {
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
} else {
|
||||||
|
WiFi.begin(config.ssid); // open network — no password
|
||||||
|
}
|
||||||
|
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||||
|
|||||||
Reference in New Issue
Block a user