Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe77b61813 | ||
|
|
aee1d3e0ef | ||
|
|
75b6a21f3c | ||
|
|
259eda6663 | ||
|
|
f42ede5fa4 | ||
|
|
d0aa68e64f | ||
|
|
5b9285aeb9 | ||
|
|
02834bada1 | ||
|
|
11aab761c3 | ||
|
|
5b8a58715e | ||
|
|
e9dc158a4e | ||
|
|
7ebe341c06 | ||
|
|
78bbd96f45 | ||
|
|
eeec0155c3 |
@@ -1,5 +1,6 @@
|
|||||||
# Arduino build artifacts
|
# Arduino build artifacts
|
||||||
build/
|
build/
|
||||||
|
.pio/
|
||||||
*.bin
|
*.bin
|
||||||
*.elf
|
*.elf
|
||||||
*.map
|
*.map
|
||||||
@@ -25,3 +26,6 @@ build/
|
|||||||
# Secrets (in case someone accidentally commits credentials)
|
# Secrets (in case someone accidentally commits credentials)
|
||||||
secrets.h
|
secrets.h
|
||||||
config_local.h
|
config_local.h
|
||||||
|
|
||||||
|
# Internal backlogs (not for public repo)
|
||||||
|
BACKLOG_*.md
|
||||||
|
|||||||
@@ -5,6 +5,88 @@ 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Weather hangs permanently after TCP timeout** (C1): `WEATHER_REQUESTING` state now has a
|
||||||
|
15-second watchdog — if the HTTP callback never fires (NAT timeout, server half-close),
|
||||||
|
state resets to IDLE and retry logic resumes
|
||||||
|
- **All retry timers freeze at ~49 days** (C2): Replaced unsafe `millis() >= nextRetryTime`
|
||||||
|
with subtraction-safe `(millis() - nextRetryTime) < 0x80000000UL` in RetryConfig and
|
||||||
|
WiFiRetryConfig; fixed boot guard with static flag
|
||||||
|
- **DST switches on wrong day** (H1): Last-Sunday formula was using year-only heuristic;
|
||||||
|
now computes weekday of the 31st from current `tm_wday`: verified correct for 2026–2030
|
||||||
|
- **Heap fragmentation from web API polling** (H2): Replaced String+= concatenation with
|
||||||
|
`snprintf`+`sendContent` in `handleAPITime`, `handleAPIStatus`, `handleAPIDebug`,
|
||||||
|
`handleAPIWeather` — eliminates permanent heap fragmentation from 1s JS polling
|
||||||
|
- **Race condition on shared state** (H3): Added `volatile` to `weatherState` and `ntpState`
|
||||||
|
— prevents compiler from caching stale values across ESPAsyncTCP callback boundaries
|
||||||
|
- **Malformed JSON on long error messages**: `lastError` clamped to 79 chars before snprintf
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- RAM usage: 37,268 bytes (46%) — down from 37,560 due to String elimination in API handlers
|
||||||
|
|
||||||
## [1.9.5] - 2026-05-18
|
## [1.9.5] - 2026-05-18
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.5"
|
#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!
|
||||||
@@ -70,7 +70,8 @@ struct RetryConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool isRetryTime() {
|
bool isRetryTime() {
|
||||||
return nextRetryTime > 0 && millis() >= nextRetryTime;
|
// Subtraction-safe: works correctly across millis() rollover at ~49.7 days
|
||||||
|
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
@@ -101,7 +102,8 @@ struct WiFiRetryConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool isRetryTime() {
|
bool isRetryTime() {
|
||||||
return nextRetryTime > 0 && millis() >= nextRetryTime;
|
// Subtraction-safe: works correctly across millis() rollover at ~49.7 days
|
||||||
|
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
@@ -118,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
|
||||||
@@ -165,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
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ extern NTPClient timeClient;
|
|||||||
extern ESP8266WebServer server;
|
extern ESP8266WebServer server;
|
||||||
extern ESP8266HTTPUpdateServer httpUpdater;
|
extern ESP8266HTTPUpdateServer httpUpdater;
|
||||||
|
|
||||||
// State machines
|
// State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
|
||||||
extern WeatherState weatherState;
|
extern volatile WeatherState weatherState;
|
||||||
extern NTPState ntpState;
|
extern volatile NTPState ntpState;
|
||||||
extern WiFiConnectionState wifiConnState;
|
extern WiFiConnectionState wifiConnState;
|
||||||
|
|
||||||
// Retry configurations
|
// Retry configurations
|
||||||
@@ -71,6 +71,7 @@ extern SunTimes sunTimes;
|
|||||||
extern uint8_t displayMode;
|
extern uint8_t displayMode;
|
||||||
extern unsigned long lastModeSwitch;
|
extern unsigned long lastModeSwitch;
|
||||||
extern unsigned long lastWeatherUpdate;
|
extern unsigned long lastWeatherUpdate;
|
||||||
|
extern unsigned long weatherRequestStart;
|
||||||
|
|
||||||
// Dissolve transition state
|
// Dissolve transition state
|
||||||
extern bool inTransition;
|
extern bool inTransition;
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
|
|||||||
|
|
||||||
// March: DST starts last Sunday at 01:00 UTC
|
// March: DST starts last Sunday at 01:00 UTC
|
||||||
if (month == 3) {
|
if (month == 3) {
|
||||||
int lastSunday = 31 - ((5 + timeinfo->tm_year) % 7);
|
// Compute weekday of the 31st from current day's weekday (tm_wday: 0=Sun)
|
||||||
|
int weekdayOf31 = (timeinfo->tm_wday + (31 - day)) % 7;
|
||||||
|
int lastSunday = 31 - weekdayOf31;
|
||||||
if (day < lastSunday) return false;
|
if (day < lastSunday) return false;
|
||||||
if (day > lastSunday) return true;
|
if (day > lastSunday) return true;
|
||||||
if (hour < 1) return false;
|
if (hour < 1) return false;
|
||||||
@@ -35,7 +37,8 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
|
|||||||
|
|
||||||
// October: DST ends last Sunday at 01:00 UTC
|
// October: DST ends last Sunday at 01:00 UTC
|
||||||
if (month == 10) {
|
if (month == 10) {
|
||||||
int lastSunday = 31 - ((1 + timeinfo->tm_year) % 7);
|
int weekdayOf31 = (timeinfo->tm_wday + (31 - day)) % 7;
|
||||||
|
int lastSunday = 31 - weekdayOf31;
|
||||||
if (day < lastSunday) return true;
|
if (day < lastSunday) return true;
|
||||||
if (day > lastSunday) return false;
|
if (day > lastSunday) return false;
|
||||||
if (hour < 1) return true;
|
if (hour < 1) return true;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -156,6 +154,7 @@ void ICACHE_FLASH_ATTR fetchWeatherAsync() {
|
|||||||
weatherRequest.setTimeout(10); // 10 seconds
|
weatherRequest.setTimeout(10); // 10 seconds
|
||||||
weatherRequest.send();
|
weatherRequest.send();
|
||||||
weatherState = WEATHER_REQUESTING;
|
weatherState = WEATHER_REQUESTING;
|
||||||
|
weatherRequestStart = millis();
|
||||||
Serial.println("Weather request sent (non-blocking)");
|
Serial.println("Weather request sent (non-blocking)");
|
||||||
} else {
|
} else {
|
||||||
weatherState = WEATHER_FAILED;
|
weatherState = WEATHER_FAILED;
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
|
|||||||
ESP8266WebServer server(80);
|
ESP8266WebServer server(80);
|
||||||
ESP8266HTTPUpdateServer httpUpdater;
|
ESP8266HTTPUpdateServer httpUpdater;
|
||||||
|
|
||||||
// State machines
|
// State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
|
||||||
WeatherState weatherState = WEATHER_IDLE;
|
volatile WeatherState weatherState = WEATHER_IDLE;
|
||||||
NTPState ntpState = NTP_IDLE;
|
volatile NTPState ntpState = NTP_IDLE;
|
||||||
WiFiConnectionState wifiConnState = WIFI_CONN_IDLE;
|
WiFiConnectionState wifiConnState = WIFI_CONN_IDLE;
|
||||||
|
|
||||||
// Retry configurations
|
// Retry configurations
|
||||||
@@ -92,6 +92,7 @@ SunTimes sunTimes;
|
|||||||
uint8_t displayMode = 0;
|
uint8_t displayMode = 0;
|
||||||
unsigned long lastModeSwitch = 0;
|
unsigned long lastModeSwitch = 0;
|
||||||
unsigned long lastWeatherUpdate = 0;
|
unsigned long lastWeatherUpdate = 0;
|
||||||
|
unsigned long weatherRequestStart = 0; // Tracks when WEATHER_REQUESTING began (TCP hang watchdog)
|
||||||
|
|
||||||
// Dissolve transition state
|
// Dissolve transition state
|
||||||
bool inTransition = false;
|
bool inTransition = false;
|
||||||
@@ -109,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() {
|
||||||
@@ -185,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)");
|
||||||
@@ -218,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();
|
||||||
|
|
||||||
@@ -270,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();
|
||||||
@@ -311,14 +397,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
|
// Check for weather retry
|
||||||
if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) {
|
if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) {
|
||||||
Serial.println("Weather retry time reached, attempting retry...");
|
Serial.println("Weather retry time reached, attempting retry...");
|
||||||
fetchWeatherAsync();
|
fetchWeatherAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update weather periodically
|
// Clear factory-reset boot counter after 10s of normal operation
|
||||||
if (config.weather_enabled && millis() > 10000) {
|
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;
|
unsigned long weatherInterval = config.weather_interval * 1000UL;
|
||||||
if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) {
|
if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) {
|
||||||
if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) {
|
if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) {
|
||||||
|
|||||||
@@ -283,122 +283,221 @@ 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() {
|
||||||
String json = "{";
|
char buf[256];
|
||||||
json += "\"time\":\"" + timeClient.getFormattedTime() + "\",";
|
|
||||||
json += "\"hours\":" + String(timeClient.getHours()) + ",";
|
|
||||||
json += "\"minutes\":" + String(timeClient.getMinutes()) + ",";
|
|
||||||
json += "\"seconds\":" + String(timeClient.getSeconds()) + ",";
|
|
||||||
json += "\"epoch\":" + String(timeClient.getEpochTime());
|
|
||||||
json += "}";
|
|
||||||
|
|
||||||
server.send(200, "application/json", json);
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "application/json", "");
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf),
|
||||||
|
PSTR("{\"time\":\"%s\",\"hours\":%d,\"minutes\":%d,\"seconds\":%d,\"epoch\":%lu}"),
|
||||||
|
timeClient.getFormattedTime().c_str(),
|
||||||
|
timeClient.getHours(),
|
||||||
|
timeClient.getMinutes(),
|
||||||
|
timeClient.getSeconds(),
|
||||||
|
timeClient.getEpochTime());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent("");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ICACHE_FLASH_ATTR handleAPIStatus() {
|
void ICACHE_FLASH_ATTR handleAPIStatus() {
|
||||||
String json = "{";
|
char buf[256];
|
||||||
json += "\"wifi\":{";
|
|
||||||
json += "\"ssid\":\"" + String(WiFi.SSID()) + "\",";
|
|
||||||
json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
|
|
||||||
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
|
|
||||||
json += "\"hostname\":\"" + String(config.hostname) + "\"";
|
|
||||||
json += "},";
|
|
||||||
json += "\"time\":{";
|
|
||||||
json += "\"current\":\"" + timeClient.getFormattedTime() + "\",";
|
|
||||||
json += "\"timezone_offset\":" + String(config.timezone_offset) + ",";
|
|
||||||
json += "\"ntp_synced\":" + String(timeClient.isTimeSet() ? "true" : "false");
|
|
||||||
json += "},";
|
|
||||||
json += "\"system\":{";
|
|
||||||
json += "\"uptime\":" + String(millis() / 1000) + ",";
|
|
||||||
json += "\"free_heap\":" + String(ESP.getFreeHeap()) + ",";
|
|
||||||
json += "\"chip_id\":\"" + String(ESP.getChipId(), HEX) + "\"";
|
|
||||||
json += "}";
|
|
||||||
json += "}";
|
|
||||||
|
|
||||||
server.send(200, "application/json", json);
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "application/json", "");
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf),
|
||||||
|
PSTR("{\"wifi\":{\"ssid\":\"%s\",\"ip\":\"%s\",\"rssi\":%d,\"hostname\":\"%s\"},"),
|
||||||
|
WiFi.SSID().c_str(),
|
||||||
|
WiFi.localIP().toString().c_str(),
|
||||||
|
WiFi.RSSI(),
|
||||||
|
config.hostname);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf),
|
||||||
|
PSTR("\"time\":{\"current\":\"%s\",\"timezone_offset\":%ld,\"ntp_synced\":%s},"),
|
||||||
|
timeClient.getFormattedTime().c_str(),
|
||||||
|
config.timezone_offset,
|
||||||
|
timeClient.isTimeSet() ? "true" : "false");
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf),
|
||||||
|
PSTR("\"system\":{\"uptime\":%lu,\"free_heap\":%u,\"chip_id\":\"%x\"}}"),
|
||||||
|
millis() / 1000,
|
||||||
|
ESP.getFreeHeap(),
|
||||||
|
ESP.getChipId());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent("");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ICACHE_FLASH_ATTR handleAPIDebug() {
|
void ICACHE_FLASH_ATTR handleAPIDebug() {
|
||||||
String json = "{";
|
char buf[256];
|
||||||
json += "\"internet_connected\":" + String(internetConnected ? "true" : "false") + ",";
|
char errBuf[80];
|
||||||
json += "\"ntp_attempts\":" + String(ntpAttempts) + ",";
|
|
||||||
json += "\"ntp_successes\":" + String(ntpSuccesses) + ",";
|
|
||||||
json += "\"last_error\":\"" + lastError + "\",";
|
|
||||||
json += "\"gateway\":\"" + WiFi.gatewayIP().toString() + "\",";
|
|
||||||
json += "\"dns\":\"" + WiFi.dnsIP().toString() + "\"";
|
|
||||||
json += "}";
|
|
||||||
|
|
||||||
server.send(200, "application/json", json);
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "application/json", "");
|
||||||
|
|
||||||
|
// Clamp lastError to prevent snprintf truncation causing malformed JSON
|
||||||
|
strncpy(errBuf, lastError.c_str(), sizeof(errBuf) - 1);
|
||||||
|
errBuf[sizeof(errBuf) - 1] = '\0';
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf),
|
||||||
|
PSTR("{\"internet_connected\":%s,\"ntp_attempts\":%d,\"ntp_successes\":%d,\"last_error\":\"%s\",\"gateway\":\"%s\",\"dns\":\"%s\"}"),
|
||||||
|
internetConnected ? "true" : "false",
|
||||||
|
ntpAttempts,
|
||||||
|
ntpSuccesses,
|
||||||
|
errBuf,
|
||||||
|
WiFi.gatewayIP().toString().c_str(),
|
||||||
|
WiFi.dnsIP().toString().c_str());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent("");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ICACHE_FLASH_ATTR handleAPIWeather() {
|
void ICACHE_FLASH_ATTR handleAPIWeather() {
|
||||||
String json = "{";
|
char buf[256];
|
||||||
json += "\"enabled\":" + String(config.weather_enabled ? "true" : "false") + ",";
|
|
||||||
json += "\"valid\":" + String(weather.valid ? "true" : "false") + ",";
|
|
||||||
json += "\"temperature\":" + String(weather.temperature, 1) + ",";
|
|
||||||
json += "\"weathercode\":" + String(weather.weathercode) + ",";
|
|
||||||
json += "\"windspeed\":" + String(weather.windspeed, 1) + ",";
|
|
||||||
json += "\"last_update\":" + String(weather.lastUpdate) + ",";
|
|
||||||
json += "\"sunrise\":\"" + String(sunTimes.sunrise) + "\",";
|
|
||||||
json += "\"sunset\":\"" + String(sunTimes.sunset) + "\",";
|
|
||||||
json += "\"sunrise_minutes\":" + String(sunTimes.sunriseMinutes) + ",";
|
|
||||||
json += "\"sunset_minutes\":" + String(sunTimes.sunsetMinutes);
|
|
||||||
json += "}";
|
|
||||||
|
|
||||||
server.send(200, "application/json", json);
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "application/json", "");
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf),
|
||||||
|
PSTR("{\"enabled\":%s,\"valid\":%s,\"temperature\":%.1f,\"weathercode\":%d,\"windspeed\":%.1f,\"last_update\":%lu,"),
|
||||||
|
config.weather_enabled ? "true" : "false",
|
||||||
|
weather.valid ? "true" : "false",
|
||||||
|
weather.temperature,
|
||||||
|
weather.weathercode,
|
||||||
|
weather.windspeed,
|
||||||
|
weather.lastUpdate);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf),
|
||||||
|
PSTR("\"sunrise\":\"%s\",\"sunset\":\"%s\",\"sunrise_minutes\":%d,\"sunset_minutes\":%d}"),
|
||||||
|
sunTimes.sunrise,
|
||||||
|
sunTimes.sunset,
|
||||||
|
sunTimes.sunriseMinutes,
|
||||||
|
sunTimes.sunsetMinutes);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent("");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ICACHE_FLASH_ATTR handleAPIConfigExport() {
|
void ICACHE_FLASH_ATTR handleAPIConfigExport() {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user