Compare commits
@@ -1,5 +1,6 @@
|
||||
# Arduino build artifacts
|
||||
build/
|
||||
.pio/
|
||||
*.bin
|
||||
*.elf
|
||||
*.map
|
||||
@@ -25,3 +26,6 @@ build/
|
||||
# Secrets (in case someone accidentally commits credentials)
|
||||
secrets.h
|
||||
config_local.h
|
||||
|
||||
# Internal backlogs (not for public repo)
|
||||
BACKLOG_*.md
|
||||
|
||||
+141
-7
@@ -5,15 +5,137 @@ 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.10] - 2026-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **WiFi password lost after captive-portal setup, no reconnect after reboot** (#12):
|
||||
after a successful connect via SDK-cached credentials (Try 1) or the WiFiManager
|
||||
portal, only the SSID was written to EEPROM. On the next boot `config.password`
|
||||
was empty, so `setupWiFi()` and the reconnect loop called `WiFi.begin(ssid)` as
|
||||
if the network were open and failed with `WL_WRONG_PASSWORD`. v1.9.6 masked this
|
||||
through the bare `WiFi.begin()` fallback in the retry loop, which 02834ba (v1.9.7)
|
||||
replaced with `WiFi.begin(config.ssid)`. Now the password is read back with
|
||||
`WiFi.psk()` and saved next to the SSID in all three places that sync the SSID.
|
||||
Devices already stuck recover by entering the password once in the web UI.
|
||||
|
||||
## [1.9.9] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Factory reset atomicity**: cleared WiFi credentials are now committed BEFORE
|
||||
the reset counter is zeroed. If power fails between the two writes, the device
|
||||
still boots into WiFiManager AP on next start (cleared creds win over stale counter)
|
||||
instead of being stuck in an inconsistent state
|
||||
- **`isValidSSID` rejected single-char SSIDs**: length-1 networks like "A" were
|
||||
incorrectly flagged as all-same garbage. Now allowed (per 802.11 spec)
|
||||
- **`ntp_interval` config changes ignored until reboot**: `NTPClient` is constructed
|
||||
in `setup()` with this value and never re-initialized. Now triggers `needsRestart`
|
||||
when the interval actually changes
|
||||
- **Open WiFi (no password) failed to connect at setup** (M2): Try-2 block in
|
||||
`setupWiFi()` required both ssid and password; now falls back to `WiFi.begin(ssid)`
|
||||
when password is empty
|
||||
- **Unseeded PRNG, identical dissolve pattern every boot** (M4): `randomSeed()`
|
||||
now called with `ESP.getChipId() ^ micros()` — varies between devices and boots
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead `NTPState` enum values** (M3): `NTP_WAITING`, `NTP_SUCCESS`, `NTP_FAILED`
|
||||
were defined but never assigned. Removed to reduce code surface area
|
||||
|
||||
## [1.9.8] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **handleConfigSave always rebooted device on save**: only reboots now if WiFi/network
|
||||
fields changed (`ssid`, `password`, `hostname`, `ntp_server`). Other settings
|
||||
(brightness, timezone, intervals, coordinates, display options) apply live without
|
||||
restart. Eliminates reboot cascades during config tweaks and makes the test suite
|
||||
safe to run repeatedly.
|
||||
|
||||
## [1.9.7] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Config endpoint accepted garbage values, bricking device** (M1): `/config` form handler
|
||||
now validates all inputs. SSID rejected if empty, >31 chars, non-printable, or all-same-char
|
||||
(fuzz garbage like "AAAA..."). Numeric fields are `constrain()`-ed to safe ranges
|
||||
(`ntp_interval`/`weather_interval`: 60–86400, `brightness`: 0–7, `timezone`: ±12h,
|
||||
`latitude`/`longitude`: physical ranges, `display_orientation`: 0–3). Invalid input
|
||||
returns HTTP 400 instead of silently saving and rebooting.
|
||||
|
||||
## [1.9.6] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Weather hangs permanently after TCP timeout** (C1): `WEATHER_REQUESTING` state now has a
|
||||
15-second watchdog — if the HTTP callback never fires (NAT timeout, server half-close),
|
||||
state resets to IDLE and retry logic resumes
|
||||
- **All retry timers freeze at ~49 days** (C2): Replaced unsafe `millis() >= nextRetryTime`
|
||||
with subtraction-safe `(millis() - nextRetryTime) < 0x80000000UL` in RetryConfig and
|
||||
WiFiRetryConfig; fixed boot guard with static flag
|
||||
- **DST switches on wrong day** (H1): Last-Sunday formula was using year-only heuristic;
|
||||
now computes weekday of the 31st from current `tm_wday`: verified correct for 2026–2030
|
||||
- **Heap fragmentation from web API polling** (H2): Replaced String+= concatenation with
|
||||
`snprintf`+`sendContent` in `handleAPITime`, `handleAPIStatus`, `handleAPIDebug`,
|
||||
`handleAPIWeather` — eliminates permanent heap fragmentation from 1s JS polling
|
||||
- **Race condition on shared state** (H3): Added `volatile` to `weatherState` and `ntpState`
|
||||
— prevents compiler from caching stale values across ESPAsyncTCP callback boundaries
|
||||
- **Malformed JSON on long error messages**: `lastError` clamped to 79 chars before snprintf
|
||||
|
||||
### Changed
|
||||
|
||||
- RAM usage: 37,268 bytes (46%) — down from 37,560 due to String elimination in API handlers
|
||||
|
||||
## [1.9.5] - 2026-05-18
|
||||
|
||||
### 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 +145,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 +160,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 +187,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 +202,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 +210,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 +225,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 +250,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 +264,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 +281,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
@@ -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
|
||||
|
||||
@@ -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!**
|
||||
@@ -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
|
||||
@@ -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!
|
||||
@@ -22,19 +22,25 @@ I bought a cute weather clock kit from AliExpress ([TJ-56-654](https://pt.aliexp
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Story**
|
||||
|
||||
- [The Discovery: When "Smart" Means "Insecure"](#the-discovery-when-smart-means-insecure)
|
||||
- [The Device](#the-device)
|
||||
- [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)
|
||||
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
|
||||
- [Hardware Quirk: Swapped I2C Pins](#hardware-quirk-swapped-i2c-pins)
|
||||
- [Version History](#version-history)
|
||||
|
||||
**Use it**
|
||||
|
||||
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
||||
- [Web Interface](#web-interface)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [Testing](#testing)
|
||||
- [Security Improvements](#security-improvements)
|
||||
- [Lessons Learned](#lessons-learned)
|
||||
- [Credits](#credits)
|
||||
- [Project Structure](#project-structure)
|
||||
|
||||
For internal architecture notes (state machines, memory, EEPROM layout), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -60,6 +66,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
|
||||
@@ -79,7 +86,7 @@ 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) |
|
||||
@@ -119,6 +126,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 +143,7 @@ To flash custom firmware, you need:
|
||||
3. **Steady hands**
|
||||
|
||||
**Wiring:**
|
||||
|
||||
```
|
||||
FTDI ESP-01S
|
||||
────────────────────
|
||||
@@ -146,12 +155,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 +187,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 +195,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
|
||||
@@ -238,320 +252,23 @@ All endpoints return JSON:
|
||||
|
||||
---
|
||||
|
||||
## Technical Deep Dive
|
||||
## Hardware Quirk: Swapped I2C Pins
|
||||
|
||||
### Architecture: Fully Async State Machines
|
||||
|
||||
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
|
||||
- **Loop phase**: Async reconnection (checks every 5 seconds)
|
||||
- Why? Don't freeze the entire system if WiFi drops
|
||||
|
||||
### Memory Optimization
|
||||
|
||||
ESP8266 has strict memory limits:
|
||||
|
||||
| Memory Type | Total | Used | Usage | Status |
|
||||
|-------------|-------|------|-------|--------|
|
||||
| **Flash** | 1,048,576 | 408,844 | 38% | ✅ Plenty |
|
||||
| **RAM** | 80,192 | 37,644 | 46% | ✅ Safe |
|
||||
| **IRAM** | 65,536 | 61,987 | **94%** | ⚠️ Critical |
|
||||
|
||||
**IRAM Crisis Solution:**
|
||||
|
||||
IRAM (Instruction RAM) is limited and fills fast. The solution: `ICACHE_FLASH_ATTR` macro.
|
||||
ESP-01S exposes only GPIO0 and GPIO2. The TJ-56-654 board designer used them as I2C — but **swapped from typical breakouts**:
|
||||
|
||||
```cpp
|
||||
void ICACHE_FLASH_ATTR handleConfig() {
|
||||
// This function's code lives in Flash, not IRAM
|
||||
// Saves precious IRAM at cost of slightly slower execution
|
||||
}
|
||||
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2 (non-standard!)
|
||||
```
|
||||
|
||||
Applied to 26 functions (web handlers, display, config utilities), reducing IRAM pressure from **overflow risk** to **sustainable 94%**.
|
||||
Standard ESP8266 boards use GPIO4=SDA, GPIO5=SCL. Easy to miss if you're porting code from a different ESP8266 setup. The display is a GM009605v4.3 OLED (SSD1306-compatible) at I2C address 0x3C.
|
||||
|
||||
**String Safety:**
|
||||
For architecture details (async state machines, memory budget, EEPROM layout, factory reset), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
Avoid String concatenation in loops (causes heap fragmentation):
|
||||
```cpp
|
||||
// ❌ BAD - 140+ concatenations
|
||||
String html = "";
|
||||
html += F("<!DOCTYPE html>");
|
||||
html += F("<head>..."); // x138 more times
|
||||
## Version History
|
||||
|
||||
// ✅ GOOD - Chunked responses
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "text/html", "");
|
||||
server.sendContent_P(HTML_HEADER);
|
||||
server.sendContent_P(HTML_FOOTER);
|
||||
server.sendContent(""); // End
|
||||
```
|
||||
The clock has gone through many iterations — display hardware discovery (v1.5–v1.7), stability and security fixes (v1.8), full async refactor (v1.9.0), and a long series of bug fixes informed by community reports and AI-assisted code review (v1.9.1–v1.9.10).
|
||||
|
||||
### Configuration Storage
|
||||
|
||||
26-field struct stored in EEPROM (512 bytes):
|
||||
|
||||
```cpp
|
||||
struct Config {
|
||||
char ssid[32];
|
||||
char password[64];
|
||||
int timezone_offset;
|
||||
bool dst_enabled;
|
||||
uint8_t brightness;
|
||||
char ntp_server[64];
|
||||
unsigned long ntp_interval;
|
||||
bool hour_format_24;
|
||||
char hostname[32];
|
||||
float latitude;
|
||||
float longitude;
|
||||
char city_name[32];
|
||||
bool weather_enabled;
|
||||
unsigned long weather_interval;
|
||||
unsigned long display_rotation_sec;
|
||||
bool show_weather;
|
||||
bool show_sunrise_sunset;
|
||||
uint8_t display_orientation;
|
||||
uint32_t magic; // 0xC10CC10C - validation
|
||||
};
|
||||
```
|
||||
|
||||
**EEPROM validation**: Magic number check prevents loading corrupted data. On validation failure, gracefully defaults to safe config.
|
||||
|
||||
### Display Hardware Discovery
|
||||
|
||||
This took **3 firmware iterations** to get right:
|
||||
|
||||
**v1.5**: Assumed TM1637 (7-segment LED driver)
|
||||
- ❌ Wrong - device has OLED, not 7-segment LEDs
|
||||
|
||||
**v1.6**: Tried TM1650 (another LED driver)
|
||||
- ❌ Wrong - I2C addresses didn't match
|
||||
|
||||
**v1.7**: Identified GM009605v4.3 (SSD1306-compatible OLED)
|
||||
- ✅ Correct! Used Adafruit_SSD1306 library
|
||||
- ✅ Discovered swapped pins: SDA on GPIO0, SCL on GPIO2
|
||||
|
||||
**Pin mapping quirk:**
|
||||
|
||||
Standard ESP8266 I2C uses GPIO4 (SDA) and GPIO5 (SCL), but ESP-01S only exposes GPIO0 and GPIO2. The board designer mapped:
|
||||
- GPIO0 → SDA (unusual)
|
||||
- GPIO2 → SCL (unusual)
|
||||
|
||||
This is **backwards** from typical breakout boards, but works perfectly once configured:
|
||||
```cpp
|
||||
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Journey: v1.7 → v1.9.2
|
||||
|
||||
### v1.7: Display Discovery ✅
|
||||
|
||||
- Identified correct display hardware
|
||||
- Basic time display working
|
||||
- WiFiManager integration
|
||||
- First OTA deployment
|
||||
|
||||
### v1.8: Stability & Security 🔒
|
||||
|
||||
**Goals**: Fix memory issues, eliminate security holes
|
||||
|
||||
**Changes:**
|
||||
- IRAM optimization (added `ICACHE_FLASH_ATTR` to 26 functions)
|
||||
- Removed hardcoded WiFi credentials
|
||||
- Fixed NTP interval bug (config value was ignored)
|
||||
- Fixed boolean parsing in JSON import
|
||||
- Added input validation (buffer overflow protection)
|
||||
- Chunked HTTP responses (eliminated 140+ String concatenations)
|
||||
|
||||
**Result**: IRAM usage 94% → 70%, no memory leaks, secure config storage
|
||||
|
||||
### v1.9.0: Full Async Refactoring ⚡
|
||||
|
||||
**Goals**: Eliminate all blocking operations
|
||||
|
||||
**Changes:**
|
||||
- Async HTTP weather fetch (AsyncHTTPRequest library)
|
||||
- Custom async NTP implementation (manual UDP packets)
|
||||
- Async WiFi connection (state machine)
|
||||
- Removed all `delay()` calls from `loop()`
|
||||
- Exponential backoff retry logic
|
||||
|
||||
**Performance:**
|
||||
|
||||
| Operation | Before (v1.8) | After (v1.9.0) | Improvement |
|
||||
|-----------|---------------|----------------|-------------|
|
||||
| Weather fetch | 1-10s blocking | 0ms | ✅ Async callback |
|
||||
| NTP sync | 5-20s blocking | 0ms | ✅ Non-blocking UDP |
|
||||
| WiFi reconnect | 15s blocking | 0ms | ✅ State machine |
|
||||
| Loop time | 10ms minimum | <1ms | ✅ 10x faster |
|
||||
|
||||
**Result**: Device stays responsive during OTA updates while weather is fetching!
|
||||
|
||||
### v1.9.1: Hybrid Fix 🎯
|
||||
|
||||
**Problem Discovered:**
|
||||
|
||||
After deploying v1.9.0, the display showed **blank screen for 10 seconds** after boot, with `DNS resolution failed` errors in logs.
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
Making WiFi fully async broke the **initialization order**:
|
||||
```cpp
|
||||
void setup() {
|
||||
setupWiFi(); // Returns immediately (async)
|
||||
setupOTA(); // WiFi NOT ready! ❌
|
||||
setupWebServer(); // WiFi NOT ready! ❌
|
||||
testInternetConnectivity(); // WiFi NOT ready! → DNS error
|
||||
}
|
||||
```
|
||||
|
||||
**Solution: Hybrid Model**
|
||||
|
||||
| Phase | WiFi Mode | Blocking? | Why? |
|
||||
|-------|-----------|-----------|------|
|
||||
| `setup()` | Synchronous | 10s max | OTA/web/NTP need WiFi ready |
|
||||
| `loop()` | Asynchronous | 0s | Don't freeze on reconnect |
|
||||
|
||||
**Results:**
|
||||
- ✅ Display shows time immediately after WiFi connects (~15 sec boot)
|
||||
- ✅ No "DNS resolution failed" errors
|
||||
- ✅ Proper initialization order guaranteed
|
||||
- ✅ Device never freezes on WiFi loss during operation
|
||||
|
||||
### v1.9.2: WiFi Resilience (Current) 🛡️
|
||||
|
||||
**Problem Discovered:**
|
||||
|
||||
After WiFi outages, the device would **clear stored credentials** and enter AP mode, requiring manual reconfiguration every time the router restarted.
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
Aggressive credential clearing on connection failure:
|
||||
```cpp
|
||||
if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
|
||||
memset(config.ssid, 0, sizeof(config.ssid)); // ❌ Clears credentials!
|
||||
memset(config.password, 0, sizeof(config.password));
|
||||
saveConfig();
|
||||
// Enter AP mode...
|
||||
}
|
||||
```
|
||||
|
||||
**Solution: Resilient WiFi**
|
||||
|
||||
| Feature | Before (v1.9.1) | After (v1.9.2) |
|
||||
|---------|-----------------|----------------|
|
||||
| Credential clearing | After 5 failed attempts | Never |
|
||||
| Retry strategy | Give up after 5 tries | Infinite with backoff |
|
||||
| Max retry interval | N/A | 5 minutes |
|
||||
| Fallback AP | After clearing credentials | After ~5 min (dual STA+AP mode) |
|
||||
| Clock during outage | Blank display | Shows last synced time |
|
||||
|
||||
**Key Changes:**
|
||||
- **Never clear credentials** on connection failure
|
||||
- **Exponential backoff**: 5s → 10s → 20s → ... → 5min max
|
||||
- **Fallback AP** ("TJ56654-Setup") enabled after ~5 min, while still retrying
|
||||
- **Dual STA+AP mode**: Device continues reconnect attempts while AP is active
|
||||
- **SDK credentials support**: Tries WiFiManager-stored credentials first, then EEPROM
|
||||
- **"No WiFi" display**: Shows retry countdown instead of cryptic numbers
|
||||
- **"!" indicator**: Shown in date line when WiFi disconnected
|
||||
|
||||
**Network Activity Summary:**
|
||||
|
||||
| Service | Interval | Endpoint | Protocol |
|
||||
|---------|----------|----------|----------|
|
||||
| NTP | 1 hour | pool.ntp.org:123 | UDP |
|
||||
| Weather | 30 min | api.open-meteo.com | HTTP |
|
||||
| mDNS | continuous | 224.0.0.251 | UDP multicast |
|
||||
|
||||
~50 requests/day total.
|
||||
|
||||
**Results:**
|
||||
- ✅ Credentials persist through WiFi outages
|
||||
- ✅ Device automatically reconnects when WiFi returns
|
||||
- ✅ Clock continues running with last synced time
|
||||
- ✅ User can reconfigure via fallback AP if needed
|
||||
|
||||
**Startup Timeline:**
|
||||
```
|
||||
[0-5s] Display init, startup animation
|
||||
[5-15s] WiFi connection (SYNCHRONOUS in setup())
|
||||
✅ WiFi connected! IP assigned
|
||||
[15-20s] OTA init, web server start, NTP client ready
|
||||
✅ Internet test: PASSED
|
||||
[20-30s] First async NTP sync
|
||||
✅ Time synced and displayed
|
||||
```
|
||||
|
||||
### Memory Evolution
|
||||
|
||||
| Version | RAM Usage | IRAM Usage | Flash Usage | Notes |
|
||||
|---------|-----------|------------|-------------|-------|
|
||||
| v1.7 | 34,980 (43%) | **61,987 (94%)** | 407,500 (38%) | IRAM crisis |
|
||||
| v1.8 | 36,980 (46%) | **45,120 (68%)** | 407,800 (38%) | ICACHE_FLASH_ATTR fix |
|
||||
| v1.9.0 | 37,516 (46%) | **61,987 (94%)** | 408,540 (38%) | Async libs added |
|
||||
| v1.9.1 | 37,644 (46%) | **61,987 (94%)** | 408,844 (38%) | Hybrid WiFi fix |
|
||||
| v1.9.2 | 37,800 (47%) | **61,987 (94%)** | 409,100 (39%) | WiFi resilience |
|
||||
|
||||
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
|
||||
|
||||
---
|
||||
|
||||
## What's Next: Home Assistant Integration
|
||||
|
||||
The firmware is designed to be extensible. Next planned features:
|
||||
|
||||
### Custom Display Screens
|
||||
|
||||
Pull data from Home Assistant via REST API:
|
||||
- **Smart home stats**: Energy usage, room temperatures
|
||||
- **Sensor data**: Air quality, CO2 levels
|
||||
- **Automation states**: Alarm status, door locks
|
||||
|
||||
### MQTT Integration
|
||||
|
||||
- Publish time/weather data to MQTT broker
|
||||
- Subscribe to topics for display content
|
||||
- Enable automation triggers (e.g., display alert when door opens)
|
||||
|
||||
### WebSocket Live Updates
|
||||
|
||||
Replace polling with WebSocket for:
|
||||
- Real-time config changes without page refresh
|
||||
- Live display preview in web UI
|
||||
- Push notifications for firmware updates
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full version history with technical details.
|
||||
|
||||
---
|
||||
|
||||
@@ -577,8 +294,9 @@ Replace polling with WebSocket for:
|
||||
- `Adafruit SSD1306`
|
||||
- `NTPClient`
|
||||
- `WiFiManager` (by tzapu)
|
||||
- `AsyncHTTPRequest_Generic`
|
||||
- `asyncHTTPrequest` (by Bob Lemaire)
|
||||
- `ESPAsyncTCP`
|
||||
- `ArduinoJson` (by Benoit Blanchon)
|
||||
|
||||
3. **Board Configuration**
|
||||
- Board: "Generic ESP8266 Module"
|
||||
@@ -591,6 +309,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 +319,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 +352,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 +360,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 +374,7 @@ Comprehensive settings form:
|
||||
- Hour format (12h/24h)
|
||||
|
||||
**Weather Settings**
|
||||
|
||||
- Enabled/disabled toggle
|
||||
- Latitude
|
||||
- Longitude
|
||||
@@ -659,6 +382,7 @@ Comprehensive settings form:
|
||||
- Update interval (seconds)
|
||||
|
||||
**Display Settings**
|
||||
|
||||
- Brightness (0-7)
|
||||
- Rotation (0°, 90°, 180°, 270°)
|
||||
- Display rotation interval (seconds)
|
||||
@@ -692,6 +416,7 @@ All endpoints return JSON (except `/update` which is for file upload).
|
||||
Current time information.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current": "14:23:45",
|
||||
@@ -707,6 +432,7 @@ Current time information.
|
||||
System status overview.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"wifi": {
|
||||
@@ -733,6 +459,7 @@ System status overview.
|
||||
Current weather data.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"temperature": 15.4,
|
||||
@@ -751,6 +478,7 @@ Current weather data.
|
||||
Export full configuration as JSON.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ssid": "MyNetwork",
|
||||
@@ -780,6 +508,7 @@ Import configuration from JSON.
|
||||
**Request Body**: Same structure as export response (password field optional for security).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
@@ -793,6 +522,7 @@ Device automatically reboots after import.
|
||||
Factory reset (clears EEPROM).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "cleared"
|
||||
@@ -806,6 +536,7 @@ Device reboots to WiFiManager captive portal.
|
||||
Remote reboot.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "rebooting"
|
||||
@@ -821,7 +552,7 @@ 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) |
|
||||
@@ -842,16 +573,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!
|
||||
```
|
||||
@@ -860,39 +594,6 @@ Device reboots immediately.
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### Hardware
|
||||
|
||||
1. **Always check pinouts**: Don't assume standard pin mappings - this device swaps SDA/SCL
|
||||
2. **FTDI is your friend**: A $2 adapter unlocks any ESP8266 device
|
||||
3. **Transparent cases are great**: Made debugging and identification trivial
|
||||
4. **Read the PCB silk screen**: Model numbers and pin labels save hours of guessing
|
||||
|
||||
### Software
|
||||
|
||||
1. **Async is hard but worth it**: Fully non-blocking architecture eliminates user-facing freezes
|
||||
2. **IRAM is precious**: On ESP8266, use `ICACHE_FLASH_ATTR` liberally
|
||||
3. **Hybrid approaches work**: Don't be dogmatic - synchronous WiFi in setup() solved a critical UX issue
|
||||
4. **State machines scale**: Better than callback hell for complex async operations
|
||||
5. **Test on real hardware**: Emulators can't catch pin mapping errors or memory constraints
|
||||
|
||||
### Security
|
||||
|
||||
1. **IoT security is often terrible**: Always audit devices before trusting them on your network
|
||||
2. **Open source is safer**: Closed firmware is a black box - you have no idea what it's doing
|
||||
3. **Defaults matter**: Insecure defaults (open AP, plaintext passwords) lead to real vulnerabilities
|
||||
4. **Defense in depth**: Multiple layers (WiFiManager timeout, password protection, validation) catch mistakes
|
||||
|
||||
### Development
|
||||
|
||||
1. **OTA from day 1**: Flashing via FTDI gets old fast - build OTA support early
|
||||
2. **Version your work**: Backup files (.bak, .bak2) saved me multiple times
|
||||
3. **Document as you go**: Release notes and architecture docs prevent "what was I thinking?" moments
|
||||
4. **Incremental improvements**: v1.7 → v1.8 → v1.9.x made debugging manageable
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
**Hardware**: TJ-56-654 Weather Clock Kit ([AliExpress](https://pt.aliexpress.com/item/1005008333782531.html))
|
||||
@@ -900,16 +601,19 @@ 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)
|
||||
- [AsyncHTTPRequest_Generic](https://github.com/khoih-prog/AsyncHTTPRequest_Generic)
|
||||
- [asyncHTTPrequest](https://github.com/boblemaire/asyncHTTPrequest)
|
||||
- [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 +625,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
|
||||
@@ -941,28 +666,6 @@ This project is released into the public domain. Do whatever you want with it. I
|
||||
|
||||
---
|
||||
|
||||
## Final Thoughts
|
||||
|
||||
This project started as "I don't trust this device" and ended as "I built something better."
|
||||
|
||||
The original firmware had security holes you could drive a truck through. The custom replacement:
|
||||
- ✅ Doesn't leak WiFi passwords
|
||||
- ✅ Uses free, open APIs
|
||||
- ✅ Updates over WiFi
|
||||
- ✅ Runs fully async (no freezing)
|
||||
- ✅ Integrates with Home Assistant (coming soon)
|
||||
- ✅ Is completely auditable (you're reading the source)
|
||||
|
||||
Total cost: €5 hardware + a weekend of tinkering.
|
||||
|
||||
If you have one of these devices, **flash this firmware**. If you're buying IoT gadgets, **always audit them first**. And if something seems insecure, **fix it yourself** - that's the hacker spirit.
|
||||
|
||||
Now go make something cool. 🚀
|
||||
|
||||
---
|
||||
|
||||
**P.S.**: If you found this useful, consider starring the repo. If you found a bug, open an issue. If you want to add Home Assistant screens, let's collaborate - I'm planning that next!
|
||||
|
||||
**Author**: apetrochenko
|
||||
**Date**: 2026-01-06
|
||||
**Firmware Version**: v1.9.2 (Production Ready)
|
||||
**Author**: apetrochenko · **License**: MIT · **Firmware**: v1.9.10
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Architecture Notes
|
||||
|
||||
Internal notes for contributors modifying the firmware. End users don't need this.
|
||||
|
||||
## Async model
|
||||
|
||||
The main loop performs zero blocking operations. All network I/O runs through callback-based state machines:
|
||||
|
||||
- **Weather**: `AsyncHTTPRequest` callback updates `weatherState` (IDLE → REQUESTING → IDLE on success/failure), with a 15s watchdog in the loop to recover from TCP hangs
|
||||
- **NTP**: Manual UDP packet build + non-blocking `parsePacket()` with 5s timeout
|
||||
- **WiFi**: Synchronous in `setup()` (so OTA / web / NTP have a working network at start), async reconnect in `loop()` with exponential backoff
|
||||
|
||||
State variables shared between callbacks and the main loop are declared `volatile` (`weatherState`, `ntpState`) since ESPAsyncTCP callbacks fire from within `yield()`/TCP poll and can interrupt the loop at any point.
|
||||
|
||||
## Timer correctness
|
||||
|
||||
`millis()` is `uint32_t` and rolls over every ~49.7 days. All deadline comparisons use the subtraction-safe pattern:
|
||||
|
||||
```cpp
|
||||
// Unsafe: fails at rollover
|
||||
if (millis() >= nextRetryTime) { ... }
|
||||
|
||||
// Safe: works across rollover
|
||||
if ((millis() - nextRetryTime) < 0x80000000UL) { ... }
|
||||
```
|
||||
|
||||
See `RetryConfig::isRetryTime()` in `config.h` for the canonical implementation.
|
||||
|
||||
## Memory budget (ESP-01S, 1MB flash)
|
||||
|
||||
| Pool | Total | Used | % | Notes |
|
||||
| --------- | --------- | -------- | --- | -------------------------------------------------------- |
|
||||
| **Flash** | 1,048,576 | ~411,000 | 39% | Headroom for features |
|
||||
| **RAM** | 80,192 | ~37,700 | 46% | Stable across all 1.9.x releases |
|
||||
| **IRAM** | 65,536 | 61,987 | 94% | **Critical** — `ICACHE_FLASH_ATTR` required on new funcs |
|
||||
|
||||
### IRAM discipline
|
||||
|
||||
IRAM is the tightest constraint. Apply `ICACHE_FLASH_ATTR` to any non-critical function so its code lives in flash instead of IRAM:
|
||||
|
||||
```cpp
|
||||
void ICACHE_FLASH_ATTR handleConfig() {
|
||||
// code in flash, slightly slower but saves IRAM
|
||||
}
|
||||
```
|
||||
|
||||
Currently 26 functions use this attribute (web handlers, display routines, config helpers). Functions called from interrupt context or hot loops should stay in IRAM.
|
||||
|
||||
### Heap fragmentation
|
||||
|
||||
ESP8266 heap is never compacted. Repeated `String` concatenation in long-running handlers (especially `/api/*` endpoints polled by the web UI) permanently fragments the heap until reboot.
|
||||
|
||||
Pattern to follow for HTTP responses:
|
||||
|
||||
```cpp
|
||||
char buf[256];
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "application/json", "");
|
||||
snprintf_P(buf, sizeof(buf), PSTR("{\"...\":%d}"), value);
|
||||
server.sendContent(buf);
|
||||
server.sendContent(""); // close chunked transfer
|
||||
```
|
||||
|
||||
Never:
|
||||
|
||||
```cpp
|
||||
String html = "";
|
||||
html += "..."; // <- heap fragmentation
|
||||
```
|
||||
|
||||
## EEPROM layout
|
||||
|
||||
512 bytes mapped via `EEPROM.begin(512)`:
|
||||
|
||||
| Offset | Size | Content |
|
||||
| ------ | ------ | ------------------------------------------------- |
|
||||
| 0 | ~260 B | `Config` struct (validated by magic `0xC10CC10C`) |
|
||||
| 480 | 2 B | `ResetCounter` for triple power-cycle reset |
|
||||
|
||||
Writes are flash-sector-erase operations (4KB), so the entire 512-byte image is rewritten on each `EEPROM.commit()`. Expect ~100k cycles before wear matters.
|
||||
|
||||
The `Config` struct includes a magic number — on validation failure, the firmware falls back to defaults rather than loading garbage.
|
||||
|
||||
## Factory reset
|
||||
|
||||
Three quick power cycles within 10 seconds (controlled by `RESET_COUNTER_WINDOW`) triggers a factory reset:
|
||||
|
||||
1. Each boot increments the counter at EEPROM offset 480
|
||||
2. If the device runs for 10s, the counter clears back to 0
|
||||
3. If count reaches 3 first, WiFi credentials are wiped and the device reboots into WiFiManager AP mode
|
||||
|
||||
For atomicity, credentials are committed BEFORE the counter is zeroed. Power loss between the two writes still results in a recoverable state (cleared creds → AP boot).
|
||||
|
||||
## Display
|
||||
|
||||
128×64 OLED, SSD1306-compatible (GM009605v4.3 panel). The ESP-01S exposes only GPIO0 and GPIO2, and the board designer mapped them as I2C:
|
||||
|
||||
```cpp
|
||||
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2 (non-standard!)
|
||||
```
|
||||
|
||||
This is the inverse of typical breakout boards — easy to get wrong if you're porting code.
|
||||
|
||||
## Web server
|
||||
|
||||
`ESP8266WebServer` (not async, but fast enough for low-frequency requests). All API responses use chunked transfer with `snprintf` + `sendContent`. Form save (`/config`) only triggers a reboot when WiFi/network fields actually change; other settings apply live.
|
||||
|
||||
Input validation in `handleConfigSave` clamps numeric fields and rejects garbage SSIDs (HTTP 400) — see `isValidSSID()` in `web_server.cpp`.
|
||||
|
||||
## Adding new features
|
||||
|
||||
1. Check IRAM usage after build — if growing, add `ICACHE_FLASH_ATTR` to your new functions
|
||||
2. Don't use `String` in handlers or callbacks — use `char buf[]` + `snprintf`
|
||||
3. New shared state between callbacks and loop → declare `volatile`
|
||||
4. New timer logic → use the subtraction-safe `millis()` pattern
|
||||
5. New API endpoints → run `tests/test_device.py` against the device to verify heap stability
|
||||
|
||||
For more context on past design decisions, see `CHANGELOG.md`.
|
||||
+68
-5
@@ -65,7 +65,7 @@ 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) |
|
||||
@@ -74,6 +74,7 @@ Install the following libraries (search by name):
|
||||
| **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
|
||||
@@ -88,7 +89,7 @@ Install the following libraries (search by name):
|
||||
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 |
|
||||
@@ -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):
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ │
|
||||
@@ -124,7 +126,7 @@ 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 |
|
||||
@@ -132,6 +134,7 @@ ESP-01S pinout (looking at module from top, antenna up):
|
||||
| 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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.10"
|
||||
|
||||
// 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;
|
||||
@@ -3,20 +3,18 @@
|
||||
* TJ-56-654 Weather Clock v1.9.3
|
||||
*/
|
||||
|
||||
// Include AsyncHTTPRequest BEFORE globals.h to provide full type definition
|
||||
// Include asyncHTTPrequest before globals.h to provide the full type definition
|
||||
#include <ESPAsyncTCP.h>
|
||||
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN_TARGET "AsyncHTTPRequest_Generic v1.13.0"
|
||||
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN 1013000
|
||||
#include <AsyncHTTPRequest_Generic.h>
|
||||
#include <asyncHTTPrequest.h>
|
||||
|
||||
#include "globals.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// Async HTTP client for weather (local to this file)
|
||||
static AsyncHTTPRequest weatherRequest;
|
||||
static asyncHTTPrequest weatherRequest;
|
||||
|
||||
// Weather response callback
|
||||
void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* request, int readyState) {
|
||||
void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, asyncHTTPrequest* request, int readyState) {
|
||||
(void)optParm; // Unused
|
||||
|
||||
if (readyState == 4) { // Request complete
|
||||
@@ -28,7 +26,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 +81,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 +94,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 +114,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 +154,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;
|
||||
+113
-6
@@ -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());
|
||||
}
|
||||
|
||||
// Reconnect STA side without changing mode (preserves AP_STA if active)
|
||||
if (WiFi.getMode() == WIFI_AP_STA) {
|
||||
// Use low-level reconnect to keep AP alive
|
||||
WiFi.disconnect(false);
|
||||
if (strlen(config.password) > 0) {
|
||||
WiFi.begin(config.ssid, config.password);
|
||||
} else {
|
||||
WiFi.begin();
|
||||
WiFi.begin(config.ssid);
|
||||
}
|
||||
WiFi.mode(WIFI_AP_STA); // Restore AP_STA after begin() may have reset it
|
||||
} else {
|
||||
if (strlen(config.password) > 0) {
|
||||
WiFi.begin(config.ssid, config.password);
|
||||
} else {
|
||||
WiFi.begin(config.ssid);
|
||||
}
|
||||
}
|
||||
wifiConnState = WIFI_CONN_CONNECTING;
|
||||
wifiConnectStart = millis();
|
||||
@@ -311,14 +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()) {
|
||||
+171
-72
@@ -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);
|
||||
|
||||
// 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() {
|
||||
+24
-6
@@ -28,9 +28,10 @@ void ICACHE_FLASH_ATTR processWiFiConnection() {
|
||||
WiFi.mode(WIFI_STA);
|
||||
}
|
||||
|
||||
// Sync connected SSID to config
|
||||
// Sync connected SSID + password to config
|
||||
if (strlen(config.ssid) == 0) {
|
||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
@@ -67,8 +68,12 @@ 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...");
|
||||
// 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)
|
||||
@@ -92,20 +97,30 @@ void ICACHE_FLASH_ATTR setupWiFi() {
|
||||
Serial.print("DNS: ");
|
||||
Serial.println(WiFi.dnsIP());
|
||||
|
||||
// Persist the password too, not just the SSID. The SDK keeps the
|
||||
// password in its own flash area, but every later boot reads
|
||||
// config.password: an empty one is treated as an open network
|
||||
// (WiFi.begin(ssid)) and fails with WL_WRONG_PASSWORD (issue #12).
|
||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
|
||||
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...");
|
||||
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);
|
||||
@@ -157,7 +172,10 @@ void ICACHE_FLASH_ATTR setupWiFi() {
|
||||
Serial.print("IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
|
||||
// Save both SSID and password (issue #12): WiFiManager stores them in the
|
||||
// SDK flash, but the next boot connects from config.* only.
|
||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
|
||||
saveConfig();
|
||||
|
||||
showIP();
|
||||
@@ -0,0 +1,19 @@
|
||||
[platformio]
|
||||
src_dir = firmware/weather_clock
|
||||
|
||||
[env:esp01_1m]
|
||||
platform = espressif8266
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
board_build.flash_mode = dio
|
||||
upload_speed = 115200
|
||||
upload_resetmethod = nodemcu
|
||||
lib_deps =
|
||||
adafruit/Adafruit GFX Library
|
||||
adafruit/Adafruit SSD1306
|
||||
arduino-libraries/NTPClient
|
||||
tzapu/WiFiManager
|
||||
boblemaire/asyncHTTPrequest @ 1.2.2
|
||||
me-no-dev/ESPAsyncTCP
|
||||
bblanchon/ArduinoJson @ ^7
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hardware-in-the-loop test suite for ESP8266 Weather Clock firmware.
|
||||
Runs against a live device via HTTP API.
|
||||
|
||||
Usage:
|
||||
python3 tests/test_device.py [device_ip]
|
||||
python3 tests/test_device.py 192.168.2.47
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
DEVICE_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.2.47"
|
||||
BASE_URL = f"http://{DEVICE_IP}"
|
||||
TIMEOUT = 10
|
||||
|
||||
# Safe config values to restore after fuzz tests
|
||||
SAFE_CONFIG = {
|
||||
"ssid": "", # keep empty — don't overwrite real credentials
|
||||
"ntp_interval": "3600",
|
||||
"weather_interval": "1800",
|
||||
"brightness": "4",
|
||||
"timezone_offset": "0",
|
||||
"latitude": "37.19",
|
||||
"longitude": "-8.54",
|
||||
"hostname": "tj56654-clock",
|
||||
"ntp_server": "pool.ntp.org",
|
||||
"city_name": "Portimao",
|
||||
}
|
||||
|
||||
# Saved before fuzz, restored after
|
||||
_config_backup: dict = {}
|
||||
|
||||
# ─── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def get(path) -> tuple[int, str]:
|
||||
try:
|
||||
r = urllib.request.urlopen(f"{BASE_URL}{path}", timeout=TIMEOUT)
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
def get_json(path) -> tuple[int, dict | None]:
|
||||
status, body = get(path)
|
||||
try:
|
||||
return status, json.loads(body)
|
||||
except Exception:
|
||||
return status, None
|
||||
|
||||
def post_form(path, data: dict) -> tuple[int, str]:
|
||||
encoded = urllib.parse.urlencode(data).encode()
|
||||
req = urllib.request.Request(f"{BASE_URL}{path}", data=encoded, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=TIMEOUT)
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
def post_raw(path, body: bytes, content_type="application/json") -> tuple[int, str]:
|
||||
req = urllib.request.Request(f"{BASE_URL}{path}", data=body, method="POST")
|
||||
req.add_header("Content-Type", content_type)
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=TIMEOUT)
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
# ─── Test runner ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str = ""
|
||||
|
||||
results: list[Result] = []
|
||||
|
||||
def test(name: str, passed: bool, detail: str = ""):
|
||||
r = Result(name, passed, detail)
|
||||
results.append(r)
|
||||
icon = "✅" if passed else "❌"
|
||||
print(f" {icon} {name}" + (f" — {detail}" if detail else ""))
|
||||
return passed
|
||||
|
||||
# ─── Test suites ─────────────────────────────────────────────────────────────
|
||||
|
||||
def suite_connectivity():
|
||||
print("\n📡 Connectivity")
|
||||
status, body = get("/")
|
||||
test("Device reachable", status == 200, f"HTTP {status}")
|
||||
test("Returns HTML", "<html" in body.lower() or "<!DOCTYPE" in body.lower(),
|
||||
f"{len(body)} bytes")
|
||||
test("Version present", "v1.9" in body, body[:80] if body else "empty")
|
||||
|
||||
def suite_api_time():
|
||||
print("\n🕐 /api/time")
|
||||
status, data = get_json("/api/time")
|
||||
test("HTTP 200", status == 200, f"got {status}")
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
test("Has 'time' field", "time" in data, str(data.keys()))
|
||||
if "time" in data:
|
||||
t = data["time"]
|
||||
test("Time format HH:MM:SS", len(t) == 8 and t[2] == ":" and t[5] == ":",
|
||||
f"got '{t}'")
|
||||
test("Has 'hours'", "hours" in data)
|
||||
test("Has 'minutes'", "minutes" in data)
|
||||
test("Has 'epoch'", "epoch" in data and data["epoch"] > 1700000000)
|
||||
|
||||
def suite_api_weather():
|
||||
print("\n🌤 /api/weather")
|
||||
status, data = get_json("/api/weather")
|
||||
test("HTTP 200", status == 200, f"got {status}")
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
test("enabled=true", data.get("enabled") == True)
|
||||
test("valid=true", data.get("valid") == True, "weather data may not be fetched yet")
|
||||
if data.get("valid"):
|
||||
t = data.get("temperature", 0)
|
||||
test("Temperature sane [-50, 70]", -50 <= t <= 70, f"{t}°C")
|
||||
w = data.get("windspeed", 0)
|
||||
test("Windspeed ≥ 0", w >= 0, f"{w} km/h")
|
||||
wc = data.get("weathercode", -1)
|
||||
test("Weathercode ≥ 0", wc >= 0, f"code={wc}")
|
||||
test("Has sunrise", "sunrise" in data, str(data.get("sunrise")))
|
||||
test("Has sunset", "sunset" in data, str(data.get("sunset")))
|
||||
|
||||
def suite_api_status():
|
||||
print("\n📊 /api/status")
|
||||
status, data = get_json("/api/status")
|
||||
test("HTTP 200", status == 200)
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
|
||||
wifi = data.get("wifi", {})
|
||||
test("Has wifi.ssid", "ssid" in wifi, str(wifi))
|
||||
test("Has wifi.ip", "ip" in wifi)
|
||||
test("Has wifi.rssi", "rssi" in wifi)
|
||||
if "rssi" in wifi:
|
||||
test("RSSI in realistic range [-100, 0]", -100 <= wifi["rssi"] <= 0,
|
||||
f"{wifi['rssi']} dBm")
|
||||
|
||||
sys_ = data.get("system", {})
|
||||
test("Has system.uptime", "uptime" in sys_)
|
||||
test("Has system.free_heap", "free_heap" in sys_)
|
||||
if "free_heap" in sys_:
|
||||
heap = sys_["free_heap"]
|
||||
test("Heap > 8KB (not fragmented)", heap > 8192, f"{heap} bytes free")
|
||||
test("Heap > 20KB (healthy)", heap > 20480, f"{heap} bytes free")
|
||||
|
||||
def suite_api_debug():
|
||||
print("\n🔍 /api/debug")
|
||||
status, data = get_json("/api/debug")
|
||||
test("HTTP 200", status == 200)
|
||||
if data is None:
|
||||
test("Valid JSON", False, "parse error"); return
|
||||
test("Valid JSON", True)
|
||||
test("Has internet_connected", "internet_connected" in data)
|
||||
test("internet_connected=true", data.get("internet_connected") == True)
|
||||
test("Has ntp_attempts", "ntp_attempts" in data)
|
||||
test("Has ntp_successes", "ntp_successes" in data)
|
||||
attempts = data.get("ntp_attempts", 0)
|
||||
successes = data.get("ntp_successes", 0)
|
||||
test("NTP attempted at least once", attempts >= 1, f"{attempts} attempts")
|
||||
test("NTP success rate > 0", successes >= 1, f"{successes}/{attempts}")
|
||||
test("last_error field present", "last_error" in data)
|
||||
test("last_error is string", isinstance(data.get("last_error"), str))
|
||||
|
||||
def backup_config():
|
||||
"""Save safe fields before fuzzing."""
|
||||
global _config_backup
|
||||
_, data = get_json("/api/status")
|
||||
_config_backup = {
|
||||
"ntp_interval": "3600",
|
||||
"weather_interval": "1800",
|
||||
"brightness": "4",
|
||||
"timezone_offset": "0",
|
||||
"ntp_server": "pool.ntp.org",
|
||||
"hostname": "tj56654-clock",
|
||||
}
|
||||
print(" 💾 Config backup saved")
|
||||
|
||||
def restore_config():
|
||||
"""Restore safe config after fuzzing — prevents bricking on bad values."""
|
||||
status, _ = post_form("/config", SAFE_CONFIG)
|
||||
time.sleep(1)
|
||||
ok = status in (200, 302)
|
||||
print(f" 🔄 Config restored {'✅' if ok else '❌'} (HTTP {status})")
|
||||
return ok
|
||||
|
||||
def suite_fuzz_config():
|
||||
print("\n🧨 Fuzz: /config boundary values")
|
||||
backup_config()
|
||||
|
||||
# Save current config to restore later
|
||||
_, before = get_json("/api/status")
|
||||
|
||||
cases = [
|
||||
# (description, field, value, expect_no_crash)
|
||||
("ntp_interval=0 (DoS risk)", "ntp_interval", "0", True),
|
||||
("ntp_interval=86401 (over max day)", "ntp_interval", "86401", True),
|
||||
("weather_interval=0", "weather_interval", "0", True),
|
||||
("brightness=255 (uint8 overflow)", "brightness", "255", True),
|
||||
("brightness=-1", "brightness", "-1", True),
|
||||
("timezone_offset=99999999", "timezone_offset", "99999999", True),
|
||||
("timezone_offset=-99999999", "timezone_offset", "-99999999", True),
|
||||
("latitude=999 (invalid)", "latitude", "999", True),
|
||||
("latitude=-999", "latitude", "-999", True),
|
||||
("longitude=999", "longitude", "999", True),
|
||||
("ssid=A*100 (overflow char[32])", "ssid", "A" * 100, True),
|
||||
("password=B*200 (overflow char[64])","password", "B" * 200, True),
|
||||
("hostname=C*100 (overflow char[32])","hostname", "C" * 100, True),
|
||||
("ntp_server=D*200 (overflow char[64])","ntp_server", "D" * 200, True),
|
||||
]
|
||||
|
||||
for desc, field_name, value, expect_survive in cases:
|
||||
status, body = post_form("/config", {field_name: value})
|
||||
survived = status in (200, 302, 400) # any response = not crashed
|
||||
test(desc, survived, f"HTTP {status}")
|
||||
|
||||
# Always restore safe config after fuzzing — critical to prevent bricking
|
||||
restore_config()
|
||||
|
||||
# Verify device still alive after fuzzing
|
||||
time.sleep(1)
|
||||
status, data = get_json("/api/status")
|
||||
test("Device alive after fuzz", status == 200 and data is not None,
|
||||
f"HTTP {status}")
|
||||
|
||||
def suite_fuzz_config_import():
|
||||
print("\n🧨 Fuzz: /api/config import (JSON endpoint)")
|
||||
|
||||
cases = [
|
||||
("Empty body", b""),
|
||||
("Not JSON", b"this is not json at all!!!"),
|
||||
("Partial JSON", b'{"ssid": "test"'),
|
||||
("Null JSON", b"null"),
|
||||
("Array JSON", b"[]"),
|
||||
("Nested bomb", b'{"a":{"b":{"c":{"d":{"e":"f"}}}}}'),
|
||||
("Very large string", json.dumps({"ssid": "X" * 5000}).encode()),
|
||||
("Unicode", '{"ssid": "тест-сеть"}'.encode("utf-8")),
|
||||
("Zero bytes field", json.dumps({"ntp_interval": 0}).encode()),
|
||||
("Negative interval", json.dumps({"weather_interval": -1}).encode()),
|
||||
("NaN float", b'{"latitude": "NaN"}'),
|
||||
("Inf float", b'{"latitude": "Infinity"}'),
|
||||
("SQL-like injection", b'{"ssid": "\'; DROP TABLE config; --"}'),
|
||||
("HTML injection", b'{"ssid": "<script>alert(1)</script>"}'),
|
||||
("Null bytes", b'{"ssid": "test\x00hidden"}'),
|
||||
]
|
||||
|
||||
for desc, body in cases:
|
||||
status, resp = post_raw("/api/config", body)
|
||||
survived = status != 0 # got any response = not crashed/hung
|
||||
test(desc, survived, f"HTTP {status}")
|
||||
|
||||
restore_config()
|
||||
|
||||
time.sleep(1)
|
||||
status, _ = get_json("/api/status")
|
||||
test("Device alive after import fuzz", status == 200, f"HTTP {status}")
|
||||
|
||||
def suite_stability():
|
||||
print("\n⏱ Stability: heap trend over 5 requests")
|
||||
heaps = []
|
||||
for i in range(5):
|
||||
_, data = get_json("/api/status")
|
||||
if data and "system" in data:
|
||||
heaps.append(data["system"].get("free_heap", 0))
|
||||
time.sleep(0.5)
|
||||
|
||||
if heaps:
|
||||
test("Got heap samples", len(heaps) == 5, f"{len(heaps)}/5")
|
||||
min_heap = min(heaps)
|
||||
max_heap = max(heaps)
|
||||
drift = max_heap - min_heap
|
||||
test("Heap stable (drift < 2KB)", drift < 2048,
|
||||
f"min={min_heap} max={max_heap} drift={drift}")
|
||||
test("Min heap > 20KB", min_heap > 20480, f"min={min_heap}")
|
||||
print(f" Heap samples: {heaps}")
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
print(f"🔌 Testing device at {BASE_URL}")
|
||||
print("=" * 55)
|
||||
|
||||
suite_connectivity()
|
||||
suite_api_time()
|
||||
suite_api_weather()
|
||||
suite_api_status()
|
||||
suite_api_debug()
|
||||
suite_fuzz_config()
|
||||
suite_fuzz_config_import()
|
||||
suite_stability()
|
||||
|
||||
print("\n" + "=" * 55)
|
||||
passed = sum(1 for r in results if r.passed)
|
||||
failed = sum(1 for r in results if not r.passed)
|
||||
total = len(results)
|
||||
print(f"Results: {passed}/{total} passed", end="")
|
||||
if failed:
|
||||
print(f" ({failed} failed)")
|
||||
print("\nFailed tests:")
|
||||
for r in results:
|
||||
if not r.passed:
|
||||
print(f" ❌ {r.name}" + (f" — {r.detail}" if r.detail else ""))
|
||||
else:
|
||||
print(" ✅ All passed!")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user