10 Commits
Author SHA1 Message Date
T fe77b61813 Add platformio 2026-09-27 15:39:38 +02:00
Alex PetrochenkoandClaude Fable 5.1 aee1d3e0ef fix(wifi): persist password alongside SSID after SDK/WiFiManager connect (v1.9.10)
After a successful connect via SDK-cached credentials (Try 1) or the
WiFiManager captive portal, only the SSID was saved to EEPROM. On the next
boot config.password was empty, so setupWiFi() and the reconnect loop called
WiFi.begin(ssid) as if the network were open and failed with
WL_WRONG_PASSWORD.

v1.9.6 masked this: with an empty password the retry loop fell back to a
bare WiFi.begin(), which reuses the SDK-stored credentials. Commit 02834ba
(v1.9.7) replaced that fallback with WiFi.begin(config.ssid), and v1.9.9
(M2) added the same call to setupWiFi(), so every version after 1.9.6
never reconnects after a reboot when the device was set up through the
portal.

Read the password back with WiFi.psk() and store it next to the SSID in
all three places that sync the SSID. Devices already in this state recover
by entering the password once in the web UI (fallback AP after ~2.5 min).

Verified: builds with PlatformIO (espressif8266 core 3.1.2, esp01_1m).

Fixes #12

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:40:46 +01:00
Alex Petrochenko 75b6a21f3c docs: trim README, extract architecture notes to docs/ARCHITECTURE.md
README went from 1094 to 671 lines (-39%) by removing:
- "Technical Deep Dive" section (had stale NTPState enum showing removed values;
  useful content moved to docs/ARCHITECTURE.md)
- "The Journey: v1.7 → v1.9.8" — ~250 lines duplicating CHANGELOG;
  replaced with one-paragraph summary and CHANGELOG link
- "Memory Evolution" table — already in CHANGELOG entries
- "What's Next: Home Assistant Integration" — year-old vaporware promise
- "Lessons Learned" section — editorial blog filler
- "Final Thoughts" + "P.S." footer — same

Added docs/ARCHITECTURE.md (~110 lines) with the useful technical content:
async model, timer correctness, memory budget, IRAM discipline,
heap fragmentation pattern, EEPROM layout, factory reset, web server.

TOC restructured into "Story" (history) and "Use it" (practical) sections.
2026-05-19 16:24:35 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 259eda6663 fix: post-review issues + close M2/M3/M4 from internal backlog (v1.9.9)
From Sonnet pre-release code review (3 real issues):
- F1 (high): factory reset atomicity — save cleared creds BEFORE zeroing reset
  counter, so a power loss between commits still results in WiFiManager AP boot
  instead of inconsistent "counter=0 + stale creds" state
