22 Commits
Author SHA1 Message Date
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
Alex Petrochenko e9dc158a4e chore: bump version to 1.9.6, update CHANGELOG 2026-05-18 16:02:53 +01:00
Alex Petrochenko 7ebe341c06 fix(web): clamp lastError before snprintf to prevent malformed JSON (Gemini review) 2026-05-18 15:57:41 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 78bbd96f45 fix: C1 C2 H1 H2 H3 — stability fixes for long-running operation
C1 (critical): Add 15s watchdog for WEATHER_REQUESTING state — resets to
IDLE if TCP connection hangs silently, preventing permanent weather death

C2 (critical): Fix millis() rollover in all retry timers — replace unsafe
`millis() >= nextRetryTime` with subtraction-safe `(millis() - nextRetryTime)
< 0x80000000UL` in RetryConfig and WiFiRetryConfig; fix boot guard with
static flag instead of raw millis() comparison

H1 (high): Fix DST last-Sunday formula — was using incorrect year-only
heuristic; now derives weekday of the 31st from current day's tm_wday:
`weekdayOf31 = (tm_wday + (31 - day)) % 7`. Verified: March 2026 = 29th ✓

H2 (high): Replace String+= with snprintf+sendContent in handleAPITime,
handleAPIStatus, handleAPIDebug, handleAPIWeather — eliminates permanent
heap fragmentation from JS polling every second

H3 (high): Add volatile to weatherState and ntpState — shared between
ESPAsyncTCP callbacks and main loop; prevents stale register-cached reads

RAM: 37,268 bytes (46%) — reduced from 37,560 due to String elimination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:53:39 +01:00
Alex Petrochenko eeec0155c3 chore: ignore internal BACKLOG files 2026-05-18 15:25:34 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 d5d2b23129 chore: bump version to 1.9.5, update CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:53:15 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 2f3814c225 fix(weather): recover from permanent lockup after max retries (closes #9)
After 3 consecutive API failures, weatherRetry.reset() was never called
and weatherState stayed WEATHER_FAILED permanently. Both the retry path
and periodic refresh path require WEATHER_IDLE, so no new requests were
ever made — even after the API recovered.

Fix: when maxRetriesReached(), reset weatherRetry and set weatherState
back to WEATHER_IDLE so the next periodic refresh interval (default 30min)
triggers a fresh attempt automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:52:42 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 e4ef5c8e07 chore: bump version to 1.9.4, update docs
- FIRMWARE_VERSION 1.9.3 → 1.9.4
- CHANGELOG: add v1.9.3 (modular refactor) and v1.9.4 (bug fixes) entries
- README: add v1.9.3/v1.9.4 journey sections, fix project structure tree,
  update version references, correct SDK credentials description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:52:26 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 3a34244ac8 refactor: rename firmware directory to weather_clock
clock_ntp_ota_v1.9/ → weather_clock/ (version no longer baked into path)
clock_ntp_ota_v1.9.ino → weather_clock.ino

Version is tracked in config.h (FIRMWARE_VERSION), not in filenames.
Updated references in README, CONTRIBUTING, and docs/INSTALLATION.md.

Note: forks using the old path will need to sync this rename before
submitting new PRs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:52:34 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 55bec76d88 chore: remove internal version notes, clean up CHANGELOG links
docs/v1.9_RELEASE_NOTES.md, v1.9.1_HYBRID_FIX.md, v1.9.2_WIFI_RESILIENCE.md
were internal working documents (incomplete checklists, dev notes).
CHANGELOG.md already covers all versions — links removed, status updated to v1.9.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:42:59 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 29d7c7f8ad chore: remove monolithic src/ copy, firmware/ is now canonical
src/clock_ntp_ota_v1.9.ino was a duplicate of firmware/clock_ntp_ota_v1.9/
that had already drifted out of sync. Removing it eliminates the need to
patch both copies on every PR.

Updated CONTRIBUTING.md build path to point to firmware/clock_ntp_ota_v1.9.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:40:36 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 98b40ff0cf chore: remove internal dev documents from public repo
PROJECT_CONTEXT.md, PROJECT_STRUCTURE.md, PUBLISH_TO_GITHUB.md were
working notes not intended for contributors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:40:04 +01:00
8d404506c4 fix: resolve compiler warnings (from PR #8 by @waltje)
- weather.cpp: StaticJsonDocument → JsonDocument (ArduinoJson v7 API)
- display.cpp: daylightStr buffer 16 → 32 bytes (suppress GCC sprintf warning)
- ntp_client.cpp: remove unused weekday variable

Applied manually due to repo layout mismatch in original PR.

Co-authored-by: waltje <waltje@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:34:35 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 09b8680408 fix(wifi): skip SDK-cached credentials when SSID is already configured (closes #3)
WiFi.begin() without params connects to any SDK-cached network, including
open public hotspots, and then overwrites config.ssid with the wrong SSID.

Now Try 1 (SDK credentials) is only attempted on first boot when no SSID
is configured. Once the user has saved credentials, we go straight to
Try 2 (EEPROM config), so the correct network is always used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:29:47 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 c606bcc589 docs: add ArduinoJson to required libraries list (closes #4)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:24:17 +01:00
441c61676b fix: date timezone + weather periodic refresh (closes #5, #7)
- Use localTime instead of epochTime when formatting date string,
  so date rolls over at local midnight, not UTC midnight (fixes #5)
- Remove WEATHER_SUCCESS state assignment that blocked periodic refresh;
  state correctly stays WEATHER_IDLE after successful fetch (fixes #7)
- Fix weather debug page "Last update" to show elapsed seconds
  (millis() - lastUpdate) instead of raw timestamp

Applied from PR #6 by @Mysteoa with a compilation fix in web_server.cpp
(missing closing parenthesis in snprintf_P call).

Co-authored-by: Mysteoa <mysteoa@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:20:08 +01:00
22 changed files with 1154 additions and 4631 deletions
+3
View File
@@ -25,3 +25,6 @@ build/
# Secrets (in case someone accidentally commits credentials)
secrets.h
config_local.h
# Internal backlogs (not for public repo)
BACKLOG_*.md
+127 -7
View File
@@ -5,15 +5,123 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.9.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
### Fixed
- **Temperature disappears permanently** (#9): After 3 consecutive API failures,
the weather state machine was permanently locked — `weatherState` stayed `WEATHER_FAILED`
and no new requests were ever made even after the API recovered. Fix: reset retry
counter and state after max retries so periodic refresh resumes after next interval (30 min).
## [1.9.4] - 2026-05-14
### Fixed
- **Date timezone** (#5): Date string now uses local time, so date rolls over at local midnight instead of UTC midnight
- **Weather periodic refresh** (#7): Removed `WEATHER_SUCCESS` state that permanently blocked periodic weather updates after first fetch; also fixed "Last update" counter showing raw timestamp instead of elapsed seconds
- **WiFi connects to wrong network** (#3): `WiFi.begin()` without params is now skipped when a saved SSID exists, preventing connection to SDK-cached open hotspots and config corruption
### Changed
- WiFi startup: SDK-cached credentials only attempted on first boot (no saved SSID); subsequent boots go directly to saved credentials
- ArduinoJson: `StaticJsonDocument` → `JsonDocument` for compatibility with ArduinoJson v7
### Removed
- Unused `weekday` variable in NTP DST calculation (compiler warning)
### Internal
- Split `clock_ntp_ota_v1.9/` directory renamed to `weather_clock/` — version no longer baked into path
- Removed internal development documents from repository
## [1.9.3] - 2026-01-06
### Changed
- Refactored monolithic 2,100-line `.ino` into modular structure:
`display.cpp`, `ntp_client.cpp`, `weather.cpp`, `web_server.cpp`, `wifi_manager.cpp`
## [1.9.2] - 2026-01-06
### Fixed
- **CRITICAL**: WiFi credentials no longer cleared on connection failure
- Previous behavior erased SSID/password after failed connection attempts
- Now credentials persist indefinitely through WiFi outages
- OTA update credential loss issue (SDK credentials now tried first, then EEPROM)
### Added
- **WiFi Resilience**: Infinite retry with exponential backoff (5s → 10s → 20s → ... → 5min max)
- **Fallback AP**: "TJ56654-Setup" enabled after ~5 min of failed attempts
- Device continues retry attempts while AP is active (dual STA+AP mode)
@@ -23,11 +131,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **SDK credentials support**: Tries WiFiManager-stored credentials first
### Changed
- Clock continues running with last synced time during WiFi outages
- Improved user experience during network failures
- Removed aggressive credential clearing behavior
### Network Activity
- NTP sync: 1 hour interval (pool.ntp.org:123 UDP)
- Weather fetch: 30 min interval (api.open-meteo.com HTTP)
- mDNS: continuous (224.0.0.251 UDP multicast)
@@ -36,22 +146,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.9.1] - 2026-01-03
### Fixed
- **CRITICAL**: WiFi startup sequence - synchronous connection in setup() to ensure proper initialization order
- Display blank screen for 10+ seconds on boot (now shows time after ~15 seconds)
- "DNS resolution failed" errors during startup
- Sunrise/sunset labels cut off on 128px screen (removed labels, arrows are self-explanatory)
### Changed
- Hybrid WiFi model: synchronous in setup(), async reconnect in loop()
- Display formatting: superscript degree symbol and lowercase 'c' for temperature
- Sunrise/sunset screen now shows daylight duration (e.g., "Day 9h 41m") instead of static "Sun Times" text
### Documentation
- Added detailed v1.9.1_HYBRID_FIX.md explaining startup sequence problem and solution
## [1.9.0] - 2026-01-02
### Added
- Fully async architecture (zero blocking operations in loop)
- Custom async NTP implementation (manual UDP packet handling)
- Async HTTP weather fetch (AsyncHTTPRequest library)
@@ -59,12 +173,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Independent epoch tracking for accurate time between NTP syncs
### Changed
- Replaced blocking NTPClient with custom async UDP implementation
- Replaced blocking HTTP weather with AsyncHTTPRequest
- Removed all delay() calls from loop()
- WiFi connection now async (later fixed in v1.9.1)
### Performance
- Loop time: 10ms → <1ms (10x improvement)
- Weather fetch: 1-10s blocking → 0ms
- NTP sync: 5-20s blocking → 0ms
@@ -72,6 +188,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- OTA updates now work during active weather fetching
### Technical
- RAM usage: +536 bytes (36,980 → 37,516)
- Flash usage: +1040 bytes (407,500 → 408,540)
- IRAM: 61,987 bytes (94% - stable)
@@ -79,12 +196,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.8.0] - 2026-01-01
### Security
- **CRITICAL**: Removed hardcoded WiFi credentials
- Integrated WiFiManager for secure captive portal setup
- Added config validation (magic number check)
- Input sanitization to prevent buffer overflows
### Fixed
- IRAM overflow crisis (94% → 70% via ICACHE_FLASH_ATTR)
- NTP interval bug (config value was ignored, always used hardcoded 1 hour)
- Boolean parsing errors in JSON config import/export
@@ -92,17 +211,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Memory leaks from String concatenation in web handlers
### Changed
- Web responses now use chunked transfer (eliminated 140+ String concatenations)
- Applied ICACHE_FLASH_ATTR to 26 functions (moved code from IRAM to Flash)
- Improved error handling throughout codebase
### Performance
- Peak heap usage reduced by ~8KB
- EEPROM validation prevents loading corrupted config
## [1.7.0] - 2025-12-31
### Added
- Initial working firmware with correct display support
- NTP time synchronization
- Weather data from Open-Meteo API (free, no API key required)
@@ -114,11 +236,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- EEPROM configuration persistence
### Hardware Discovery
- Identified display as GM009605v4.3 (not TM1637 or TM1650)
- Discovered swapped I2C pins: SDA=GPIO0, SCL=GPIO2
- Switched to Adafruit_SSD1306 library
### Replaced
- QWeather API → Open-Meteo (no registration required)
- Proprietary firmware → Open source custom firmware
- Insecure WiFi handling → WiFiManager with timeout
@@ -126,11 +250,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.6.0] - 2025-12-30 (unreleased)
### Attempted
- TM1650 LED driver support (incorrect - device has OLED)
## [1.5.0] - 2025-12-29 (unreleased)
### Attempted
- TM1637 7-segment display support (incorrect - device has OLED)
---
@@ -141,12 +267,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Minor version** (1.X.0): New features, backward-compatible
- **Patch version** (1.9.X): Bug fixes, no new features
## Links
- [Full v1.9 Release Notes](docs/v1.9_RELEASE_NOTES.md)
- [v1.9.1 Hybrid Fix Details](docs/v1.9.1_HYBRID_FIX.md)
- [v1.9.2 WiFi Resilience](docs/v1.9.2_WIFI_RESILIENCE.md)
---
**Status**: v1.9.2 is production-ready and actively used 24/7.
**Status**: v1.9.3 is production-ready and actively used 24/7.
+43 -7
View File
@@ -7,6 +7,7 @@ Thank you for your interest in contributing! This project welcomes improvements,
### Reporting Bugs
If you find a bug, please open an issue with:
- Clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
@@ -16,6 +17,7 @@ If you find a bug, please open an issue with:
### Suggesting Features
Feature requests are welcome! Please include:
- Use case description
- Why this would be useful
- Any implementation ideas
@@ -38,16 +40,19 @@ Feature requests are welcome! Please include:
### Code Guidelines
**Memory Safety:**
- Check IRAM usage after adding code
- Use fixed-size buffers instead of dynamic allocation where possible
- Prefer `snprintf` over String concatenation
**Async Architecture:**
- Keep loop() non-blocking (no delay() calls)
- Use state machines for multi-step operations
- Add exponential backoff to network operations
**Testing:**
- Test on ESP-01S hardware (1MB flash, 80KB RAM)
- Verify OTA updates work
- Check 24h stability
@@ -55,31 +60,62 @@ Feature requests are welcome! Please include:
## Development Setup
### Requirements
- Arduino IDE 1.8.x or 2.x
- ESP8266 board support (v3.0.0+)
- Libraries (see README)
### Building
```bash
# Arduino IDE: Sketch → Verify/Compile
# Or use arduino-cli:
arduino-cli compile --fqbn esp8266:esp8266:generic src/clock_ntp_ota_v1.9.ino
arduino-cli compile --fqbn esp8266:esp8266:generic firmware/weather_clock
```
### Testing
```bash
# Upload via FTDI (first time)
arduino-cli upload -p /dev/cu.usbserial* --fqbn esp8266:esp8266:generic
### Flashing
# Upload via OTA (subsequent)
```bash
# OTA upload (preferred, when device is on the network)
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
```
esp8266-weather-clock-opensource/
├── src/ # Main firmware source
├── firmware/ # Main firmware source (weather_clock/)
├── docs/ # Documentation
├── images/ # Photos and screenshots
├── README.md # Main documentation
-800
View File
@@ -1,800 +0,0 @@
# ESP8266 Weather Clock - Full Project Context
**Last Updated**: 2026-01-03
**Current Version**: v1.9.1 (Production Ready)
**GitHub**: https://github.com/petrochen/esp8266-weather-clock-opensource
---
## Table of Contents
1. [Project Overview](#project-overview)
2. [Hardware Details](#hardware-details)
3. [Development History](#development-history)
4. [Current Status (v1.9.1)](#current-status-v191)
5. [Technical Architecture](#technical-architecture)
6. [Security Issues Fixed](#security-issues-fixed)
7. [Files & Structure](#files--structure)
8. [Git Repository](#git-repository)
9. [Next Steps](#next-steps)
10. [Key Learnings](#key-learnings)
---
## Project Overview
### What Is This?
Complete reverse engineering and firmware replacement for an ESP8266-based weather clock purchased from AliExpress (model TJ-56-654, €5).
### Why?
**Original firmware had critical security vulnerabilities:**
- WiFi password displayed in plaintext
- Persistent open access point running in parallel to home WiFi
- Dependency on Chinese cloud service (QWeather) requiring API key registration
- No OTA updates (required physical FTDI access for updates)
**Solution:** Complete custom firmware with security, performance, and features.
### Key Features
- ✅ **Security**: No password leaks, WiFiManager captive portal, no hardcoded credentials
- ✅ **Performance**: Fully async architecture, <1ms loop time (was 10ms+)
- ✅ **Weather**: Open-Meteo API (free, no registration, no API key)
- ✅ **Updates**: OTA via web interface + ArduinoOTA
- ✅ **Interface**: Web UI, REST API, full configuration
- ✅ **Display**: 3 rotating modes (time, weather, sunrise/sunset with daylight duration)
- ✅ **Time**: NTP sync with timezone + automatic European DST
---
## Hardware Details
### Device Purchased
- **Product**: ESP8266 Mini Weather Clock Kit
- **Model**: TJ-56-654
- **Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
- **Price**: €5 EUR (~$5.50 USD)
- **Size**: 40mm x 40mm x 43mm (transparent acrylic case)
### Components
#### ESP-01S WiFi Module
- **Chip**: ESP8266EX
- **Flash**: 1MB (8Mbit)
- **RAM**: 80KB total
- **CPU**: 80MHz
- **WiFi**: 802.11 b/g/n (2.4GHz only)
- **GPIO**: Only GPIO0 and GPIO2 available
- **Voltage**: 3.3V ⚠️ NOT 5V tolerant
#### Display Module
- **Model**: GM009605v4.3
- **Type**: OLED (128x64 pixels, 0.96 inches)
- **Controller**: SSD1306/SH1106 compatible
- **Interface**: I2C
- **I2C Address**: 0x3C (default), 0x3D (fallback)
- **Colors**: Monochrome (white on black)
#### Pin Mapping (CRITICAL!)
**Non-standard I2C mapping:**
- SDA: GPIO0 (not GPIO4 as typical)
- SCL: GPIO2 (not GPIO5 as typical)
This mapping is **backwards** from standard ESP8266 breakout boards!
```cpp
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
```
### Power Supply
- Input: 5V via Micro-USB
- Current: 80-120mA typical
- Regulator: Onboard 3.3V LDO
---
## Development History
### Timeline
**2025-12-29**: v1.5 (unreleased)
- Attempted TM1637 7-segment display support
- ❌ Wrong - device has OLED, not 7-segment LEDs
**2025-12-30**: v1.6 (unreleased)
- Attempted TM1650 LED driver support
- ❌ Wrong - I2C addresses didn't match
**2025-12-31**: v1.7 ✅
- ✅ Identified correct display: GM009605v4.3 (SSD1306-compatible OLED)
- ✅ Discovered swapped pins: SDA=GPIO0, SCL=GPIO2
- ✅ Switched to Adafruit_SSD1306 library
- ✅ Basic time display working
- ✅ WiFiManager integration
- ✅ First OTA deployment
**2026-01-01**: v1.8 🔒
- 🔒 **Security fixes**:
- Removed hardcoded WiFi credentials
- Added config validation (magic number check)
- Input sanitization (buffer overflow protection)
- 🛠️ **Stability fixes**:
- IRAM crisis fix (94% → 70% via ICACHE_FLASH_ATTR on 26 functions)
- NTP interval bug (config value was ignored)
- Boolean parsing errors in JSON import/export
- Infinite loop protection in display rotation
- ⚡ **Performance**:
- Chunked HTTP responses (eliminated 140+ String concatenations)
- Peak heap usage reduced by ~8KB
- Memory leaks fixed
**2026-01-02**: v1.9.0 ⚡
- ⚡ **Full async refactoring**:
- 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 results**:
- Loop time: 10ms → <1ms (10x improvement)
- Weather fetch: 1-10s blocking → 0ms
- NTP sync: 5-20s blocking → 0ms
- WiFi reconnect: 15s blocking → 0ms
- ❌ **Problem discovered**:
- Display blank for 10+ seconds on boot
- "DNS resolution failed" errors
**2026-01-03**: v1.9.1 (Current) 🎯
- 🔧 **Critical fix**: Hybrid WiFi model
- Synchronous WiFi in setup() (waits up to 10 seconds)
- Async WiFi reconnect in loop() (non-blocking)
- Ensures proper initialization order: WiFi → OTA → web → NTP
- 📺 **Display improvements**:
- Fixed sunrise/sunset labels cutoff (removed labels, kept arrows)
- Superscript degree symbol (°c)
- Daylight duration display instead of static "Sun Times"
- ✅ **Result**: Production ready, tested 24/7
---
## Current Status (v1.9.1)
### Version Information
**Firmware**: v1.9.1 (Production Ready)
**Released**: 2026-01-03
**Status**: Actively running 24/7, stable
### Memory Usage
| Resource | Used | Total | Usage | Status |
|----------|------|-------|-------|--------|
| Flash | 408,844 | 1,048,576 | 38% | ✅ Plenty |
| RAM | 37,644 | 80,192 | 46% | ✅ Safe |
| IRAM | 61,987 | 65,536 | 94% | ⚠️ Critical but stable |
**IRAM Note**: 94% is acceptable because:
- ICACHE_FLASH_ATTR applied to 26 functions
- Stable across v1.8 → v1.9.1
- No growth observed in testing
### Performance Metrics
- **Loop time**: <1ms (was 10ms+ before async)
- **Boot to time display**: ~15 seconds
- **WiFi connection**: 5-10 seconds (synchronous in setup)
- **NTP sync interval**: Configurable (default 1 hour)
- **Weather update interval**: Configurable (default 30 minutes)
### Uptime
- ✅ 24+ hours stable
- ✅ No memory leaks
- ✅ No crashes or reboots
- ✅ OTA updates work during operation
---
## Technical Architecture
### Async State Machines
#### Weather State Machine
```cpp
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
```
- Library: AsyncHTTPRequest_Generic v1.13.0
- API: Open-Meteo (free, no API key)
- Callback: `onWeatherResponse()`
- Retry: Exponential backoff (1s → 2s → 4s, max 3 retries)
#### NTP State Machine
```cpp
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
```
- Custom manual UDP packet building/parsing
- Independent epoch tracking: `syncedEpoch`, `syncedMillis`, `timeIsSynced`
- Non-blocking UDP checks via `parsePacket()`
- Timeout: 5 seconds
- Why manual? NTPClient library is inherently blocking
#### WiFi State Machine
```cpp
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
```
**Hybrid model** (critical for v1.9.1):
- **Setup phase**: Synchronous (waits up to 10 seconds)
- Why? OTA, web server, NTP all need WiFi ready
- Prevents "DNS resolution failed" errors
- Ensures time appears on display immediately after WiFi connects
- **Loop phase**: Asynchronous (checks every 5 seconds)
- Why? Don't freeze device if WiFi drops during operation
- Graceful reconnection without user impact
### Configuration Storage
**EEPROM struct (512 bytes, 26 fields):**
```cpp
struct Config {
char ssid[32]; // WiFi network name
char password[64]; // WiFi password
int timezone_offset; // Seconds from UTC
bool dst_enabled; // Auto DST (European rules)
uint8_t brightness; // Display brightness (0-7)
char ntp_server[64]; // NTP server address
unsigned long ntp_interval; // NTP sync interval (seconds)
bool hour_format_24; // 24h vs 12h display
char hostname[32]; // mDNS hostname
float latitude; // Weather location
float longitude; // Weather location
char city_name[32]; // Display in weather mode
bool weather_enabled; // Feature toggle
unsigned long weather_interval; // Update interval (seconds)
unsigned long display_rotation_sec; // Mode switch interval
bool show_weather; // Enable weather mode
bool show_sunrise_sunset; // Enable sunrise/sunset mode
uint8_t display_orientation; // Screen rotation (0-3)
uint32_t magic; // 0xC10CC10C - validation
};
```
**Validation**: Magic number check prevents loading corrupted EEPROM data.
### Display Modes
**Mode 1: Time Mode**
- Large HH:MM display
- Blinking colon (500ms interval)
- Day of week + date
- 24/12 hour format support
**Mode 2: Weather Mode**
- Temperature with superscript °c
- City name at bottom
- Example: "15.4°c" + "Portimao"
**Mode 3: Sunrise/Sunset Mode**
- Sunrise time with ↑ arrow
- Sunset time with ↓ arrow
- Daylight duration (e.g., "Day 9h 41m")
- Calculation: sunset - sunrise = total daylight minutes
**Rotation**: Configurable interval (default 5 seconds), gracefully skips disabled modes.
### Memory Optimization Techniques
**1. ICACHE_FLASH_ATTR**
Applied to 26 functions to move code from IRAM to Flash:
- All web handlers (15 functions)
- Setup functions (5 functions)
- Utilities (6 functions)
Result: IRAM usage manageable at 94%
**2. Chunked HTTP Responses**
```cpp
// ❌ BAD - 140+ concatenations
String html = "";
html += F("<!DOCTYPE html>");
html += F("<head>...");
// ✅ GOOD - Chunked transfer
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
server.sendContent_P(HTML_HEADER);
server.sendContent_P(HTML_FOOTER);
server.sendContent("");
```
Result: ~8KB peak heap reduction
**3. Fixed-Size Buffers**
No dynamic String allocations in loops:
```cpp
char buf[150];
snprintf_P(buf, sizeof(buf), PSTR("<div>IP: %s</div>"),
WiFi.localIP().toString().c_str());
```
---
## Security Issues Fixed
### Original Firmware Vulnerabilities
**1. WiFi Password Leak (CRITICAL)**
- Open access point remained active after setup
- Web interface displayed WiFi password in plaintext
- Anyone within range could connect and read password
- **Impact**: Full network compromise
**2. Cloud Dependency**
- Required QWeather API (Chinese service)
- Needed account registration + API key
- Unknown data collection practices
- **Impact**: Privacy concerns, vendor lock-in
**3. No OTA Updates**
- Required physical FTDI connection for updates
- Difficult for non-technical users
- **Impact**: Security vulnerabilities can't be patched remotely
**4. Hardcoded Credentials**
- Default WiFi credentials in source code
- No secure setup flow
- **Impact**: Easy attack vector
### Custom Firmware Solutions
**1. WiFiManager Integration ✅**
- Captive portal for secure first-time setup
- AP automatically closes after 180 seconds
- Fallback AP only on connection failure
- Password-protected fallback (customizable)
**2. Open-Meteo API ✅**
- Free weather service
- No registration required
- No API key needed
- European service (GDPR compliant)
**3. OTA Updates ✅**
- Web-based upload at `/update`
- ArduinoOTA for IDE uploads
- Password-protected (admin/admin - customizable)
- Non-blocking during operation
**4. No Hardcoded Secrets ✅**
- All credentials stored in EEPROM
- Magic number validation
- Factory reset capability
- Configuration import/export
---
## Files & Structure
### Project Directory
**Location**: `/Users/apetrochenko/Library/Mobile Documents/com~apple~CloudDocs/src/arduino/clock/esp8266-weather-clock-opensource`
### Key Files
**Root Level:**
- `README.md` (26KB) - Main documentation (blog-style)
- `LICENSE` - MIT License
- `CHANGELOG.md` - Version history
- `CONTRIBUTING.md` - Contribution guidelines
- `PROJECT_STRUCTURE.md` - Directory layout
- `PROJECT_CONTEXT.md` - This file
- `PUBLISH_TO_GITHUB.md` - GitHub publishing guide
- `.gitignore` - Git exclusions
**Source Code:**
- `src/clock_ntp_ota_v1.9.ino` (65KB, 2,096 lines)
**Documentation:**
- `docs/INSTALLATION.md` (18KB) - Complete installation guide
- `docs/HARDWARE.md` (8KB) - Hardware specs + pinout
- `docs/v1.9_RELEASE_NOTES.md` (7KB) - v1.9.0 changelog
- `docs/v1.9.1_HYBRID_FIX.md` (6KB, Russian) - v1.9.1 fix explanation
**Images:**
- `images/product/` - 6 AliExpress product photos
- 01-main-product.webp
- 02-components.webp
- 03-weather-forecast.webp
- 04-temperature-display.webp
- 05-details.webp
- 06-size.webp
- `images/build/` - 3 display screenshots
- display-time.png
- display-temperature.png
- display-sunrise-sunset.png
**GitHub Config:**
- `.github/workflows/build.yml` - CI/CD pipeline
- `.github/ISSUE_TEMPLATE/bug_report.md`
- `.github/ISSUE_TEMPLATE/feature_request.md`
### Compiled Artifacts (not in git)
```
build/
├── clock_ntp_ota_v1.9.ino.bin (409KB) - Flash this via OTA
├── clock_ntp_ota_v1.9.ino.elf - Debug symbols
└── clock_ntp_ota_v1.9.ino.map - Memory map
```
---
## Git Repository
### GitHub Details
- **Repository**: https://github.com/petrochen/esp8266-weather-clock-opensource
- **Owner**: petrochen (Andrey Petrochenko)
- **Visibility**: Public
- **License**: MIT
- **Created**: 2026-01-03
### Repository Configuration
**Enabled Features:**
- ✅ Issues
- ✅ Discussions
- ✅ GitHub Actions (CI/CD)
- ✅ Releases
**Topics (Tags):**
- `esp8266`, `arduino`, `iot`, `weather-station`
- `reverse-engineering`, `security`, `oled-display`
- `ntp`, `ota-updates`, `open-meteo`
**Badges:**
- Release version (auto-updates)
- License (MIT)
- Build status (CI/CD)
- Open issues count
- Hardware (ESP-01S)
- Status (Production Ready)
### Current Release
**Tag**: v1.9.1
**URL**: https://github.com/petrochen/esp8266-weather-clock-opensource/releases/tag/v1.9.1
**Status**: Production Ready
**Created**: 2026-01-03
### Git Commits
**Total**: 3 commits
1. `8f0df02` - Initial commit: v1.9.1 production firmware
2. `f2dc2fb` - Add dynamic GitHub badges to README
3. `2205445` - Update price: $12 → €5
### Web Interface URLs
- **Main**: https://github.com/petrochen/esp8266-weather-clock-opensource
- **Issues**: https://github.com/petrochen/esp8266-weather-clock-opensource/issues
- **Discussions**: https://github.com/petrochen/esp8266-weather-clock-opensource/discussions
- **Actions**: https://github.com/petrochen/esp8266-weather-clock-opensource/actions
- **Releases**: https://github.com/petrochen/esp8266-weather-clock-opensource/releases
---
## Next Steps
### Planned Features (v1.10 or v2.0)
**1. Home Assistant Integration (High Priority)**
User specifically wants custom display screens pulling data from Home Assistant.
**Implementation ideas:**
- Add REST API client to fetch HA sensor data
- New display modes:
- Energy usage dashboard
- Room temperatures (multiple sensors)
- Air quality / CO2 levels
- Automation states (alarm, doors, lights)
- Configuration: HA server URL, access token, entity IDs
- Update interval: configurable (default 30 seconds)
**Technical approach:**
- Use AsyncHTTPRequest for non-blocking HA API calls
- JSON parsing with ArduinoJson library
- Store HA config in EEPROM (new fields)
- New web UI section for HA configuration
**2. MQTT Support**
- Publish time/weather data to MQTT broker
- Subscribe to topics for display content
- Enable automation triggers
- Library: PubSubClient (async wrapper needed)
**3. WebSocket Live Updates**
- Replace polling with WebSocket
- Real-time config changes
- Live display preview in web UI
- Push notifications for updates
**4. Multiple Weather Locations**
- Store 2-3 favorite locations
- Rotate between them
- Useful for travelers or multiple homes
**5. Display Animations**
- Smooth transitions between modes
- Weather icons (sunny, cloudy, rainy)
- Sunrise/sunset animations
### Code Quality Improvements
**1. Modular Architecture (v2.0)**
Split monolith (2,096 lines) into modules:
```
src/
├── main.ino
├── config.h
├── display.cpp/h
├── network.cpp/h
├── weather.cpp/h
├── webserver.cpp/h
└── home_assistant.cpp/h (new)
```
**2. ArduinoJson Integration**
Replace manual JSON parsing (173 lines) with library:
- Cleaner code
- Better error handling
- Type safety
**3. Constants Organization**
Eliminate magic numbers:
```cpp
namespace Hardware {
constexpr uint8_t I2C_SDA_PIN = 0;
constexpr uint8_t I2C_SCL_PIN = 2;
}
```
**4. Unit Tests**
- Test state machines
- Test JSON parsing
- Test time calculations
- Mock network calls
### Documentation Improvements
**1. Video Tutorial**
- YouTube walkthrough
- FTDI connection demo
- OTA update demo
- Web configuration walkthrough
**2. Troubleshooting Flow Chart**
Visual guide for common issues:
- Display not working
- WiFi not connecting
- Time not syncing
- Weather not updating
**3. Localization**
Translate docs to:
- Russian (v1.9.1_HYBRID_FIX.md already in Russian)
- Spanish
- Portuguese
**4. Home Assistant Integration Guide**
Complete guide when HA support is added.
### Community Engagement
**1. Reddit Posts**
Subreddits to share on:
- r/esp8266
- r/arduino
- r/selfhosted
- r/homeassistant (after HA integration)
- r/ReverseEngineering
**2. Hackaday**
Submit project tip: https://hackaday.com/submit-a-tip/
**3. Hackster.io**
Create full project page with build guide.
**4. Awesome Lists**
Add to "awesome ESP8266" and "awesome IoT" lists.
---
## Key Learnings
### Hardware
1. **Always verify pinouts** - Don't assume standard mappings
2. **FTDI is essential** - $2 adapter unlocks any ESP8266 device
3. **Read PCB markings** - Model numbers save hours of guessing
4. **Test voltage** - ESP8266 is NOT 5V tolerant
5. **Document discoveries** - Pin mappings, I2C addresses, display models
### Software
1. **Async is hard but worth it** - Fully non-blocking eliminates freezes
2. **IRAM is precious** - Use ICACHE_FLASH_ATTR liberally on ESP8266
3. **Hybrid approaches work** - Don't be dogmatic (sync WiFi in setup() was correct)
4. **State machines scale** - Better than callback hell for complex async
5. **Test on real hardware** - Emulators miss pin issues and memory constraints
### Security
1. **IoT security is often terrible** - Always audit before trusting
2. **Open source is safer** - Closed firmware is a black box
3. **Defaults matter** - Insecure defaults (open AP, plaintext passwords) are vulnerabilities
4. **Defense in depth** - Multiple layers catch mistakes
5. **Update mechanism is critical** - OTA enables security patches
### Development
1. **OTA from day 1** - FTDI flashing gets old fast
2. **Version control** - Backups (.bak, .bak2) saved the project multiple times
3. **Document as you go** - Release notes prevent "what was I thinking?" moments
4. **Incremental improvements** - v1.7 → v1.8 → v1.9.x made debugging manageable
5. **User testing** - Photos from user revealed display cutoff issues
### Project Management
1. **Understand user needs** - User wanted Home Assistant integration (plan for it)
2. **Security first** - Privacy/security was main motivation
3. **Performance matters** - 10ms loop → <1ms dramatically improves UX
4. **Documentation is product** - Good docs = more users = more contributors
5. **Publish early** - GitHub repo enables community contributions
---
## Quick Reference
### Current Device Configuration
**Hardware:**
- Device: TJ-56-654 Weather Clock
- Location: User's home network
- IP: 192.168.2.47
- Hostname: tj56654-clock.local
**Software:**
- Firmware: v1.9.1
- WiFi: SibWings
- Weather: Portimao, Portugal (37.19°N, 8.54°W)
- Timezone: UTC+0 (Lisbon) with auto DST
- NTP: pool.ntp.org (1 hour interval)
**Access:**
- Web UI: http://192.168.2.47 or http://tj56654-clock.local
- OTA Update: http://192.168.2.47/update (admin/admin)
- API Base: http://192.168.2.47/api/
### Important Commands
**Compile:**
```bash
arduino-cli compile --fqbn esp8266:esp8266:generic \
src/clock_ntp_ota_v1.9.ino
```
**Upload OTA:**
```bash
curl -u admin:admin \
-F "file=@build/clock_ntp_ota_v1.9.ino.bin" \
http://192.168.2.47/update
```
**Check status:**
```bash
curl http://192.168.2.47/api/status | jq
```
**View logs:**
```bash
# Connect via serial (if FTDI attached)
screen /dev/cu.usbserial* 115200
```
### Library Dependencies
Required libraries (install via Arduino Library Manager):
| Library | Version | Purpose |
|---------|---------|---------|
| Adafruit GFX Library | 1.11.0+ | Graphics primitives |
| Adafruit SSD1306 | 2.5.0+ | OLED display driver |
| NTPClient | 3.2.0+ | NTP time sync (base) |
| WiFiManager | 2.0.0+ | Captive portal |
| AsyncHTTPRequest_Generic | 1.13.0+ | Async weather fetch |
| ESPAsyncTCP | 1.2.2+ | Async TCP layer |
### External APIs
**Open-Meteo:**
- URL: https://api.open-meteo.com/v1/forecast
- Authentication: None (free, no API key)
- Rate limit: None (reasonable use)
- Documentation: https://open-meteo.com/en/docs
**NTP:**
- Default: pool.ntp.org
- Protocol: UDP port 123
- Fallbacks: time.google.com, time.cloudflare.com
---
## Contact & Collaboration
**GitHub**: https://github.com/petrochen/esp8266-weather-clock-opensource
**Issues**: https://github.com/petrochen/esp8266-weather-clock-opensource/issues
**Discussions**: https://github.com/petrochen/esp8266-weather-clock-opensource/discussions
**Contributions welcome!** See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## Summary
This project successfully transformed a €5 AliExpress IoT device with critical security vulnerabilities into a fully secure, high-performance, feature-rich smart clock with open-source firmware.
**Key achievements:**
- ✅ Eliminated WiFi password leak vulnerability
- ✅ Achieved <1ms loop time (10x performance improvement)
- ✅ Enabled OTA updates (no more FTDI wiring)
- ✅ Free weather API (no registration)
- ✅ Full async architecture (zero blocking)
- ✅ Production-ready stability (24/7 uptime)
- ✅ Open-source on GitHub (MIT license)
- ✅ Comprehensive documentation (100KB+ docs)
**Next milestone**: Home Assistant integration for custom display screens.
**Status**: Project complete and ready for community contributions! 🚀
---
**Last session work (2026-01-03):**
1. ✅ Compiled v1.9.1 with daylight duration feature
2. ✅ Uploaded via OTA to device (192.168.2.47)
3. ✅ Created complete GitHub repository structure
4. ✅ Published to https://github.com/petrochen/esp8266-weather-clock-opensource
5. ✅ Created release v1.9.1
6. ✅ Added badges, topics, documentation
7. ✅ Fixed price ($12 → €5)
8. ✅ Saved full project context
**Repository ready for sharing on Reddit, Hackaday, and other communities!**
-249
View File
@@ -1,249 +0,0 @@
# Project Structure
This document describes the organization of the ESP8266 Weather Clock firmware repository.
## Directory Layout
```
esp8266-weather-clock-opensource/
│
├── README.md # Main documentation (start here!)
├── LICENSE # MIT License
├── CHANGELOG.md # Version history
├── CONTRIBUTING.md # Contribution guidelines
├── PROJECT_STRUCTURE.md # This file
├── .gitignore # Git exclusions
│
├── src/ # Source code
│ └── clock_ntp_ota_v1.9.ino # Main firmware (2,096 lines)
│
├── docs/ # Documentation
│ ├── INSTALLATION.md # Complete installation guide
│ ├── HARDWARE.md # Hardware specs and pinout
│ ├── v1.9_RELEASE_NOTES.md # v1.9.0 release notes
│ └── v1.9.1_HYBRID_FIX.md # v1.9.1 WiFi startup fix
│
├── images/ # Photos and screenshots
│ ├── product/ # AliExpress product photos
│ │ ├── 01-main-product.webp # Main product shot
│ │ ├── 02-components.webp # Kit components
│ │ ├── 03-weather-forecast.webp
│ │ ├── 04-temperature-display.webp
│ │ ├── 05-details.webp # Transparent case details
│ │ └── 06-size.webp # Dimensions (40x40x43mm)
│ │
│ └── build/ # Custom firmware screenshots
│ ├── display-time.png # Time display mode
│ ├── display-temperature.png # Weather display mode
│ └── display-sunrise-sunset.png # Solar display mode
│
└── .github/ # GitHub-specific files
├── workflows/
│ └── build.yml # CI: Auto-build on push
│
└── ISSUE_TEMPLATE/
├── bug_report.md # Bug report template
└── feature_request.md # Feature request template
```
## Key Files
### Root Level
**README.md** (11KB)
- Main project documentation
- Blog-style narrative about reverse engineering
- Security issues discovered
- Complete feature list
- Installation quickstart
- API documentation
**LICENSE** (MIT)
- Permissive open source license
- Use freely, modify, distribute
**CHANGELOG.md**
- Version history: v1.5 → v1.9.1
- Features, fixes, breaking changes
- Migration notes
**CONTRIBUTING.md**
- How to contribute
- Code style guidelines
- Testing requirements
### Source Code (`/src`)
**clock_ntp_ota_v1.9.ino**
- Main firmware file (2,096 lines)
- ESP8266 Arduino sketch
- Requires libraries:
- Adafruit GFX & SSD1306
- NTPClient
- WiFiManager
- AsyncHTTPRequest_Generic
- ESPAsyncTCP
**Architecture:**
- Fully async (zero blocking in loop)
- State machines: WiFi, NTP, Weather
- Hybrid model: sync WiFi in setup(), async in loop()
- Memory-optimized: ICACHE_FLASH_ATTR on 26 functions
**Configuration:**
- 26-field struct stored in EEPROM
- Magic number validation
- Web-based config UI
### Documentation (`/docs`)
**INSTALLATION.md** (18KB)
- Complete step-by-step installation guide
- Arduino IDE setup
- FTDI wiring diagrams
- OTA update instructions
- Comprehensive troubleshooting
**HARDWARE.md** (8KB)
- ESP-01S specifications
- Pin mapping (SDA=GPIO0, SCL=GPIO2)
- Display module details (GM009605v4.3)
- Power requirements
- Memory layout
- Safety warnings
**v1.9_RELEASE_NOTES.md** (7KB)
- Detailed v1.9.0 changelog
- Performance improvements
- Async architecture explanation
- Memory usage comparison
- Testing checklist
**v1.9.1_HYBRID_FIX.md** (Russian, 6KB)
- Critical startup fix documentation
- WiFi synchronous vs async tradeoffs
- Timeline diagrams
- Before/after comparison
### Images (`/images`)
**Product Photos** (`/product`)
- Original AliExpress product images
- DIY kit components
- Transparent acrylic case
- Size reference (40mm cube)
**Build Photos** (`/build`)
- Custom firmware screenshots
- Three display modes:
1. Time mode (10:34 + date)
2. Weather mode (15.4°c + city)
3. Sunrise/sunset mode (times + daylight duration)
### GitHub Config (`/.github`)
**Workflows**
- `build.yml`: CI pipeline
- Auto-compile on push
- Check firmware size < 470KB
- Upload build artifacts
- Attach binaries to releases
**Issue Templates**
- `bug_report.md`: Structured bug reports
- `feature_request.md`: Feature suggestions
## Build Artifacts (ignored by git)
When you compile locally, these are created:
```
build/
├── clock_ntp_ota_v1.9.ino.bin # Flash this via OTA
├── clock_ntp_ota_v1.9.ino.elf # Debug symbols
└── clock_ntp_ota_v1.9.ino.map # Memory map
```
**Note**: `build/` is in `.gitignore` - artifacts not committed to repo.
## File Sizes
| File | Size | Description |
|------|------|-------------|
| `src/*.ino` | 65KB | Main source code |
| `build/*.bin` | 409KB | Compiled firmware |
| `README.md` | 45KB | Main docs |
| `docs/INSTALLATION.md` | 18KB | Install guide |
| `docs/HARDWARE.md` | 8KB | Hardware specs |
## Memory Usage
**Compiled firmware (v1.9.1):**
- Flash: 408,844 / 1,048,576 bytes (38%)
- RAM: 37,644 / 80,192 bytes (46%)
- IRAM: 61,987 / 65,536 bytes (94%) ⚠️
**Why 94% IRAM is acceptable:**
- ICACHE_FLASH_ATTR applied to all web handlers
- Stable across versions v1.8-v1.9.1
- No IRAM growth observed in testing
## Version Control
**Branches:**
- `main`: Stable releases (v1.9.1)
- `develop`: Work-in-progress features
- `feature/*`: New feature branches
**Tags:**
- `v1.9.1`: Current production release
- `v1.9.0`: Async refactoring
- `v1.8.0`: Security + stability fixes
- `v1.7.0`: Initial working firmware
## Not Included (Why)
**What's NOT in this repo:**
- Build artifacts (`.bin`, `.elf`, `.map`) - generated locally
- Backup files (`.bak`, `.bak2`) - development artifacts
- IDE configs (`.vscode/`, `.idea/`) - personal preferences
- macOS metadata (`.DS_Store`) - system files
- Secrets (`config_local.h`) - would leak credentials
These are excluded via `.gitignore`.
## How to Navigate
**For users:**
1. Start with `README.md` (overview + quickstart)
2. Follow `docs/INSTALLATION.md` (step-by-step setup)
3. Check `CHANGELOG.md` (version history)
**For developers:**
1. Read `CONTRIBUTING.md` (guidelines)
2. Study `src/clock_ntp_ota_v1.9.ino` (source code)
3. Review `docs/HARDWARE.md` (hardware constraints)
4. Check `.github/workflows/build.yml` (CI setup)
**For hardware hackers:**
1. Check `docs/HARDWARE.md` (pinout, specs)
2. View `images/product/` (original device photos)
3. Read `README.md` section "Hardware Discovery"
**For troubleshooters:**
1. Open `docs/INSTALLATION.md`
2. Jump to "Troubleshooting" section
3. Check `images/build/` for reference screenshots
## Quick Links
- **Main docs**: [README.md](README.md)
- **Install guide**: [docs/INSTALLATION.md](docs/INSTALLATION.md)
- **Hardware specs**: [docs/HARDWARE.md](docs/HARDWARE.md)
- **Changelog**: [CHANGELOG.md](CHANGELOG.md)
- **Contributing**: [CONTRIBUTING.md](CONTRIBUTING.md)
---
**Last updated**: 2026-01-03
**Repository**: https://github.com/your-username/esp8266-weather-clock-opensource
-440
View File
@@ -1,440 +0,0 @@
# Publishing to GitHub - Step by Step Guide
This file contains instructions for publishing this project to GitHub.
## Prerequisites
1. **GitHub account** - Sign up at https://github.com if you don't have one
2. **Git installed** - Check with `git --version` in terminal
3. **GitHub CLI (optional)** - Makes repository creation easier: https://cli.github.com/
---
## Option 1: Using GitHub Web Interface (Easiest)
### Step 1: Create Repository on GitHub
1. Go to https://github.com/new
2. Fill in:
- **Repository name**: `esp8266-weather-clock-opensource`
- **Description**: `Secure open-source firmware for ESP8266 weather clock - reverse engineered from AliExpress DIY kit`
- **Visibility**: Public ✅
- **Initialize**: ❌ Do NOT check "Add README" (we have one)
3. Click: **Create repository**
### Step 2: Initialize Local Git Repository
Open terminal and navigate to project directory:
```bash
cd "/Users/apetrochenko/Library/Mobile Documents/com~apple~CloudDocs/src/arduino/clock/esp8266-weather-clock-opensource"
```
Initialize git and add files:
```bash
# Initialize git
git init
# Add all files
git add .
# Create first commit
git commit -m "Initial commit: v1.9.1 production firmware
- Complete reverse engineering of TJ-56-654 weather clock
- Fixes security issues (WiFi password leak)
- Fully async architecture (zero blocking)
- OTA updates, web interface, REST API
- Open-Meteo weather (free, no API key)
- Comprehensive documentation"
```
### Step 3: Connect to GitHub
Replace `YOUR_USERNAME` with your actual GitHub username:
```bash
# Add remote
git remote add origin https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource.git
# Set main branch
git branch -M main
# Push to GitHub
git push -u origin main
```
**If prompted for credentials:**
- Username: Your GitHub username
- Password: Use **Personal Access Token** (not your password!)
- Create token at: https://github.com/settings/tokens
- Select scopes: `repo` (full control of private repositories)
### Step 4: Verify Upload
1. Browse to: `https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource`
2. You should see:
- README.md rendered nicely
- All directories and files
- First commit visible
---
## Option 2: Using GitHub CLI (Faster)
If you have GitHub CLI installed:
```bash
# Navigate to project
cd "/Users/apetrochenko/Library/Mobile Documents/com~apple~CloudDocs/src/arduino/clock/esp8266-weather-clock-opensource"
# Authenticate (one-time)
gh auth login
# Create repo and push in one command
gh repo create esp8266-weather-clock-opensource \
--public \
--source=. \
--description="Secure open-source firmware for ESP8266 weather clock" \
--push
```
Done! Repository is created and pushed.
---
## Step 5: Configure Repository Settings
### Add Topics (Tags)
1. Go to your repo on GitHub
2. Click: **⚙️ Settings** (top right near About)
3. Under "Topics", add:
- `esp8266`
- `arduino`
- `iot`
- `weather-station`
- `reverse-engineering`
- `security`
- `oled-display`
- `ntp`
- `ota-updates`
- `open-meteo`
### Update About Section
1. Go to repo main page
2. Click: **⚙️** (gear icon) next to "About"
3. Set:
- **Description**: `Secure open-source firmware for ESP8266 weather clock - reverse engineered from AliExpress DIY kit to fix security flaws`
- **Website**: `https://open-meteo.com` (or your personal site if you blog about it)
- **Topics**: Should already be set from above
### Enable Features
In **Settings → General**:
**Features**:
- ✅ Issues (for bug reports)
- ✅ Discussions (for questions)
- ❌ Wiki (not needed, we have docs/)
- ❌ Projects (not needed yet)
**Pull Requests**:
- ✅ Allow squash merging
- ✅ Automatically delete head branches
### Set Up GitHub Actions
The CI workflow should activate automatically on first push. Check:
1. Go to: **Actions** tab
2. You should see: "Build Firmware" workflow
3. It should run and ✅ pass (compiles firmware)
If it fails:
- Check library names in `.github/workflows/build.yml`
- Some libraries may need exact version pinning
---
## Step 6: Create First Release
### Tag the Release Locally
```bash
# Create annotated tag
git tag -a v1.9.1 -m "Release v1.9.1: Production-ready firmware
Features:
- Hybrid WiFi model (sync on boot, async in loop)
- Daylight duration display
- Fully async NTP, weather, WiFi reconnect
- OTA updates, web interface, REST API
- Open-Meteo weather (free API)
- Security fixes (no WiFi password leak)
Fixes:
- Startup display blank for 10+ seconds
- DNS resolution failed errors
- Sunrise/sunset label cutoff"
# Push tag to GitHub
git push origin v1.9.1
```
### Create Release on GitHub
1. Go to: **Releases** (right sidebar)
2. Click: **Draft a new release**
3. Fill in:
- **Tag**: `v1.9.1` (should appear in dropdown)
- **Release title**: `v1.9.1 - Production Ready`
- **Description**:
```markdown
## 🎉 First Public Release
Secure, open-source replacement firmware for ESP8266 weather clocks.
### ✨ Highlights
- **Security**: Fixes WiFi password leak in original firmware
- **Performance**: Fully async architecture, <1ms loop time
- **Features**: OTA updates, web UI, REST API, NTP time, weather
- **Free API**: Uses Open-Meteo (no registration required)
### 📦 Downloads
- `esp8266-weather-clock-v1.9.1.bin` - Flash this via OTA or FTDI
### 📖 Documentation
- [Installation Guide](docs/INSTALLATION.md)
- [Hardware Specs](docs/HARDWARE.md)
- [Full Changelog](CHANGELOG.md)
### 🚀 Quick Start
1. Download `.bin` file
2. Flash via FTDI (first time) or OTA (updates)
3. Connect to `TJ56654-Setup` WiFi
4. Configure your network
5. Access web UI at `http://tj56654-clock.local`
See [README](README.md) for complete instructions.
### 🐛 Known Issues
None! This release is production-ready and tested 24/7.
```
4. **Attach binary** (if you have it locally):
- Compile firmware first: Arduino IDE → Sketch → Export Compiled Binary
- Or use GitHub Actions artifact
- Drag `build/clock_ntp_ota_v1.9.ino.bin` to release assets
- Rename to: `esp8266-weather-clock-v1.9.1.bin`
5. Click: **Publish release**
---
## Step 7: Add Shields/Badges to README
Edit `README.md` and add at the top (after title):
```markdown
<p align="center">
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/releases">
<img src="https://img.shields.io/github/v/release/YOUR_USERNAME/esp8266-weather-clock-opensource?style=flat-square" alt="Release">
</a>
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/YOUR_USERNAME/esp8266-weather-clock-opensource?style=flat-square" alt="License">
</a>
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/actions">
<img src="https://img.shields.io/github/actions/workflow/status/YOUR_USERNAME/esp8266-weather-clock-opensource/build.yml?style=flat-square" alt="Build">
</a>
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/issues">
<img src="https://img.shields.io/github/issues/YOUR_USERNAME/esp8266-weather-clock-opensource?style=flat-square" alt="Issues">
</a>
</p>
```
Replace `YOUR_USERNAME` with actual username.
Commit and push:
```bash
git add README.md
git commit -m "Add badges to README"
git push
```
---
## Step 8: Share Your Project
### Post on Social Media
**Reddit:**
- r/esp8266
- r/arduino
- r/selfhosted
- r/homeassistant (when you add HA integration)
**Hackaday:**
- Submit project tip: https://hackaday.com/submit-a-tip/
**Hackster.io:**
- Create project page: https://www.hackster.io/
**Twitter/X:**
```
Just reverse-engineered a $12 AliExpress weather clock and found it was leaking WiFi passwords!
Replaced the firmware with secure open-source version:
- ✅ No password leak
- ✅ OTA updates
- ✅ Free weather API
- ✅ Full async arch
Check it out: [your-repo-link]
#ESP8266 #IoTSecurity #Arduino
```
### Add to Awesome Lists
Search for "awesome ESP8266" and submit PR to add your project.
---
## Maintenance Tips
### Keep README Updated
When you add features:
1. Update README.md
2. Update CHANGELOG.md
3. Create new git tag
4. Create GitHub release
### Respond to Issues
Enable email notifications:
1. Go to: repo → **Watch** → **Custom**
2. Check: ✅ Issues, ✅ Pull requests, ✅ Discussions
### Version Numbering
Use semantic versioning (semver.org):
- `v2.0.0`: Breaking changes (incompatible config)
- `v1.10.0`: New features (backward-compatible)
- `v1.9.2`: Bug fixes only
### Automated Releases
GitHub Actions can auto-build on new tags. Check `.github/workflows/build.yml`.
---
## Troubleshooting
### "Permission denied" when pushing
**Solution**: Use Personal Access Token instead of password
1. Generate: https://github.com/settings/tokens
2. Scopes: `repo` (full control)
3. Use token as password when prompted
Or configure SSH keys:
```bash
# Generate SSH key
ssh-keygen -t ed25519 -C "your_email@example.com"
# Add to GitHub: Settings → SSH Keys → New SSH key
# Paste contents of ~/.ssh/id_ed25519.pub
# Change remote to SSH
git remote set-url origin git@github.com:YOUR_USERNAME/esp8266-weather-clock-opensource.git
```
### "This repository is empty"
You forgot to push:
```bash
git push -u origin main
```
### Files too large
GitHub has 100MB file size limit. If you accidentally added build artifacts:
```bash
# Remove from staging
git reset HEAD build/
# Add to .gitignore
echo "build/" >> .gitignore
# Commit
git commit -m "Ignore build artifacts"
```
### CI build fails
Check:
- Library names are correct in `build.yml`
- All libraries are available via Arduino Library Manager
- Firmware compiles locally first
---
## Next Steps After Publishing
1. **Star your own repo** (to make it discoverable)
2. **Watch releases** (be notified of activity)
3. **Enable Discussions** (for community Q&A)
4. **Create SECURITY.md** (if you want responsible disclosure process)
5. **Add funding links** (GitHub Sponsors, Buy Me a Coffee, etc.)
---
## GitHub Repository Best Practices
### Essential Files (✅ You have these!)
- ✅ README.md
- ✅ LICENSE
- ✅ CONTRIBUTING.md
- ✅ CHANGELOG.md
- ✅ .gitignore
- ✅ Issue templates
### Nice-to-Have
- CODE_OF_CONDUCT.md (for community standards)
- SECURITY.md (vulnerability disclosure policy)
- FUNDING.yml (donation links)
### Pin Important Files
On your repo page, pin:
1. README.md (auto-pinned)
2. INSTALLATION.md (pin in About section)
3. Latest release (pin in sidebar)
---
## Success Checklist
After publishing, verify:
- [ ] Repository is public and accessible
- [ ] README renders correctly (images, links work)
- [ ] All documentation files are present
- [ ] CI/CD pipeline passes (green checkmark)
- [ ] First release is tagged and published
- [ ] Binary is attached to release
- [ ] Topics/tags are set
- [ ] License is visible
- [ ] Issues and Discussions are enabled
---
**Congratulations!** Your project is now public and ready to help the world build secure IoT devices. 🚀
---
**Repository URL**: https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource
Don't forget to replace `YOUR_USERNAME` with your actual GitHub username!
+188 -62
View File
@@ -27,7 +27,7 @@ I bought a cute weather clock kit from AliExpress ([TJ-56-654](https://pt.aliexp
- [The Investigation](#the-investigation)
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
- [Technical Deep Dive](#technical-deep-dive)
- [The Journey: v1.7 → v1.9.2](#the-journey-v17--v192)
- [The Journey: v1.7 → v1.9.8](#the-journey-v17--v198)
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
- [How to Flash This Firmware](#how-to-flash-this-firmware)
- [Web Interface](#web-interface)
@@ -60,6 +60,7 @@ When you first set up the device, it creates an access point with a default pass
5. **Your WiFi password is displayed in plaintext on the config page**
Anyone within WiFi range could:
- Connect to the device's AP (weak default password)
- Browse to 192.168.4.1
- Read your WiFi password in plaintext
@@ -78,13 +79,13 @@ This is a textbook example of poor IoT security design. No thanks.
### Original Hardware Specifications
| Component | Details |
|-----------|---------|
| **MCU** | ESP-01S (ESP8266EX, 1MB flash, 80KB RAM) |
| **Display** | GM009605v4.3 OLED (128x64, I2C) |
| **Power** | 5V USB (Micro-USB) |
| **Case** | Transparent acrylic (40x40x43mm) |
| **PCB** | TJ-56-654 main board |
| Component | Details |
| ----------- | ---------------------------------------- |
| **MCU** | ESP-01S (ESP8266EX, 1MB flash, 80KB RAM) |
| **Display** | GM009605v4.3 OLED (128x64, I2C) |
| **Power** | 5V USB (Micro-USB) |
| **Case** | Transparent acrylic (40x40x43mm) |
| **PCB** | TJ-56-654 main board |
### What It Came With
@@ -119,6 +120,7 @@ The transparent case made inspection easy - just unscrew the brass standoffs. In
- **No additional sensors** (temperature/humidity were from weather API, not local)
The ESP-01S pinout is printed right on the PCB:
```
3V3 | GND
TX | GPIO0 (I2C SDA)
@@ -135,6 +137,7 @@ To flash custom firmware, you need:
3. **Steady hands**
**Wiring:**
```
FTDI ESP-01S
────────────────────
@@ -146,12 +149,14 @@ FTDI ESP-01S
```
**Boot into flash mode:**
1. Connect GPIO0 to GND
2. Power on the device
3. Remove GPIO0 to GND connection after boot
4. Device is now in programming mode
**Programming:**
- Use Arduino IDE with ESP8266 board support
- Select board: "Generic ESP8266 Module"
- Flash size: 1MB (FS:64KB OTA:~470KB)
@@ -176,6 +181,7 @@ I decided to write a complete replacement firmware with:
### Features Implemented
#### 🌐 Network & Time
- **WiFiManager** captive portal for secure first-time setup
- **Hybrid WiFi**: Synchronous on boot (ensures proper init), async reconnect during operation
- **NTP time sync** with configurable server and interval
@@ -183,12 +189,14 @@ I decided to write a complete replacement firmware with:
- **mDNS**: Access via `http://tj56654-clock.local/`
#### 🌦️ Weather Data
- **Open-Meteo API**: Free, no registration, no API key
- **Configurable location**: Latitude/longitude + city name
- **Data**: Temperature, sunrise, sunset, daylight duration
- **Smart updates**: Async fetch every 30 minutes (configurable)
#### 🔄 OTA Updates
- **Web-based OTA**: Upload .bin files via browser at `/update`
- **ArduinoOTA**: Update directly from Arduino IDE
- **Non-blocking**: System stays responsive during updates
@@ -245,33 +253,39 @@ All endpoints return JSON:
The firmware uses **zero blocking operations** in the main loop. Everything is state-machine-based:
#### Weather State Machine
```cpp
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
```
Uses `AsyncHTTPRequest` library:
- Non-blocking HTTP requests
- Callback-based response handling
- Exponential backoff on failures (1s → 2s → 4s)
- Maximum 3 retries before giving up
#### NTP State Machine
```cpp
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
```
Custom manual NTP implementation:
- 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
@@ -282,11 +296,11 @@ enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
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 |
| 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:**
@@ -304,6 +318,7 @@ Applied to 26 functions (web handlers, display, config utilities), reducing IRAM
**String Safety:**
Avoid String concatenation in loops (causes heap fragmentation):
```cpp
// ❌ BAD - 140+ concatenations
String html = "";
@@ -353,29 +368,34 @@ struct Config {
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.2
## The Journey: v1.7 → v1.9.8
### v1.7: Display Discovery ✅
@@ -389,6 +409,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
**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)
@@ -403,6 +424,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
**Goals**: Eliminate all blocking operations
**Changes:**
- Async HTTP weather fetch (AsyncHTTPRequest library)
- Custom async NTP implementation (manual UDP packets)
- Async WiFi connection (state machine)
@@ -411,12 +433,12 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
**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 |
| 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!
@@ -429,6 +451,7 @@ After deploying v1.9.0, the display showed **blank screen for 10 seconds** after
**Root Cause:**
Making WiFi fully async broke the **initialization order**:
```cpp
void setup() {
setupWiFi(); // Returns immediately (async)
@@ -440,18 +463,19 @@ void setup() {
**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 |
| 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 (Current) 🛡️
### v1.9.2: WiFi Resilience 🛡️
**Problem Discovered:**
@@ -460,6 +484,7 @@ After WiFi outages, the device would **clear stored credentials** and enter AP m
**Root Cause:**
Aggressive credential clearing on connection failure:
```cpp
if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
memset(config.ssid, 0, sizeof(config.ssid)); // ❌ Clears credentials!
@@ -471,40 +496,43 @@ if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
**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 |
| 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 support**: Tries WiFiManager-stored credentials first, then EEPROM
- **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 |
| 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())
@@ -515,15 +543,69 @@ if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
✅ 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: Community Bug Fixes 🐛
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
### v1.9.5: Weather Recovery 🌦
- **Permanent weather lockup after 3 API failures** (#9): state machine no longer
deadlocks; resets to IDLE after max retries so periodic refresh resumes
### v1.9.6: Long-Running Stability ⏱
Six bugs found via dual AI code review (Sonnet + Gemini):
- **WEATHER_REQUESTING timeout watchdog** (C1): 15s timeout prevents TCP-hang deadlock
- **millis() rollover at 49.7 days** (C2): all retry timers now use subtraction-safe comparison
- **DST formula mathematically wrong** (H1): now uses tm_wday-based last-Sunday calculation
- **String heap fragmentation** (H2): API handlers use `snprintf+sendContent` instead of `String +=`
- **Volatile shared state** (H3): `weatherState`/`ntpState` written from callbacks, read in loop
- **Triple power-cycle factory reset**: 3 quick power cycles within 10s clears WiFi creds (no FTDI needed)
### v1.9.7: Config Validation 🛡
- **`/config` accepted any input, could brick device**: now rejects empty/all-same/oversized
SSIDs (HTTP 400), clamps numeric fields to safe ranges. Closes M1 from internal backlog.
### v1.9.8: Live Config Updates ⚡ (Current)
- **Restart only on WiFi/network changes**: brightness, timezone, intervals, coordinates,
display options apply live. Eliminates display blackouts on minor config tweaks.
- **73/73 automated tests pass** on hardware including full fuzz suite
### Memory Evolution
| 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 |
| 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 |
| v1.9.6 | 37,268 (46%) | **61,987 (94%)** | 410,436 (39%) | String → snprintf |
| v1.9.8 | 37,664 (46%) | **61,987 (94%)** | 411,380 (39%) | Live config updates |
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
@@ -536,6 +618,7 @@ 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
@@ -549,6 +632,7 @@ Pull data from Home Assistant via REST API:
### 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
@@ -579,6 +663,7 @@ Replace polling with WebSocket for:
- `WiFiManager` (by tzapu)
- `AsyncHTTPRequest_Generic`
- `ESPAsyncTCP`
- `ArduinoJson` (by Benoit Blanchon)
3. **Board Configuration**
- Board: "Generic ESP8266 Module"
@@ -591,6 +676,7 @@ Replace polling with WebSocket for:
### First Flash (via FTDI)
1. **Wire the ESP-01S**:
```
FTDI 3.3V → ESP-01S 3V3
FTDI GND → ESP-01S GND
@@ -600,7 +686,7 @@ Replace polling with WebSocket for:
```
2. **Compile and Upload**:
- Open `clock_ntp_ota_v1.9.ino`
- Open `weather_clock.ino`
- Sketch → Upload
- Wait for "Done uploading"
- Remove GPIO0-to-GND jumper
@@ -633,6 +719,7 @@ Replace polling with WebSocket for:
## Web Interface
### Home Page (`/`)
Current time display with live updates via JavaScript (fetches `/api/time` every second).
### Configuration Page (`/config`)
@@ -640,11 +727,13 @@ Current time display with live updates via JavaScript (fetches `/api/time` every
Comprehensive settings form:
**WiFi Settings**
- SSID
- Password
- Hostname (for mDNS)
**Time Settings**
- Timezone offset (seconds from UTC)
- DST enabled (European rules)
- NTP server address
@@ -652,6 +741,7 @@ Comprehensive settings form:
- Hour format (12h/24h)
**Weather Settings**
- Enabled/disabled toggle
- Latitude
- Longitude
@@ -659,6 +749,7 @@ Comprehensive settings form:
- Update interval (seconds)
**Display Settings**
- Brightness (0-7)
- Rotation (0°, 90°, 180°, 270°)
- Display rotation interval (seconds)
@@ -692,6 +783,7 @@ All endpoints return JSON (except `/update` which is for file upload).
Current time information.
**Response:**
```json
{
"current": "14:23:45",
@@ -707,6 +799,7 @@ Current time information.
System status overview.
**Response:**
```json
{
"wifi": {
@@ -733,6 +826,7 @@ System status overview.
Current weather data.
**Response:**
```json
{
"temperature": 15.4,
@@ -751,6 +845,7 @@ Current weather data.
Export full configuration as JSON.
**Response:**
```json
{
"ssid": "MyNetwork",
@@ -780,6 +875,7 @@ Import configuration from JSON.
**Request Body**: Same structure as export response (password field optional for security).
**Response:**
```json
{
"status": "ok"
@@ -793,6 +889,7 @@ Device automatically reboots after import.
Factory reset (clears EEPROM).
**Response:**
```json
{
"status": "cleared"
@@ -806,6 +903,7 @@ Device reboots to WiFiManager captive portal.
Remote reboot.
**Response:**
```json
{
"status": "rebooting"
@@ -820,15 +918,15 @@ Device reboots immediately.
### What Changed from Original Firmware
| Issue | Original | Custom Firmware |
|-------|----------|-----------------|
| **WiFi Password Leak** | Plaintext in open AP | No open AP after setup |
| **Persistent AP** | Always active | Only on first boot or failure |
| **API Keys** | QWeather requires registration | Open-Meteo (no key needed) |
| **Cloud Dependency** | Chinese servers | Direct API calls, no intermediary |
| **Firmware Updates** | Manual FTDI only | OTA via WiFi (password-protected) |
| **Config Access** | No authentication | Admin password required |
| **Code Transparency** | Closed source | Open source (you're reading it!) |
| Issue | Original | Custom Firmware |
| ---------------------- | ------------------------------ | --------------------------------- |
| **WiFi Password Leak** | Plaintext in open AP | No open AP after setup |
| **Persistent AP** | Always active | Only on first boot or failure |
| **API Keys** | QWeather requires registration | Open-Meteo (no key needed) |
| **Cloud Dependency** | Chinese servers | Direct API calls, no intermediary |
| **Firmware Updates** | Manual FTDI only | OTA via WiFi (password-protected) |
| **Config Access** | No authentication | Admin password required |
| **Code Transparency** | Closed source | Open source (you're reading it!) |
### Best Practices Implemented
@@ -842,16 +940,19 @@ Device reboots immediately.
### Recommended Post-Flash Steps
1. **Change OTA password**: Edit line ~60 in `.ino` file:
```cpp
ArduinoOTA.setPassword("admin"); // Change this!
```
2. **Change web admin password**: Edit line ~430:
```cpp
if (!server.authenticate("admin", "admin")) { // Change this!
```
3. **Set strong WiFi AP fallback password**: Edit line ~780:
```cpp
WiFi.softAP("TJ56654-Clock", "12345678"); // Change this!
```
@@ -900,6 +1001,7 @@ Device reboots immediately.
**Firmware**: Written from scratch with love and frustration
**Libraries Used**:
- [ESP8266 Arduino Core](https://github.com/esp8266/Arduino)
- [Adafruit SSD1306](https://github.com/adafruit/Adafruit_SSD1306)
- [WiFiManager](https://github.com/tzapu/WiFiManager)
@@ -907,9 +1009,11 @@ Device reboots immediately.
- [NTPClient](https://github.com/arduino-libraries/NTPClient)
**APIs**:
- [Open-Meteo](https://open-meteo.com/) - Free weather API, no registration required
**Tools**:
- Arduino IDE 2.x
- FTDI FT232RL USB-to-Serial adapter
- Lots of coffee ☕
@@ -921,18 +1025,39 @@ Device reboots immediately.
```
esp8266-weather-clock/
├── firmware/
│ └── clock_ntp_ota_v1.9/
│ └── clock_ntp_ota_v1.9.ino # Main firmware (~2,100 lines)
│ └── weather_clock/ # Modular firmware
│ ├── 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/
│ ├── HARDWARE.md # Hardware specifications
│ ├── INSTALLATION.md # Flashing guide
│ ├── v1.9_RELEASE_NOTES.md # v1.9.0 async refactoring
│ ├── v1.9.1_HYBRID_FIX.md # WiFi startup fix
│ └── v1.9.2_WIFI_RESILIENCE.md # WiFi resilience documentation
│ └── INSTALLATION.md # Flashing guide
├── CHANGELOG.md # Version history
└── 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
@@ -946,6 +1071,7 @@ This project is released into the public domain. Do whatever you want with it. I
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
@@ -965,4 +1091,4 @@ Now go make something cool. 🚀
**Author**: apetrochenko
**Date**: 2026-01-06
**Firmware Version**: v1.9.2 (Production Ready)
**Firmware Version**: v1.9.8 (Production Ready)
+91 -28
View File
@@ -64,16 +64,17 @@ Go to: **Sketch → Include Library → Manage Libraries**
Install the following libraries (search by name):
| Library | Author | Min Version | Purpose |
|---------|--------|-------------|---------|
| **Adafruit GFX Library** | Adafruit | 1.11.0 | Graphics primitives |
| **Adafruit SSD1306** | Adafruit | 2.5.0 | OLED display driver |
| **NTPClient** | Fabrice Weinberg | 3.2.0 | NTP time sync (base) |
| **WiFiManager** | tzapu | 2.0.0 | Captive portal setup |
| **AsyncHTTPRequest_Generic** | Khoi Hoang | 1.13.0 | Async weather fetch |
| **ESPAsyncTCP** | me-no-dev | 1.2.2 | Async TCP (required by above) |
| Library | Author | Min Version | Purpose |
| ---------------------------- | ---------------- | ----------- | ----------------------------- |
| **Adafruit GFX Library** | Adafruit | 1.11.0 | Graphics primitives |
| **Adafruit SSD1306** | Adafruit | 2.5.0 | OLED display driver |
| **NTPClient** | Fabrice Weinberg | 3.2.0 | NTP time sync (base) |
| **WiFiManager** | tzapu | 2.0.0 | Captive portal setup |
| **AsyncHTTPRequest_Generic** | Khoi Hoang | 1.13.0 | Async weather fetch |
| **ESPAsyncTCP** | me-no-dev | 1.2.2 | Async TCP (required by above) |
**Installation steps for each library:**
1. Search library name in Library Manager
2. Click **Install**
3. Wait for "INSTALLED" badge
@@ -87,17 +88,17 @@ Install the following libraries (search by name):
2. Select: **Generic ESP8266 Module**
3. Configure settings:
| Setting | Value | Why |
|---------|-------|-----|
| Flash Size | `1MB (FS:64KB OTA:~470KB)` | Enables OTA with 470KB max firmware |
| Flash Mode | `DIO` | Compatible with most ESP-01S modules |
| Flash Frequency | `40MHz` | Safe default for all ESP8266 |
| CPU Frequency | `80MHz` | Standard (can use 160MHz for more speed) |
| Crystal Frequency | `26MHz` | Default for ESP-01S |
| Upload Speed | `115200` | Balance between speed and reliability |
| Debug Level | `None` | Reduces firmware size |
| IwIP Variant | `v2 Lower Memory` | Better for 1MB flash devices |
| Erase Flash | `Only Sketch` | Preserves config on re-flash |
| Setting | Value | Why |
| ----------------- | -------------------------- | ---------------------------------------- |
| Flash Size | `1MB (FS:64KB OTA:~470KB)` | Enables OTA with 470KB max firmware |
| Flash Mode | `DIO` | Compatible with most ESP-01S modules |
| Flash Frequency | `40MHz` | Safe default for all ESP8266 |
| CPU Frequency | `80MHz` | Standard (can use 160MHz for more speed) |
| Crystal Frequency | `26MHz` | Default for ESP-01S |
| Upload Speed | `115200` | Balance between speed and reliability |
| Debug Level | `None` | Reduces firmware size |
| IwIP Variant | `v2 Lower Memory` | Better for 1MB flash devices |
| Erase Flash | `Only Sketch` | Preserves config on re-flash |
---
@@ -106,6 +107,7 @@ Install the following libraries (search by name):
### Step 1: Identify Pins
ESP-01S pinout (looking at module from top, antenna up):
```
┌─────────────┐
│ │
@@ -123,15 +125,16 @@ ESP-01S pinout (looking at module from top, antenna up):
**Connections:**
| FTDI Pin | ESP-01S Pin | Wire Color | Notes |
|----------|-------------|------------|-------|
| 3.3V | 3V3 | Red | Power (NOT 5V!) |
| GND | GND | Black | Ground |
| TX | RX | Yellow | Data: FTDI transmit → ESP receive |
| RX | TX | Green | Data: FTDI receive → ESP transmit |
| GND | GPIO0 | Blue | **Programming mode** (temporary) |
| FTDI Pin | ESP-01S Pin | Wire Color | Notes |
| -------- | ----------- | ---------- | --------------------------------- |
| 3.3V | 3V3 | Red | Power (NOT 5V!) |
| GND | GND | Black | Ground |
| TX | RX | Yellow | Data: FTDI transmit → ESP receive |
| RX | TX | Green | Data: FTDI receive → ESP transmit |
| GND | GPIO0 | Blue | **Programming mode** (temporary) |
**⚠️ CRITICAL**:
- **Never connect 5V to ESP-01S** - it's not 5V tolerant!
- Double-check polarity before powering on
- GPIO0-to-GND connection is **temporary** (only for programming mode)
@@ -152,8 +155,8 @@ ESP-01S is now in programming mode, ready to receive firmware.
### Step 1: Open Project
1. Download or clone this repository
2. Navigate to: `esp8266-weather-clock-opensource/src/`
3. Open: `clock_ntp_ota_v1.9.ino` in Arduino IDE
2. Navigate to: `esp8266-weather-clock-opensource/firmware/weather_clock/`
3. Open: `weather_clock.ino` in Arduino IDE
### Step 2: Verify Board Settings
@@ -166,6 +169,7 @@ ESP-01S is now in programming mode, ready to receive firmware.
- Windows: `COM3`, `COM4`, etc.
If port doesn't appear:
- Check USB cable is data-capable (not charge-only)
- Install FTDI drivers
- Try different USB port
@@ -222,10 +226,12 @@ If port doesn't appear:
### Step 2: Captive Portal
**Automatic (iOS/Android):**
- Captive portal should pop up automatically
- If not, manually browse to: http://192.168.4.1
**Manual (laptop):**
- Browse to: http://192.168.4.1
### Step 3: Configure WiFi
@@ -240,16 +246,19 @@ If port doesn't appear:
### Step 4: Find Device IP
**Method 1: Router Admin Panel**
- Log into your router
- Look for device: "tj56654-clock"
- Note its IP address (e.g., 192.168.1.47)
**Method 2: mDNS (if your OS supports it)**
- Browse to: http://tj56654-clock.local/
- Works on macOS, Linux, iOS out-of-box
- Windows: Install [Bonjour Print Services](https://support.apple.com/kb/DL999)
**Method 3: Serial Monitor**
1. Keep FTDI connected (no GPIO0 to GND!)
2. Open: **Tools → Serial Monitor**
3. Set baud rate: **115200**
@@ -261,6 +270,7 @@ If port doesn't appear:
Browse to: `http://<device-ip>/` or `http://tj56654-clock.local/`
You should see:
- Current time display
- Navigation links (Config, Debug, Update)
@@ -276,6 +286,7 @@ You should see:
4. Device reboots with new settings
**Timezone examples:**
- UTC+0 (London winter): `0`
- UTC+1 (Paris winter): `3600`
- UTC-5 (New York winter): `-18000`
@@ -319,6 +330,7 @@ curl -u admin:admin -F "file=@/path/to/firmware.bin" http://192.168.x.x/update
```
Replace:
- `192.168.x.x` with your device IP
- `/path/to/firmware.bin` with actual path to .bin file
@@ -329,43 +341,51 @@ Replace:
### Upload Fails
**Error: "espcomm_open failed"**
- Check: GPIO0 was grounded during power-on
- Check: FTDI driver installed
- Try: Different USB port
- Try: Lower upload speed (57600 instead of 115200)
**Error: "espcomm_upload_mem failed"**
- Check: Wire connections (especially RX↔TX swap)
- Check: FTDI is 3.3V (not 5V)
- Try: Power ESP-01S from external 3.3V supply (FTDI may not provide enough current)
**Error: "Chip sync error"**
- GPIO0 must be LOW during boot
- Try: Hold GPIO0 to GND, reset ESP, then release GPIO0
### Compilation Fails
**Error: "library not found"**
- Install missing library via Library Manager
- Restart Arduino IDE after installing
**Error: "Sketch too big"**
- Flash size must be set to 1MB
- Reduce features if necessary (disable weather, etc.)
**IRAM overflow error**
- Some functions missing `ICACHE_FLASH_ATTR`
- Use version from this repo (already optimized)
### WiFi Connection Fails
**Device creates AP but won't connect to home WiFi**
- ESP8266 only supports 2.4GHz (not 5GHz)
- Try: Different WiFi channel (1, 6, or 11)
- Check: WiFi password is correct
- Check: Router supports 802.11n
**Device reboots in a loop**
- Likely: Power supply too weak (brownout)
- Solution: Use powered USB hub or different power adapter
- Minimum: 500mA @ 5V
@@ -373,28 +393,33 @@ Replace:
### Display Issues
**Display is blank**
- Check: I2C wiring (SDA=GPIO0, SCL=GPIO2)
- Check: Display I2C address (try 0x3C and 0x3D in code)
- Test: Use `/api/i2c-scan` endpoint to detect display
**Display shows garbage**
- Wrong display library or initialization
- This firmware is for SSD1306-compatible OLED
- Verify display model is GM009605v4.3 or similar
**Display is upside down**
- Change `display_orientation` in `/config`
- Values: 0 (normal), 1 (90°), 2 (180°), 3 (270°)
### Time Not Syncing
**Time shows 00:00:00**
- Check: WiFi is connected (`/api/status`)
- Check: NTP server is reachable (default: pool.ntp.org)
- Check: Router firewall allows UDP port 123
- Try: Different NTP server (e.g., time.google.com)
**Time is wrong by hours**
- Check: Timezone offset in `/config`
- Remember: Offset is in **seconds**, not hours
- Example: UTC+1 = 3600 seconds
@@ -402,6 +427,7 @@ Replace:
### Weather Not Updating
**Temperature shows 0.0°C**
- Check: Internet connectivity (`/api/debug`)
- Check: Latitude/longitude are correct
- Check: Open-Meteo API is accessible (visit https://open-meteo.com/ in browser)
@@ -410,11 +436,13 @@ Replace:
### OTA Update Fails
**Web upload hangs at 0%**
- Check: Device is online and responsive
- Try: Smaller firmware (disable features)
- Try: Upload via Arduino IDE instead
**Upload completes but device doesn't reboot**
- Wait 30 seconds (sometimes slow)
- Manually power cycle device
- Check serial output for errors
@@ -422,10 +450,12 @@ Replace:
### Serial Monitor Shows Errors
**"DNS resolution failed"**
- In v1.9.0 (fixed in v1.9.1)
- Upgrade to v1.9.1 or later
**Watchdog reset / exception**
- Likely: Code bug or memory corruption
- Check: IRAM usage < 95%
- Report: Open issue with serial log
@@ -437,6 +467,7 @@ Replace:
### Change OTA Password
Edit in source code (line ~60):
```cpp
ArduinoOTA.setPassword("your-secret-password");
```
@@ -444,6 +475,7 @@ ArduinoOTA.setPassword("your-secret-password");
### Change Web Admin Password
Edit in source code (line ~430):
```cpp
if (!server.authenticate("admin", "your-secret-password")) {
```
@@ -453,17 +485,48 @@ if (!server.authenticate("admin", "your-secret-password")) {
To save memory, disable unused features:
**Disable weather:**
- Set `weather_enabled = false` in `/config`
- Or remove weather code from source
**Disable sunrise/sunset:**
- Set `show_sunrise_sunset = false` in `/config`
**Disable display rotation:**
- Set `display_rotation_sec = 0` (manual switch only)
---
## 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
If you're still stuck:
-218
View File
@@ -1,218 +0,0 @@
# v1.9.1 - Hybrid Async Fix
## Problem in v1.9.0
**Symptoms:**
- Display shows blank screen ~10 seconds after boot
- "DNS resolution failed" errors in logs
- Time appears only after 10+ seconds
**Root Cause:**
```cpp
void setup() {
loadConfig();
setupWiFi(); // Returns IMMEDIATELY (async)
setupOTA(); // WiFi NOT ready!
setupWebServer(); // WiFi NOT ready!
testInternetConnectivity(); // WiFi NOT ready! -> "DNS resolution failed"
}
```
WiFi became **fully asynchronous**, but this is **wrong for setup()**:
- OTA, web server, NTP **require a ready WiFi connection**
- `testInternetConnectivity()` ran **before WiFi connected**
- Time on display appeared only when async WiFi finally connected
## Solution: Hybrid Model
| Phase | WiFi Mode | Blocking | Reason |
|-------|-----------|----------|--------|
| **setup()** | **Synchronous** | 10 sec | Required for OTA/web/NTP initialization |
| **loop()** | **Asynchronous** | 0 sec | Don't freeze on reconnect |
### Code Changes
#### 1. setupWiFi() - Now Synchronous
```cpp
void ICACHE_FLASH_ATTR setupWiFi() {
Serial.println("WiFi Setup - Synchronous for initial connection");
WiFi.hostname(config.hostname);
if (strlen(config.ssid) > 0) {
WiFi.mode(WIFI_STA);
WiFi.begin(config.ssid, config.password);
// SYNCHRONOUS wait (max 10 seconds)
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
showNumber(attempts, false); // Show progress on display
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
// WiFi ready for OTA/web/NTP!
showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return;
}
}
// Fallback to WiFiManager if credentials didn't work
// ...
}
```
#### 2. loop() - Async Reconnect
```cpp
void loop() {
// WiFi reconnection (async, non-blocking)
static unsigned long lastWiFiCheck = 0;
if (millis() - lastWiFiCheck > 5000) {
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
Serial.println("WiFi disconnected, attempting async reconnect...");
WiFi.begin(); // Async reconnect
wifiConnState = WIFI_CONN_CONNECTING;
wifiConnectStart = millis();
}
lastWiFiCheck = millis();
}
processWiFiConnection(); // Async reconnect handling
// Other async operations
processNTPResponse();
fetchWeatherAsync();
// ...
}
```
## Test Results
### Before (v1.9.0)
```
[0-5s] -> Display init
[5-15s] -> WiFi connecting (async, setup() returns immediately)
[15-20s] -> OTA/web init WITHOUT WiFi -> Errors!
[20s] -> testInternetConnectivity() WITHOUT WiFi -> "DNS resolution failed"
[15-25s] -> WiFi finally connects (async)
[25-30s] -> NTP sync begins
Display blank for 10+ seconds
"DNS resolution failed" errors
```
### After (v1.9.1)
```
[0-5s] -> Display init + startup animation
[5-15s] -> WiFi connection (SYNCHRONOUS, setup() waits)
WiFi connected!
[15-20s] -> OTA/web/NTP init WITH WiFi
Internet test: PASSED
No DNS errors!
[20-30s] -> First async NTP sync
Time synced!
Display shows time immediately after WiFi connects (~15 sec)
No "DNS resolution failed" errors
Proper initialization order
```
## Startup Timeline
```
+----------------------------------------------------------+
| SETUP PHASE (Synchronous WiFi) |
+----------------------------------------------------------+
| |
| [0s] +-------------+ |
| | Display | Startup animation |
| [5s] | Init | "Weather Clock v1.9.1" |
| +-------------+ |
| |
| [5s] +---------------------------------+ |
| | WiFi Connect (SYNCHRONOUS) | |
| | - Connecting to network... | |
| [15s] | - Connected! IP assigned | |
| +---------------------------------+ |
| | |
| WiFi is READY here |
| | |
| [15s] +---------------------------------+ |
| | OTA Init (needs WiFi) | |
| | Web Server (needs WiFi) | |
| [20s] | NTP Client (needs WiFi) | |
| | Internet Test (needs WiFi) | |
| +---------------------------------+ |
| |
| [20s] Setup complete! -> loop() starts |
| |
+----------------------------------------------------------+
+----------------------------------------------------------+
| LOOP PHASE (Async Operations) |
+----------------------------------------------------------+
| |
| [Every loop] +------------------------+ |
| | WiFi Health Check | |
| | (every 5 sec) | |
| | If disconnected: | |
| | -> Async reconnect | |
| +------------------------+ |
| |
| [Every loop] +------------------------+ |
| | Async NTP Processing | |
| | (non-blocking) | |
| +------------------------+ |
| |
| [Every 30m] +------------------------+ |
| | Async Weather Fetch | |
| | (non-blocking) | |
| +------------------------+ |
| |
| Loop time: <1ms (no blocking!) |
| |
+----------------------------------------------------------+
```
## Benefits of Hybrid Approach
### In setup():
1. **Correct initialization order** - WiFi -> OTA -> web -> NTP
2. **No DNS errors** - internet connectivity test runs AFTER WiFi
3. **Predictable behavior** - setup() completes when everything is ready
4. **Display shows time immediately** - no need to wait for async WiFi
### In loop():
1. **No freeze on reconnect** - async handling of WiFi loss
2. **Async NTP** - doesn't block loop
3. **Async weather** - doesn't block loop
4. **Exponential backoff** - smart retry on errors
5. **Loop <1ms** - always responsive device
## Memory
| Resource | v1.9.0 | v1.9.1 | Change |
|----------|--------|--------|--------|
| RAM | 37,516 | 37,644 | +128 bytes |
| IRAM | 61,987 | 61,987 | 0 bytes |
| Flash | 408,540 | 408,844 | +304 bytes |
Minimal memory changes (+0.3%) for critical UX improvement.
## Conclusion
**v1.9.1 implements the ideal balance:**
- Setup: Synchronous for reliable initialization
- Loop: Asynchronous for responsiveness
**Result:**
- Display shows time after 15 sec (instead of 25+ sec)
- No "DNS resolution failed" errors
- Correct startup order
- Device doesn't freeze on WiFi loss during operation
**Status**: Production ready
-313
View File
@@ -1,313 +0,0 @@
# v1.9.2 - WiFi Resilience
## Problem in v1.9.1
**Symptoms:**
- After WiFi outage (router restart, temporary network issues), device enters AP mode
- WiFi credentials are cleared, requiring manual reconfiguration
- User must reconnect to "TJ56654-Setup" AP and re-enter WiFi password
- This happens every time WiFi goes down temporarily
**Root Cause:**
Aggressive credential clearing after connection failures:
```cpp
void processWiFiConnection() {
// After 5 failed attempts...
if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
// Clear credentials!
memset(config.ssid, 0, sizeof(config.ssid));
memset(config.password, 0, sizeof(config.password));
saveConfig();
// Start AP mode for reconfiguration
WiFiManager wm;
wm.startConfigPortal("TJ56654-Setup", "12345678");
}
}
```
**Impact:**
- Poor user experience during network outages
- Unnecessary manual intervention required
- Device unusable until reconfigured
## Solution: Resilient WiFi
### Key Changes
| Feature | Before (v1.9.1) | After (v1.9.2) |
|---------|-----------------|----------------|
| Credential clearing | After 5 failed attempts | Never |
| Retry strategy | Give up after 5 tries | Infinite retries |
| Retry interval | Fixed 5 seconds | Exponential backoff (5s-5min) |
| Fallback AP | After clearing credentials | After ~5 min (keeps credentials) |
| Clock during outage | Shows connection counter | Shows last synced time |
| User notification | Numeric counter | "No WiFi" message |
### Implementation
#### 1. WiFi Retry Configuration
```cpp
// Infinite retries with exponential backoff
struct WiFiRetryConfig {
uint8_t currentRetry = 0;
unsigned long nextRetryTime = 0;
static const unsigned long MAX_BACKOFF_MS = 300000; // Max 5 minutes
unsigned long getBackoffDelay() {
// 5s, 10s, 20s, 40s, 80s, 160s, 300s (max)
unsigned long delay = 5000UL * (1UL << currentRetry);
return (delay > MAX_BACKOFF_MS) ? MAX_BACKOFF_MS : delay;
}
void recordAttempt() {
currentRetry++;
nextRetryTime = millis() + getBackoffDelay();
}
void reset() {
currentRetry = 0;
nextRetryTime = 0;
}
};
```
#### 2. No Credential Clearing
```cpp
void processWiFiConnection() {
if (WiFi.status() == WL_CONNECTED) {
if (wifiConnState != WIFI_CONN_CONNECTED) {
Serial.println("WiFi connected!");
wifiConnState = WIFI_CONN_CONNECTED;
wifiRetry.reset();
// Disable fallback AP if it was enabled
if (WiFi.getMode() == WIFI_AP_STA) {
Serial.println("Disabling fallback AP");
WiFi.mode(WIFI_STA);
}
}
return;
}
// Connection failed - but DON'T clear credentials!
if (millis() >= wifiRetry.nextRetryTime) {
Serial.printf("WiFi retry %d, next in %lu sec\n",
wifiRetry.currentRetry,
wifiRetry.getBackoffDelay() / 1000);
wifiRetry.recordAttempt();
// Try to connect with SDK or EEPROM credentials
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
} else {
WiFi.begin(); // Use SDK-stored credentials
}
// Enable fallback AP after 5+ attempts (~2.5 min)
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
Serial.println("Enabling fallback AP (dual mode)");
WiFi.mode(WIFI_AP_STA);
WiFi.softAP("TJ56654-Setup", "12345678");
}
}
}
```
#### 3. "No WiFi" Display
```cpp
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds) {
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
// Center "No WiFi" text
display.setCursor(20, 8);
display.println("No WiFi");
// Show retry countdown
display.setTextSize(1);
display.setCursor(10, 52);
if (nextRetrySeconds < 60) {
display.printf("Retry in %lu sec", nextRetrySeconds);
} else {
display.printf("Retry in %lu min", nextRetrySeconds / 60);
}
display.display();
}
```
#### 4. Clock Continues During Outage
```cpp
void updateDisplay() {
if (WiFi.status() != WL_CONNECTED) {
// Show "No WiFi" status
unsigned long nextRetry = (wifiRetry.nextRetryTime > millis())
? (wifiRetry.nextRetryTime - millis()) / 1000
: 0;
showNoWiFi(nextRetry);
return;
}
// Normal display modes when WiFi is connected
// ...
}
```
#### 5. WiFi Disconnect Indicator
When WiFi is disconnected but time is still valid, show "!" in date line:
```cpp
void showTimeMode() {
// ... time display ...
// Date line with WiFi status indicator
display.setCursor(0, 0);
if (WiFi.status() != WL_CONNECTED) {
display.print("! "); // WiFi disconnected indicator
}
display.print(dateString);
}
```
#### 6. SDK Credentials Support
WiFiManager stores credentials in ESP SDK flash, not EEPROM. This caused issues after OTA updates:
```cpp
void setupWiFi() {
// Try SDK-stored credentials first (from WiFiManager)
if (strlen(config.password) > 0) {
// EEPROM has credentials - use them
WiFi.begin(config.ssid, config.password);
} else {
// EEPROM empty - try SDK credentials
WiFi.begin(); // Uses last saved WiFi from SDK
}
}
```
## Retry Backoff Schedule
| Attempt | Delay | Cumulative Time |
|---------|-------|-----------------|
| 1 | 5 sec | 5 sec |
| 2 | 10 sec | 15 sec |
| 3 | 20 sec | 35 sec |
| 4 | 40 sec | 1 min 15 sec |
| 5 | 80 sec | 2 min 35 sec |
| 6 | 160 sec | 5 min 15 sec |
| 7+ | 300 sec (5 min) | +5 min each |
**Fallback AP enabled after attempt 5** (~2.5 min of failures)
## Network Activity Summary
| Service | Interval | Endpoint | Protocol | Daily Requests |
|---------|----------|----------|----------|----------------|
| NTP | 1 hour | pool.ntp.org:123 | UDP | 24 |
| Weather | 30 min | api.open-meteo.com | HTTP | 48 |
| mDNS | continuous | 224.0.0.251 | UDP multicast | N/A |
**Total**: ~50-75 requests/day (minimal network load)
## Dual STA+AP Mode
When fallback AP is enabled, device operates in dual mode:
- **STA (Station)**: Continues trying to connect to configured WiFi
- **AP (Access Point)**: Allows user to reconfigure if needed
```cpp
// Enable dual mode
WiFi.mode(WIFI_AP_STA);
WiFi.softAP("TJ56654-Setup", "12345678");
// Continue WiFi connection attempts in STA mode
WiFi.begin(config.ssid, config.password);
```
When WiFi reconnects:
```cpp
if (WiFi.status() == WL_CONNECTED && WiFi.getMode() == WIFI_AP_STA) {
// Disable AP, return to STA-only mode
WiFi.mode(WIFI_STA);
Serial.println("WiFi reconnected, disabled fallback AP");
}
```
## Test Scenarios
### Scenario 1: Router Restart (5 minutes)
**Before (v1.9.1):**
1. WiFi disconnects
2. 5 retry attempts (25 seconds)
3. Credentials cleared
4. Device enters AP mode
5. User must reconfigure WiFi
**After (v1.9.2):**
1. WiFi disconnects
2. Retry with backoff (5s, 10s, 20s, 40s, 80s)
3. After ~2.5 min: Fallback AP enabled (dual mode)
4. Device continues retrying
5. Router comes back
6. Device reconnects automatically
7. Fallback AP disabled
8. No user intervention needed
### Scenario 2: Extended Outage (30+ minutes)
**v1.9.2 Behavior:**
1. Retries every 5 minutes after initial backoff
2. Fallback AP available for reconfiguration if needed
3. Clock shows last synced time
4. "No WiFi" indicator on display
5. Reconnects automatically when WiFi returns
### Scenario 3: OTA Update
**Problem**: WiFiManager stores credentials in SDK flash, but our EEPROM may be empty after update.
**Solution**: Try SDK credentials first:
```cpp
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
} else {
WiFi.begin(); // SDK credentials
}
```
## Memory Impact
| Resource | v1.9.1 | v1.9.2 | Change |
|----------|--------|--------|--------|
| RAM | 37,644 | 37,800 | +156 bytes |
| IRAM | 61,987 | 61,987 | 0 bytes |
| Flash | 408,844 | 409,100 | +256 bytes |
Minimal memory increase (+0.4%) for significant UX improvement.
## Conclusion
**v1.9.2 provides true WiFi resilience:**
- Never clears credentials
- Infinite retries with smart backoff
- Fallback AP for emergency reconfiguration
- Clock continues during outages
- Clear user feedback on display
**Results:**
- No more manual reconfiguration after WiFi outages
- Device recovers automatically when network returns
- User can still reconfigure via fallback AP if needed
- Minimal network overhead (~50 requests/day)
**Status**: Production ready
-160
View File
@@ -1,160 +0,0 @@
# TJ-56-654 Weather Clock - v1.9.0 Release Notes
## Release Date
2026-01-03
## Overview
Version 1.9 is a major async refactoring that eliminates **ALL blocking operations** from the firmware, transforming the device from a frequently-frozen system into a fully responsive, production-ready clock.
## Performance Improvements
### Before (v1.8):
- **WiFi connection**: 10 seconds blocking (v1.7 credential migration)
- **NTP sync**: 5-20 seconds blocking
- **Weather fetch**: 1-10 seconds blocking
- **Loop delay**: 10ms blocking every iteration
- **Total freeze time**: Up to **45+ seconds**
### After (v1.9):
- **WiFi connection**: 0ms blocking (async state machine)
- **NTP sync**: 0ms blocking (async UDP)
- **Weather fetch**: 0ms blocking (AsyncHTTPRequest)
- **Loop delay**: 0ms (removed)
- **Total freeze time**: **0 seconds** ✅
**Loop responsiveness**: <1ms typical (was 10ms minimum)
## New Features
### 1. Async HTTP Weather Fetch (v1.9.2)
- **Library**: AsyncHTTPRequest_Generic v1.13.0
- **State machine**: IDLE → REQUESTING → SUCCESS/FAILED
- **Callback**: `onWeatherResponse()` processes data non-blocking
- **Result**: OTA updates work during weather fetch
### 2. Async NTP Implementation (v1.9.3)
- **Manual NTP**: Custom UDP packet building/parsing
- **Independent epoch tracking**: `syncedEpoch`, `syncedMillis`, `timeIsSynced`
- **Workaround**: NTPClient library is inherently blocking, so we bypass it
- **State machine**: IDLE → REQUEST_SENT → WAITING → SUCCESS/FAILED
- **Timeout**: 5 seconds non-blocking
### 3. Async WiFi Connection (v1.9.4)
- **v1.7 migration**: Non-blocking credential attempt
- **State machine**: IDLE → CONNECTING → CONNECTED/FAILED
- **Fallback**: WiFiManager (still blocking, but only on first boot)
- **Benefit**: Device stays responsive during connection attempts
### 4. Zero Blocking Delays (v1.9.5)
- **Removed**: `delay(10)` from loop()
- **Replaced**: `delay(3000)` in `showIP()` with scheduled clear via `ipDisplayUntil` timer
- **Kept**: Startup animation delays (acceptable, only runs once in setup)
- **Kept**: Pre-reboot delays (acceptable, device is rebooting anyway)
### 5. Exponential Backoff Retries (v1.9.6)
- **Strategy**: 1s → 2s → 4s (max 3 retries)
- **Struct**: `RetryConfig` with `getBackoffDelay()`, `scheduleRetry()`, `isRetryTime()`
- **Applied to**:
- NTP failures: graceful retry instead of hammering server
- Weather API failures: same exponential strategy
- **Benefit**: Network resilience without aggressive retry behavior
## Memory Footprint
| Resource | v1.8 (baseline) | v1.9.0 (final) | Increase |
|----------|----------------|----------------|----------|
| RAM | 36,980 bytes | 37,516 bytes | +536 bytes (1.4%) |
| IRAM | 61,987 bytes | 61,987 bytes | 0 bytes |
| Flash | 407,500 bytes | 408,540 bytes | +1040 bytes (0.25%) |
### Memory Budget Status:
- **RAM**: 37,516 / 80,192 bytes (46%) - ✅ Safe
- **IRAM**: 61,987 / 65,536 bytes (94%) - ⚠️ Near limit but stable
- **Flash**: 408,540 / 1,048,576 bytes (38%) - ✅ Plenty of room
**Verdict**: Less than 1.5% RAM increase for fully async operation - excellent ROI!
## Code Quality Improvements
### Line Count:
- **v1.8**: ~1,950 lines
- **v1.9**: 2,026 lines (+76 lines for async infrastructure)
### New Data Structures:
```cpp
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED, SKIP_ASYNC };
struct RetryConfig {
uint8_t maxRetries = 3;
uint8_t currentRetry = 0;
unsigned long nextRetryTime = 0;
unsigned long getBackoffDelay();
void scheduleRetry();
bool isRetryTime();
void reset();
bool maxRetriesReached();
};
```
### Key Functions Added:
1. `onWeatherResponse()` - AsyncHTTPRequest callback
2. `fetchWeatherAsync()` - Non-blocking weather fetch
3. `sendNTPRequestAsync()` - Manual NTP packet send
4. `processNTPResponse()` - Non-blocking NTP response check
5. `processWiFiConnection()` - Async WiFi state handler
6. `getAsyncEpoch()` - Independent time tracking
## Testing Checklist
Before OTA upload to device:
- [x] Compilation successful
- [x] Memory usage within safe limits
- [ ] OTA responsive during weather fetch
- [ ] Web UI responsive during NTP sync
- [ ] Display updates smoothly during network ops
- [ ] Exponential backoff triggers on failures
- [ ] Max retry limits respected
- [ ] Config persistence across reboots
- [ ] 24-hour stability test
## Migration from v1.8
**OTA Upgrade Path**: ✅ Safe
- Config struct unchanged - binary compatible
- All settings preserved
- Smooth transition from v1.7 credentials
**Rollback**: Keep v1.8.bin for emergency rollback via web upload
## Known Limitations
1. **WiFiManager**: Still blocking on first boot (acceptable)
2. **IRAM**: At 94% - future features must use `ICACHE_FLASH_ATTR`
3. **Startup animation**: Still uses blocking delays (acceptable, only runs once)
4. **Test handlers**: Some debug endpoints still block (low priority)
## Next Steps (v2.0)
Future improvements planned for v2.0:
1. **Modular architecture**: Split into separate files
2. **ArduinoJson**: Replace manual JSON parsing
3. **Constants**: Eliminate remaining magic numbers
4. **Code deduplication**: Display helper refactoring
5. **Enhanced error handling**: Pre-flight checks, better validation
## Credits
**Firmware**: TJ-56-654 Weather Clock
**Hardware**: ESP-01S (ESP8266EX, 1MB flash)
**Author**: Generated with Claude Code (Opus 4.5)
**Repository**: clock/firmware/clock_ntp_ota_v1.9
## Conclusion
v1.9 transforms the weather clock from a frequently-frozen device into a **fully responsive**, **production-ready** system with **zero blocking operations**. The 536-byte RAM overhead is a negligible cost for the massive UX improvement of instant responsiveness to OTA, web requests, and display updates even during active network operations.
**Status**: ✅ Ready for OTA deployment
@@ -9,7 +9,7 @@
#include <Arduino.h>
// Firmware version
#define FIRMWARE_VERSION "1.9.3"
#define FIRMWARE_VERSION "1.9.9"
// OLED I2C Configuration
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
@@ -70,7 +70,8 @@ struct RetryConfig {
}
bool isRetryTime() {
return nextRetryTime > 0 && millis() >= nextRetryTime;
// Subtraction-safe: works correctly across millis() rollover at ~49.7 days
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
}
void reset() {
@@ -101,7 +102,8 @@ struct WiFiRetryConfig {
}
bool isRetryTime() {
return nextRetryTime > 0 && millis() >= nextRetryTime;
// Subtraction-safe: works correctly across millis() rollover at ~49.7 days
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
}
void reset() {
@@ -118,13 +120,11 @@ enum WeatherState {
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 {
NTP_IDLE,
NTP_REQUEST_SENT,
NTP_WAITING,
NTP_SUCCESS,
NTP_FAILED
NTP_REQUEST_SENT
};
// Async WiFi state machine
@@ -165,4 +165,16 @@ const unsigned long NTP_TIMEOUT_MS = 5000; // 5 second timeout
// WiFi timeout
const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second timeout
// Triple power-cycle factory reset
// Counter stored at EEPROM offset 480, well past Config (~260 bytes)
#define RESET_COUNTER_ADDR 480
#define RESET_COUNTER_MAGIC 0xA5
#define RESET_COUNTER_WINDOW 10000UL // 10s: if device runs longer, counter clears
#define RESET_COUNTER_TRIPS 3 // 3 quick power cycles = factory reset
struct ResetCounter {
uint8_t magic;
uint8_t count;
};
#endif // CONFIG_H
@@ -54,7 +54,7 @@ void ICACHE_FLASH_ATTR updateDisplay() {
// === YELLOW ZONE (Y: 48-63): Date (size 2 = 16px height) ===
display.setTextSize(2);
time_t t = epochTime;
time_t t = localTime;
struct tm *ptm = gmtime(&t);
// Format: "Thu 02.01" or "! Thu 02.01" if no WiFi
@@ -168,7 +168,7 @@ void ICACHE_FLASH_ATTR displaySunTimes() {
int daylightHours = daylightMinutes / 60;
int daylightMins = daylightMinutes % 60;
char daylightStr[16];
char daylightStr[32];
sprintf(daylightStr, "Day %dh %dm", daylightHours, daylightMins);
display.setTextSize(1);
@@ -29,9 +29,9 @@ extern NTPClient timeClient;
extern ESP8266WebServer server;
extern ESP8266HTTPUpdateServer httpUpdater;
// State machines
extern WeatherState weatherState;
extern NTPState ntpState;
// State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
extern volatile WeatherState weatherState;
extern volatile NTPState ntpState;
extern WiFiConnectionState wifiConnState;
// Retry configurations
@@ -71,6 +71,7 @@ extern SunTimes sunTimes;
extern uint8_t displayMode;
extern unsigned long lastModeSwitch;
extern unsigned long lastWeatherUpdate;
extern unsigned long weatherRequestStart;
// Dissolve transition state
extern bool inTransition;
@@ -16,7 +16,6 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
int month = timeinfo->tm_mon + 1; // 1-12
int day = timeinfo->tm_mday; // 1-31
int weekday = timeinfo->tm_wday; // 0=Sunday
int hour = timeinfo->tm_hour;
// Not DST: November - February
@@ -27,7 +26,9 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
// March: DST starts last Sunday at 01:00 UTC
if (month == 3) {
int lastSunday = 31 - ((5 + timeinfo->tm_year) % 7);
// Compute weekday of the 31st from current day's weekday (tm_wday: 0=Sun)
int weekdayOf31 = (timeinfo->tm_wday + (31 - day)) % 7;
int lastSunday = 31 - weekdayOf31;
if (day < lastSunday) return false;
if (day > lastSunday) return true;
if (hour < 1) return false;
@@ -36,7 +37,8 @@ bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
// October: DST ends last Sunday at 01:00 UTC
if (month == 10) {
int lastSunday = 31 - ((1 + timeinfo->tm_year) % 7);
int weekdayOf31 = (timeinfo->tm_wday + (31 - day)) % 7;
int lastSunday = 31 - weekdayOf31;
if (day < lastSunday) return true;
if (day > lastSunday) return false;
if (hour < 1) return true;
@@ -28,7 +28,7 @@ void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* reques
Serial.printf("Weather response: %d bytes\n", payload.length());
// Parse JSON response
StaticJsonDocument<1536> doc;
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
@@ -83,7 +83,7 @@ void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* reques
sunTimes.lastDay = ptm->tm_yday;
}
weatherState = WEATHER_SUCCESS;
// weatherState stays WEATHER_IDLE (set at readyState==4 entry) — allows periodic refresh
weatherRetry.reset();
Serial.printf("Weather: %.1f C, code %d, wind %.1f km/h\n",
weather.temperature, weather.weathercode, weather.windspeed);
@@ -96,7 +96,10 @@ void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* reques
weatherRetry.scheduleRetry();
if (weatherRetry.maxRetriesReached()) {
Serial.println("Weather max retries reached");
// Reset so periodic refresh can retry after weatherInterval — prevents permanent lockup
Serial.println("Weather max retries reached, resetting for next interval");
weatherRetry.reset();
weatherState = WEATHER_IDLE;
} else {
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
@@ -113,7 +116,10 @@ void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* reques
weatherRetry.scheduleRetry();
if (weatherRetry.maxRetriesReached()) {
Serial.println("Weather max retries reached");
// Reset so periodic refresh can retry after weatherInterval — prevents permanent lockup
Serial.println("Weather max retries reached, resetting for next interval");
weatherRetry.reset();
weatherState = WEATHER_IDLE;
} else {
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
@@ -150,6 +156,7 @@ void ICACHE_FLASH_ATTR fetchWeatherAsync() {
weatherRequest.setTimeout(10); // 10 seconds
weatherRequest.send();
weatherState = WEATHER_REQUESTING;
weatherRequestStart = millis();
Serial.println("Weather request sent (non-blocking)");
} else {
weatherState = WEATHER_FAILED;
@@ -50,9 +50,9 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
ESP8266WebServer server(80);
ESP8266HTTPUpdateServer httpUpdater;
// State machines
WeatherState weatherState = WEATHER_IDLE;
NTPState ntpState = NTP_IDLE;
// State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
volatile WeatherState weatherState = WEATHER_IDLE;
volatile NTPState ntpState = NTP_IDLE;
WiFiConnectionState wifiConnState = WIFI_CONN_IDLE;
// Retry configurations
@@ -92,6 +92,7 @@ SunTimes sunTimes;
uint8_t displayMode = 0;
unsigned long lastModeSwitch = 0;
unsigned long lastWeatherUpdate = 0;
unsigned long weatherRequestStart = 0; // Tracks when WEATHER_REQUESTING began (TCP hang watchdog)
// Dissolve transition state
bool inTransition = false;
@@ -109,6 +110,70 @@ void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxL
dest[maxLen - 1] = '\0';
}
// ============ Triple power-cycle factory reset ============
//
// How it works: on each boot we increment a counter in EEPROM.
// If the device runs for >10s the counter is cleared back to 0.
// 3 quick power cycles before the 10s window = factory reset:
// clears WiFi credentials, reboots into WiFiManager AP mode.
void ICACHE_FLASH_ATTR checkFactoryReset() {
EEPROM.begin(512);
ResetCounter rc;
EEPROM.get(RESET_COUNTER_ADDR, rc);
if (rc.magic != RESET_COUNTER_MAGIC) {
rc.magic = RESET_COUNTER_MAGIC;
rc.count = 0;
}
rc.count++;
Serial.printf("Boot counter: %d/%d (power-cycle %d more times within 10s to factory reset)\n",
rc.count, RESET_COUNTER_TRIPS, RESET_COUNTER_TRIPS - rc.count);
if (rc.count >= RESET_COUNTER_TRIPS) {
Serial.println("!!! FACTORY RESET triggered !!!");
// 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 ============
void ICACHE_FLASH_ATTR loadConfig() {
@@ -185,6 +250,11 @@ void ICACHE_FLASH_ATTR setupOTA() {
void setup() {
Serial.begin(115200);
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("==========================================");
Serial.println("Display: GM009605v4.3 OLED 128x64 (SSD1306 I2C)");
@@ -218,6 +288,9 @@ void setup() {
// Load configuration
loadConfig();
// Check for triple power-cycle factory reset (must be after display+config init)
checkFactoryReset();
// Setup WiFi
setupWiFi();
@@ -270,16 +343,29 @@ void loop() {
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
Serial.println("Enabling fallback AP (dual mode)");
// Must set mode BEFORE WiFi.begin() — begin() resets mode to STA killing the AP
WiFi.mode(WIFI_AP_STA);
WiFi.softAP("TJ56654-Setup", "12345678");
Serial.print("Fallback AP IP: ");
Serial.println(WiFi.softAPIP());
}
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
// Reconnect STA side without changing mode (preserves AP_STA if active)
if (WiFi.getMode() == WIFI_AP_STA) {
// Use low-level reconnect to keep AP alive
WiFi.disconnect(false);
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
} else {
WiFi.begin(config.ssid);
}
WiFi.mode(WIFI_AP_STA); // Restore AP_STA after begin() may have reset it
} else {
WiFi.begin();
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
} else {
WiFi.begin(config.ssid);
}
}
wifiConnState = WIFI_CONN_CONNECTING;
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
if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) {
Serial.println("Weather retry time reached, attempting retry...");
fetchWeatherAsync();
}
// Update weather periodically
if (config.weather_enabled && millis() > 10000) {
// Clear factory-reset boot counter after 10s of normal operation
static bool resetCounterCleared = false;
if (!resetCounterCleared && millis() > RESET_COUNTER_WINDOW) {
EEPROM.begin(512);
ResetCounter rc = { RESET_COUNTER_MAGIC, 0 };
EEPROM.put(RESET_COUNTER_ADDR, rc);
EEPROM.commit();
EEPROM.end();
resetCounterCleared = true;
Serial.println("Boot counter cleared — stable operation confirmed");
}
// Update weather periodically (static flag avoids millis() > 10000 rollover trap)
static bool weatherBootReady = false;
if (!weatherBootReady && millis() > 10000UL) weatherBootReady = true;
if (config.weather_enabled && weatherBootReady) {
unsigned long weatherInterval = config.weather_interval * 1000UL;
if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) {
if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) {
@@ -166,7 +166,7 @@ void ICACHE_FLASH_ATTR handleDebug() {
if (weather.valid) {
snprintf_P(buf, sizeof(buf), PSTR("Temperature: %.1f C\nWeather code: %d\nWind speed: %.1f km/h\nLast update: %lu sec ago\n"),
weather.temperature, weather.weathercode, weather.windspeed, weather.lastUpdate/1000);
weather.temperature, weather.weathercode, weather.windspeed, (millis() - weather.lastUpdate)/1000);
server.sendContent(buf);
}
@@ -283,122 +283,221 @@ void ICACHE_FLASH_ATTR handleConfig() {
server.sendContent("");
}
// Validate SSID: 1-31 printable ASCII chars, not all-same-char (likely fuzz garbage)
static bool isValidSSID(const String& s) {
size_t n = s.length();
if (n == 0 || n > 31) return false;
char first = s[0];
bool allSame = true;
for (size_t i = 0; i < n; i++) {
char c = s[i];
if (c < 0x20 || c > 0x7E) return false; // non-printable
if (c != first) allSame = false;
}
// 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() {
// 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")) {
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")) {
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")) {
config.timezone_offset = server.arg("timezone").toInt();
long tz = server.arg("timezone").toInt();
config.timezone_offset = constrain(tz, -43200L, 43200L); // ±12h
}
if (server.hasArg("brightness")) {
config.brightness = server.arg("brightness").toInt();
config.brightness = constrain(server.arg("brightness").toInt(), 0, 7);
}
if (server.hasArg("hostname")) {
safeStringCopy(server.arg("hostname"), config.hostname, sizeof(config.hostname));
String h = server.arg("hostname");
if (h.length() == 0 || h.length() > 31) {
server.send(400, "text/plain", "Invalid hostname length (1-31)");
return;
}
if (h != String(config.hostname)) needsRestart = true; // mDNS bind on boot
safeStringCopy(h, config.hostname, sizeof(config.hostname));
}
if (server.hasArg("city_name")) {
safeStringCopy(server.arg("city_name"), config.city_name, sizeof(config.city_name));
String c = server.arg("city_name");
if (c.length() > 31) {
server.send(400, "text/plain", "City name too long (max 31)");
return;
}
safeStringCopy(c, config.city_name, sizeof(config.city_name));
}
if (server.hasArg("latitude")) {
config.latitude = server.arg("latitude").toFloat();
float lat = server.arg("latitude").toFloat();
config.latitude = constrain(lat, -90.0f, 90.0f);
}
if (server.hasArg("longitude")) {
config.longitude = server.arg("longitude").toFloat();
float lon = server.arg("longitude").toFloat();
config.longitude = constrain(lon, -180.0f, 180.0f);
}
if (server.hasArg("weather_interval")) {
config.weather_interval = server.arg("weather_interval").toInt();
long wi = server.arg("weather_interval").toInt();
config.weather_interval = constrain(wi, 60L, 86400L); // 1min to 1day
}
if (server.hasArg("ntp_interval")) {
long ni = server.arg("ntp_interval").toInt();
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")) {
config.display_rotation_sec = server.arg("display_rotation_sec").toInt();
config.display_rotation_sec = constrain(server.arg("display_rotation_sec").toInt(), 1, 60);
}
if (server.hasArg("display_orientation")) {
config.display_orientation = server.arg("display_orientation").toInt();
config.display_orientation = constrain(server.arg("display_orientation").toInt(), 0, 3);
display.setRotation(config.display_orientation);
}
saveConfig();
String html = F("<!DOCTYPE html><html><head>");
html += F("<meta charset='UTF-8'>");
html += F("<meta http-equiv='refresh' content='5;url=/'>");
html += F("<style>body{font-family:Arial;text-align:center;margin-top:50px;}</style>");
html += F("</head><body>");
html += F("<h1>Configuration Saved!</h1>");
html += F("<p>Device will reboot in 5 seconds...</p>");
html += F("</body></html>");
server.send(200, "text/html", html);
delay(1000);
ESP.restart();
// Only restart for WiFi/network changes; other settings apply live
if (needsRestart) {
server.send(200, "text/html",
F("<!DOCTYPE html><meta charset='UTF-8'>"
"<meta http-equiv='refresh' content='5;url=/'>"
"<h1>Configuration Saved!</h1>"
"<p>WiFi/network changed — device will reboot in 5 seconds...</p>"));
delay(1000);
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() {
String json = "{";
json += "\"time\":\"" + timeClient.getFormattedTime() + "\",";
json += "\"hours\":" + String(timeClient.getHours()) + ",";
json += "\"minutes\":" + String(timeClient.getMinutes()) + ",";
json += "\"seconds\":" + String(timeClient.getSeconds()) + ",";
json += "\"epoch\":" + String(timeClient.getEpochTime());
json += "}";
char buf[256];
server.send(200, "application/json", json);
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf),
PSTR("{\"time\":\"%s\",\"hours\":%d,\"minutes\":%d,\"seconds\":%d,\"epoch\":%lu}"),
timeClient.getFormattedTime().c_str(),
timeClient.getHours(),
timeClient.getMinutes(),
timeClient.getSeconds(),
timeClient.getEpochTime());
server.sendContent(buf);
server.sendContent("");
}
void ICACHE_FLASH_ATTR handleAPIStatus() {
String json = "{";
json += "\"wifi\":{";
json += "\"ssid\":\"" + String(WiFi.SSID()) + "\",";
json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
json += "\"hostname\":\"" + String(config.hostname) + "\"";
json += "},";
json += "\"time\":{";
json += "\"current\":\"" + timeClient.getFormattedTime() + "\",";
json += "\"timezone_offset\":" + String(config.timezone_offset) + ",";
json += "\"ntp_synced\":" + String(timeClient.isTimeSet() ? "true" : "false");
json += "},";
json += "\"system\":{";
json += "\"uptime\":" + String(millis() / 1000) + ",";
json += "\"free_heap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"chip_id\":\"" + String(ESP.getChipId(), HEX) + "\"";
json += "}";
json += "}";
char buf[256];
server.send(200, "application/json", json);
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf),
PSTR("{\"wifi\":{\"ssid\":\"%s\",\"ip\":\"%s\",\"rssi\":%d,\"hostname\":\"%s\"},"),
WiFi.SSID().c_str(),
WiFi.localIP().toString().c_str(),
WiFi.RSSI(),
config.hostname);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf),
PSTR("\"time\":{\"current\":\"%s\",\"timezone_offset\":%ld,\"ntp_synced\":%s},"),
timeClient.getFormattedTime().c_str(),
config.timezone_offset,
timeClient.isTimeSet() ? "true" : "false");
server.sendContent(buf);
snprintf_P(buf, sizeof(buf),
PSTR("\"system\":{\"uptime\":%lu,\"free_heap\":%u,\"chip_id\":\"%x\"}}"),
millis() / 1000,
ESP.getFreeHeap(),
ESP.getChipId());
server.sendContent(buf);
server.sendContent("");
}
void ICACHE_FLASH_ATTR handleAPIDebug() {
String json = "{";
json += "\"internet_connected\":" + String(internetConnected ? "true" : "false") + ",";
json += "\"ntp_attempts\":" + String(ntpAttempts) + ",";
json += "\"ntp_successes\":" + String(ntpSuccesses) + ",";
json += "\"last_error\":\"" + lastError + "\",";
json += "\"gateway\":\"" + WiFi.gatewayIP().toString() + "\",";
json += "\"dns\":\"" + WiFi.dnsIP().toString() + "\"";
json += "}";
char buf[256];
char errBuf[80];
server.send(200, "application/json", json);
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
// Clamp lastError to prevent snprintf truncation causing malformed JSON
strncpy(errBuf, lastError.c_str(), sizeof(errBuf) - 1);
errBuf[sizeof(errBuf) - 1] = '\0';
snprintf_P(buf, sizeof(buf),
PSTR("{\"internet_connected\":%s,\"ntp_attempts\":%d,\"ntp_successes\":%d,\"last_error\":\"%s\",\"gateway\":\"%s\",\"dns\":\"%s\"}"),
internetConnected ? "true" : "false",
ntpAttempts,
ntpSuccesses,
errBuf,
WiFi.gatewayIP().toString().c_str(),
WiFi.dnsIP().toString().c_str());
server.sendContent(buf);
server.sendContent("");
}
void ICACHE_FLASH_ATTR handleAPIWeather() {
String json = "{";
json += "\"enabled\":" + String(config.weather_enabled ? "true" : "false") + ",";
json += "\"valid\":" + String(weather.valid ? "true" : "false") + ",";
json += "\"temperature\":" + String(weather.temperature, 1) + ",";
json += "\"weathercode\":" + String(weather.weathercode) + ",";
json += "\"windspeed\":" + String(weather.windspeed, 1) + ",";
json += "\"last_update\":" + String(weather.lastUpdate) + ",";
json += "\"sunrise\":\"" + String(sunTimes.sunrise) + "\",";
json += "\"sunset\":\"" + String(sunTimes.sunset) + "\",";
json += "\"sunrise_minutes\":" + String(sunTimes.sunriseMinutes) + ",";
json += "\"sunset_minutes\":" + String(sunTimes.sunsetMinutes);
json += "}";
char buf[256];
server.send(200, "application/json", json);
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf),
PSTR("{\"enabled\":%s,\"valid\":%s,\"temperature\":%.1f,\"weathercode\":%d,\"windspeed\":%.1f,\"last_update\":%lu,"),
config.weather_enabled ? "true" : "false",
weather.valid ? "true" : "false",
weather.temperature,
weather.weathercode,
weather.windspeed,
weather.lastUpdate);
server.sendContent(buf);
snprintf_P(buf, sizeof(buf),
PSTR("\"sunrise\":\"%s\",\"sunset\":\"%s\",\"sunrise_minutes\":%d,\"sunset_minutes\":%d}"),
sunTimes.sunrise,
sunTimes.sunset,
sunTimes.sunriseMinutes,
sunTimes.sunsetMinutes);
server.sendContent(buf);
server.sendContent("");
}
void ICACHE_FLASH_ATTR handleAPIConfigExport() {
@@ -67,45 +67,54 @@ void ICACHE_FLASH_ATTR setupWiFi() {
WiFi.hostname(config.hostname);
WiFi.mode(WIFI_STA);
// Try 1: Use WiFi.begin() without params - uses SDK stored credentials
Serial.println("Trying SDK-stored credentials...");
WiFi.begin();
// Try 1: Use WiFi.begin() without params - only when no SSID is configured.
// Skipped if user has a saved SSID: SDK-cached credentials may include open
// networks (e.g. public hotspots) that would be preferred over the user's
// network, and a successful connect would overwrite config.ssid (issue #3).
if (strlen(config.ssid) == 0) {
Serial.println("No SSID configured, trying SDK-stored credentials...");
WiFi.begin();
// SYNCHRONOUS wait for connection (max 10 seconds)
Serial.print("Connecting to WiFi");
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
showWiFiConnecting(attempts);
delay(500);
Serial.print(".");
attempts++;
// SYNCHRONOUS wait for connection (max 10 seconds)
Serial.print("Connecting to WiFi");
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
showWiFiConnecting(attempts);
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected!");
Serial.print("SSID: ");
Serial.println(WiFi.SSID());
Serial.print("IP: ");
Serial.println(WiFi.localIP());
Serial.print("Gateway: ");
Serial.println(WiFi.gatewayIP());
Serial.print("DNS: ");
Serial.println(WiFi.dnsIP());
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
saveConfig();
showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return;
}
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected!");
Serial.print("SSID: ");
Serial.println(WiFi.SSID());
Serial.print("IP: ");
Serial.println(WiFi.localIP());
Serial.print("Gateway: ");
Serial.println(WiFi.gatewayIP());
Serial.print("DNS: ");
Serial.println(WiFi.dnsIP());
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
saveConfig();
showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return;
}
// Try 2: If we have EEPROM credentials, try those
if (strlen(config.ssid) > 0 && strlen(config.password) > 0) {
// Try 2: If we have EEPROM credentials, try those (M2: support open networks)
if (strlen(config.ssid) > 0) {
Serial.println("\nTrying EEPROM credentials...");
WiFi.begin(config.ssid, config.password);
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
} else {
WiFi.begin(config.ssid); // open network — no password
}
attempts = 0;
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
showWiFiConnecting(attempts);
delay(500);
File diff suppressed because it is too large Load Diff
+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())