- F2: isValidSSID now allows single-char SSIDs (per 802.11 spec)
- F3: ntp_interval changes now trigger needsRestart (NTPClient constructed once
  in setup() with this value, doesn't pick up runtime changes)

From internal backlog:
- M2: setupWiFi Try-2 now supports open networks (no password) — fixed the
  &&-condition that required both ssid and password
- M3: removed dead NTP_WAITING/NTP_SUCCESS/NTP_FAILED enum values
- M4: randomSeed() with ESP.getChipId() ^ micros() — dissolve pattern varies

Tested on hardware: 72/73 tests pass (single failure is expected — ntp_interval
fuzz cases now trigger restart due to F3, second case lands in reboot window).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:05:57 +01:00
Alex Petrochenko f42ede5fa4 docs: bring README/CONTRIBUTING/INSTALLATION up to v1.9.8
README:
- Journey section: add v1.9.5 (weather recovery), v1.9.6 (stability fixes via
  dual AI review), v1.9.7 (config validation), v1.9.8 (live config updates)
- Memory Evolution table: add v1.9.6 and v1.9.8 rows
- Project Structure: list all modular firmware files + tests/test_device.py
- Add Testing section explaining the 73-case hardware-in-the-loop suite
- Bump current version footer to v1.9.8

CONTRIBUTING:
- Flashing section: OTA preferred, FTDI fallback with exact esptool commands
- Add test suite invocation
- Add recovery section (triple power-cycle factory reset since v1.9.6)

INSTALLATION:
- New Factory Reset section explaining the 3x power-cycle trick
- New Verifying Installation section pointing to test_device.py
2026-05-19 14:16:47 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 d0aa68e64f fix(web): only reboot on WiFi/network config changes (v1.9.8)
Previously every successful config save triggered ESP.restart() — meant rapid
config changes caused reboot cascades and made the test suite unable to run
multiple cases against /config. Now restarts only if ssid/password/hostname/
ntp_server changed; other settings (brightness, timezone, intervals, coords,
display options) apply live without reboot.

tests/test_device.py: fixed expected field names for /api/time
(time/hours/minutes/epoch instead of current/timezone_offset/ntp_synced).

Test suite: 73/73 passing on hardware. Heap drift <1KB across full fuzz run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:12:21 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 5b9285aeb9 fix(web): validate inputs in handleConfigSave to prevent fuzz bricking (M1)
Previously /config accepted any value and called ESP.restart() — fuzz tests
(or any malicious POST) could save garbage SSIDs and brick the device until
FTDI recovery. Now:

- SSID: rejected if empty, >31 chars, non-printable, or all-same-char (HTTP 400)
- Password: rejected if >63 chars
- Hostname/city_name/ntp_server: length-validated
- Numeric fields (timezone, brightness, intervals, lat/lon, display): clamped
  to safe ranges via constrain()

Tested on hardware: ssid="AAAA..." now correctly returns HTTP 400 and
preserves existing config. Device survives entire fuzz suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:40:43 +01:00
Alex Petrochenko 02834bada1 fix(wifi): preserve AP_STA mode after WiFi.begin() kills the AP
WiFi.begin() internally resets mode to STA, immediately destroying the
fallback AP that was just created. Fix: restore WIFI_AP_STA mode after
begin() when in AP mode, so TJ56654-Setup stays visible.

This was why the fallback AP was never visible despite being 'created'.
2026-05-18 20:52:14 +01:00
Alex Petrochenko 11aab761c3 test: add config backup/restore teardown before/after fuzz tests
Prevents bricking the device with garbage SSID/ntp_interval=0 etc.
Safe values are restored after every fuzz suite run.
2026-05-18 20:44:07 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 5b8a58715e feat: triple power-cycle factory reset (no FTDI needed)
Power-cycle the device 3 times within 10 seconds to trigger factory reset:
- Clears WiFi credentials (SSID + password) in EEPROM
- Shows "FACTORY RESET / WiFi: TJ56654-Setup / Pass: 12345678" on display
- Reboots into WiFiManager AP mode for reconfiguration

Counter stored at EEPROM offset 480 (well past Config ~260 bytes).
Clears automatically after 10s of stable operation.

Prevents the need for FTDI/USB recovery when credentials are corrupted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:25:32 +01:00
13 changed files with 860 additions and 466 deletions
+1
View File
@@ -1,5 +1,6 @@
# Arduino build artifacts # Arduino build artifacts
build/ build/
.pio/
*.bin *.bin
*.elf *.elf
*.map *.map
+59
View File
@@ -5,6 +5,65 @@ 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.10] - 2026-09-20
### Fixed
- **WiFi password lost after captive-portal setup, no reconnect after reboot** (#12):
after a successful connect via SDK-cached credentials (Try 1) or the WiFiManager
portal, only the SSID was written to EEPROM. On the next boot `config.password`
was empty, so `setupWiFi()` and the reconnect loop called `WiFi.begin(ssid)` as
if the network were open and failed with `WL_WRONG_PASSWORD`. v1.9.6 masked this
through the bare `WiFi.begin()` fallback in the retry loop, which 02834ba (v1.9.7)
replaced with `WiFi.begin(config.ssid)`. Now the password is read back with
`WiFi.psk()` and saved next to the SSID in all three places that sync the SSID.
Devices already stuck recover by entering the password once in the web UI.
## [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
### Fixed
- **handleConfigSave always rebooted device on save**: only reboots now if WiFi/network
fields changed (`ssid`, `password`, `hostname`, `ntp_server`). Other settings
(brightness, timezone, intervals, coordinates, display options) apply live without
restart. Eliminates reboot cascades during config tweaks and makes the test suite
safe to run repeatedly.
## [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 ## [1.9.6] - 2026-05-18
### Fixed ### Fixed
+33 -5
View File
@@ -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
+48 -418
View File
@@ -22,19 +22,25 @@ I bought a cute weather clock kit from AliExpress ([TJ-56-654](https://pt.aliexp
## Table of Contents ## Table of Contents
**Story**
- [The Discovery: When "Smart" Means "Insecure"](#the-discovery-when-smart-means-insecure) - [The Discovery: When "Smart" Means "Insecure"](#the-discovery-when-smart-means-insecure)
- [The Device](#the-device) - [The Device](#the-device)
- [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) - [Hardware Quirk: Swapped I2C Pins](#hardware-quirk-swapped-i2c-pins)
- [The Journey: v1.7 → v1.9.4](#the-journey-v17--v194) - [Version History](#version-history)
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
**Use it**
- [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)
- [API Documentation](#api-documentation) - [API Documentation](#api-documentation)
- [Testing](#testing)
- [Security Improvements](#security-improvements) - [Security Improvements](#security-improvements)
- [Lessons Learned](#lessons-learned) - [Project Structure](#project-structure)
- [Credits](#credits)
For internal architecture notes (state machines, memory, EEPROM layout), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
--- ---
@@ -246,367 +252,23 @@ All endpoints return JSON:
--- ---
## Technical Deep Dive ## Hardware Quirk: Swapped I2C Pins
### Architecture: Fully Async State Machines ESP-01S exposes only GPIO0 and GPIO2. The TJ-56-654 board designer used them as I2C — but **swapped from typical breakouts**:
The firmware uses **zero blocking operations** in the main loop. Everything is state-machine-based:
#### Weather State Machine
```cpp ```cpp
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED }; Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2 (non-standard!)
``` ```
Uses `AsyncHTTPRequest` library: Standard ESP8266 boards use GPIO4=SDA, GPIO5=SCL. Easy to miss if you're porting code from a different ESP8266 setup. The display is a GM009605v4.3 OLED (SSD1306-compatible) at I2C address 0x3C.
- Non-blocking HTTP requests For architecture details (async state machines, memory budget, EEPROM layout, factory reset), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
- Callback-based response handling
- Exponential backoff on failures (1s → 2s → 4s)
- Maximum 3 retries before giving up
#### NTP State Machine ## Version History
```cpp The clock has gone through many iterations — display hardware discovery (v1.5–v1.7), stability and security fixes (v1.8), full async refactor (v1.9.0), and a long series of bug fixes informed by community reports and AI-assisted code review (v1.9.1–v1.9.10).
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
```
Custom manual NTP implementation: See [CHANGELOG.md](CHANGELOG.md) for the full version history with technical details.
- Builds raw UDP packets (48 bytes)
- Non-blocking `parsePacket()` checks
- 5-second timeout
- Independent epoch tracking for accuracy between syncs
#### WiFi State Machine
```cpp
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
```
**Hybrid model** (this was critical!):
- **Setup phase**: Synchronous connection (waits up to 10 seconds)
- Why? OTA, web server, NTP all need WiFi ready
- Without this, device shows blank display for 10+ seconds
- **Loop phase**: Async reconnection (checks every 5 seconds)
- Why? Don't freeze the entire system if WiFi drops
### Memory Optimization
ESP8266 has strict memory limits:
| Memory Type | Total | Used | Usage | Status |
| ----------- | --------- | ------- | ------- | ----------- |
| **Flash** | 1,048,576 | 408,844 | 38% | ✅ Plenty |
| **RAM** | 80,192 | 37,644 | 46% | ✅ Safe |
| **IRAM** | 65,536 | 61,987 | **94%** | ⚠️ Critical |
**IRAM Crisis Solution:**
IRAM (Instruction RAM) is limited and fills fast. The solution: `ICACHE_FLASH_ATTR` macro.
```cpp
void ICACHE_FLASH_ATTR handleConfig() {
// This function's code lives in Flash, not IRAM
// Saves precious IRAM at cost of slightly slower execution
}
```
Applied to 26 functions (web handlers, display, config utilities), reducing IRAM pressure from **overflow risk** to **sustainable 94%**.
**String Safety:**
Avoid String concatenation in loops (causes heap fragmentation):
```cpp
// ❌ BAD - 140+ concatenations
String html = "";
html += F("<!DOCTYPE html>");
html += F("<head>..."); // x138 more times
// ✅ GOOD - Chunked responses
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
server.sendContent_P(HTML_HEADER);
server.sendContent_P(HTML_FOOTER);
server.sendContent(""); // End
```
### Configuration Storage
26-field struct stored in EEPROM (512 bytes):
```cpp
struct Config {
char ssid[32];
char password[64];
int timezone_offset;
bool dst_enabled;
uint8_t brightness;
char ntp_server[64];
unsigned long ntp_interval;
bool hour_format_24;
char hostname[32];
float latitude;
float longitude;
char city_name[32];
bool weather_enabled;
unsigned long weather_interval;
unsigned long display_rotation_sec;
bool show_weather;
bool show_sunrise_sunset;
uint8_t display_orientation;
uint32_t magic; // 0xC10CC10C - validation
};
```
**EEPROM validation**: Magic number check prevents loading corrupted data. On validation failure, gracefully defaults to safe config.
### Display Hardware Discovery
This took **3 firmware iterations** to get right:
**v1.5**: Assumed TM1637 (7-segment LED driver)
- ❌ Wrong - device has OLED, not 7-segment LEDs
**v1.6**: Tried TM1650 (another LED driver)
- ❌ Wrong - I2C addresses didn't match
**v1.7**: Identified GM009605v4.3 (SSD1306-compatible OLED)
- ✅ Correct! Used Adafruit_SSD1306 library
- ✅ Discovered swapped pins: SDA on GPIO0, SCL on GPIO2
**Pin mapping quirk:**
Standard ESP8266 I2C uses GPIO4 (SDA) and GPIO5 (SCL), but ESP-01S only exposes GPIO0 and GPIO2. The board designer mapped:
- GPIO0 → SDA (unusual)
- GPIO2 → SCL (unusual)
This is **backwards** from typical breakout boards, but works perfectly once configured:
```cpp
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
```
---
## The Journey: v1.7 → v1.9.4
### v1.7: Display Discovery ✅
- Identified correct display hardware
- Basic time display working
- WiFiManager integration
- First OTA deployment
### v1.8: Stability & Security 🔒
**Goals**: Fix memory issues, eliminate security holes
**Changes:**
- IRAM optimization (added `ICACHE_FLASH_ATTR` to 26 functions)
- Removed hardcoded WiFi credentials
- Fixed NTP interval bug (config value was ignored)
- Fixed boolean parsing in JSON import
- Added input validation (buffer overflow protection)
- Chunked HTTP responses (eliminated 140+ String concatenations)
**Result**: IRAM usage 94% → 70%, no memory leaks, secure config storage
### v1.9.0: Full Async Refactoring ⚡
**Goals**: Eliminate all blocking operations
**Changes:**
- Async HTTP weather fetch (AsyncHTTPRequest library)
- Custom async NTP implementation (manual UDP packets)
- Async WiFi connection (state machine)
- Removed all `delay()` calls from `loop()`
- Exponential backoff retry logic
**Performance:**
| Operation | Before (v1.8) | After (v1.9.0) | Improvement |
| -------------- | -------------- | -------------- | ------------------- |
| Weather fetch | 1-10s blocking | 0ms | ✅ Async callback |
| NTP sync | 5-20s blocking | 0ms | ✅ Non-blocking UDP |
| WiFi reconnect | 15s blocking | 0ms | ✅ State machine |
| Loop time | 10ms minimum | <1ms | ✅ 10x faster |
**Result**: Device stays responsive during OTA updates while weather is fetching!
### v1.9.1: Hybrid Fix 🎯
**Problem Discovered:**
After deploying v1.9.0, the display showed **blank screen for 10 seconds** after boot, with `DNS resolution failed` errors in logs.
**Root Cause:**
Making WiFi fully async broke the **initialization order**:
```cpp
void setup() {
setupWiFi(); // Returns immediately (async)
setupOTA(); // WiFi NOT ready! ❌
setupWebServer(); // WiFi NOT ready! ❌
testInternetConnectivity(); // WiFi NOT ready! → DNS error
}
```
**Solution: Hybrid Model**
| Phase | WiFi Mode | Blocking? | Why? |
| --------- | ------------ | --------- | --------------------------- |
| `setup()` | Synchronous | 10s max | OTA/web/NTP need WiFi ready |
| `loop()` | Asynchronous | 0s | Don't freeze on reconnect |
**Results:**
- ✅ Display shows time immediately after WiFi connects (~15 sec boot)
- ✅ No "DNS resolution failed" errors
- ✅ Proper initialization order guaranteed
- ✅ Device never freezes on WiFi loss during operation
### v1.9.2: WiFi Resilience 🛡️
**Problem Discovered:**
After WiFi outages, the device would **clear stored credentials** and enter AP mode, requiring manual reconfiguration every time the router restarted.
**Root Cause:**
Aggressive credential clearing on connection failure:
```cpp
if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
memset(config.ssid, 0, sizeof(config.ssid)); // ❌ Clears credentials!
memset(config.password, 0, sizeof(config.password));
saveConfig();
// Enter AP mode...
}
```
**Solution: Resilient WiFi**
| Feature | Before (v1.9.1) | After (v1.9.2) |
| ------------------- | -------------------------- | ------------------------------- |
| Credential clearing | After 5 failed attempts | Never |
| Retry strategy | Give up after 5 tries | Infinite with backoff |
| Max retry interval | N/A | 5 minutes |
| Fallback AP | After clearing credentials | After ~5 min (dual STA+AP mode) |
| Clock during outage | Blank display | Shows last synced time |
**Key Changes:**
- **Never clear credentials** on connection failure
- **Exponential backoff**: 5s → 10s → 20s → ... → 5min max
- **Fallback AP** ("TJ56654-Setup") enabled after ~5 min, while still retrying
- **Dual STA+AP mode**: Device continues reconnect attempts while AP is active
- **SDK credentials**: Used only on first boot (no saved SSID); subsequent boots go straight to saved credentials
- **"No WiFi" display**: Shows retry countdown instead of cryptic numbers
- **"!" indicator**: Shown in date line when WiFi disconnected
**Network Activity Summary:**
| Service | Interval | Endpoint | Protocol |
| ------- | ---------- | ------------------ | ------------- |
| NTP | 1 hour | pool.ntp.org:123 | UDP |
| Weather | 30 min | api.open-meteo.com | HTTP |
| mDNS | continuous | 224.0.0.251 | UDP multicast |
~50 requests/day total.
**Results:**
- ✅ Credentials persist through WiFi outages
- ✅ Device automatically reconnects when WiFi returns
- ✅ Clock continues running with last synced time
- ✅ User can reconfigure via fallback AP if needed
**Startup Timeline:**
```
[0-5s] Display init, startup animation
[5-15s] WiFi connection (SYNCHRONOUS in setup())
✅ WiFi connected! IP assigned
[15-20s] OTA init, web server start, NTP client ready
✅ Internet test: PASSED
[20-30s] First async NTP sync
✅ Time synced and displayed
```
### v1.9.3: Modular Architecture 🗂️
Split monolithic 2,100-line `.ino` into focused modules:
| File | Responsibility |
| ------------------- | --------------------------------------- |
| `weather_clock.ino` | Entry point: `setup()` and `loop()` |
| `config.h` | Config struct, EEPROM layout, constants |
| `globals.h` | Shared state and extern declarations |
| `display.cpp` | OLED rendering |
| `ntp_client.cpp` | Async NTP sync |
| `weather.cpp` | Open-Meteo API fetch |
| `web_server.cpp` | Web UI and REST API |
| `wifi_manager.cpp` | WiFi connection and resilience |
### v1.9.4: Bug Fixes & Cleanup ✅ (Current)
Community-reported bugs fixed:
- **Date timezone** (#5): Date now changes at local midnight, not UTC midnight
- **Weather refresh** (#7): Periodic weather updates no longer blocked after first fetch
- **WiFi hotspot** (#3): Device no longer connects to open SDK-cached networks (e.g. public hotspots) when a saved SSID exists, preventing config corruption
- **ArduinoJson v7**: Updated `StaticJsonDocument` → `JsonDocument` for library compatibility
- **Compiler warnings**: Removed unused variable, fixed sprintf buffer size
### Memory Evolution
| Version | RAM Usage | IRAM Usage | Flash Usage | Notes |
| ------- | ------------ | ---------------- | ------------- | --------------------- |
| v1.7 | 34,980 (43%) | **61,987 (94%)** | 407,500 (38%) | IRAM crisis |
| v1.8 | 36,980 (46%) | **45,120 (68%)** | 407,800 (38%) | ICACHE_FLASH_ATTR fix |
| 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.2 | 37,800 (47%) | **61,987 (94%)** | 409,100 (39%) | WiFi resilience |
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
---
## What's Next: Home Assistant Integration
The firmware is designed to be extensible. Next planned features:
### Custom Display Screens
Pull data from Home Assistant via REST API:
- **Smart home stats**: Energy usage, room temperatures
- **Sensor data**: Air quality, CO2 levels
- **Automation states**: Alarm status, door locks
### MQTT Integration
- Publish time/weather data to MQTT broker
- Subscribe to topics for display content
- Enable automation triggers (e.g., display alert when door opens)
### WebSocket Live Updates
Replace polling with WebSocket for:
- Real-time config changes without page refresh
- Live display preview in web UI
- Push notifications for firmware updates
--- ---
@@ -632,7 +294,7 @@ Replace polling with WebSocket for:
- `Adafruit SSD1306` - `Adafruit SSD1306`
- `NTPClient` - `NTPClient`
- `WiFiManager` (by tzapu) - `WiFiManager` (by tzapu)
- `AsyncHTTPRequest_Generic` - `asyncHTTPrequest` (by Bob Lemaire)
- `ESPAsyncTCP` - `ESPAsyncTCP`
- `ArduinoJson` (by Benoit Blanchon) - `ArduinoJson` (by Benoit Blanchon)
@@ -932,39 +594,6 @@ Device reboots immediately.
--- ---
## Lessons Learned
### Hardware
1. **Always check pinouts**: Don't assume standard pin mappings - this device swaps SDA/SCL
2. **FTDI is your friend**: A $2 adapter unlocks any ESP8266 device
3. **Transparent cases are great**: Made debugging and identification trivial
4. **Read the PCB silk screen**: Model numbers and pin labels save hours of guessing
### Software
1. **Async is hard but worth it**: Fully non-blocking architecture eliminates user-facing freezes
2. **IRAM is precious**: On ESP8266, use `ICACHE_FLASH_ATTR` liberally
3. **Hybrid approaches work**: Don't be dogmatic - synchronous WiFi in setup() solved a critical UX issue
4. **State machines scale**: Better than callback hell for complex async operations
5. **Test on real hardware**: Emulators can't catch pin mapping errors or memory constraints
### Security
1. **IoT security is often terrible**: Always audit devices before trusting them on your network
2. **Open source is safer**: Closed firmware is a black box - you have no idea what it's doing
3. **Defaults matter**: Insecure defaults (open AP, plaintext passwords) lead to real vulnerabilities
4. **Defense in depth**: Multiple layers (WiFiManager timeout, password protection, validation) catch mistakes
### Development
1. **OTA from day 1**: Flashing via FTDI gets old fast - build OTA support early
2. **Version your work**: Backup files (.bak, .bak2) saved me multiple times
3. **Document as you go**: Release notes and architecture docs prevent "what was I thinking?" moments
4. **Incremental improvements**: v1.7 → v1.8 → v1.9.x made debugging manageable
---
## Credits ## Credits
**Hardware**: TJ-56-654 Weather Clock Kit ([AliExpress](https://pt.aliexpress.com/item/1005008333782531.html)) **Hardware**: TJ-56-654 Weather Clock Kit ([AliExpress](https://pt.aliexpress.com/item/1005008333782531.html))
@@ -976,7 +605,7 @@ Device reboots immediately.
- [ESP8266 Arduino Core](https://github.com/esp8266/Arduino) - [ESP8266 Arduino Core](https://github.com/esp8266/Arduino)
- [Adafruit SSD1306](https://github.com/adafruit/Adafruit_SSD1306) - [Adafruit SSD1306](https://github.com/adafruit/Adafruit_SSD1306)
- [WiFiManager](https://github.com/tzapu/WiFiManager) - [WiFiManager](https://github.com/tzapu/WiFiManager)
- [AsyncHTTPRequest_Generic](https://github.com/khoih-prog/AsyncHTTPRequest_Generic) - [asyncHTTPrequest](https://github.com/boblemaire/asyncHTTPrequest)
- [NTPClient](https://github.com/arduino-libraries/NTPClient) - [NTPClient](https://github.com/arduino-libraries/NTPClient)
**APIs**: **APIs**:
@@ -996,8 +625,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 +643,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
@@ -1013,29 +666,6 @@ This project is released into the public domain. Do whatever you want with it. I
--- ---
## Final Thoughts
This project started as "I don't trust this device" and ended as "I built something better."
The original firmware had security holes you could drive a truck through. The custom replacement:
- ✅ Doesn't leak WiFi passwords
- ✅ Uses free, open APIs
- ✅ Updates over WiFi
- ✅ Runs fully async (no freezing)
- ✅ Integrates with Home Assistant (coming soon)
- ✅ Is completely auditable (you're reading the source)
Total cost: €5 hardware + a weekend of tinkering.
If you have one of these devices, **flash this firmware**. If you're buying IoT gadgets, **always audit them first**. And if something seems insecure, **fix it yourself** - that's the hacker spirit.
Now go make something cool. 🚀
--- ---
**P.S.**: If you found this useful, consider starring the repo. If you found a bug, open an issue. If you want to add Home Assistant screens, let's collaborate - I'm planning that next! **Author**: apetrochenko · **License**: MIT · **Firmware**: v1.9.10
**Author**: apetrochenko
**Date**: 2026-01-06
**Firmware Version**: v1.9.4 (Production Ready)
+118
View File
@@ -0,0 +1,118 @@
# Architecture Notes
Internal notes for contributors modifying the firmware. End users don't need this.
## Async model
The main loop performs zero blocking operations. All network I/O runs through callback-based state machines:
- **Weather**: `AsyncHTTPRequest` callback updates `weatherState` (IDLE → REQUESTING → IDLE on success/failure), with a 15s watchdog in the loop to recover from TCP hangs
- **NTP**: Manual UDP packet build + non-blocking `parsePacket()` with 5s timeout
- **WiFi**: Synchronous in `setup()` (so OTA / web / NTP have a working network at start), async reconnect in `loop()` with exponential backoff
State variables shared between callbacks and the main loop are declared `volatile` (`weatherState`, `ntpState`) since ESPAsyncTCP callbacks fire from within `yield()`/TCP poll and can interrupt the loop at any point.
## Timer correctness
`millis()` is `uint32_t` and rolls over every ~49.7 days. All deadline comparisons use the subtraction-safe pattern:
```cpp
// Unsafe: fails at rollover
if (millis() >= nextRetryTime) { ... }
// Safe: works across rollover
if ((millis() - nextRetryTime) < 0x80000000UL) { ... }
```
See `RetryConfig::isRetryTime()` in `config.h` for the canonical implementation.
## Memory budget (ESP-01S, 1MB flash)
| Pool | Total | Used | % | Notes |
| --------- | --------- | -------- | --- | -------------------------------------------------------- |
| **Flash** | 1,048,576 | ~411,000 | 39% | Headroom for features |
| **RAM** | 80,192 | ~37,700 | 46% | Stable across all 1.9.x releases |
| **IRAM** | 65,536 | 61,987 | 94% | **Critical** — `ICACHE_FLASH_ATTR` required on new funcs |
### IRAM discipline
IRAM is the tightest constraint. Apply `ICACHE_FLASH_ATTR` to any non-critical function so its code lives in flash instead of IRAM:
```cpp
void ICACHE_FLASH_ATTR handleConfig() {
// code in flash, slightly slower but saves IRAM
}
```
Currently 26 functions use this attribute (web handlers, display routines, config helpers). Functions called from interrupt context or hot loops should stay in IRAM.
### Heap fragmentation
ESP8266 heap is never compacted. Repeated `String` concatenation in long-running handlers (especially `/api/*` endpoints polled by the web UI) permanently fragments the heap until reboot.
Pattern to follow for HTTP responses:
```cpp
char buf[256];
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf), PSTR("{\"...\":%d}"), value);
server.sendContent(buf);
server.sendContent(""); // close chunked transfer
```
Never:
```cpp
String html = "";
html += "..."; // <- heap fragmentation
```
## EEPROM layout
512 bytes mapped via `EEPROM.begin(512)`:
| Offset | Size | Content |
| ------ | ------ | ------------------------------------------------- |
| 0 | ~260 B | `Config` struct (validated by magic `0xC10CC10C`) |
| 480 | 2 B | `ResetCounter` for triple power-cycle reset |
Writes are flash-sector-erase operations (4KB), so the entire 512-byte image is rewritten on each `EEPROM.commit()`. Expect ~100k cycles before wear matters.
The `Config` struct includes a magic number — on validation failure, the firmware falls back to defaults rather than loading garbage.
## Factory reset
Three quick power cycles within 10 seconds (controlled by `RESET_COUNTER_WINDOW`) triggers a factory reset:
1. Each boot increments the counter at EEPROM offset 480
2. If the device runs for 10s, the counter clears back to 0
3. If count reaches 3 first, WiFi credentials are wiped and the device reboots into WiFiManager AP mode
For atomicity, credentials are committed BEFORE the counter is zeroed. Power loss between the two writes still results in a recoverable state (cleared creds → AP boot).
## Display
128×64 OLED, SSD1306-compatible (GM009605v4.3 panel). The ESP-01S exposes only GPIO0 and GPIO2, and the board designer mapped them as I2C:
```cpp
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2 (non-standard!)
```
This is the inverse of typical breakout boards — easy to get wrong if you're porting code.
## Web server
`ESP8266WebServer` (not async, but fast enough for low-frequency requests). All API responses use chunked transfer with `snprintf` + `sendContent`. Form save (`/config`) only triggers a reboot when WiFi/network fields actually change; other settings apply live.
Input validation in `handleConfigSave` clamps numeric fields and rejects garbage SSIDs (HTTP 400) — see `isValidSSID()` in `web_server.cpp`.
## Adding new features
1. Check IRAM usage after build — if growing, add `ICACHE_FLASH_ATTR` to your new functions
2. Don't use `String` in handlers or callbacks — use `char buf[]` + `snprintf`
3. New shared state between callbacks and loop → declare `volatile`
4. New timer logic → use the subtraction-safe `millis()` pattern
5. New API endpoints → run `tests/test_device.py` against the device to verify heap stability
For more context on past design decisions, see `CHANGELOG.md`.
+28
View File
@@ -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:
+16 -6
View File
@@ -9,7 +9,7 @@
#include <Arduino.h> #include <Arduino.h>
// Firmware version // Firmware version
#define FIRMWARE_VERSION "1.9.6" #define FIRMWARE_VERSION "1.9.10"
// 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
@@ -167,4 +165,16 @@ const unsigned long NTP_TIMEOUT_MS = 5000; // 5 second timeout
// WiFi timeout // WiFi timeout
const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second 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 #endif // CONFIG_H
+4 -6
View File
@@ -3,20 +3,18 @@
* TJ-56-654 Weather Clock v1.9.3 * TJ-56-654 Weather Clock v1.9.3
*/ */
// Include AsyncHTTPRequest BEFORE globals.h to provide full type definition // Include asyncHTTPrequest before globals.h to provide the full type definition
#include <ESPAsyncTCP.h> #include <ESPAsyncTCP.h>
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN_TARGET "AsyncHTTPRequest_Generic v1.13.0" #include <asyncHTTPrequest.h>
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN 1013000
#include <AsyncHTTPRequest_Generic.h>
#include "globals.h" #include "globals.h"
#include <ArduinoJson.h> #include <ArduinoJson.h>
// Async HTTP client for weather (local to this file) // Async HTTP client for weather (local to this file)
static AsyncHTTPRequest weatherRequest; static asyncHTTPrequest weatherRequest;
// Weather response callback // Weather response callback
void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* request, int readyState) { void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, asyncHTTPrequest* request, int readyState) {
(void)optParm; // Unused (void)optParm; // Unused
if (readyState == 4) { // Request complete if (readyState == 4) { // Request complete
+98 -1
View File
@@ -110,6 +110,70 @@ void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxL
dest[maxLen - 1] = '\0'; 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 !!!");
// 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;
EEPROM.put(RESET_COUNTER_ADDR, rc);
EEPROM.commit();
EEPROM.end();
// 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 ============ // ============ EEPROM functions ============
void ICACHE_FLASH_ATTR loadConfig() { void ICACHE_FLASH_ATTR loadConfig() {
@@ -186,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)");
@@ -219,6 +288,9 @@ void setup() {
// Load configuration // Load configuration
loadConfig(); loadConfig();
// Check for triple power-cycle factory reset (must be after display+config init)
checkFactoryReset();
// Setup WiFi // Setup WiFi
setupWiFi(); setupWiFi();
@@ -271,16 +343,29 @@ void loop() {
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) { if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
Serial.println("Enabling fallback AP (dual mode)"); 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.mode(WIFI_AP_STA);
WiFi.softAP("TJ56654-Setup", "12345678"); WiFi.softAP("TJ56654-Setup", "12345678");
Serial.print("Fallback AP IP: "); Serial.print("Fallback AP IP: ");
Serial.println(WiFi.softAPIP()); 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) { if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password); WiFi.begin(config.ssid, config.password);
} else { } 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; wifiConnState = WIFI_CONN_CONNECTING;
wifiConnectStart = millis(); wifiConnectStart = millis();
@@ -325,6 +410,18 @@ void loop() {
fetchWeatherAsync(); fetchWeatherAsync();
} }
// 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) // Update weather periodically (static flag avoids millis() > 10000 rollover trap)
static bool weatherBootReady = false; static bool weatherBootReady = false;
if (!weatherBootReady && millis() > 10000UL) weatherBootReady = true; if (!weatherBootReady && millis() > 10000UL) weatherBootReady = true;
+86 -22
View File
@@ -283,57 +283,121 @@ void ICACHE_FLASH_ATTR handleConfig() {
server.sendContent(""); 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;
}
// 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() {
// Track if reboot is required (only WiFi/network changes need it)
bool needsRestart = false;
// Validate before saving — reject obviously bad input rather than brick the device
if (server.hasArg("ssid")) { 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;
}
if (s != String(config.ssid)) needsRestart = true;
safeStringCopy(s, config.ssid, sizeof(config.ssid));
} }
if (server.hasArg("password")) { 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;
}
if (p != String(config.password)) needsRestart = true;
safeStringCopy(p, config.password, sizeof(config.password));
} }
if (server.hasArg("timezone")) { 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")) { if (server.hasArg("brightness")) {
config.brightness = server.arg("brightness").toInt(); config.brightness = constrain(server.arg("brightness").toInt(), 0, 7);
} }
if (server.hasArg("hostname")) { 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;
}
if (h != String(config.hostname)) needsRestart = true; // mDNS bind on boot
safeStringCopy(h, config.hostname, sizeof(config.hostname));
} }
if (server.hasArg("city_name")) { 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")) { 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")) { 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")) { 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();
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")) {
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;
}
if (n != String(config.ntp_server)) needsRestart = true; // NTPClient re-init
safeStringCopy(n, config.ntp_server, sizeof(config.ntp_server));
} }
if (server.hasArg("display_rotation_sec")) { 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")) { 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); display.setRotation(config.display_orientation);
} }
saveConfig(); saveConfig();
String html = F("<!DOCTYPE html><html><head>"); // Only restart for WiFi/network changes; other settings apply live
html += F("<meta charset='UTF-8'>"); if (needsRestart) {
html += F("<meta http-equiv='refresh' content='5;url=/'>"); server.send(200, "text/html",
html += F("<style>body{font-family:Arial;text-align:center;margin-top:50px;}</style>"); F("<!DOCTYPE html><meta charset='UTF-8'>"
html += F("</head><body>"); "<meta http-equiv='refresh' content='5;url=/'>"
html += F("<h1>Configuration Saved!</h1>"); "<h1>Configuration Saved!</h1>"
html += F("<p>Device will reboot in 5 seconds...</p>"); "<p>WiFi/network changed — device will reboot in 5 seconds...</p>"));
html += F("</body></html>");
server.send(200, "text/html", html);
delay(1000); delay(1000);
ESP.restart(); ESP.restart();
} else {
server.send(200, "text/html",
F("<!DOCTYPE html><meta charset='UTF-8'>"
"<meta http-equiv='refresh' content='2;url=/'>"
"<h1>Configuration Saved</h1>"
"<p>Applied without reboot. Returning to main page...</p>"));
}
} }
void ICACHE_FLASH_ATTR handleAPITime() { void ICACHE_FLASH_ATTR handleAPITime() {
+16 -3
View File
@@ -28,9 +28,10 @@ void ICACHE_FLASH_ATTR processWiFiConnection() {
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
} }
// Sync connected SSID to config // Sync connected SSID + password to config
if (strlen(config.ssid) == 0) { if (strlen(config.ssid) == 0) {
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid)); safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
saveConfig(); saveConfig();
} }
@@ -96,7 +97,12 @@ void ICACHE_FLASH_ATTR setupWiFi() {
Serial.print("DNS: "); Serial.print("DNS: ");
Serial.println(WiFi.dnsIP()); Serial.println(WiFi.dnsIP());
// Persist the password too, not just the SSID. The SDK keeps the
// password in its own flash area, but every later boot reads
// config.password: an empty one is treated as an open network
// (WiFi.begin(ssid)) and fails with WL_WRONG_PASSWORD (issue #12).
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid)); safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
saveConfig(); saveConfig();
showIP(); showIP();
@@ -105,10 +111,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...");
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password); 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) {
@@ -162,7 +172,10 @@ void ICACHE_FLASH_ATTR setupWiFi() {
Serial.print("IP: "); Serial.print("IP: ");
Serial.println(WiFi.localIP()); Serial.println(WiFi.localIP());
// Save both SSID and password (issue #12): WiFiManager stores them in the
// SDK flash, but the next boot connects from config.* only.
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid)); safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
saveConfig(); saveConfig();
showIP(); showIP();
+19
View File
@@ -0,0 +1,19 @@
[platformio]
src_dir = firmware/weather_clock
[env:esp01_1m]
platform = espressif8266
board = esp01_1m
framework = arduino
board_build.ldscript = eagle.flash.1m64.ld
board_build.flash_mode = dio
upload_speed = 115200
upload_resetmethod = nodemcu
lib_deps =
adafruit/Adafruit GFX Library
adafruit/Adafruit SSD1306
arduino-libraries/NTPClient
tzapu/WiFiManager
boblemaire/asyncHTTPrequest @ 1.2.2
me-no-dev/ESPAsyncTCP
bblanchon/ArduinoJson @ ^7
+329
View File
@@ -0,0 +1,329 @@
#!/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 'time' field", "time" in data, str(data.keys()))
if "time" in data:
t = data["time"]
test("Time format HH:MM:SS", len(t) == 8 and t[2] == ":" and t[5] == ":",
f"got '{t}'")
test("Has 'hours'", "hours" in data)
test("Has 'minutes'", "minutes" in data)
test("Has 'epoch'", "epoch" in data and data["epoch"] > 1700000000)
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())