Compare commits
@@ -1,73 +0,0 @@
|
|||||||
name: Build Firmware
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
release:
|
|
||||||
types: [ created ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Arduino CLI
|
|
||||||
uses: arduino/setup-arduino-cli@v1
|
|
||||||
|
|
||||||
- name: Install ESP8266 platform
|
|
||||||
run: |
|
|
||||||
arduino-cli core update-index
|
|
||||||
arduino-cli core install esp8266:esp8266
|
|
||||||
|
|
||||||
- name: Install libraries
|
|
||||||
run: |
|
|
||||||
arduino-cli lib install "Adafruit GFX Library"
|
|
||||||
arduino-cli lib install "Adafruit SSD1306"
|
|
||||||
arduino-cli lib install "NTPClient"
|
|
||||||
arduino-cli lib install "WiFiManager"
|
|
||||||
arduino-cli lib install "AsyncHTTPRequest_Generic"
|
|
||||||
arduino-cli lib install "ESPAsyncTCP"
|
|
||||||
|
|
||||||
- name: Compile firmware
|
|
||||||
run: |
|
|
||||||
arduino-cli compile --fqbn esp8266:esp8266:generic \
|
|
||||||
--build-property "build.flash_size=1M64" \
|
|
||||||
--build-property "build.flash_mode=dio" \
|
|
||||||
--output-dir build \
|
|
||||||
src/clock_ntp_ota_v1.9.ino
|
|
||||||
|
|
||||||
- name: Check firmware size
|
|
||||||
run: |
|
|
||||||
SIZE=$(stat -c%s "build/clock_ntp_ota_v1.9.ino.bin")
|
|
||||||
echo "Firmware size: $SIZE bytes"
|
|
||||||
MAX_SIZE=470000 # 470KB max for OTA
|
|
||||||
if [ $SIZE -gt $MAX_SIZE ]; then
|
|
||||||
echo "ERROR: Firmware too large ($SIZE > $MAX_SIZE)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: firmware
|
|
||||||
path: |
|
|
||||||
build/clock_ntp_ota_v1.9.ino.bin
|
|
||||||
build/clock_ntp_ota_v1.9.ino.elf
|
|
||||||
build/clock_ntp_ota_v1.9.ino.map
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
if: github.event_name == 'release'
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ github.event.release.upload_url }}
|
|
||||||
asset_path: build/clock_ntp_ota_v1.9.ino.bin
|
|
||||||
asset_name: esp8266-weather-clock-${{ github.event.release.tag_name }}.bin
|
|
||||||
asset_content_type: application/octet-stream
|
|
||||||
@@ -25,3 +25,6 @@ build/
|
|||||||
# Secrets (in case someone accidentally commits credentials)
|
# Secrets (in case someone accidentally commits credentials)
|
||||||
secrets.h
|
secrets.h
|
||||||
config_local.h
|
config_local.h
|
||||||
|
|
||||||
|
# Internal backlogs (not for public repo)
|
||||||
|
BACKLOG_*.md
|
||||||
|
|||||||
+121
-6
@@ -5,25 +5,133 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.9.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)
|
||||||
|
- AP automatically disabled when WiFi reconnects
|
||||||
|
- **"No WiFi" display**: Shows retry countdown instead of numeric counter
|
||||||
|
- **"!" indicator**: Shown in date line when WiFi is disconnected
|
||||||
|
- **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)
|
||||||
|
- ~50 requests/day total
|
||||||
|
|
||||||
## [1.9.1] - 2026-01-03
|
## [1.9.1] - 2026-01-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **CRITICAL**: WiFi startup sequence - synchronous connection in setup() to ensure proper initialization order
|
- **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)
|
- Display blank screen for 10+ seconds on boot (now shows time after ~15 seconds)
|
||||||
- "DNS resolution failed" errors during startup
|
- "DNS resolution failed" errors during startup
|
||||||
- Sunrise/sunset labels cut off on 128px screen (removed labels, arrows are self-explanatory)
|
- Sunrise/sunset labels cut off on 128px screen (removed labels, arrows are self-explanatory)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Hybrid WiFi model: synchronous in setup(), async reconnect in loop()
|
- Hybrid WiFi model: synchronous in setup(), async reconnect in loop()
|
||||||
- Display formatting: superscript degree symbol and lowercase 'c' for temperature
|
- 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
|
- Sunrise/sunset screen now shows daylight duration (e.g., "Day 9h 41m") instead of static "Sun Times" text
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
- Added detailed v1.9.1_HYBRID_FIX.md explaining startup sequence problem and solution
|
- Added detailed v1.9.1_HYBRID_FIX.md explaining startup sequence problem and solution
|
||||||
|
|
||||||
## [1.9.0] - 2026-01-02
|
## [1.9.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Fully async architecture (zero blocking operations in loop)
|
- Fully async architecture (zero blocking operations in loop)
|
||||||
- Custom async NTP implementation (manual UDP packet handling)
|
- Custom async NTP implementation (manual UDP packet handling)
|
||||||
- Async HTTP weather fetch (AsyncHTTPRequest library)
|
- Async HTTP weather fetch (AsyncHTTPRequest library)
|
||||||
@@ -31,12 +139,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Independent epoch tracking for accurate time between NTP syncs
|
- Independent epoch tracking for accurate time between NTP syncs
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Replaced blocking NTPClient with custom async UDP implementation
|
- Replaced blocking NTPClient with custom async UDP implementation
|
||||||
- Replaced blocking HTTP weather with AsyncHTTPRequest
|
- Replaced blocking HTTP weather with AsyncHTTPRequest
|
||||||
- Removed all delay() calls from loop()
|
- Removed all delay() calls from loop()
|
||||||
- WiFi connection now async (later fixed in v1.9.1)
|
- WiFi connection now async (later fixed in v1.9.1)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- Loop time: 10ms → <1ms (10x improvement)
|
- Loop time: 10ms → <1ms (10x improvement)
|
||||||
- Weather fetch: 1-10s blocking → 0ms
|
- Weather fetch: 1-10s blocking → 0ms
|
||||||
- NTP sync: 5-20s blocking → 0ms
|
- NTP sync: 5-20s blocking → 0ms
|
||||||
@@ -44,6 +154,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- OTA updates now work during active weather fetching
|
- OTA updates now work during active weather fetching
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- RAM usage: +536 bytes (36,980 → 37,516)
|
- RAM usage: +536 bytes (36,980 → 37,516)
|
||||||
- Flash usage: +1040 bytes (407,500 → 408,540)
|
- Flash usage: +1040 bytes (407,500 → 408,540)
|
||||||
- IRAM: 61,987 bytes (94% - stable)
|
- IRAM: 61,987 bytes (94% - stable)
|
||||||
@@ -51,12 +162,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [1.8.0] - 2026-01-01
|
## [1.8.0] - 2026-01-01
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- **CRITICAL**: Removed hardcoded WiFi credentials
|
- **CRITICAL**: Removed hardcoded WiFi credentials
|
||||||
- Integrated WiFiManager for secure captive portal setup
|
- Integrated WiFiManager for secure captive portal setup
|
||||||
- Added config validation (magic number check)
|
- Added config validation (magic number check)
|
||||||
- Input sanitization to prevent buffer overflows
|
- Input sanitization to prevent buffer overflows
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- IRAM overflow crisis (94% → 70% via ICACHE_FLASH_ATTR)
|
- IRAM overflow crisis (94% → 70% via ICACHE_FLASH_ATTR)
|
||||||
- NTP interval bug (config value was ignored, always used hardcoded 1 hour)
|
- NTP interval bug (config value was ignored, always used hardcoded 1 hour)
|
||||||
- Boolean parsing errors in JSON config import/export
|
- Boolean parsing errors in JSON config import/export
|
||||||
@@ -64,17 +177,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Memory leaks from String concatenation in web handlers
|
- Memory leaks from String concatenation in web handlers
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Web responses now use chunked transfer (eliminated 140+ String concatenations)
|
- Web responses now use chunked transfer (eliminated 140+ String concatenations)
|
||||||
- Applied ICACHE_FLASH_ATTR to 26 functions (moved code from IRAM to Flash)
|
- Applied ICACHE_FLASH_ATTR to 26 functions (moved code from IRAM to Flash)
|
||||||
- Improved error handling throughout codebase
|
- Improved error handling throughout codebase
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- Peak heap usage reduced by ~8KB
|
- Peak heap usage reduced by ~8KB
|
||||||
- EEPROM validation prevents loading corrupted config
|
- EEPROM validation prevents loading corrupted config
|
||||||
|
|
||||||
## [1.7.0] - 2025-12-31
|
## [1.7.0] - 2025-12-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Initial working firmware with correct display support
|
- Initial working firmware with correct display support
|
||||||
- NTP time synchronization
|
- NTP time synchronization
|
||||||
- Weather data from Open-Meteo API (free, no API key required)
|
- Weather data from Open-Meteo API (free, no API key required)
|
||||||
@@ -86,11 +202,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- EEPROM configuration persistence
|
- EEPROM configuration persistence
|
||||||
|
|
||||||
### Hardware Discovery
|
### Hardware Discovery
|
||||||
|
|
||||||
- Identified display as GM009605v4.3 (not TM1637 or TM1650)
|
- Identified display as GM009605v4.3 (not TM1637 or TM1650)
|
||||||
- Discovered swapped I2C pins: SDA=GPIO0, SCL=GPIO2
|
- Discovered swapped I2C pins: SDA=GPIO0, SCL=GPIO2
|
||||||
- Switched to Adafruit_SSD1306 library
|
- Switched to Adafruit_SSD1306 library
|
||||||
|
|
||||||
### Replaced
|
### Replaced
|
||||||
|
|
||||||
- QWeather API → Open-Meteo (no registration required)
|
- QWeather API → Open-Meteo (no registration required)
|
||||||
- Proprietary firmware → Open source custom firmware
|
- Proprietary firmware → Open source custom firmware
|
||||||
- Insecure WiFi handling → WiFiManager with timeout
|
- Insecure WiFi handling → WiFiManager with timeout
|
||||||
@@ -98,11 +216,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [1.6.0] - 2025-12-30 (unreleased)
|
## [1.6.0] - 2025-12-30 (unreleased)
|
||||||
|
|
||||||
### Attempted
|
### Attempted
|
||||||
|
|
||||||
- TM1650 LED driver support (incorrect - device has OLED)
|
- TM1650 LED driver support (incorrect - device has OLED)
|
||||||
|
|
||||||
## [1.5.0] - 2025-12-29 (unreleased)
|
## [1.5.0] - 2025-12-29 (unreleased)
|
||||||
|
|
||||||
### Attempted
|
### Attempted
|
||||||
|
|
||||||
- TM1637 7-segment display support (incorrect - device has OLED)
|
- TM1637 7-segment display support (incorrect - device has OLED)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,11 +233,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Minor version** (1.X.0): New features, backward-compatible
|
- **Minor version** (1.X.0): New features, backward-compatible
|
||||||
- **Patch version** (1.9.X): Bug fixes, no new features
|
- **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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Status**: v1.9.1 is production-ready and actively used 24/7.
|
**Status**: v1.9.3 is production-ready and actively used 24/7.
|
||||||
|
|||||||
+10
-2
@@ -7,6 +7,7 @@ Thank you for your interest in contributing! This project welcomes improvements,
|
|||||||
### Reporting Bugs
|
### Reporting Bugs
|
||||||
|
|
||||||
If you find a bug, please open an issue with:
|
If you find a bug, please open an issue with:
|
||||||
|
|
||||||
- Clear description of the problem
|
- Clear description of the problem
|
||||||
- Steps to reproduce
|
- Steps to reproduce
|
||||||
- Expected vs actual behavior
|
- Expected vs actual behavior
|
||||||
@@ -16,6 +17,7 @@ If you find a bug, please open an issue with:
|
|||||||
### Suggesting Features
|
### Suggesting Features
|
||||||
|
|
||||||
Feature requests are welcome! Please include:
|
Feature requests are welcome! Please include:
|
||||||
|
|
||||||
- Use case description
|
- Use case description
|
||||||
- Why this would be useful
|
- Why this would be useful
|
||||||
- Any implementation ideas
|
- Any implementation ideas
|
||||||
@@ -38,16 +40,19 @@ Feature requests are welcome! Please include:
|
|||||||
### Code Guidelines
|
### Code Guidelines
|
||||||
|
|
||||||
**Memory Safety:**
|
**Memory Safety:**
|
||||||
|
|
||||||
- Check IRAM usage after adding code
|
- Check IRAM usage after adding code
|
||||||
- Use fixed-size buffers instead of dynamic allocation where possible
|
- Use fixed-size buffers instead of dynamic allocation where possible
|
||||||
- Prefer `snprintf` over String concatenation
|
- Prefer `snprintf` over String concatenation
|
||||||
|
|
||||||
**Async Architecture:**
|
**Async Architecture:**
|
||||||
|
|
||||||
- Keep loop() non-blocking (no delay() calls)
|
- Keep loop() non-blocking (no delay() calls)
|
||||||
- Use state machines for multi-step operations
|
- Use state machines for multi-step operations
|
||||||
- Add exponential backoff to network operations
|
- Add exponential backoff to network operations
|
||||||
|
|
||||||
**Testing:**
|
**Testing:**
|
||||||
|
|
||||||
- Test on ESP-01S hardware (1MB flash, 80KB RAM)
|
- Test on ESP-01S hardware (1MB flash, 80KB RAM)
|
||||||
- Verify OTA updates work
|
- Verify OTA updates work
|
||||||
- Check 24h stability
|
- Check 24h stability
|
||||||
@@ -55,18 +60,21 @@ Feature requests are welcome! Please include:
|
|||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Arduino IDE 1.8.x or 2.x
|
- Arduino IDE 1.8.x or 2.x
|
||||||
- ESP8266 board support (v3.0.0+)
|
- ESP8266 board support (v3.0.0+)
|
||||||
- Libraries (see README)
|
- Libraries (see README)
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Arduino IDE: Sketch → Verify/Compile
|
# Arduino IDE: Sketch → Verify/Compile
|
||||||
# Or use arduino-cli:
|
# 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
|
### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Upload via FTDI (first time)
|
# Upload via FTDI (first time)
|
||||||
arduino-cli upload -p /dev/cu.usbserial* --fqbn esp8266:esp8266:generic
|
arduino-cli upload -p /dev/cu.usbserial* --fqbn esp8266:esp8266:generic
|
||||||
@@ -79,7 +87,7 @@ curl -u admin:admin -F "file=@build/*.bin" http://192.168.x.x/update
|
|||||||
|
|
||||||
```
|
```
|
||||||
esp8266-weather-clock-opensource/
|
esp8266-weather-clock-opensource/
|
||||||
├── src/ # Main firmware source
|
├── firmware/ # Main firmware source (weather_clock/)
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
├── images/ # Photos and screenshots
|
├── images/ # Photos and screenshots
|
||||||
├── README.md # Main documentation
|
├── README.md # Main documentation
|
||||||
|
|||||||
@@ -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!
|
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
# Reverse Engineering a $12 AliExpress Weather Clock: A Security Story
|
# Reverse Engineering a €5 AliExpress Weather Clock: A Security Story
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/ESP8266-ESP--01S-blue?style=flat-square" />
|
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/releases">
|
||||||
<img src="https://img.shields.io/badge/Firmware-v1.9.1-green?style=flat-square" />
|
<img src="https://img.shields.io/github/v/release/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=Release&color=green" alt="Release">
|
||||||
<img src="https://img.shields.io/badge/OTA-Enabled-orange?style=flat-square" />
|
</a>
|
||||||
<img src="https://img.shields.io/badge/Status-Production%20Ready-brightgreen?style=flat-square" />
|
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/blob/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=License&color=blue" alt="License">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/issues">
|
||||||
|
<img src="https://img.shields.io/github/issues/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=Issues&color=orange" alt="Issues">
|
||||||
|
</a>
|
||||||
|
<img src="https://img.shields.io/badge/ESP8266-ESP--01S-blue?style=flat-square" alt="Hardware">
|
||||||
|
<img src="https://img.shields.io/badge/Status-Production%20Ready-brightgreen?style=flat-square" alt="Status">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
@@ -20,7 +27,7 @@ I bought a cute weather clock kit from AliExpress ([TJ-56-654](https://pt.aliexp
|
|||||||
- [The Investigation](#the-investigation)
|
- [The Investigation](#the-investigation)
|
||||||
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
|
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
|
||||||
- [Technical Deep Dive](#technical-deep-dive)
|
- [Technical Deep Dive](#technical-deep-dive)
|
||||||
- [The Journey: v1.7 → v1.9.1](#the-journey-v17--v191)
|
- [The Journey: v1.7 → v1.9.4](#the-journey-v17--v194)
|
||||||
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
|
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
|
||||||
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
||||||
- [Web Interface](#web-interface)
|
- [Web Interface](#web-interface)
|
||||||
@@ -53,6 +60,7 @@ When you first set up the device, it creates an access point with a default pass
|
|||||||
5. **Your WiFi password is displayed in plaintext on the config page**
|
5. **Your WiFi password is displayed in plaintext on the config page**
|
||||||
|
|
||||||
Anyone within WiFi range could:
|
Anyone within WiFi range could:
|
||||||
|
|
||||||
- Connect to the device's AP (weak default password)
|
- Connect to the device's AP (weak default password)
|
||||||
- Browse to 192.168.4.1
|
- Browse to 192.168.4.1
|
||||||
- Read your WiFi password in plaintext
|
- Read your WiFi password in plaintext
|
||||||
@@ -66,18 +74,18 @@ This is a textbook example of poor IoT security design. No thanks.
|
|||||||
|
|
||||||
**Product**: ESP8266 Mini Weather Clock Kit
|
**Product**: ESP8266 Mini Weather Clock Kit
|
||||||
**Model**: TJ-56-654
|
**Model**: TJ-56-654
|
||||||
**Price**: ~$12 USD
|
**Price**: ~€5 EUR
|
||||||
**Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
|
**Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
|
||||||
|
|
||||||
### Original Hardware Specifications
|
### Original Hardware Specifications
|
||||||
|
|
||||||
| Component | Details |
|
| Component | Details |
|
||||||
|-----------|---------|
|
| ----------- | ---------------------------------------- |
|
||||||
| **MCU** | ESP-01S (ESP8266EX, 1MB flash, 80KB RAM) |
|
| **MCU** | ESP-01S (ESP8266EX, 1MB flash, 80KB RAM) |
|
||||||
| **Display** | GM009605v4.3 OLED (128x64, I2C) |
|
| **Display** | GM009605v4.3 OLED (128x64, I2C) |
|
||||||
| **Power** | 5V USB (Micro-USB) |
|
| **Power** | 5V USB (Micro-USB) |
|
||||||
| **Case** | Transparent acrylic (40x40x43mm) |
|
| **Case** | Transparent acrylic (40x40x43mm) |
|
||||||
| **PCB** | TJ-56-654 main board |
|
| **PCB** | TJ-56-654 main board |
|
||||||
|
|
||||||
### What It Came With
|
### What It Came With
|
||||||
|
|
||||||
@@ -112,6 +120,7 @@ The transparent case made inspection easy - just unscrew the brass standoffs. In
|
|||||||
- **No additional sensors** (temperature/humidity were from weather API, not local)
|
- **No additional sensors** (temperature/humidity were from weather API, not local)
|
||||||
|
|
||||||
The ESP-01S pinout is printed right on the PCB:
|
The ESP-01S pinout is printed right on the PCB:
|
||||||
|
|
||||||
```
|
```
|
||||||
3V3 | GND
|
3V3 | GND
|
||||||
TX | GPIO0 (I2C SDA)
|
TX | GPIO0 (I2C SDA)
|
||||||
@@ -128,6 +137,7 @@ To flash custom firmware, you need:
|
|||||||
3. **Steady hands**
|
3. **Steady hands**
|
||||||
|
|
||||||
**Wiring:**
|
**Wiring:**
|
||||||
|
|
||||||
```
|
```
|
||||||
FTDI ESP-01S
|
FTDI ESP-01S
|
||||||
────────────────────
|
────────────────────
|
||||||
@@ -139,12 +149,14 @@ FTDI ESP-01S
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Boot into flash mode:**
|
**Boot into flash mode:**
|
||||||
|
|
||||||
1. Connect GPIO0 to GND
|
1. Connect GPIO0 to GND
|
||||||
2. Power on the device
|
2. Power on the device
|
||||||
3. Remove GPIO0 to GND connection after boot
|
3. Remove GPIO0 to GND connection after boot
|
||||||
4. Device is now in programming mode
|
4. Device is now in programming mode
|
||||||
|
|
||||||
**Programming:**
|
**Programming:**
|
||||||
|
|
||||||
- Use Arduino IDE with ESP8266 board support
|
- Use Arduino IDE with ESP8266 board support
|
||||||
- Select board: "Generic ESP8266 Module"
|
- Select board: "Generic ESP8266 Module"
|
||||||
- Flash size: 1MB (FS:64KB OTA:~470KB)
|
- Flash size: 1MB (FS:64KB OTA:~470KB)
|
||||||
@@ -169,6 +181,7 @@ I decided to write a complete replacement firmware with:
|
|||||||
### Features Implemented
|
### Features Implemented
|
||||||
|
|
||||||
#### 🌐 Network & Time
|
#### 🌐 Network & Time
|
||||||
|
|
||||||
- **WiFiManager** captive portal for secure first-time setup
|
- **WiFiManager** captive portal for secure first-time setup
|
||||||
- **Hybrid WiFi**: Synchronous on boot (ensures proper init), async reconnect during operation
|
- **Hybrid WiFi**: Synchronous on boot (ensures proper init), async reconnect during operation
|
||||||
- **NTP time sync** with configurable server and interval
|
- **NTP time sync** with configurable server and interval
|
||||||
@@ -176,12 +189,14 @@ I decided to write a complete replacement firmware with:
|
|||||||
- **mDNS**: Access via `http://tj56654-clock.local/`
|
- **mDNS**: Access via `http://tj56654-clock.local/`
|
||||||
|
|
||||||
#### 🌦️ Weather Data
|
#### 🌦️ Weather Data
|
||||||
|
|
||||||
- **Open-Meteo API**: Free, no registration, no API key
|
- **Open-Meteo API**: Free, no registration, no API key
|
||||||
- **Configurable location**: Latitude/longitude + city name
|
- **Configurable location**: Latitude/longitude + city name
|
||||||
- **Data**: Temperature, sunrise, sunset, daylight duration
|
- **Data**: Temperature, sunrise, sunset, daylight duration
|
||||||
- **Smart updates**: Async fetch every 30 minutes (configurable)
|
- **Smart updates**: Async fetch every 30 minutes (configurable)
|
||||||
|
|
||||||
#### 🔄 OTA Updates
|
#### 🔄 OTA Updates
|
||||||
|
|
||||||
- **Web-based OTA**: Upload .bin files via browser at `/update`
|
- **Web-based OTA**: Upload .bin files via browser at `/update`
|
||||||
- **ArduinoOTA**: Update directly from Arduino IDE
|
- **ArduinoOTA**: Update directly from Arduino IDE
|
||||||
- **Non-blocking**: System stays responsive during updates
|
- **Non-blocking**: System stays responsive during updates
|
||||||
@@ -238,33 +253,39 @@ All endpoints return JSON:
|
|||||||
The firmware uses **zero blocking operations** in the main loop. Everything is state-machine-based:
|
The firmware uses **zero blocking operations** in the main loop. Everything is state-machine-based:
|
||||||
|
|
||||||
#### Weather State Machine
|
#### Weather State Machine
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
|
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
|
||||||
```
|
```
|
||||||
|
|
||||||
Uses `AsyncHTTPRequest` library:
|
Uses `AsyncHTTPRequest` library:
|
||||||
|
|
||||||
- Non-blocking HTTP requests
|
- Non-blocking HTTP requests
|
||||||
- Callback-based response handling
|
- Callback-based response handling
|
||||||
- Exponential backoff on failures (1s → 2s → 4s)
|
- Exponential backoff on failures (1s → 2s → 4s)
|
||||||
- Maximum 3 retries before giving up
|
- Maximum 3 retries before giving up
|
||||||
|
|
||||||
#### NTP State Machine
|
#### NTP State Machine
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
|
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
|
||||||
```
|
```
|
||||||
|
|
||||||
Custom manual NTP implementation:
|
Custom manual NTP implementation:
|
||||||
|
|
||||||
- Builds raw UDP packets (48 bytes)
|
- Builds raw UDP packets (48 bytes)
|
||||||
- Non-blocking `parsePacket()` checks
|
- Non-blocking `parsePacket()` checks
|
||||||
- 5-second timeout
|
- 5-second timeout
|
||||||
- Independent epoch tracking for accuracy between syncs
|
- Independent epoch tracking for accuracy between syncs
|
||||||
|
|
||||||
#### WiFi State Machine
|
#### WiFi State Machine
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
|
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hybrid model** (this was critical!):
|
**Hybrid model** (this was critical!):
|
||||||
|
|
||||||
- **Setup phase**: Synchronous connection (waits up to 10 seconds)
|
- **Setup phase**: Synchronous connection (waits up to 10 seconds)
|
||||||
- Why? OTA, web server, NTP all need WiFi ready
|
- Why? OTA, web server, NTP all need WiFi ready
|
||||||
- Without this, device shows blank display for 10+ seconds
|
- Without this, device shows blank display for 10+ seconds
|
||||||
@@ -275,11 +296,11 @@ enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
|
|||||||
|
|
||||||
ESP8266 has strict memory limits:
|
ESP8266 has strict memory limits:
|
||||||
|
|
||||||
| Memory Type | Total | Used | Usage | Status |
|
| Memory Type | Total | Used | Usage | Status |
|
||||||
|-------------|-------|------|-------|--------|
|
| ----------- | --------- | ------- | ------- | ----------- |
|
||||||
| **Flash** | 1,048,576 | 408,844 | 38% | ✅ Plenty |
|
| **Flash** | 1,048,576 | 408,844 | 38% | ✅ Plenty |
|
||||||
| **RAM** | 80,192 | 37,644 | 46% | ✅ Safe |
|
| **RAM** | 80,192 | 37,644 | 46% | ✅ Safe |
|
||||||
| **IRAM** | 65,536 | 61,987 | **94%** | ⚠️ Critical |
|
| **IRAM** | 65,536 | 61,987 | **94%** | ⚠️ Critical |
|
||||||
|
|
||||||
**IRAM Crisis Solution:**
|
**IRAM Crisis Solution:**
|
||||||
|
|
||||||
@@ -297,6 +318,7 @@ Applied to 26 functions (web handlers, display, config utilities), reducing IRAM
|
|||||||
**String Safety:**
|
**String Safety:**
|
||||||
|
|
||||||
Avoid String concatenation in loops (causes heap fragmentation):
|
Avoid String concatenation in loops (causes heap fragmentation):
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
// ❌ BAD - 140+ concatenations
|
// ❌ BAD - 140+ concatenations
|
||||||
String html = "";
|
String html = "";
|
||||||
@@ -346,29 +368,34 @@ struct Config {
|
|||||||
This took **3 firmware iterations** to get right:
|
This took **3 firmware iterations** to get right:
|
||||||
|
|
||||||
**v1.5**: Assumed TM1637 (7-segment LED driver)
|
**v1.5**: Assumed TM1637 (7-segment LED driver)
|
||||||
|
|
||||||
- ❌ Wrong - device has OLED, not 7-segment LEDs
|
- ❌ Wrong - device has OLED, not 7-segment LEDs
|
||||||
|
|
||||||
**v1.6**: Tried TM1650 (another LED driver)
|
**v1.6**: Tried TM1650 (another LED driver)
|
||||||
|
|
||||||
- ❌ Wrong - I2C addresses didn't match
|
- ❌ Wrong - I2C addresses didn't match
|
||||||
|
|
||||||
**v1.7**: Identified GM009605v4.3 (SSD1306-compatible OLED)
|
**v1.7**: Identified GM009605v4.3 (SSD1306-compatible OLED)
|
||||||
|
|
||||||
- ✅ Correct! Used Adafruit_SSD1306 library
|
- ✅ Correct! Used Adafruit_SSD1306 library
|
||||||
- ✅ Discovered swapped pins: SDA on GPIO0, SCL on GPIO2
|
- ✅ Discovered swapped pins: SDA on GPIO0, SCL on GPIO2
|
||||||
|
|
||||||
**Pin mapping quirk:**
|
**Pin mapping quirk:**
|
||||||
|
|
||||||
Standard ESP8266 I2C uses GPIO4 (SDA) and GPIO5 (SCL), but ESP-01S only exposes GPIO0 and GPIO2. The board designer mapped:
|
Standard ESP8266 I2C uses GPIO4 (SDA) and GPIO5 (SCL), but ESP-01S only exposes GPIO0 and GPIO2. The board designer mapped:
|
||||||
|
|
||||||
- GPIO0 → SDA (unusual)
|
- GPIO0 → SDA (unusual)
|
||||||
- GPIO2 → SCL (unusual)
|
- GPIO2 → SCL (unusual)
|
||||||
|
|
||||||
This is **backwards** from typical breakout boards, but works perfectly once configured:
|
This is **backwards** from typical breakout boards, but works perfectly once configured:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Journey: v1.7 → v1.9.1
|
## The Journey: v1.7 → v1.9.4
|
||||||
|
|
||||||
### v1.7: Display Discovery ✅
|
### v1.7: Display Discovery ✅
|
||||||
|
|
||||||
@@ -382,6 +409,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
|||||||
**Goals**: Fix memory issues, eliminate security holes
|
**Goals**: Fix memory issues, eliminate security holes
|
||||||
|
|
||||||
**Changes:**
|
**Changes:**
|
||||||
|
|
||||||
- IRAM optimization (added `ICACHE_FLASH_ATTR` to 26 functions)
|
- IRAM optimization (added `ICACHE_FLASH_ATTR` to 26 functions)
|
||||||
- Removed hardcoded WiFi credentials
|
- Removed hardcoded WiFi credentials
|
||||||
- Fixed NTP interval bug (config value was ignored)
|
- Fixed NTP interval bug (config value was ignored)
|
||||||
@@ -396,6 +424,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
|||||||
**Goals**: Eliminate all blocking operations
|
**Goals**: Eliminate all blocking operations
|
||||||
|
|
||||||
**Changes:**
|
**Changes:**
|
||||||
|
|
||||||
- Async HTTP weather fetch (AsyncHTTPRequest library)
|
- Async HTTP weather fetch (AsyncHTTPRequest library)
|
||||||
- Custom async NTP implementation (manual UDP packets)
|
- Custom async NTP implementation (manual UDP packets)
|
||||||
- Async WiFi connection (state machine)
|
- Async WiFi connection (state machine)
|
||||||
@@ -404,16 +433,16 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
|||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
|
|
||||||
| Operation | Before (v1.8) | After (v1.9.0) | Improvement |
|
| Operation | Before (v1.8) | After (v1.9.0) | Improvement |
|
||||||
|-----------|---------------|----------------|-------------|
|
| -------------- | -------------- | -------------- | ------------------- |
|
||||||
| Weather fetch | 1-10s blocking | 0ms | ✅ Async callback |
|
| Weather fetch | 1-10s blocking | 0ms | ✅ Async callback |
|
||||||
| NTP sync | 5-20s blocking | 0ms | ✅ Non-blocking UDP |
|
| NTP sync | 5-20s blocking | 0ms | ✅ Non-blocking UDP |
|
||||||
| WiFi reconnect | 15s blocking | 0ms | ✅ State machine |
|
| WiFi reconnect | 15s blocking | 0ms | ✅ State machine |
|
||||||
| Loop time | 10ms minimum | <1ms | ✅ 10x faster |
|
| Loop time | 10ms minimum | <1ms | ✅ 10x faster |
|
||||||
|
|
||||||
**Result**: Device stays responsive during OTA updates while weather is fetching!
|
**Result**: Device stays responsive during OTA updates while weather is fetching!
|
||||||
|
|
||||||
### v1.9.1: Hybrid Fix (Current) 🎯
|
### v1.9.1: Hybrid Fix 🎯
|
||||||
|
|
||||||
**Problem Discovered:**
|
**Problem Discovered:**
|
||||||
|
|
||||||
@@ -422,6 +451,7 @@ After deploying v1.9.0, the display showed **blank screen for 10 seconds** after
|
|||||||
**Root Cause:**
|
**Root Cause:**
|
||||||
|
|
||||||
Making WiFi fully async broke the **initialization order**:
|
Making WiFi fully async broke the **initialization order**:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
void setup() {
|
void setup() {
|
||||||
setupWiFi(); // Returns immediately (async)
|
setupWiFi(); // Returns immediately (async)
|
||||||
@@ -433,18 +463,76 @@ void setup() {
|
|||||||
|
|
||||||
**Solution: Hybrid Model**
|
**Solution: Hybrid Model**
|
||||||
|
|
||||||
| Phase | WiFi Mode | Blocking? | Why? |
|
| Phase | WiFi Mode | Blocking? | Why? |
|
||||||
|-------|-----------|-----------|------|
|
| --------- | ------------ | --------- | --------------------------- |
|
||||||
| `setup()` | Synchronous | 10s max | OTA/web/NTP need WiFi ready |
|
| `setup()` | Synchronous | 10s max | OTA/web/NTP need WiFi ready |
|
||||||
| `loop()` | Asynchronous | 0s | Don't freeze on reconnect |
|
| `loop()` | Asynchronous | 0s | Don't freeze on reconnect |
|
||||||
|
|
||||||
**Results:**
|
**Results:**
|
||||||
|
|
||||||
- ✅ Display shows time immediately after WiFi connects (~15 sec boot)
|
- ✅ Display shows time immediately after WiFi connects (~15 sec boot)
|
||||||
- ✅ No "DNS resolution failed" errors
|
- ✅ No "DNS resolution failed" errors
|
||||||
- ✅ Proper initialization order guaranteed
|
- ✅ Proper initialization order guaranteed
|
||||||
- ✅ Device never freezes on WiFi loss during operation
|
- ✅ Device never freezes on WiFi loss during operation
|
||||||
|
|
||||||
|
### v1.9.2: WiFi Resilience 🛡️
|
||||||
|
|
||||||
|
**Problem Discovered:**
|
||||||
|
|
||||||
|
After WiFi outages, the device would **clear stored credentials** and enter AP mode, requiring manual reconfiguration every time the router restarted.
|
||||||
|
|
||||||
|
**Root Cause:**
|
||||||
|
|
||||||
|
Aggressive credential clearing on connection failure:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
|
||||||
|
memset(config.ssid, 0, sizeof(config.ssid)); // ❌ Clears credentials!
|
||||||
|
memset(config.password, 0, sizeof(config.password));
|
||||||
|
saveConfig();
|
||||||
|
// Enter AP mode...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution: Resilient WiFi**
|
||||||
|
|
||||||
|
| Feature | Before (v1.9.1) | After (v1.9.2) |
|
||||||
|
| ------------------- | -------------------------- | ------------------------------- |
|
||||||
|
| Credential clearing | After 5 failed attempts | Never |
|
||||||
|
| Retry strategy | Give up after 5 tries | Infinite with backoff |
|
||||||
|
| Max retry interval | N/A | 5 minutes |
|
||||||
|
| Fallback AP | After clearing credentials | After ~5 min (dual STA+AP mode) |
|
||||||
|
| Clock during outage | Blank display | Shows last synced time |
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
|
||||||
|
- **Never clear credentials** on connection failure
|
||||||
|
- **Exponential backoff**: 5s → 10s → 20s → ... → 5min max
|
||||||
|
- **Fallback AP** ("TJ56654-Setup") enabled after ~5 min, while still retrying
|
||||||
|
- **Dual STA+AP mode**: Device continues reconnect attempts while AP is active
|
||||||
|
- **SDK credentials**: Used only on first boot (no saved SSID); subsequent boots go straight to saved credentials
|
||||||
|
- **"No WiFi" display**: Shows retry countdown instead of cryptic numbers
|
||||||
|
- **"!" indicator**: Shown in date line when WiFi disconnected
|
||||||
|
|
||||||
|
**Network Activity Summary:**
|
||||||
|
|
||||||
|
| Service | Interval | Endpoint | Protocol |
|
||||||
|
| ------- | ---------- | ------------------ | ------------- |
|
||||||
|
| NTP | 1 hour | pool.ntp.org:123 | UDP |
|
||||||
|
| Weather | 30 min | api.open-meteo.com | HTTP |
|
||||||
|
| mDNS | continuous | 224.0.0.251 | UDP multicast |
|
||||||
|
|
||||||
|
~50 requests/day total.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
|
||||||
|
- ✅ Credentials persist through WiFi outages
|
||||||
|
- ✅ Device automatically reconnects when WiFi returns
|
||||||
|
- ✅ Clock continues running with last synced time
|
||||||
|
- ✅ User can reconfigure via fallback AP if needed
|
||||||
|
|
||||||
**Startup Timeline:**
|
**Startup Timeline:**
|
||||||
|
|
||||||
```
|
```
|
||||||
[0-5s] Display init, startup animation
|
[0-5s] Display init, startup animation
|
||||||
[5-15s] WiFi connection (SYNCHRONOUS in setup())
|
[5-15s] WiFi connection (SYNCHRONOUS in setup())
|
||||||
@@ -455,14 +543,40 @@ void setup() {
|
|||||||
✅ Time synced and displayed
|
✅ Time synced and displayed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### v1.9.3: Modular Architecture 🗂️
|
||||||
|
|
||||||
|
Split monolithic 2,100-line `.ino` into focused modules:
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| ------------------- | --------------------------------------- |
|
||||||
|
| `weather_clock.ino` | Entry point: `setup()` and `loop()` |
|
||||||
|
| `config.h` | Config struct, EEPROM layout, constants |
|
||||||
|
| `globals.h` | Shared state and extern declarations |
|
||||||
|
| `display.cpp` | OLED rendering |
|
||||||
|
| `ntp_client.cpp` | Async NTP sync |
|
||||||
|
| `weather.cpp` | Open-Meteo API fetch |
|
||||||
|
| `web_server.cpp` | Web UI and REST API |
|
||||||
|
| `wifi_manager.cpp` | WiFi connection and resilience |
|
||||||
|
|
||||||
|
### v1.9.4: Bug Fixes & Cleanup ✅ (Current)
|
||||||
|
|
||||||
|
Community-reported bugs fixed:
|
||||||
|
|
||||||
|
- **Date timezone** (#5): Date now changes at local midnight, not UTC midnight
|
||||||
|
- **Weather refresh** (#7): Periodic weather updates no longer blocked after first fetch
|
||||||
|
- **WiFi hotspot** (#3): Device no longer connects to open SDK-cached networks (e.g. public hotspots) when a saved SSID exists, preventing config corruption
|
||||||
|
- **ArduinoJson v7**: Updated `StaticJsonDocument` → `JsonDocument` for library compatibility
|
||||||
|
- **Compiler warnings**: Removed unused variable, fixed sprintf buffer size
|
||||||
|
|
||||||
### Memory Evolution
|
### Memory Evolution
|
||||||
|
|
||||||
| Version | RAM Usage | IRAM Usage | Flash Usage | Notes |
|
| Version | RAM Usage | IRAM Usage | Flash Usage | Notes |
|
||||||
|---------|-----------|------------|-------------|-------|
|
| ------- | ------------ | ---------------- | ------------- | --------------------- |
|
||||||
| v1.7 | 34,980 (43%) | **61,987 (94%)** | 407,500 (38%) | IRAM crisis |
|
| 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.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.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%) | Production ready |
|
| 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.
|
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
|
||||||
|
|
||||||
@@ -475,6 +589,7 @@ The firmware is designed to be extensible. Next planned features:
|
|||||||
### Custom Display Screens
|
### Custom Display Screens
|
||||||
|
|
||||||
Pull data from Home Assistant via REST API:
|
Pull data from Home Assistant via REST API:
|
||||||
|
|
||||||
- **Smart home stats**: Energy usage, room temperatures
|
- **Smart home stats**: Energy usage, room temperatures
|
||||||
- **Sensor data**: Air quality, CO2 levels
|
- **Sensor data**: Air quality, CO2 levels
|
||||||
- **Automation states**: Alarm status, door locks
|
- **Automation states**: Alarm status, door locks
|
||||||
@@ -488,6 +603,7 @@ Pull data from Home Assistant via REST API:
|
|||||||
### WebSocket Live Updates
|
### WebSocket Live Updates
|
||||||
|
|
||||||
Replace polling with WebSocket for:
|
Replace polling with WebSocket for:
|
||||||
|
|
||||||
- Real-time config changes without page refresh
|
- Real-time config changes without page refresh
|
||||||
- Live display preview in web UI
|
- Live display preview in web UI
|
||||||
- Push notifications for firmware updates
|
- Push notifications for firmware updates
|
||||||
@@ -518,6 +634,7 @@ Replace polling with WebSocket for:
|
|||||||
- `WiFiManager` (by tzapu)
|
- `WiFiManager` (by tzapu)
|
||||||
- `AsyncHTTPRequest_Generic`
|
- `AsyncHTTPRequest_Generic`
|
||||||
- `ESPAsyncTCP`
|
- `ESPAsyncTCP`
|
||||||
|
- `ArduinoJson` (by Benoit Blanchon)
|
||||||
|
|
||||||
3. **Board Configuration**
|
3. **Board Configuration**
|
||||||
- Board: "Generic ESP8266 Module"
|
- Board: "Generic ESP8266 Module"
|
||||||
@@ -530,6 +647,7 @@ Replace polling with WebSocket for:
|
|||||||
### First Flash (via FTDI)
|
### First Flash (via FTDI)
|
||||||
|
|
||||||
1. **Wire the ESP-01S**:
|
1. **Wire the ESP-01S**:
|
||||||
|
|
||||||
```
|
```
|
||||||
FTDI 3.3V → ESP-01S 3V3
|
FTDI 3.3V → ESP-01S 3V3
|
||||||
FTDI GND → ESP-01S GND
|
FTDI GND → ESP-01S GND
|
||||||
@@ -539,7 +657,7 @@ Replace polling with WebSocket for:
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Compile and Upload**:
|
2. **Compile and Upload**:
|
||||||
- Open `clock_ntp_ota_v1.9.ino`
|
- Open `weather_clock.ino`
|
||||||
- Sketch → Upload
|
- Sketch → Upload
|
||||||
- Wait for "Done uploading"
|
- Wait for "Done uploading"
|
||||||
- Remove GPIO0-to-GND jumper
|
- Remove GPIO0-to-GND jumper
|
||||||
@@ -572,6 +690,7 @@ Replace polling with WebSocket for:
|
|||||||
## Web Interface
|
## Web Interface
|
||||||
|
|
||||||
### Home Page (`/`)
|
### Home Page (`/`)
|
||||||
|
|
||||||
Current time display with live updates via JavaScript (fetches `/api/time` every second).
|
Current time display with live updates via JavaScript (fetches `/api/time` every second).
|
||||||
|
|
||||||
### Configuration Page (`/config`)
|
### Configuration Page (`/config`)
|
||||||
@@ -579,11 +698,13 @@ Current time display with live updates via JavaScript (fetches `/api/time` every
|
|||||||
Comprehensive settings form:
|
Comprehensive settings form:
|
||||||
|
|
||||||
**WiFi Settings**
|
**WiFi Settings**
|
||||||
|
|
||||||
- SSID
|
- SSID
|
||||||
- Password
|
- Password
|
||||||
- Hostname (for mDNS)
|
- Hostname (for mDNS)
|
||||||
|
|
||||||
**Time Settings**
|
**Time Settings**
|
||||||
|
|
||||||
- Timezone offset (seconds from UTC)
|
- Timezone offset (seconds from UTC)
|
||||||
- DST enabled (European rules)
|
- DST enabled (European rules)
|
||||||
- NTP server address
|
- NTP server address
|
||||||
@@ -591,6 +712,7 @@ Comprehensive settings form:
|
|||||||
- Hour format (12h/24h)
|
- Hour format (12h/24h)
|
||||||
|
|
||||||
**Weather Settings**
|
**Weather Settings**
|
||||||
|
|
||||||
- Enabled/disabled toggle
|
- Enabled/disabled toggle
|
||||||
- Latitude
|
- Latitude
|
||||||
- Longitude
|
- Longitude
|
||||||
@@ -598,6 +720,7 @@ Comprehensive settings form:
|
|||||||
- Update interval (seconds)
|
- Update interval (seconds)
|
||||||
|
|
||||||
**Display Settings**
|
**Display Settings**
|
||||||
|
|
||||||
- Brightness (0-7)
|
- Brightness (0-7)
|
||||||
- Rotation (0°, 90°, 180°, 270°)
|
- Rotation (0°, 90°, 180°, 270°)
|
||||||
- Display rotation interval (seconds)
|
- Display rotation interval (seconds)
|
||||||
@@ -631,6 +754,7 @@ All endpoints return JSON (except `/update` which is for file upload).
|
|||||||
Current time information.
|
Current time information.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"current": "14:23:45",
|
"current": "14:23:45",
|
||||||
@@ -646,6 +770,7 @@ Current time information.
|
|||||||
System status overview.
|
System status overview.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"wifi": {
|
"wifi": {
|
||||||
@@ -672,6 +797,7 @@ System status overview.
|
|||||||
Current weather data.
|
Current weather data.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"temperature": 15.4,
|
"temperature": 15.4,
|
||||||
@@ -690,6 +816,7 @@ Current weather data.
|
|||||||
Export full configuration as JSON.
|
Export full configuration as JSON.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ssid": "MyNetwork",
|
"ssid": "MyNetwork",
|
||||||
@@ -719,6 +846,7 @@ Import configuration from JSON.
|
|||||||
**Request Body**: Same structure as export response (password field optional for security).
|
**Request Body**: Same structure as export response (password field optional for security).
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok"
|
"status": "ok"
|
||||||
@@ -732,6 +860,7 @@ Device automatically reboots after import.
|
|||||||
Factory reset (clears EEPROM).
|
Factory reset (clears EEPROM).
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "cleared"
|
"status": "cleared"
|
||||||
@@ -745,6 +874,7 @@ Device reboots to WiFiManager captive portal.
|
|||||||
Remote reboot.
|
Remote reboot.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "rebooting"
|
"status": "rebooting"
|
||||||
@@ -759,15 +889,15 @@ Device reboots immediately.
|
|||||||
|
|
||||||
### What Changed from Original Firmware
|
### What Changed from Original Firmware
|
||||||
|
|
||||||
| Issue | Original | Custom Firmware |
|
| Issue | Original | Custom Firmware |
|
||||||
|-------|----------|-----------------|
|
| ---------------------- | ------------------------------ | --------------------------------- |
|
||||||
| **WiFi Password Leak** | Plaintext in open AP | No open AP after setup |
|
| **WiFi Password Leak** | Plaintext in open AP | No open AP after setup |
|
||||||
| **Persistent AP** | Always active | Only on first boot or failure |
|
| **Persistent AP** | Always active | Only on first boot or failure |
|
||||||
| **API Keys** | QWeather requires registration | Open-Meteo (no key needed) |
|
| **API Keys** | QWeather requires registration | Open-Meteo (no key needed) |
|
||||||
| **Cloud Dependency** | Chinese servers | Direct API calls, no intermediary |
|
| **Cloud Dependency** | Chinese servers | Direct API calls, no intermediary |
|
||||||
| **Firmware Updates** | Manual FTDI only | OTA via WiFi (password-protected) |
|
| **Firmware Updates** | Manual FTDI only | OTA via WiFi (password-protected) |
|
||||||
| **Config Access** | No authentication | Admin password required |
|
| **Config Access** | No authentication | Admin password required |
|
||||||
| **Code Transparency** | Closed source | Open source (you're reading it!) |
|
| **Code Transparency** | Closed source | Open source (you're reading it!) |
|
||||||
|
|
||||||
### Best Practices Implemented
|
### Best Practices Implemented
|
||||||
|
|
||||||
@@ -781,16 +911,19 @@ Device reboots immediately.
|
|||||||
### Recommended Post-Flash Steps
|
### Recommended Post-Flash Steps
|
||||||
|
|
||||||
1. **Change OTA password**: Edit line ~60 in `.ino` file:
|
1. **Change OTA password**: Edit line ~60 in `.ino` file:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
ArduinoOTA.setPassword("admin"); // Change this!
|
ArduinoOTA.setPassword("admin"); // Change this!
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Change web admin password**: Edit line ~430:
|
2. **Change web admin password**: Edit line ~430:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
if (!server.authenticate("admin", "admin")) { // Change this!
|
if (!server.authenticate("admin", "admin")) { // Change this!
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Set strong WiFi AP fallback password**: Edit line ~780:
|
3. **Set strong WiFi AP fallback password**: Edit line ~780:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
WiFi.softAP("TJ56654-Clock", "12345678"); // Change this!
|
WiFi.softAP("TJ56654-Clock", "12345678"); // Change this!
|
||||||
```
|
```
|
||||||
@@ -839,6 +972,7 @@ Device reboots immediately.
|
|||||||
**Firmware**: Written from scratch with love and frustration
|
**Firmware**: Written from scratch with love and frustration
|
||||||
|
|
||||||
**Libraries Used**:
|
**Libraries Used**:
|
||||||
|
|
||||||
- [ESP8266 Arduino Core](https://github.com/esp8266/Arduino)
|
- [ESP8266 Arduino Core](https://github.com/esp8266/Arduino)
|
||||||
- [Adafruit SSD1306](https://github.com/adafruit/Adafruit_SSD1306)
|
- [Adafruit SSD1306](https://github.com/adafruit/Adafruit_SSD1306)
|
||||||
- [WiFiManager](https://github.com/tzapu/WiFiManager)
|
- [WiFiManager](https://github.com/tzapu/WiFiManager)
|
||||||
@@ -846,9 +980,11 @@ Device reboots immediately.
|
|||||||
- [NTPClient](https://github.com/arduino-libraries/NTPClient)
|
- [NTPClient](https://github.com/arduino-libraries/NTPClient)
|
||||||
|
|
||||||
**APIs**:
|
**APIs**:
|
||||||
|
|
||||||
- [Open-Meteo](https://open-meteo.com/) - Free weather API, no registration required
|
- [Open-Meteo](https://open-meteo.com/) - Free weather API, no registration required
|
||||||
|
|
||||||
**Tools**:
|
**Tools**:
|
||||||
|
|
||||||
- Arduino IDE 2.x
|
- Arduino IDE 2.x
|
||||||
- FTDI FT232RL USB-to-Serial adapter
|
- FTDI FT232RL USB-to-Serial adapter
|
||||||
- Lots of coffee ☕
|
- Lots of coffee ☕
|
||||||
@@ -858,15 +994,15 @@ Device reboots immediately.
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
clock_ntp_ota_v1.9/
|
esp8266-weather-clock/
|
||||||
├── clock_ntp_ota_v1.9.ino # Main firmware (2,096 lines)
|
├── firmware/
|
||||||
├── v1.9_RELEASE_NOTES.md # Detailed changelog
|
│ └── weather_clock/
|
||||||
├── v1.9.1_HYBRID_FIX.md # WiFi startup fix documentation
|
│ └── weather_clock.ino # Main firmware (~2,100 lines)
|
||||||
├── README.md # This file
|
├── docs/
|
||||||
└── build/
|
│ ├── HARDWARE.md # Hardware specifications
|
||||||
├── clock_ntp_ota_v1.9.ino.bin # Compiled firmware
|
│ └── INSTALLATION.md # Flashing guide
|
||||||
├── clock_ntp_ota_v1.9.ino.elf # Debug symbols
|
├── CHANGELOG.md # Version history
|
||||||
└── clock_ntp_ota_v1.9.ino.map # Memory map
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -882,6 +1018,7 @@ This project is released into the public domain. Do whatever you want with it. I
|
|||||||
This project started as "I don't trust this device" and ended as "I built something better."
|
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:
|
The original firmware had security holes you could drive a truck through. The custom replacement:
|
||||||
|
|
||||||
- ✅ Doesn't leak WiFi passwords
|
- ✅ Doesn't leak WiFi passwords
|
||||||
- ✅ Uses free, open APIs
|
- ✅ Uses free, open APIs
|
||||||
- ✅ Updates over WiFi
|
- ✅ Updates over WiFi
|
||||||
@@ -889,7 +1026,7 @@ The original firmware had security holes you could drive a truck through. The cu
|
|||||||
- ✅ Integrates with Home Assistant (coming soon)
|
- ✅ Integrates with Home Assistant (coming soon)
|
||||||
- ✅ Is completely auditable (you're reading the source)
|
- ✅ Is completely auditable (you're reading the source)
|
||||||
|
|
||||||
Total cost: $12 hardware + a weekend of tinkering.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -899,7 +1036,6 @@ 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!
|
**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!
|
||||||
|
|
||||||
**Built with**: Claude Code (Opus 4.5)
|
|
||||||
**Author**: apetrochenko
|
**Author**: apetrochenko
|
||||||
**Date**: 2026-01-03
|
**Date**: 2026-01-06
|
||||||
**Firmware Version**: v1.9.1 (Production Ready)
|
**Firmware Version**: v1.9.4 (Production Ready)
|
||||||
|
|||||||
+63
-28
@@ -64,16 +64,17 @@ Go to: **Sketch → Include Library → Manage Libraries**
|
|||||||
|
|
||||||
Install the following libraries (search by name):
|
Install the following libraries (search by name):
|
||||||
|
|
||||||
| Library | Author | Min Version | Purpose |
|
| Library | Author | Min Version | Purpose |
|
||||||
|---------|--------|-------------|---------|
|
| ---------------------------- | ---------------- | ----------- | ----------------------------- |
|
||||||
| **Adafruit GFX Library** | Adafruit | 1.11.0 | Graphics primitives |
|
| **Adafruit GFX Library** | Adafruit | 1.11.0 | Graphics primitives |
|
||||||
| **Adafruit SSD1306** | Adafruit | 2.5.0 | OLED display driver |
|
| **Adafruit SSD1306** | Adafruit | 2.5.0 | OLED display driver |
|
||||||
| **NTPClient** | Fabrice Weinberg | 3.2.0 | NTP time sync (base) |
|
| **NTPClient** | Fabrice Weinberg | 3.2.0 | NTP time sync (base) |
|
||||||
| **WiFiManager** | tzapu | 2.0.0 | Captive portal setup |
|
| **WiFiManager** | tzapu | 2.0.0 | Captive portal setup |
|
||||||
| **AsyncHTTPRequest_Generic** | Khoi Hoang | 1.13.0 | Async weather fetch |
|
| **AsyncHTTPRequest_Generic** | Khoi Hoang | 1.13.0 | Async weather fetch |
|
||||||
| **ESPAsyncTCP** | me-no-dev | 1.2.2 | Async TCP (required by above) |
|
| **ESPAsyncTCP** | me-no-dev | 1.2.2 | Async TCP (required by above) |
|
||||||
|
|
||||||
**Installation steps for each library:**
|
**Installation steps for each library:**
|
||||||
|
|
||||||
1. Search library name in Library Manager
|
1. Search library name in Library Manager
|
||||||
2. Click **Install**
|
2. Click **Install**
|
||||||
3. Wait for "INSTALLED" badge
|
3. Wait for "INSTALLED" badge
|
||||||
@@ -87,17 +88,17 @@ Install the following libraries (search by name):
|
|||||||
2. Select: **Generic ESP8266 Module**
|
2. Select: **Generic ESP8266 Module**
|
||||||
3. Configure settings:
|
3. Configure settings:
|
||||||
|
|
||||||
| Setting | Value | Why |
|
| Setting | Value | Why |
|
||||||
|---------|-------|-----|
|
| ----------------- | -------------------------- | ---------------------------------------- |
|
||||||
| Flash Size | `1MB (FS:64KB OTA:~470KB)` | Enables OTA with 470KB max firmware |
|
| Flash Size | `1MB (FS:64KB OTA:~470KB)` | Enables OTA with 470KB max firmware |
|
||||||
| Flash Mode | `DIO` | Compatible with most ESP-01S modules |
|
| Flash Mode | `DIO` | Compatible with most ESP-01S modules |
|
||||||
| Flash Frequency | `40MHz` | Safe default for all ESP8266 |
|
| Flash Frequency | `40MHz` | Safe default for all ESP8266 |
|
||||||
| CPU Frequency | `80MHz` | Standard (can use 160MHz for more speed) |
|
| CPU Frequency | `80MHz` | Standard (can use 160MHz for more speed) |
|
||||||
| Crystal Frequency | `26MHz` | Default for ESP-01S |
|
| Crystal Frequency | `26MHz` | Default for ESP-01S |
|
||||||
| Upload Speed | `115200` | Balance between speed and reliability |
|
| Upload Speed | `115200` | Balance between speed and reliability |
|
||||||
| Debug Level | `None` | Reduces firmware size |
|
| Debug Level | `None` | Reduces firmware size |
|
||||||
| IwIP Variant | `v2 Lower Memory` | Better for 1MB flash devices |
|
| IwIP Variant | `v2 Lower Memory` | Better for 1MB flash devices |
|
||||||
| Erase Flash | `Only Sketch` | Preserves config on re-flash |
|
| Erase Flash | `Only Sketch` | Preserves config on re-flash |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ Install the following libraries (search by name):
|
|||||||
### Step 1: Identify Pins
|
### Step 1: Identify Pins
|
||||||
|
|
||||||
ESP-01S pinout (looking at module from top, antenna up):
|
ESP-01S pinout (looking at module from top, antenna up):
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐
|
┌─────────────┐
|
||||||
│ │
|
│ │
|
||||||
@@ -123,15 +125,16 @@ ESP-01S pinout (looking at module from top, antenna up):
|
|||||||
|
|
||||||
**Connections:**
|
**Connections:**
|
||||||
|
|
||||||
| FTDI Pin | ESP-01S Pin | Wire Color | Notes |
|
| FTDI Pin | ESP-01S Pin | Wire Color | Notes |
|
||||||
|----------|-------------|------------|-------|
|
| -------- | ----------- | ---------- | --------------------------------- |
|
||||||
| 3.3V | 3V3 | Red | Power (NOT 5V!) |
|
| 3.3V | 3V3 | Red | Power (NOT 5V!) |
|
||||||
| GND | GND | Black | Ground |
|
| GND | GND | Black | Ground |
|
||||||
| TX | RX | Yellow | Data: FTDI transmit → ESP receive |
|
| TX | RX | Yellow | Data: FTDI transmit → ESP receive |
|
||||||
| RX | TX | Green | Data: FTDI receive → ESP transmit |
|
| RX | TX | Green | Data: FTDI receive → ESP transmit |
|
||||||
| GND | GPIO0 | Blue | **Programming mode** (temporary) |
|
| GND | GPIO0 | Blue | **Programming mode** (temporary) |
|
||||||
|
|
||||||
**⚠️ CRITICAL**:
|
**⚠️ CRITICAL**:
|
||||||
|
|
||||||
- **Never connect 5V to ESP-01S** - it's not 5V tolerant!
|
- **Never connect 5V to ESP-01S** - it's not 5V tolerant!
|
||||||
- Double-check polarity before powering on
|
- Double-check polarity before powering on
|
||||||
- GPIO0-to-GND connection is **temporary** (only for programming mode)
|
- 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
|
### Step 1: Open Project
|
||||||
|
|
||||||
1. Download or clone this repository
|
1. Download or clone this repository
|
||||||
2. Navigate to: `esp8266-weather-clock-opensource/src/`
|
2. Navigate to: `esp8266-weather-clock-opensource/firmware/weather_clock/`
|
||||||
3. Open: `clock_ntp_ota_v1.9.ino` in Arduino IDE
|
3. Open: `weather_clock.ino` in Arduino IDE
|
||||||
|
|
||||||
### Step 2: Verify Board Settings
|
### Step 2: Verify Board Settings
|
||||||
|
|
||||||
@@ -166,6 +169,7 @@ ESP-01S is now in programming mode, ready to receive firmware.
|
|||||||
- Windows: `COM3`, `COM4`, etc.
|
- Windows: `COM3`, `COM4`, etc.
|
||||||
|
|
||||||
If port doesn't appear:
|
If port doesn't appear:
|
||||||
|
|
||||||
- Check USB cable is data-capable (not charge-only)
|
- Check USB cable is data-capable (not charge-only)
|
||||||
- Install FTDI drivers
|
- Install FTDI drivers
|
||||||
- Try different USB port
|
- Try different USB port
|
||||||
@@ -222,10 +226,12 @@ If port doesn't appear:
|
|||||||
### Step 2: Captive Portal
|
### Step 2: Captive Portal
|
||||||
|
|
||||||
**Automatic (iOS/Android):**
|
**Automatic (iOS/Android):**
|
||||||
|
|
||||||
- Captive portal should pop up automatically
|
- Captive portal should pop up automatically
|
||||||
- If not, manually browse to: http://192.168.4.1
|
- If not, manually browse to: http://192.168.4.1
|
||||||
|
|
||||||
**Manual (laptop):**
|
**Manual (laptop):**
|
||||||
|
|
||||||
- Browse to: http://192.168.4.1
|
- Browse to: http://192.168.4.1
|
||||||
|
|
||||||
### Step 3: Configure WiFi
|
### Step 3: Configure WiFi
|
||||||
@@ -240,16 +246,19 @@ If port doesn't appear:
|
|||||||
### Step 4: Find Device IP
|
### Step 4: Find Device IP
|
||||||
|
|
||||||
**Method 1: Router Admin Panel**
|
**Method 1: Router Admin Panel**
|
||||||
|
|
||||||
- Log into your router
|
- Log into your router
|
||||||
- Look for device: "tj56654-clock"
|
- Look for device: "tj56654-clock"
|
||||||
- Note its IP address (e.g., 192.168.1.47)
|
- Note its IP address (e.g., 192.168.1.47)
|
||||||
|
|
||||||
**Method 2: mDNS (if your OS supports it)**
|
**Method 2: mDNS (if your OS supports it)**
|
||||||
|
|
||||||
- Browse to: http://tj56654-clock.local/
|
- Browse to: http://tj56654-clock.local/
|
||||||
- Works on macOS, Linux, iOS out-of-box
|
- Works on macOS, Linux, iOS out-of-box
|
||||||
- Windows: Install [Bonjour Print Services](https://support.apple.com/kb/DL999)
|
- Windows: Install [Bonjour Print Services](https://support.apple.com/kb/DL999)
|
||||||
|
|
||||||
**Method 3: Serial Monitor**
|
**Method 3: Serial Monitor**
|
||||||
|
|
||||||
1. Keep FTDI connected (no GPIO0 to GND!)
|
1. Keep FTDI connected (no GPIO0 to GND!)
|
||||||
2. Open: **Tools → Serial Monitor**
|
2. Open: **Tools → Serial Monitor**
|
||||||
3. Set baud rate: **115200**
|
3. Set baud rate: **115200**
|
||||||
@@ -261,6 +270,7 @@ If port doesn't appear:
|
|||||||
Browse to: `http://<device-ip>/` or `http://tj56654-clock.local/`
|
Browse to: `http://<device-ip>/` or `http://tj56654-clock.local/`
|
||||||
|
|
||||||
You should see:
|
You should see:
|
||||||
|
|
||||||
- Current time display
|
- Current time display
|
||||||
- Navigation links (Config, Debug, Update)
|
- Navigation links (Config, Debug, Update)
|
||||||
|
|
||||||
@@ -276,6 +286,7 @@ You should see:
|
|||||||
4. Device reboots with new settings
|
4. Device reboots with new settings
|
||||||
|
|
||||||
**Timezone examples:**
|
**Timezone examples:**
|
||||||
|
|
||||||
- UTC+0 (London winter): `0`
|
- UTC+0 (London winter): `0`
|
||||||
- UTC+1 (Paris winter): `3600`
|
- UTC+1 (Paris winter): `3600`
|
||||||
- UTC-5 (New York winter): `-18000`
|
- 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:
|
Replace:
|
||||||
|
|
||||||
- `192.168.x.x` with your device IP
|
- `192.168.x.x` with your device IP
|
||||||
- `/path/to/firmware.bin` with actual path to .bin file
|
- `/path/to/firmware.bin` with actual path to .bin file
|
||||||
|
|
||||||
@@ -329,43 +341,51 @@ Replace:
|
|||||||
### Upload Fails
|
### Upload Fails
|
||||||
|
|
||||||
**Error: "espcomm_open failed"**
|
**Error: "espcomm_open failed"**
|
||||||
|
|
||||||
- Check: GPIO0 was grounded during power-on
|
- Check: GPIO0 was grounded during power-on
|
||||||
- Check: FTDI driver installed
|
- Check: FTDI driver installed
|
||||||
- Try: Different USB port
|
- Try: Different USB port
|
||||||
- Try: Lower upload speed (57600 instead of 115200)
|
- Try: Lower upload speed (57600 instead of 115200)
|
||||||
|
|
||||||
**Error: "espcomm_upload_mem failed"**
|
**Error: "espcomm_upload_mem failed"**
|
||||||
|
|
||||||
- Check: Wire connections (especially RX↔TX swap)
|
- Check: Wire connections (especially RX↔TX swap)
|
||||||
- Check: FTDI is 3.3V (not 5V)
|
- Check: FTDI is 3.3V (not 5V)
|
||||||
- Try: Power ESP-01S from external 3.3V supply (FTDI may not provide enough current)
|
- Try: Power ESP-01S from external 3.3V supply (FTDI may not provide enough current)
|
||||||
|
|
||||||
**Error: "Chip sync error"**
|
**Error: "Chip sync error"**
|
||||||
|
|
||||||
- GPIO0 must be LOW during boot
|
- GPIO0 must be LOW during boot
|
||||||
- Try: Hold GPIO0 to GND, reset ESP, then release GPIO0
|
- Try: Hold GPIO0 to GND, reset ESP, then release GPIO0
|
||||||
|
|
||||||
### Compilation Fails
|
### Compilation Fails
|
||||||
|
|
||||||
**Error: "library not found"**
|
**Error: "library not found"**
|
||||||
|
|
||||||
- Install missing library via Library Manager
|
- Install missing library via Library Manager
|
||||||
- Restart Arduino IDE after installing
|
- Restart Arduino IDE after installing
|
||||||
|
|
||||||
**Error: "Sketch too big"**
|
**Error: "Sketch too big"**
|
||||||
|
|
||||||
- Flash size must be set to 1MB
|
- Flash size must be set to 1MB
|
||||||
- Reduce features if necessary (disable weather, etc.)
|
- Reduce features if necessary (disable weather, etc.)
|
||||||
|
|
||||||
**IRAM overflow error**
|
**IRAM overflow error**
|
||||||
|
|
||||||
- Some functions missing `ICACHE_FLASH_ATTR`
|
- Some functions missing `ICACHE_FLASH_ATTR`
|
||||||
- Use version from this repo (already optimized)
|
- Use version from this repo (already optimized)
|
||||||
|
|
||||||
### WiFi Connection Fails
|
### WiFi Connection Fails
|
||||||
|
|
||||||
**Device creates AP but won't connect to home WiFi**
|
**Device creates AP but won't connect to home WiFi**
|
||||||
|
|
||||||
- ESP8266 only supports 2.4GHz (not 5GHz)
|
- ESP8266 only supports 2.4GHz (not 5GHz)
|
||||||
- Try: Different WiFi channel (1, 6, or 11)
|
- Try: Different WiFi channel (1, 6, or 11)
|
||||||
- Check: WiFi password is correct
|
- Check: WiFi password is correct
|
||||||
- Check: Router supports 802.11n
|
- Check: Router supports 802.11n
|
||||||
|
|
||||||
**Device reboots in a loop**
|
**Device reboots in a loop**
|
||||||
|
|
||||||
- Likely: Power supply too weak (brownout)
|
- Likely: Power supply too weak (brownout)
|
||||||
- Solution: Use powered USB hub or different power adapter
|
- Solution: Use powered USB hub or different power adapter
|
||||||
- Minimum: 500mA @ 5V
|
- Minimum: 500mA @ 5V
|
||||||
@@ -373,28 +393,33 @@ Replace:
|
|||||||
### Display Issues
|
### Display Issues
|
||||||
|
|
||||||
**Display is blank**
|
**Display is blank**
|
||||||
|
|
||||||
- Check: I2C wiring (SDA=GPIO0, SCL=GPIO2)
|
- Check: I2C wiring (SDA=GPIO0, SCL=GPIO2)
|
||||||
- Check: Display I2C address (try 0x3C and 0x3D in code)
|
- Check: Display I2C address (try 0x3C and 0x3D in code)
|
||||||
- Test: Use `/api/i2c-scan` endpoint to detect display
|
- Test: Use `/api/i2c-scan` endpoint to detect display
|
||||||
|
|
||||||
**Display shows garbage**
|
**Display shows garbage**
|
||||||
|
|
||||||
- Wrong display library or initialization
|
- Wrong display library or initialization
|
||||||
- This firmware is for SSD1306-compatible OLED
|
- This firmware is for SSD1306-compatible OLED
|
||||||
- Verify display model is GM009605v4.3 or similar
|
- Verify display model is GM009605v4.3 or similar
|
||||||
|
|
||||||
**Display is upside down**
|
**Display is upside down**
|
||||||
|
|
||||||
- Change `display_orientation` in `/config`
|
- Change `display_orientation` in `/config`
|
||||||
- Values: 0 (normal), 1 (90°), 2 (180°), 3 (270°)
|
- Values: 0 (normal), 1 (90°), 2 (180°), 3 (270°)
|
||||||
|
|
||||||
### Time Not Syncing
|
### Time Not Syncing
|
||||||
|
|
||||||
**Time shows 00:00:00**
|
**Time shows 00:00:00**
|
||||||
|
|
||||||
- Check: WiFi is connected (`/api/status`)
|
- Check: WiFi is connected (`/api/status`)
|
||||||
- Check: NTP server is reachable (default: pool.ntp.org)
|
- Check: NTP server is reachable (default: pool.ntp.org)
|
||||||
- Check: Router firewall allows UDP port 123
|
- Check: Router firewall allows UDP port 123
|
||||||
- Try: Different NTP server (e.g., time.google.com)
|
- Try: Different NTP server (e.g., time.google.com)
|
||||||
|
|
||||||
**Time is wrong by hours**
|
**Time is wrong by hours**
|
||||||
|
|
||||||
- Check: Timezone offset in `/config`
|
- Check: Timezone offset in `/config`
|
||||||
- Remember: Offset is in **seconds**, not hours
|
- Remember: Offset is in **seconds**, not hours
|
||||||
- Example: UTC+1 = 3600 seconds
|
- Example: UTC+1 = 3600 seconds
|
||||||
@@ -402,6 +427,7 @@ Replace:
|
|||||||
### Weather Not Updating
|
### Weather Not Updating
|
||||||
|
|
||||||
**Temperature shows 0.0°C**
|
**Temperature shows 0.0°C**
|
||||||
|
|
||||||
- Check: Internet connectivity (`/api/debug`)
|
- Check: Internet connectivity (`/api/debug`)
|
||||||
- Check: Latitude/longitude are correct
|
- Check: Latitude/longitude are correct
|
||||||
- Check: Open-Meteo API is accessible (visit https://open-meteo.com/ in browser)
|
- Check: Open-Meteo API is accessible (visit https://open-meteo.com/ in browser)
|
||||||
@@ -410,11 +436,13 @@ Replace:
|
|||||||
### OTA Update Fails
|
### OTA Update Fails
|
||||||
|
|
||||||
**Web upload hangs at 0%**
|
**Web upload hangs at 0%**
|
||||||
|
|
||||||
- Check: Device is online and responsive
|
- Check: Device is online and responsive
|
||||||
- Try: Smaller firmware (disable features)
|
- Try: Smaller firmware (disable features)
|
||||||
- Try: Upload via Arduino IDE instead
|
- Try: Upload via Arduino IDE instead
|
||||||
|
|
||||||
**Upload completes but device doesn't reboot**
|
**Upload completes but device doesn't reboot**
|
||||||
|
|
||||||
- Wait 30 seconds (sometimes slow)
|
- Wait 30 seconds (sometimes slow)
|
||||||
- Manually power cycle device
|
- Manually power cycle device
|
||||||
- Check serial output for errors
|
- Check serial output for errors
|
||||||
@@ -422,10 +450,12 @@ Replace:
|
|||||||
### Serial Monitor Shows Errors
|
### Serial Monitor Shows Errors
|
||||||
|
|
||||||
**"DNS resolution failed"**
|
**"DNS resolution failed"**
|
||||||
|
|
||||||
- In v1.9.0 (fixed in v1.9.1)
|
- In v1.9.0 (fixed in v1.9.1)
|
||||||
- Upgrade to v1.9.1 or later
|
- Upgrade to v1.9.1 or later
|
||||||
|
|
||||||
**Watchdog reset / exception**
|
**Watchdog reset / exception**
|
||||||
|
|
||||||
- Likely: Code bug or memory corruption
|
- Likely: Code bug or memory corruption
|
||||||
- Check: IRAM usage < 95%
|
- Check: IRAM usage < 95%
|
||||||
- Report: Open issue with serial log
|
- Report: Open issue with serial log
|
||||||
@@ -437,6 +467,7 @@ Replace:
|
|||||||
### Change OTA Password
|
### Change OTA Password
|
||||||
|
|
||||||
Edit in source code (line ~60):
|
Edit in source code (line ~60):
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
ArduinoOTA.setPassword("your-secret-password");
|
ArduinoOTA.setPassword("your-secret-password");
|
||||||
```
|
```
|
||||||
@@ -444,6 +475,7 @@ ArduinoOTA.setPassword("your-secret-password");
|
|||||||
### Change Web Admin Password
|
### Change Web Admin Password
|
||||||
|
|
||||||
Edit in source code (line ~430):
|
Edit in source code (line ~430):
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
if (!server.authenticate("admin", "your-secret-password")) {
|
if (!server.authenticate("admin", "your-secret-password")) {
|
||||||
```
|
```
|
||||||
@@ -453,13 +485,16 @@ if (!server.authenticate("admin", "your-secret-password")) {
|
|||||||
To save memory, disable unused features:
|
To save memory, disable unused features:
|
||||||
|
|
||||||
**Disable weather:**
|
**Disable weather:**
|
||||||
|
|
||||||
- Set `weather_enabled = false` in `/config`
|
- Set `weather_enabled = false` in `/config`
|
||||||
- Or remove weather code from source
|
- Or remove weather code from source
|
||||||
|
|
||||||
**Disable sunrise/sunset:**
|
**Disable sunrise/sunset:**
|
||||||
|
|
||||||
- Set `show_sunrise_sunset = false` in `/config`
|
- Set `show_sunrise_sunset = false` in `/config`
|
||||||
|
|
||||||
**Disable display rotation:**
|
**Disable display rotation:**
|
||||||
|
|
||||||
- Set `display_rotation_sec = 0` (manual switch only)
|
- Set `display_rotation_sec = 0` (manual switch only)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
# v1.9.1 - Hybrid Async Fix
|
|
||||||
|
|
||||||
## Проблема в v1.9.0
|
|
||||||
|
|
||||||
**Симптомы:**
|
|
||||||
- Дисплей показывает пустой экран ~10 секунд после загрузки
|
|
||||||
- Ошибка "DNS resolution failed" в логах
|
|
||||||
- Время появляется только через 10+ секунд
|
|
||||||
|
|
||||||
**Причина:**
|
|
||||||
```cpp
|
|
||||||
void setup() {
|
|
||||||
loadConfig();
|
|
||||||
setupWiFi(); // ← Возвращается СРАЗУ (async)
|
|
||||||
setupOTA(); // ← WiFi НЕ готов! ✗
|
|
||||||
setupWebServer(); // ← WiFi НЕ готов! ✗
|
|
||||||
testInternetConnectivity(); // ← WiFi НЕ готов! → "DNS resolution failed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
WiFi стал **полностью асинхронным**, но это **неправильно для setup()**:
|
|
||||||
- OTA, web server, NTP **требуют готовое WiFi соединение**
|
|
||||||
- `testInternetConnectivity()` запускался **до подключения WiFi**
|
|
||||||
- Время на дисплее появлялось только когда async WiFi наконец подключался
|
|
||||||
|
|
||||||
## Решение: Гибридная модель
|
|
||||||
|
|
||||||
| Фаза | WiFi режим | Блокировка | Причина |
|
|
||||||
|------|------------|------------|---------|
|
|
||||||
| **setup()** | **Синхронный** | 10 сек | Нужен для инициализации OTA/web/NTP |
|
|
||||||
| **loop()** | **Асинхронный** | 0 сек | Не замораживать при reconnect |
|
|
||||||
|
|
||||||
### Изменения в коде
|
|
||||||
|
|
||||||
#### 1. setupWiFi() - теперь синхронный
|
|
||||||
|
|
||||||
```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);
|
|
||||||
|
|
||||||
// СИНХРОННОЕ ожидание (max 10 секунд)
|
|
||||||
int attempts = 0;
|
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
|
||||||
delay(500);
|
|
||||||
showNumber(attempts, false); // Показываем прогресс на дисплее
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
// ✅ WiFi готов для OTA/web/NTP!
|
|
||||||
showIP();
|
|
||||||
wifiConnState = WIFI_CONN_CONNECTED;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to WiFiManager если credentials не сработали
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 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
|
|
||||||
|
|
||||||
// Остальные async операции
|
|
||||||
processNTPResponse();
|
|
||||||
fetchWeatherAsync();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Результаты тестирования
|
|
||||||
|
|
||||||
### До (v1.9.0)
|
|
||||||
```
|
|
||||||
⏱️ 0-5s → Display init
|
|
||||||
⏱️ 5-15s → WiFi connecting (async, setup() возвращается сразу)
|
|
||||||
⏱️ 15-20s → OTA/web init БЕЗ WiFi → ✗ Errors!
|
|
||||||
⏱️ 20s → testInternetConnectivity() БЕЗ WiFi → "DNS resolution failed"
|
|
||||||
⏱️ 15-25s → WiFi finally connects (async)
|
|
||||||
⏱️ 25-30s → NTP sync начинается
|
|
||||||
|
|
||||||
❌ Display blank for 10+ seconds
|
|
||||||
❌ "DNS resolution failed" errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### После (v1.9.1)
|
|
||||||
```
|
|
||||||
⏱️ 0-5s → Display init + startup animation
|
|
||||||
⏱️ 5-15s → WiFi connection (SYNCHRONOUS, setup() waits)
|
|
||||||
✅ WiFi connected!
|
|
||||||
⏱️ 15-20s → OTA/web/NTP init С 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 SibWings... │ │
|
|
||||||
│ [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!) │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Преимущества гибридного подхода
|
|
||||||
|
|
||||||
### ✅ В setup():
|
|
||||||
1. **Правильный порядок инициализации** - WiFi → OTA → web → NTP
|
|
||||||
2. **Нет ошибок DNS** - internet connectivity test запускается ПОСЛЕ WiFi
|
|
||||||
3. **Предсказуемое поведение** - setup() завершается когда всё готово
|
|
||||||
4. **Дисплей показывает время сразу** - не нужно ждать async WiFi
|
|
||||||
|
|
||||||
### ✅ В loop():
|
|
||||||
1. **Не зависает при reconnect** - async обработка потери WiFi
|
|
||||||
2. **Async NTP** - не блокирует loop
|
|
||||||
3. **Async weather** - не блокирует loop
|
|
||||||
4. **Exponential backoff** - умные retry при ошибках
|
|
||||||
5. **Loop <1ms** - всегда отзывчивое устройство
|
|
||||||
|
|
||||||
## Память
|
|
||||||
|
|
||||||
| Ресурс | v1.9.0 | v1.9.1 | Изменение |
|
|
||||||
|--------|--------|--------|-----------|
|
|
||||||
| RAM | 37,516 | 37,644 | +128 bytes |
|
|
||||||
| IRAM | 61,987 | 61,987 | 0 bytes |
|
|
||||||
| Flash | 408,540 | 408,844 | +304 bytes |
|
|
||||||
|
|
||||||
Минимальные изменения памяти (+0.3%) для критического улучшения UX.
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
**v1.9.1 реализует идеальный баланс:**
|
|
||||||
- Setup: Синхронный для надежной инициализации
|
|
||||||
- Loop: Асинхронный для отзывчивости
|
|
||||||
|
|
||||||
**Результат:**
|
|
||||||
- ✅ Дисплей показывает время через 15 сек (вместо 25+ сек)
|
|
||||||
- ✅ Никаких ошибок "DNS resolution failed"
|
|
||||||
- ✅ Правильный порядок старта
|
|
||||||
- ✅ Устройство не зависает при потере WiFi в работе
|
|
||||||
|
|
||||||
**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
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
build/
|
||||||
|
*.bak
|
||||||
|
*.bak2
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
* config.h - Configuration structures and constants
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef CONFIG_H
|
||||||
|
#define CONFIG_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// Firmware version
|
||||||
|
#define FIRMWARE_VERSION "1.9.7"
|
||||||
|
|
||||||
|
// OLED I2C Configuration
|
||||||
|
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
|
||||||
|
#define I2C_SCL 2 // GPIO2 (I2C Clock) - SWAPPED!
|
||||||
|
#define SCREEN_WIDTH 128
|
||||||
|
#define SCREEN_HEIGHT 64
|
||||||
|
#define OLED_RESET -1 // No reset pin
|
||||||
|
#define OLED_ADDRESS 0x3C
|
||||||
|
|
||||||
|
// Configuration structure with validation
|
||||||
|
#define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
uint32_t magic = CONFIG_MAGIC; // Magic number for validation
|
||||||
|
char ssid[32] = ""; // Empty - configured via WiFiManager captive portal
|
||||||
|
char password[64] = ""; // Empty - configured via WiFiManager captive portal
|
||||||
|
long timezone_offset = 0; // Base UTC offset in seconds (0=Lisbon/London, 3600=Paris/Berlin)
|
||||||
|
bool dst_enabled = true; // Auto DST: +1 hour during summer (European rules: last Sun Mar-Oct)
|
||||||
|
int brightness = 4; // 0-7
|
||||||
|
char ntp_server[64] = "pool.ntp.org";
|
||||||
|
unsigned long ntp_interval = 3600; // NTP update interval in seconds (default: 1 hour)
|
||||||
|
bool hour_format_24 = true; // true=24h, false=12h
|
||||||
|
char hostname[32] = "tj56654-clock";
|
||||||
|
|
||||||
|
// Weather settings
|
||||||
|
float latitude = 37.19; // Portimao, Portugal
|
||||||
|
float longitude = -8.54;
|
||||||
|
char city_name[32] = "Portimao";
|
||||||
|
bool weather_enabled = true;
|
||||||
|
unsigned long weather_interval = 1800; // 30 minutes in seconds
|
||||||
|
|
||||||
|
// Display settings
|
||||||
|
uint8_t display_rotation_sec = 5; // Seconds per screen
|
||||||
|
bool show_weather = true;
|
||||||
|
bool show_sunrise_sunset = true;
|
||||||
|
uint8_t display_orientation = 2; // 0=0°, 1=90°, 2=180°, 3=270°
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exponential backoff retry configuration (for NTP/Weather)
|
||||||
|
struct RetryConfig {
|
||||||
|
uint8_t maxRetries = 3; // Give up after 3 tries
|
||||||
|
uint8_t currentRetry = 0;
|
||||||
|
unsigned long nextRetryTime = 0;
|
||||||
|
unsigned long maxBackoffMs = 8000; // Max backoff 8 seconds
|
||||||
|
|
||||||
|
unsigned long getBackoffDelay() {
|
||||||
|
unsigned long delay = 1000UL * (1UL << currentRetry); // 1s, 2s, 4s, 8s...
|
||||||
|
return (delay > maxBackoffMs) ? maxBackoffMs : delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
void scheduleRetry() {
|
||||||
|
if (currentRetry < maxRetries) {
|
||||||
|
nextRetryTime = millis() + getBackoffDelay();
|
||||||
|
currentRetry++;
|
||||||
|
} else {
|
||||||
|
nextRetryTime = 0; // Max retries reached, stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isRetryTime() {
|
||||||
|
// Subtraction-safe: works correctly across millis() rollover at ~49.7 days
|
||||||
|
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
currentRetry = 0;
|
||||||
|
nextRetryTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool maxRetriesReached() {
|
||||||
|
return currentRetry >= maxRetries;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// WiFi retry configuration - infinite retries with longer backoff
|
||||||
|
struct WiFiRetryConfig {
|
||||||
|
uint8_t currentRetry = 0;
|
||||||
|
unsigned long nextRetryTime = 0;
|
||||||
|
static const unsigned long MAX_BACKOFF_MS = 300000; // Max 5 minutes between retries
|
||||||
|
|
||||||
|
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 scheduleRetry() {
|
||||||
|
nextRetryTime = millis() + getBackoffDelay();
|
||||||
|
if (currentRetry < 10) currentRetry++; // Cap at 10 to prevent overflow
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isRetryTime() {
|
||||||
|
// Subtraction-safe: works correctly across millis() rollover at ~49.7 days
|
||||||
|
return nextRetryTime > 0 && (millis() - nextRetryTime) < 0x80000000UL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
currentRetry = 0;
|
||||||
|
nextRetryTime = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weather fetch state machine
|
||||||
|
enum WeatherState {
|
||||||
|
WEATHER_IDLE,
|
||||||
|
WEATHER_REQUESTING,
|
||||||
|
WEATHER_SUCCESS,
|
||||||
|
WEATHER_FAILED
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async NTP state machine
|
||||||
|
enum NTPState {
|
||||||
|
NTP_IDLE,
|
||||||
|
NTP_REQUEST_SENT,
|
||||||
|
NTP_WAITING,
|
||||||
|
NTP_SUCCESS,
|
||||||
|
NTP_FAILED
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async WiFi state machine
|
||||||
|
enum WiFiConnectionState {
|
||||||
|
WIFI_CONN_IDLE,
|
||||||
|
WIFI_CONN_CONNECTING,
|
||||||
|
WIFI_CONN_CONNECTED,
|
||||||
|
WIFI_CONN_FAILED,
|
||||||
|
WIFI_CONN_SKIP_ASYNC // Skip async, go straight to WiFiManager
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weather data cache
|
||||||
|
struct WeatherData {
|
||||||
|
float temperature = 0.0;
|
||||||
|
int weathercode = -1; // WMO weather code
|
||||||
|
int humidity = 0;
|
||||||
|
float windspeed = 0.0;
|
||||||
|
unsigned long lastUpdate = 0;
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sunrise/Sunset cache
|
||||||
|
struct SunTimes {
|
||||||
|
int sunriseMinutes = 0; // Minutes since midnight
|
||||||
|
int sunsetMinutes = 0;
|
||||||
|
int lastDay = -1; // Day of year
|
||||||
|
char sunrise[6] = "--:--"; // HH:MM format
|
||||||
|
char sunset[6] = "--:--";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dissolve transition constants
|
||||||
|
const unsigned long DISSOLVE_DURATION = 2000; // 2 sec total (1s dissolve out + 1s dissolve in)
|
||||||
|
const unsigned long DISSOLVE_FRAME_INTERVAL = 100; // 100ms per frame
|
||||||
|
|
||||||
|
// NTP timeout
|
||||||
|
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
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/*
|
||||||
|
* display.cpp - Display functions for OLED
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "globals.h"
|
||||||
|
|
||||||
|
// Update main time display
|
||||||
|
void ICACHE_FLASH_ATTR updateDisplay() {
|
||||||
|
static unsigned long lastUpdate = 0;
|
||||||
|
|
||||||
|
// Update display only every 500ms (not every loop cycle)
|
||||||
|
// BUT skip throttle during transition for smooth animation
|
||||||
|
if (!inTransition && millis() - lastUpdate < 500) return;
|
||||||
|
lastUpdate = millis();
|
||||||
|
|
||||||
|
display.clearDisplay();
|
||||||
|
|
||||||
|
// Check if we have ANY time source (NTPClient or async sync)
|
||||||
|
bool hasTime = timeClient.isTimeSet() || timeIsSynced;
|
||||||
|
|
||||||
|
if (!hasTime) {
|
||||||
|
display.setTextSize(3);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setCursor(20, 24);
|
||||||
|
display.println("--:--");
|
||||||
|
|
||||||
|
// Show WiFi status in yellow zone
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setCursor(25, 52);
|
||||||
|
if (wifiConnState == WIFI_CONN_CONNECTED) {
|
||||||
|
display.print("Syncing NTP...");
|
||||||
|
} else {
|
||||||
|
display.print("No WiFi");
|
||||||
|
}
|
||||||
|
if (!inTransition) {
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get epoch from best available source
|
||||||
|
unsigned long epochTime = timeIsSynced ? getAsyncEpoch() : timeClient.getEpochTime();
|
||||||
|
unsigned long localTime = epochTime + getTotalOffset(epochTime);
|
||||||
|
|
||||||
|
int hours = (localTime / 3600) % 24;
|
||||||
|
int minutes = (localTime / 60) % 60;
|
||||||
|
|
||||||
|
// Convert to 12h format if needed
|
||||||
|
if (!config.hour_format_24) {
|
||||||
|
if (hours == 0) hours = 12;
|
||||||
|
else if (hours > 12) hours -= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === YELLOW ZONE (Y: 48-63): Date (size 2 = 16px height) ===
|
||||||
|
display.setTextSize(2);
|
||||||
|
time_t t = localTime;
|
||||||
|
struct tm *ptm = gmtime(&t);
|
||||||
|
|
||||||
|
// Format: "Thu 02.01" or "! Thu 02.01" if no WiFi
|
||||||
|
const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
|
||||||
|
char dateStr[20];
|
||||||
|
|
||||||
|
if (wifiConnState != WIFI_CONN_CONNECTED) {
|
||||||
|
// Show "!" indicator when WiFi is down
|
||||||
|
sprintf(dateStr, "!%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
|
||||||
|
} else {
|
||||||
|
sprintf(dateStr, "%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center in yellow zone (Y: 48-63)
|
||||||
|
int dateWidth = strlen(dateStr) * 12; // Size 2 = ~12px per char
|
||||||
|
int dateX = (128 - dateWidth) / 2;
|
||||||
|
display.setCursor(dateX, 48);
|
||||||
|
display.print(dateStr);
|
||||||
|
|
||||||
|
// === BLUE ZONE (Y: 0-47): Large time (size 3 = 24px height) ===
|
||||||
|
display.setTextSize(3);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// Calculate center position for HH:MM
|
||||||
|
display.setCursor(10, 12); // Y=12 centers in blue zone (0-47)
|
||||||
|
display.printf("%02d", hours);
|
||||||
|
|
||||||
|
// Blinking colon
|
||||||
|
if (colonBlink) {
|
||||||
|
display.print(":");
|
||||||
|
} else {
|
||||||
|
display.print(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
display.printf("%02d", minutes);
|
||||||
|
|
||||||
|
// Don't send to screen during transition - crossfade will do it
|
||||||
|
if (!inTransition) {
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather display
|
||||||
|
void ICACHE_FLASH_ATTR displayWeather() {
|
||||||
|
display.clearDisplay();
|
||||||
|
|
||||||
|
if (!weather.valid) {
|
||||||
|
display.setTextSize(3);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setCursor(20, 24);
|
||||||
|
display.println("No Data");
|
||||||
|
if (!inTransition) {
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === YELLOW ZONE (Y: 48-63): City name (size 2 = 16px) ===
|
||||||
|
display.setTextSize(2);
|
||||||
|
int cityWidth = strlen(config.city_name) * 12;
|
||||||
|
int cityX = (128 - cityWidth) / 2;
|
||||||
|
display.setCursor(cityX, 48);
|
||||||
|
display.print(config.city_name);
|
||||||
|
|
||||||
|
// === BLUE ZONE (Y: 0-47): Temperature (size 3 = 24px height) ===
|
||||||
|
display.setTextSize(3);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// Format temperature value only
|
||||||
|
char tempStr[16];
|
||||||
|
sprintf(tempStr, "%.1f", weather.temperature);
|
||||||
|
|
||||||
|
// Center temperature + degree symbol
|
||||||
|
int tempValueWidth = strlen(tempStr) * 18; // Size 3 = ~18px per char
|
||||||
|
int degreeSymbolWidth = 6; // Size 1 = 6px per char
|
||||||
|
int totalWidth = tempValueWidth + degreeSymbolWidth + 6;
|
||||||
|
int startX = (128 - totalWidth) / 2;
|
||||||
|
|
||||||
|
// Print temperature value
|
||||||
|
display.setCursor(startX, 12);
|
||||||
|
display.print(tempStr);
|
||||||
|
|
||||||
|
// Print degree symbol and C (small, raised)
|
||||||
|
display.setTextSize(1);
|
||||||
|
int degreeX = startX + tempValueWidth;
|
||||||
|
display.setCursor(degreeX, 12);
|
||||||
|
display.print("\xF8" "c"); // °c
|
||||||
|
|
||||||
|
if (!inTransition) {
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sunrise/Sunset display
|
||||||
|
void ICACHE_FLASH_ATTR displaySunTimes() {
|
||||||
|
display.clearDisplay();
|
||||||
|
|
||||||
|
if (sunTimes.lastDay == -1) {
|
||||||
|
display.setTextSize(3);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setCursor(30, 24);
|
||||||
|
display.println("----");
|
||||||
|
if (!inTransition) {
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === YELLOW ZONE (Y: 48-63): Daylight duration ===
|
||||||
|
int daylightMinutes = sunTimes.sunsetMinutes - sunTimes.sunriseMinutes;
|
||||||
|
int daylightHours = daylightMinutes / 60;
|
||||||
|
int daylightMins = daylightMinutes % 60;
|
||||||
|
|
||||||
|
char daylightStr[32];
|
||||||
|
sprintf(daylightStr, "Day %dh %dm", daylightHours, daylightMins);
|
||||||
|
|
||||||
|
display.setTextSize(1);
|
||||||
|
int textWidth = strlen(daylightStr) * 6;
|
||||||
|
int textX = (128 - textWidth) / 2;
|
||||||
|
display.setCursor(textX, 52);
|
||||||
|
display.print(daylightStr);
|
||||||
|
|
||||||
|
// === BLUE ZONE (Y: 0-47): Sunrise and Sunset times ===
|
||||||
|
display.setTextSize(2);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// Line 1: Sunrise
|
||||||
|
display.setCursor(5, 4);
|
||||||
|
display.print("\x18 "); // Up arrow
|
||||||
|
display.print(sunTimes.sunrise);
|
||||||
|
|
||||||
|
// Line 2: Sunset
|
||||||
|
display.setCursor(5, 28);
|
||||||
|
display.print("\x19 "); // Down arrow
|
||||||
|
display.print(sunTimes.sunset);
|
||||||
|
|
||||||
|
if (!inTransition) {
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply dissolve effect with optional drift (Thanos-style)
|
||||||
|
void ICACHE_FLASH_ATTR applyDissolveEffect(uint8_t hidePercent, bool withDrift) {
|
||||||
|
// Apply drift effect - shift buffer to the right
|
||||||
|
if (withDrift && hidePercent > 10) {
|
||||||
|
uint8_t* buffer = display.getBuffer();
|
||||||
|
// SSD1306 buffer: 8 pages (8 rows each), 128 bytes per page
|
||||||
|
for (int page = 0; page < 8; page++) {
|
||||||
|
int pageOffset = page * SCREEN_WIDTH;
|
||||||
|
// Shift right by 2 pixels per frame
|
||||||
|
for (int x = SCREEN_WIDTH - 1; x > 1; x--) {
|
||||||
|
buffer[pageOffset + x] = buffer[pageOffset + x - 2];
|
||||||
|
}
|
||||||
|
buffer[pageOffset] = 0;
|
||||||
|
buffer[pageOffset + 1] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiply by 4 to compensate for random overlaps
|
||||||
|
uint32_t pixelsToHide = ((uint32_t)SCREEN_WIDTH * SCREEN_HEIGHT * hidePercent * 4) / 100;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < pixelsToHide; i++) {
|
||||||
|
uint8_t x = random(SCREEN_WIDTH);
|
||||||
|
uint8_t y = random(SCREEN_HEIGHT);
|
||||||
|
display.drawPixel(x, y, SSD1306_BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display rotation with dissolve transition
|
||||||
|
void ICACHE_FLASH_ATTR updateDisplayRotation() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
unsigned long interval = config.display_rotation_sec * 1000UL;
|
||||||
|
|
||||||
|
// Handle active dissolve transition (two phases)
|
||||||
|
if (inTransition) {
|
||||||
|
unsigned long elapsed = now - transitionStart;
|
||||||
|
|
||||||
|
if (elapsed >= DISSOLVE_DURATION) {
|
||||||
|
displayMode = nextDisplayMode;
|
||||||
|
inTransition = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - lastDissolveFrame < DISSOLVE_FRAME_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastDissolveFrame = now;
|
||||||
|
|
||||||
|
uint8_t currentMode;
|
||||||
|
uint8_t hidePercent;
|
||||||
|
unsigned long halfDuration = DISSOLVE_DURATION / 2;
|
||||||
|
bool isDriftPhase;
|
||||||
|
|
||||||
|
if (elapsed < halfDuration) {
|
||||||
|
// Phase 1: dissolve OUT old content
|
||||||
|
currentMode = displayMode;
|
||||||
|
hidePercent = (elapsed * 100) / halfDuration;
|
||||||
|
isDriftPhase = true;
|
||||||
|
} else {
|
||||||
|
// Phase 2: dissolve IN new content
|
||||||
|
currentMode = nextDisplayMode;
|
||||||
|
unsigned long phase2Elapsed = elapsed - halfDuration;
|
||||||
|
hidePercent = 100 - (phase2Elapsed * 100) / halfDuration;
|
||||||
|
isDriftPhase = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(currentMode) {
|
||||||
|
case 0: updateDisplay(); break;
|
||||||
|
case 1: displayWeather(); break;
|
||||||
|
case 2: displaySunTimes(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDissolveEffect(hidePercent, isDriftPhase);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if time to switch modes
|
||||||
|
if (now - lastModeSwitch > interval) {
|
||||||
|
uint8_t attempts = 0;
|
||||||
|
nextDisplayMode = displayMode;
|
||||||
|
do {
|
||||||
|
nextDisplayMode = (nextDisplayMode + 1) % 3;
|
||||||
|
attempts++;
|
||||||
|
if (attempts >= 3) {
|
||||||
|
nextDisplayMode = 0;
|
||||||
|
Serial.println("WARNING: No display mode enabled, forcing time mode");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while (!isModeEnabled(nextDisplayMode));
|
||||||
|
|
||||||
|
inTransition = true;
|
||||||
|
transitionStart = now;
|
||||||
|
lastModeSwitch = now;
|
||||||
|
lastDissolveFrame = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal display update
|
||||||
|
switch(displayMode) {
|
||||||
|
case 0: updateDisplay(); break;
|
||||||
|
case 1: displayWeather(); break;
|
||||||
|
case 2: displaySunTimes(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if display mode is enabled
|
||||||
|
bool ICACHE_FLASH_ATTR isModeEnabled(uint8_t mode) {
|
||||||
|
switch(mode) {
|
||||||
|
case 0: return true; // Time always enabled
|
||||||
|
case 1: return config.show_weather && weather.valid;
|
||||||
|
case 2: return config.show_sunrise_sunset && sunTimes.lastDay != -1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear display
|
||||||
|
void ICACHE_FLASH_ATTR clearDisplay() {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show number on OLED
|
||||||
|
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros) {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextSize(3);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setCursor(20, 20);
|
||||||
|
|
||||||
|
if (leadingZeros) {
|
||||||
|
display.printf("%04d", num);
|
||||||
|
} else {
|
||||||
|
display.print(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show "No WiFi" status
|
||||||
|
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds) {
|
||||||
|
display.clearDisplay();
|
||||||
|
|
||||||
|
display.setTextSize(2);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setCursor(20, 8);
|
||||||
|
display.println("No WiFi");
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup animation - just logo and version
|
||||||
|
// Layout: Blue zone (Y 0-47), Yellow zone (Y 48-63)
|
||||||
|
void ICACHE_FLASH_ATTR showStartupAnimation() {
|
||||||
|
Serial.println(" Boot: Show logo");
|
||||||
|
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// "TJ-56" centered in BLUE zone
|
||||||
|
// 5 chars × 10px + 4 gaps × 2px = 58px → X = (128-58)/2 = 35
|
||||||
|
display.setTextSize(2);
|
||||||
|
display.setCursor(35, 10);
|
||||||
|
display.print("TJ-56");
|
||||||
|
|
||||||
|
// "Weather Clock" centered in BLUE zone
|
||||||
|
// 13 chars × 5px + 12 gaps × 1px = 77px → X = (128-77)/2 = 26
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setCursor(26, 30);
|
||||||
|
display.print("Weather Clock");
|
||||||
|
|
||||||
|
// Version in YELLOW zone (centered)
|
||||||
|
// 6 chars × 5px + 5 gaps × 1px = 35px → X = (128-35)/2 = 47
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setCursor(47, 52);
|
||||||
|
display.print("v");
|
||||||
|
display.print(FIRMWARE_VERSION);
|
||||||
|
|
||||||
|
display.display();
|
||||||
|
delay(1500);
|
||||||
|
|
||||||
|
Serial.println(" Boot: Logo done");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show WiFi connecting animation with dots
|
||||||
|
// Layout: Blue zone (Y 0-47), Yellow zone (Y 48-63)
|
||||||
|
void ICACHE_FLASH_ATTR showWiFiConnecting(int step) {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// "WiFi..." in BLUE zone (centered)
|
||||||
|
// 7 chars × 10px + 6 gaps × 2px = 82px → X = 23
|
||||||
|
display.setTextSize(2);
|
||||||
|
display.setCursor(23, 10);
|
||||||
|
display.print("WiFi...");
|
||||||
|
|
||||||
|
// Animated dots in BLUE zone (centered)
|
||||||
|
// "* * * * * * " = 12 chars × 5px + 11 gaps × 1px = 71px → X = 29
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setCursor(29, 32);
|
||||||
|
|
||||||
|
int dots = (step % 6) + 1;
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
if (i < dots) {
|
||||||
|
display.print("* ");
|
||||||
|
} else {
|
||||||
|
display.print(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Connecting" in YELLOW zone (centered)
|
||||||
|
// 10 chars × 5px + 9 gaps × 1px = 59px → X = 35
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setCursor(35, 52);
|
||||||
|
display.print("Connecting");
|
||||||
|
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show connected status with SSID and IP
|
||||||
|
// Layout: Blue zone (Y 0-47), Yellow zone (Y 48-63)
|
||||||
|
void ICACHE_FLASH_ATTR showConnected() {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// SSID in BLUE zone (centered)
|
||||||
|
// Formula: n chars × 5px + (n-1) gaps × 1px
|
||||||
|
display.setTextSize(1);
|
||||||
|
String ssid = WiFi.SSID();
|
||||||
|
int ssidLen = ssid.length();
|
||||||
|
int ssidWidth = ssidLen * 5 + (ssidLen - 1) * 1;
|
||||||
|
int ssidX = (128 - ssidWidth) / 2;
|
||||||
|
display.setCursor(ssidX, 8);
|
||||||
|
display.print(ssid);
|
||||||
|
|
||||||
|
// IP address in BLUE zone
|
||||||
|
IPAddress ip = WiFi.localIP();
|
||||||
|
char ipStr[16];
|
||||||
|
sprintf(ipStr, "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||||
|
int ipLen = strlen(ipStr);
|
||||||
|
|
||||||
|
// Try size 2 first: n chars × 10px + (n-1) gaps × 2px
|
||||||
|
int ipWidth2 = ipLen * 10 + (ipLen - 1) * 2;
|
||||||
|
|
||||||
|
if (ipWidth2 <= 128) {
|
||||||
|
display.setTextSize(2);
|
||||||
|
int ipX = (128 - ipWidth2) / 2;
|
||||||
|
display.setCursor(ipX, 24);
|
||||||
|
} else {
|
||||||
|
// Fallback to size 1: n chars × 5px + (n-1) gaps × 1px
|
||||||
|
display.setTextSize(1);
|
||||||
|
int ipWidth1 = ipLen * 5 + (ipLen - 1) * 1;
|
||||||
|
int ipX = (128 - ipWidth1) / 2;
|
||||||
|
display.setCursor(ipX, 24);
|
||||||
|
}
|
||||||
|
display.print(ipStr);
|
||||||
|
|
||||||
|
// "OK" in YELLOW zone (centered)
|
||||||
|
// 2 chars × 5px + 1 gap × 1px = 11px → X = 59
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setCursor(59, 52);
|
||||||
|
display.print("OK");
|
||||||
|
|
||||||
|
display.display();
|
||||||
|
delay(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show IP address (legacy, for reconnection)
|
||||||
|
void ICACHE_FLASH_ATTR showIP() {
|
||||||
|
showConnected(); // Use new unified function
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
* globals.h - Global variables and extern declarations
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef GLOBALS_H
|
||||||
|
#define GLOBALS_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ESP8266WiFi.h>
|
||||||
|
#include <ESP8266WebServer.h>
|
||||||
|
#include <ESP8266HTTPUpdateServer.h>
|
||||||
|
#include <WiFiUdp.h>
|
||||||
|
#include <NTPClient.h>
|
||||||
|
#include <Adafruit_SSD1306.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
extern Config config;
|
||||||
|
|
||||||
|
// OLED Display
|
||||||
|
extern Adafruit_SSD1306 display;
|
||||||
|
|
||||||
|
// NTP Client
|
||||||
|
extern WiFiUDP ntpUDP;
|
||||||
|
extern NTPClient timeClient;
|
||||||
|
|
||||||
|
// Web server
|
||||||
|
extern ESP8266WebServer server;
|
||||||
|
extern ESP8266HTTPUpdateServer httpUpdater;
|
||||||
|
|
||||||
|
// State machines — volatile: written from ESPAsyncTCP callbacks, read in main loop
|
||||||
|
extern volatile WeatherState weatherState;
|
||||||
|
extern volatile NTPState ntpState;
|
||||||
|
extern WiFiConnectionState wifiConnState;
|
||||||
|
|
||||||
|
// Retry configurations
|
||||||
|
extern RetryConfig ntpRetry;
|
||||||
|
extern RetryConfig weatherRetry;
|
||||||
|
extern WiFiRetryConfig wifiRetry;
|
||||||
|
|
||||||
|
// NTP packet buffer and timing
|
||||||
|
extern byte ntpPacketBuffer[48];
|
||||||
|
extern unsigned long ntpRequestTime;
|
||||||
|
|
||||||
|
// Independent epoch tracking for async NTP
|
||||||
|
extern unsigned long syncedEpoch;
|
||||||
|
extern unsigned long syncedMillis;
|
||||||
|
extern bool timeIsSynced;
|
||||||
|
|
||||||
|
// WiFi connection timing
|
||||||
|
extern unsigned long wifiConnectStart;
|
||||||
|
|
||||||
|
// Display state
|
||||||
|
extern bool colonBlink;
|
||||||
|
extern unsigned long lastBlinkTime;
|
||||||
|
extern unsigned long lastNTPUpdate;
|
||||||
|
extern unsigned long ipDisplayUntil;
|
||||||
|
|
||||||
|
// Debug variables
|
||||||
|
extern String lastError;
|
||||||
|
extern int ntpAttempts;
|
||||||
|
extern int ntpSuccesses;
|
||||||
|
extern bool internetConnected;
|
||||||
|
|
||||||
|
// Weather and sun data
|
||||||
|
extern WeatherData weather;
|
||||||
|
extern SunTimes sunTimes;
|
||||||
|
|
||||||
|
// Display rotation state
|
||||||
|
extern uint8_t displayMode;
|
||||||
|
extern unsigned long lastModeSwitch;
|
||||||
|
extern unsigned long lastWeatherUpdate;
|
||||||
|
extern unsigned long weatherRequestStart;
|
||||||
|
|
||||||
|
// Dissolve transition state
|
||||||
|
extern bool inTransition;
|
||||||
|
extern unsigned long transitionStart;
|
||||||
|
extern unsigned long lastDissolveFrame;
|
||||||
|
extern uint8_t nextDisplayMode;
|
||||||
|
|
||||||
|
// ============ Function declarations ============
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxLen);
|
||||||
|
|
||||||
|
// Time functions (ntp_client.cpp)
|
||||||
|
bool isDST(unsigned long epochTime);
|
||||||
|
long getTotalOffset(unsigned long epochTime);
|
||||||
|
unsigned long getAsyncEpoch();
|
||||||
|
void sendNTPRequestAsync();
|
||||||
|
void processNTPResponse();
|
||||||
|
void ICACHE_FLASH_ATTR updateNTPTime();
|
||||||
|
void ICACHE_FLASH_ATTR testInternetConnectivity();
|
||||||
|
|
||||||
|
// WiFi functions (wifi_manager.cpp)
|
||||||
|
void ICACHE_FLASH_ATTR setupWiFi();
|
||||||
|
void processWiFiConnection();
|
||||||
|
|
||||||
|
// Display functions (display.cpp)
|
||||||
|
void ICACHE_FLASH_ATTR clearDisplay();
|
||||||
|
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros);
|
||||||
|
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds);
|
||||||
|
void ICACHE_FLASH_ATTR showStartupAnimation();
|
||||||
|
void ICACHE_FLASH_ATTR showWiFiConnecting(int step);
|
||||||
|
void ICACHE_FLASH_ATTR showConnected();
|
||||||
|
void ICACHE_FLASH_ATTR showIP();
|
||||||
|
void updateDisplay();
|
||||||
|
void ICACHE_FLASH_ATTR displayWeather();
|
||||||
|
void ICACHE_FLASH_ATTR displaySunTimes();
|
||||||
|
void ICACHE_FLASH_ATTR applyDissolveEffect(uint8_t hidePercent, bool withDrift);
|
||||||
|
void ICACHE_FLASH_ATTR updateDisplayRotation();
|
||||||
|
bool ICACHE_FLASH_ATTR isModeEnabled(uint8_t mode);
|
||||||
|
|
||||||
|
// Weather functions (weather.cpp)
|
||||||
|
void fetchWeatherAsync();
|
||||||
|
void calculateSunTimes();
|
||||||
|
|
||||||
|
// Web server functions (web_server.cpp)
|
||||||
|
void ICACHE_FLASH_ATTR setupWebServer();
|
||||||
|
void ICACHE_FLASH_ATTR handleRoot();
|
||||||
|
void ICACHE_FLASH_ATTR handleDebug();
|
||||||
|
void ICACHE_FLASH_ATTR handleTestNTP();
|
||||||
|
void ICACHE_FLASH_ATTR handleTestDisplay();
|
||||||
|
void ICACHE_FLASH_ATTR handleConfig();
|
||||||
|
void ICACHE_FLASH_ATTR handleConfigSave();
|
||||||
|
void ICACHE_FLASH_ATTR handleAPITime();
|
||||||
|
void ICACHE_FLASH_ATTR handleAPIStatus();
|
||||||
|
void ICACHE_FLASH_ATTR handleAPIDebug();
|
||||||
|
void ICACHE_FLASH_ATTR handleAPIWeather();
|
||||||
|
void ICACHE_FLASH_ATTR handleAPIConfigExport();
|
||||||
|
void ICACHE_FLASH_ATTR handleAPIConfigImport();
|
||||||
|
void ICACHE_FLASH_ATTR handleEEPROMClear();
|
||||||
|
void ICACHE_FLASH_ATTR handleReboot();
|
||||||
|
void ICACHE_FLASH_ATTR handleI2CScan();
|
||||||
|
|
||||||
|
// Config functions (in main .ino)
|
||||||
|
void ICACHE_FLASH_ATTR loadConfig();
|
||||||
|
void ICACHE_FLASH_ATTR saveConfig();
|
||||||
|
|
||||||
|
// OTA functions (in main .ino)
|
||||||
|
void ICACHE_FLASH_ATTR setupOTA();
|
||||||
|
|
||||||
|
#endif // GLOBALS_H
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
* ntp_client.cpp - NTP client and time functions
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "globals.h"
|
||||||
|
|
||||||
|
// DST calculation for European rules
|
||||||
|
// DST starts: last Sunday of March at 01:00 UTC
|
||||||
|
// DST ends: last Sunday of October at 01:00 UTC
|
||||||
|
bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
|
||||||
|
if (!config.dst_enabled) return false;
|
||||||
|
|
||||||
|
time_t t = epochTime;
|
||||||
|
struct tm *timeinfo = gmtime(&t);
|
||||||
|
|
||||||
|
int month = timeinfo->tm_mon + 1; // 1-12
|
||||||
|
int day = timeinfo->tm_mday; // 1-31
|
||||||
|
int hour = timeinfo->tm_hour;
|
||||||
|
|
||||||
|
// Not DST: November - February
|
||||||
|
if (month < 3 || month > 10) return false;
|
||||||
|
|
||||||
|
// Always DST: April - September
|
||||||
|
if (month > 3 && month < 10) return true;
|
||||||
|
|
||||||
|
// March: DST starts last Sunday at 01:00 UTC
|
||||||
|
if (month == 3) {
|
||||||
|
// 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;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// October: DST ends last Sunday at 01:00 UTC
|
||||||
|
if (month == 10) {
|
||||||
|
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;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total timezone offset including DST
|
||||||
|
long ICACHE_FLASH_ATTR getTotalOffset(unsigned long epochTime) {
|
||||||
|
long offset = config.timezone_offset;
|
||||||
|
if (isDST(epochTime)) {
|
||||||
|
offset += 3600; // Add 1 hour for DST
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current epoch (async NTP independent tracking)
|
||||||
|
unsigned long ICACHE_FLASH_ATTR getAsyncEpoch() {
|
||||||
|
if (!timeIsSynced) return timeClient.getEpochTime();
|
||||||
|
unsigned long elapsed = (millis() - syncedMillis) / 1000;
|
||||||
|
return syncedEpoch + elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test internet connectivity
|
||||||
|
void ICACHE_FLASH_ATTR testInternetConnectivity() {
|
||||||
|
Serial.println("\n=== Testing Internet Connectivity ===");
|
||||||
|
|
||||||
|
IPAddress ntpIP;
|
||||||
|
if (WiFi.hostByName(config.ntp_server, ntpIP)) {
|
||||||
|
Serial.print("DNS works: ");
|
||||||
|
Serial.print(config.ntp_server);
|
||||||
|
Serial.print(" -> ");
|
||||||
|
Serial.println(ntpIP);
|
||||||
|
} else {
|
||||||
|
Serial.print("DNS failed: cannot resolve ");
|
||||||
|
Serial.println(config.ntp_server);
|
||||||
|
lastError = "DNS resolution failed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.hostByName("google.com", ntpIP)) {
|
||||||
|
Serial.println("Can resolve google.com");
|
||||||
|
internetConnected = true;
|
||||||
|
} else {
|
||||||
|
Serial.println("Cannot resolve google.com - no internet?");
|
||||||
|
lastError = "No internet connectivity";
|
||||||
|
internetConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update NTP time (blocking)
|
||||||
|
void ICACHE_FLASH_ATTR updateNTPTime() {
|
||||||
|
Serial.println("\n=== Updating NTP Time ===");
|
||||||
|
ntpAttempts++;
|
||||||
|
|
||||||
|
if (!internetConnected) {
|
||||||
|
Serial.println("Skipping NTP update - no internet");
|
||||||
|
lastError = "No internet connection";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool success = timeClient.update();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
ntpSuccesses++;
|
||||||
|
Serial.print("NTP sync successful: ");
|
||||||
|
Serial.println(timeClient.getFormattedTime());
|
||||||
|
lastError = "";
|
||||||
|
} else {
|
||||||
|
Serial.println("NTP sync failed");
|
||||||
|
lastError = "NTP sync failed (timeout or no response)";
|
||||||
|
|
||||||
|
Serial.println(" Trying force update...");
|
||||||
|
if (timeClient.forceUpdate()) {
|
||||||
|
ntpSuccesses++;
|
||||||
|
Serial.print("Force update successful: ");
|
||||||
|
Serial.println(timeClient.getFormattedTime());
|
||||||
|
lastError = "";
|
||||||
|
} else {
|
||||||
|
Serial.println("Force update also failed");
|
||||||
|
testInternetConnectivity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.print("NTP Stats: ");
|
||||||
|
Serial.print(ntpSuccesses);
|
||||||
|
Serial.print(" / ");
|
||||||
|
Serial.print(ntpAttempts);
|
||||||
|
Serial.println(" successful");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async NTP - Send request (non-blocking)
|
||||||
|
void ICACHE_FLASH_ATTR sendNTPRequestAsync() {
|
||||||
|
if (ntpState != NTP_IDLE) return;
|
||||||
|
|
||||||
|
if (!internetConnected) {
|
||||||
|
Serial.println(F("Skip NTP - no internet"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println(F("NTP request (async)..."));
|
||||||
|
ntpAttempts++;
|
||||||
|
|
||||||
|
memset(ntpPacketBuffer, 0, 48);
|
||||||
|
ntpPacketBuffer[0] = 0b11100011;
|
||||||
|
ntpPacketBuffer[1] = 0;
|
||||||
|
ntpPacketBuffer[2] = 6;
|
||||||
|
ntpPacketBuffer[3] = 0xEC;
|
||||||
|
|
||||||
|
ntpUDP.beginPacket(config.ntp_server, 123);
|
||||||
|
ntpUDP.write(ntpPacketBuffer, 48);
|
||||||
|
ntpUDP.endPacket();
|
||||||
|
|
||||||
|
ntpState = NTP_REQUEST_SENT;
|
||||||
|
ntpRequestTime = millis();
|
||||||
|
Serial.println("NTP sent (non-blocking)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async NTP - Process response (call in loop)
|
||||||
|
void ICACHE_FLASH_ATTR processNTPResponse() {
|
||||||
|
if (ntpState == NTP_IDLE) return;
|
||||||
|
|
||||||
|
// Timeout check with exponential backoff retry
|
||||||
|
if (millis() - ntpRequestTime > NTP_TIMEOUT_MS) {
|
||||||
|
ntpState = NTP_IDLE;
|
||||||
|
Serial.printf("NTP timeout (attempt %d/%d)\n", ntpRetry.currentRetry + 1, ntpRetry.maxRetries);
|
||||||
|
|
||||||
|
ntpRetry.scheduleRetry();
|
||||||
|
if (ntpRetry.maxRetriesReached()) {
|
||||||
|
Serial.println("NTP max retries reached, will try again later");
|
||||||
|
lastError = "NTP timeout - max retries";
|
||||||
|
} else {
|
||||||
|
unsigned long backoff = ntpRetry.getBackoffDelay() / 1000;
|
||||||
|
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for response packet
|
||||||
|
if (ntpUDP.parsePacket() >= 48) {
|
||||||
|
ntpUDP.read(ntpPacketBuffer, 48);
|
||||||
|
|
||||||
|
unsigned long high = word(ntpPacketBuffer[40], ntpPacketBuffer[41]);
|
||||||
|
unsigned long low = word(ntpPacketBuffer[42], ntpPacketBuffer[43]);
|
||||||
|
unsigned long epoch = (high << 16 | low) - 2208988800UL;
|
||||||
|
|
||||||
|
syncedEpoch = epoch;
|
||||||
|
syncedMillis = millis();
|
||||||
|
timeIsSynced = true;
|
||||||
|
|
||||||
|
timeClient = NTPClient(ntpUDP, config.ntp_server, 0, config.ntp_interval * 1000);
|
||||||
|
timeClient.begin();
|
||||||
|
timeClient.update();
|
||||||
|
|
||||||
|
ntpState = NTP_IDLE;
|
||||||
|
ntpSuccesses++;
|
||||||
|
ntpRetry.reset();
|
||||||
|
lastError = "";
|
||||||
|
|
||||||
|
unsigned long h = (epoch % 86400L) / 3600;
|
||||||
|
unsigned long m = (epoch % 3600) / 60;
|
||||||
|
Serial.printf("NTP synced (async): %02lu:%02lu UTC\n", h, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* weather.cpp - Weather API functions
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Include AsyncHTTPRequest BEFORE globals.h to provide 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 "globals.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
// Async HTTP client for weather (local to this file)
|
||||||
|
static AsyncHTTPRequest weatherRequest;
|
||||||
|
|
||||||
|
// Weather response callback
|
||||||
|
void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* request, int readyState) {
|
||||||
|
(void)optParm; // Unused
|
||||||
|
|
||||||
|
if (readyState == 4) { // Request complete
|
||||||
|
weatherState = WEATHER_IDLE;
|
||||||
|
|
||||||
|
int httpCode = request->responseHTTPcode();
|
||||||
|
if (httpCode == 200) {
|
||||||
|
String payload = request->responseText();
|
||||||
|
Serial.printf("Weather response: %d bytes\n", payload.length());
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, payload);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
// Extract current weather
|
||||||
|
JsonObject current = doc["current_weather"];
|
||||||
|
weather.temperature = current["temperature"] | 0.0f;
|
||||||
|
weather.weathercode = current["weathercode"] | -1;
|
||||||
|
weather.windspeed = current["windspeed"] | 0.0f;
|
||||||
|
weather.lastUpdate = millis();
|
||||||
|
weather.valid = true;
|
||||||
|
|
||||||
|
// Extract sunrise/sunset
|
||||||
|
JsonArray daily_sunrise = doc["daily"]["sunrise"];
|
||||||
|
JsonArray daily_sunset = doc["daily"]["sunset"];
|
||||||
|
|
||||||
|
if (daily_sunrise.size() > 0 && daily_sunset.size() > 0) {
|
||||||
|
const char* sunrise_str = daily_sunrise[0];
|
||||||
|
const char* sunset_str = daily_sunset[0];
|
||||||
|
|
||||||
|
// Parse ISO time (2026-01-02T07:52) -> HH:MM
|
||||||
|
if (sunrise_str && strlen(sunrise_str) >= 16) {
|
||||||
|
sunTimes.sunrise[0] = sunrise_str[11];
|
||||||
|
sunTimes.sunrise[1] = sunrise_str[12];
|
||||||
|
sunTimes.sunrise[2] = ':';
|
||||||
|
sunTimes.sunrise[3] = sunrise_str[14];
|
||||||
|
sunTimes.sunrise[4] = sunrise_str[15];
|
||||||
|
sunTimes.sunrise[5] = '\0';
|
||||||
|
|
||||||
|
sunTimes.sunriseMinutes = (sunrise_str[11] - '0') * 600 +
|
||||||
|
(sunrise_str[12] - '0') * 60 +
|
||||||
|
(sunrise_str[14] - '0') * 10 +
|
||||||
|
(sunrise_str[15] - '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sunset_str && strlen(sunset_str) >= 16) {
|
||||||
|
sunTimes.sunset[0] = sunset_str[11];
|
||||||
|
sunTimes.sunset[1] = sunset_str[12];
|
||||||
|
sunTimes.sunset[2] = ':';
|
||||||
|
sunTimes.sunset[3] = sunset_str[14];
|
||||||
|
sunTimes.sunset[4] = sunset_str[15];
|
||||||
|
sunTimes.sunset[5] = '\0';
|
||||||
|
|
||||||
|
sunTimes.sunsetMinutes = (sunset_str[11] - '0') * 600 +
|
||||||
|
(sunset_str[12] - '0') * 60 +
|
||||||
|
(sunset_str[14] - '0') * 10 +
|
||||||
|
(sunset_str[15] - '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastDay
|
||||||
|
time_t epochTime = timeClient.getEpochTime();
|
||||||
|
struct tm *ptm = gmtime(&epochTime);
|
||||||
|
sunTimes.lastDay = ptm->tm_yday;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
} else {
|
||||||
|
weatherState = WEATHER_FAILED;
|
||||||
|
weather.valid = false;
|
||||||
|
lastError = String("JSON: ") + error.c_str();
|
||||||
|
Serial.printf("JSON parse error (attempt %d/%d): %s\n",
|
||||||
|
weatherRetry.currentRetry + 1, weatherRetry.maxRetries, error.c_str());
|
||||||
|
|
||||||
|
weatherRetry.scheduleRetry();
|
||||||
|
if (weatherRetry.maxRetriesReached()) {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.clear();
|
||||||
|
} else {
|
||||||
|
weatherState = WEATHER_FAILED;
|
||||||
|
weather.valid = false;
|
||||||
|
lastError = "Weather API: " + String(httpCode);
|
||||||
|
Serial.printf("HTTP error %d (attempt %d/%d)\n",
|
||||||
|
httpCode, weatherRetry.currentRetry + 1, weatherRetry.maxRetries);
|
||||||
|
|
||||||
|
weatherRetry.scheduleRetry();
|
||||||
|
if (weatherRetry.maxRetriesReached()) {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async weather fetch - non-blocking!
|
||||||
|
void ICACHE_FLASH_ATTR fetchWeatherAsync() {
|
||||||
|
if (!config.weather_enabled) {
|
||||||
|
Serial.println(F("Weather disabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weatherState != WEATHER_IDLE) {
|
||||||
|
Serial.println(F("Weather request already in progress"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URL
|
||||||
|
String url = "http://api.open-meteo.com/v1/forecast?";
|
||||||
|
url += "latitude=" + String(config.latitude, 2);
|
||||||
|
url += "&longitude=" + String(config.longitude, 2);
|
||||||
|
url += "¤t_weather=true";
|
||||||
|
url += "&daily=sunrise,sunset";
|
||||||
|
url += "&timezone=auto";
|
||||||
|
url += "&forecast_days=1";
|
||||||
|
|
||||||
|
Serial.println(F("Fetching weather (async)..."));
|
||||||
|
|
||||||
|
if (weatherRequest.open("GET", url.c_str())) {
|
||||||
|
weatherRequest.onReadyStateChange(onWeatherResponse);
|
||||||
|
weatherRequest.setTimeout(10); // 10 seconds
|
||||||
|
weatherRequest.send();
|
||||||
|
weatherState = WEATHER_REQUESTING;
|
||||||
|
weatherRequestStart = millis();
|
||||||
|
Serial.println("Weather request sent (non-blocking)");
|
||||||
|
} else {
|
||||||
|
weatherState = WEATHER_FAILED;
|
||||||
|
Serial.println("Failed to open weather request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sun times (placeholder - data comes from API)
|
||||||
|
void ICACHE_FLASH_ATTR calculateSunTimes() {
|
||||||
|
if (!config.show_sunrise_sunset) return;
|
||||||
|
|
||||||
|
if (sunTimes.lastDay != -1) {
|
||||||
|
Serial.println(F("Sun times already available from API"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println(F("Sun times not available yet - will be fetched with weather"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
/*
|
||||||
|
* TJ-56-654 Weather Clock - Custom NTP Firmware with OTA v1.9.3
|
||||||
|
*
|
||||||
|
* Hardware:
|
||||||
|
* - ESP-01S (ESP8266)
|
||||||
|
* - GM009605v4.3 OLED 128x64 display (SSD1306 I2C)
|
||||||
|
*
|
||||||
|
* Connections:
|
||||||
|
* - GPIO0 -> OLED SDA (I2C Data) - SWAPPED!
|
||||||
|
* - GPIO2 -> OLED SCL (I2C Clock) - SWAPPED!
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Async NTP time sync
|
||||||
|
* - Async weather from Open-Meteo API
|
||||||
|
* - OTA updates (web + ArduinoOTA)
|
||||||
|
* - WiFi resilience with exponential backoff
|
||||||
|
* - "Thanos snap" dissolve transition effect
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <ESP8266WiFi.h>
|
||||||
|
#include <ESP8266WebServer.h>
|
||||||
|
#include <ESP8266HTTPUpdateServer.h>
|
||||||
|
#include <ESP8266mDNS.h>
|
||||||
|
#include <ArduinoOTA.h>
|
||||||
|
#include <WiFiUdp.h>
|
||||||
|
#include <NTPClient.h>
|
||||||
|
#include <EEPROM.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <Adafruit_SSD1306.h>
|
||||||
|
#include <WiFiManager.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "globals.h"
|
||||||
|
|
||||||
|
// ============ Global variable definitions ============
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
Config config;
|
||||||
|
|
||||||
|
// OLED Display
|
||||||
|
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
|
||||||
|
|
||||||
|
// NTP Client
|
||||||
|
WiFiUDP ntpUDP;
|
||||||
|
NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
|
||||||
|
|
||||||
|
// Web server
|
||||||
|
ESP8266WebServer server(80);
|
||||||
|
ESP8266HTTPUpdateServer httpUpdater;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
RetryConfig ntpRetry;
|
||||||
|
RetryConfig weatherRetry;
|
||||||
|
WiFiRetryConfig wifiRetry;
|
||||||
|
|
||||||
|
// NTP packet buffer and timing
|
||||||
|
byte ntpPacketBuffer[48];
|
||||||
|
unsigned long ntpRequestTime = 0;
|
||||||
|
|
||||||
|
// Independent epoch tracking
|
||||||
|
unsigned long syncedEpoch = 0;
|
||||||
|
unsigned long syncedMillis = 0;
|
||||||
|
bool timeIsSynced = false;
|
||||||
|
|
||||||
|
// WiFi connection timing
|
||||||
|
unsigned long wifiConnectStart = 0;
|
||||||
|
|
||||||
|
// Display state
|
||||||
|
bool colonBlink = false;
|
||||||
|
unsigned long lastBlinkTime = 0;
|
||||||
|
unsigned long lastNTPUpdate = 0;
|
||||||
|
unsigned long ipDisplayUntil = 0;
|
||||||
|
|
||||||
|
// Debug variables
|
||||||
|
String lastError = "";
|
||||||
|
int ntpAttempts = 0;
|
||||||
|
int ntpSuccesses = 0;
|
||||||
|
bool internetConnected = false;
|
||||||
|
|
||||||
|
// Weather and sun data
|
||||||
|
WeatherData weather;
|
||||||
|
SunTimes sunTimes;
|
||||||
|
|
||||||
|
// Display rotation state
|
||||||
|
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;
|
||||||
|
unsigned long transitionStart = 0;
|
||||||
|
unsigned long lastDissolveFrame = 0;
|
||||||
|
uint8_t nextDisplayMode = 0;
|
||||||
|
|
||||||
|
// ============ Helper functions ============
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxLen) {
|
||||||
|
if (src.length() >= maxLen) {
|
||||||
|
Serial.printf("WARNING: String truncated from %d to %d chars\n", src.length(), maxLen - 1);
|
||||||
|
}
|
||||||
|
src.toCharArray(dest, maxLen);
|
||||||
|
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 !!!");
|
||||||
|
rc.count = 0;
|
||||||
|
EEPROM.put(RESET_COUNTER_ADDR, rc);
|
||||||
|
EEPROM.commit();
|
||||||
|
EEPROM.end();
|
||||||
|
|
||||||
|
// Clear WiFi credentials only — keep other settings
|
||||||
|
memset(config.ssid, 0, sizeof(config.ssid));
|
||||||
|
memset(config.password, 0, sizeof(config.password));
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
// Show reset screen
|
||||||
|
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() {
|
||||||
|
EEPROM.begin(512);
|
||||||
|
|
||||||
|
Config tempConfig;
|
||||||
|
EEPROM.get(0, tempConfig);
|
||||||
|
|
||||||
|
if (tempConfig.magic == CONFIG_MAGIC) {
|
||||||
|
config = tempConfig;
|
||||||
|
Serial.println("Valid configuration loaded from EEPROM");
|
||||||
|
} else {
|
||||||
|
Serial.println("Invalid EEPROM data, using defaults");
|
||||||
|
config.magic = CONFIG_MAGIC;
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
EEPROM.end();
|
||||||
|
|
||||||
|
Serial.println("Configuration:");
|
||||||
|
Serial.printf(" Magic: 0x%08X %s\n", config.magic, config.magic == CONFIG_MAGIC ? "OK" : "INVALID");
|
||||||
|
Serial.printf(" SSID: %s\n", config.ssid);
|
||||||
|
Serial.printf(" Timezone: %ld\n", config.timezone_offset);
|
||||||
|
Serial.printf(" Hostname: %s\n", config.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR saveConfig() {
|
||||||
|
EEPROM.begin(512);
|
||||||
|
EEPROM.put(0, config);
|
||||||
|
EEPROM.commit();
|
||||||
|
EEPROM.end();
|
||||||
|
|
||||||
|
Serial.println("Configuration saved!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ OTA setup ============
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR setupOTA() {
|
||||||
|
ArduinoOTA.setHostname(config.hostname);
|
||||||
|
|
||||||
|
ArduinoOTA.onStart([]() {
|
||||||
|
String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem";
|
||||||
|
Serial.println("Start OTA updating " + type);
|
||||||
|
clearDisplay();
|
||||||
|
showNumber(0, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.onEnd([]() {
|
||||||
|
Serial.println("\nOTA Update complete!");
|
||||||
|
showNumber(100, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
|
||||||
|
int percent = (progress / (total / 100));
|
||||||
|
Serial.printf("Progress: %u%%\r", percent);
|
||||||
|
showNumber(percent, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.onError([](ota_error_t error) {
|
||||||
|
Serial.printf("Error[%u]: ", error);
|
||||||
|
if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
|
||||||
|
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
|
||||||
|
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
|
||||||
|
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
|
||||||
|
else if (error == OTA_END_ERROR) Serial.println("End Failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.begin();
|
||||||
|
Serial.println("OTA ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Setup ============
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(100);
|
||||||
|
Serial.println("\n\nTJ-56-654 NTP Clock with OTA v" FIRMWARE_VERSION);
|
||||||
|
Serial.println("==========================================");
|
||||||
|
Serial.println("Display: GM009605v4.3 OLED 128x64 (SSD1306 I2C)");
|
||||||
|
|
||||||
|
// Initialize I2C
|
||||||
|
Wire.begin(I2C_SDA, I2C_SCL);
|
||||||
|
|
||||||
|
// Initialize OLED display
|
||||||
|
Serial.print("Initializing OLED at 0x");
|
||||||
|
Serial.println(OLED_ADDRESS, HEX);
|
||||||
|
|
||||||
|
if(!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
|
||||||
|
Serial.println("OLED initialization FAILED!");
|
||||||
|
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3D)) {
|
||||||
|
Serial.println("OLED not found at 0x3C or 0x3D!");
|
||||||
|
} else {
|
||||||
|
Serial.println("OLED found at 0x3D");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Serial.println("OLED initialized successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set display rotation
|
||||||
|
display.setRotation(config.display_orientation);
|
||||||
|
Serial.printf("Display rotation: %d (180 deg)\n", config.display_orientation);
|
||||||
|
|
||||||
|
// Show startup animation
|
||||||
|
Serial.println("Showing startup animation...");
|
||||||
|
showStartupAnimation();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// Check for triple power-cycle factory reset (must be after display+config init)
|
||||||
|
checkFactoryReset();
|
||||||
|
|
||||||
|
// Setup WiFi
|
||||||
|
setupWiFi();
|
||||||
|
|
||||||
|
// Setup OTA
|
||||||
|
setupOTA();
|
||||||
|
|
||||||
|
// Setup web server
|
||||||
|
setupWebServer();
|
||||||
|
|
||||||
|
// Setup NTP
|
||||||
|
timeClient = NTPClient(ntpUDP, config.ntp_server, 0, config.ntp_interval * 1000);
|
||||||
|
timeClient.begin();
|
||||||
|
|
||||||
|
// Test internet connectivity
|
||||||
|
testInternetConnectivity();
|
||||||
|
|
||||||
|
// Initialize timing to prevent immediate flicker/transition
|
||||||
|
lastBlinkTime = millis();
|
||||||
|
lastModeSwitch = millis();
|
||||||
|
colonBlink = true;
|
||||||
|
|
||||||
|
Serial.println("Setup complete!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Loop ============
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Handle OTA updates
|
||||||
|
ArduinoOTA.handle();
|
||||||
|
|
||||||
|
// Handle web server
|
||||||
|
server.handleClient();
|
||||||
|
MDNS.update();
|
||||||
|
|
||||||
|
// WiFi reconnection logic
|
||||||
|
static unsigned long lastWiFiCheck = 0;
|
||||||
|
if (millis() - lastWiFiCheck > 1000) {
|
||||||
|
lastWiFiCheck = millis();
|
||||||
|
|
||||||
|
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
|
||||||
|
Serial.println("WiFi disconnected!");
|
||||||
|
wifiConnState = WIFI_CONN_FAILED;
|
||||||
|
internetConnected = false;
|
||||||
|
wifiRetry.reset();
|
||||||
|
wifiRetry.scheduleRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wifiConnState == WIFI_CONN_FAILED && wifiRetry.isRetryTime()) {
|
||||||
|
Serial.printf("WiFi retry attempt (backoff level %d)...\n", wifiRetry.currentRetry);
|
||||||
|
|
||||||
|
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(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process async WiFi reconnection
|
||||||
|
processWiFiConnection();
|
||||||
|
|
||||||
|
// Process async NTP response
|
||||||
|
processNTPResponse();
|
||||||
|
|
||||||
|
// Check for NTP retry
|
||||||
|
if (ntpRetry.isRetryTime() && ntpState == NTP_IDLE) {
|
||||||
|
Serial.println("NTP retry time reached, attempting retry...");
|
||||||
|
sendNTPRequestAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger async NTP update periodically
|
||||||
|
unsigned long ntpInterval = config.ntp_interval * 1000UL;
|
||||||
|
if (millis() - lastNTPUpdate > ntpInterval || lastNTPUpdate == 0) {
|
||||||
|
if (ntpState == NTP_IDLE && !ntpRetry.isRetryTime()) {
|
||||||
|
sendNTPRequestAsync();
|
||||||
|
lastNTPUpdate = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeIsSynced || timeClient.isTimeSet()) {
|
||||||
|
calculateSunTimes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()) {
|
||||||
|
fetchWeatherAsync();
|
||||||
|
lastWeatherUpdate = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if IP display should be cleared
|
||||||
|
if (ipDisplayUntil > 0 && millis() >= ipDisplayUntil) {
|
||||||
|
clearDisplay();
|
||||||
|
ipDisplayUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display with rotation
|
||||||
|
updateDisplayRotation();
|
||||||
|
|
||||||
|
// Blink colon every second
|
||||||
|
if (millis() - lastBlinkTime > 500) {
|
||||||
|
colonBlink = !colonBlink;
|
||||||
|
lastBlinkTime = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
/*
|
||||||
|
* web_server.cpp - Web server handlers
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "globals.h"
|
||||||
|
#include <EEPROM.h>
|
||||||
|
#include <ESP8266mDNS.h>
|
||||||
|
|
||||||
|
// Dummy function for compatibility
|
||||||
|
void ICACHE_FLASH_ATTR displaySegments(const uint8_t segments[]) {
|
||||||
|
// Not used with OLED
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROGMEM templates for handleRoot()
|
||||||
|
const char ROOT_HTML_HEADER[] PROGMEM =
|
||||||
|
"<!DOCTYPE html><html><head>"
|
||||||
|
"<meta charset='UTF-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||||
|
"<title>TJ-56-654 Clock v" FIRMWARE_VERSION "</title>"
|
||||||
|
"<style>"
|
||||||
|
"body{font-family:Arial;margin:20px;background:#f0f0f0;}"
|
||||||
|
".container{max-width:600px;margin:0 auto;background:white;padding:20px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,0.1);}"
|
||||||
|
"h1{color:#333;}.time{font-size:48px;text-align:center;margin:20px 0;font-weight:bold;color:#0066cc;}"
|
||||||
|
".info{margin:10px 0;padding:10px;background:#f9f9f9;border-radius:5px;}"
|
||||||
|
".error{background:#ffebee;color:#c62828;}"
|
||||||
|
"a.button{display:inline-block;background:#0066cc;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;margin:10px 5px;}"
|
||||||
|
"a.button:hover{background:#0052a3;}"
|
||||||
|
".debug{background:#fff3cd;color:#856404;padding:10px;border-radius:5px;margin:10px 0;}"
|
||||||
|
"</style>"
|
||||||
|
"<script>"
|
||||||
|
"function updateTime(){fetch('/api/time').then(r=>r.json()).then(d=>{document.getElementById('time').innerText=d.time;});}"
|
||||||
|
"setInterval(updateTime,1000);updateTime();"
|
||||||
|
"</script>"
|
||||||
|
"</head><body>"
|
||||||
|
"<div class='container'>"
|
||||||
|
"<h1>TJ-56-654 NTP Clock v" FIRMWARE_VERSION "</h1>"
|
||||||
|
"<div class='time' id='time'>--:--:--</div>";
|
||||||
|
|
||||||
|
const char ROOT_HTML_FOOTER[] PROGMEM =
|
||||||
|
"<a href='/config' class='button'>Configuration</a>"
|
||||||
|
"<a href='/debug' class='button'>Debug Info</a>"
|
||||||
|
"<a href='/update' class='button'>Firmware Update</a>"
|
||||||
|
"<a href='/api/status' class='button'>Status (JSON)</a>"
|
||||||
|
"<button class='button' onclick=\"if(confirm('Reboot device?')) fetch('/api/reboot', {method:'POST'}).then(()=>alert('Rebooting...'))\">Reboot</button>"
|
||||||
|
"</div></body></html>";
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleRoot() {
|
||||||
|
char buf[150];
|
||||||
|
|
||||||
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "text/html", "");
|
||||||
|
|
||||||
|
server.sendContent_P(ROOT_HTML_HEADER);
|
||||||
|
|
||||||
|
if (lastError != "") {
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<div class='info error'><strong>Error:</strong> %s</div>"), lastError.c_str());
|
||||||
|
server.sendContent(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>WiFi:</strong> %s</div>"), WiFi.SSID().c_str());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>IP:</strong> %s</div>"), WiFi.localIP().toString().c_str());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>Hostname:</strong> %s.local</div>"), config.hostname);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>Uptime:</strong> %lu seconds</div>"), millis()/1000);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
if (!timeClient.isTimeSet()) {
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<div class='debug'><strong>NTP not synced yet</strong><br>Attempts: %d | Success: %d</div>"),
|
||||||
|
ntpAttempts, ntpSuccesses);
|
||||||
|
server.sendContent(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.sendContent_P(ROOT_HTML_FOOTER);
|
||||||
|
server.sendContent("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROGMEM templates for handleDebug()
|
||||||
|
const char DEBUG_HTML_HEADER[] PROGMEM =
|
||||||
|
"<!DOCTYPE html><html><head>"
|
||||||
|
"<meta charset='UTF-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||||
|
"<title>Debug Info</title>"
|
||||||
|
"<style>"
|
||||||
|
"body{font-family:monospace;margin:20px;background:#f0f0f0;}"
|
||||||
|
".container{max-width:800px;margin:0 auto;background:white;padding:20px;border-radius:10px;}"
|
||||||
|
".ok{color:green;}.fail{color:red;}"
|
||||||
|
"pre{background:#f5f5f5;padding:10px;border-radius:5px;overflow-x:auto;}"
|
||||||
|
"button{background:#0066cc;color:white;padding:10px 20px;border:none;border-radius:5px;cursor:pointer;margin:5px;}"
|
||||||
|
"</style>"
|
||||||
|
"</head><body>"
|
||||||
|
"<div class='container'>"
|
||||||
|
"<h1>Debug Information</h1>"
|
||||||
|
"<h2>Network</h2><pre>";
|
||||||
|
|
||||||
|
const char DEBUG_HTML_FOOTER[] PROGMEM =
|
||||||
|
"<h2>Actions</h2>"
|
||||||
|
"<button onclick=\"location.href='/test-ntp'\">Test NTP Now</button>"
|
||||||
|
"<button onclick=\"location.href='/test-display'\">Test Display (8888)</button>"
|
||||||
|
"<button onclick=\"location.reload()\">Refresh</button>"
|
||||||
|
"<button onclick=\"location.href='/api/config'\">Download Config (JSON)</button>"
|
||||||
|
"<button onclick=\"if(confirm('Clear EEPROM and reboot?')) fetch('/api/eeprom-clear', {method:'POST'}).then(()=>alert('Rebooting...'))\">Clear EEPROM</button>"
|
||||||
|
"<button onclick=\"location.href='/'\">Back</button>"
|
||||||
|
"</div></body></html>";
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleDebug() {
|
||||||
|
char buf[200];
|
||||||
|
|
||||||
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "text/html", "");
|
||||||
|
|
||||||
|
server.sendContent_P(DEBUG_HTML_HEADER);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("SSID: %s\nIP: %s\nGateway: %s\nDNS: %s\nRSSI: %d dBm\nHostname: %s\n</pre>"),
|
||||||
|
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.gatewayIP().toString().c_str(),
|
||||||
|
WiFi.dnsIP().toString().c_str(), WiFi.RSSI(), config.hostname);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2>Internet Connectivity</h2><pre>Status: "));
|
||||||
|
server.sendContent_P(internetConnected ? PSTR("<span class='ok'>Connected</span>\n</pre>") : PSTR("<span class='fail'>Not connected</span>\n</pre>"));
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2>NTP</h2><pre>"));
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Server: %s\nUpdate interval: %u seconds\nSynced: "), config.ntp_server, config.ntp_interval);
|
||||||
|
server.sendContent(buf);
|
||||||
|
server.sendContent_P(timeClient.isTimeSet() ? PSTR("<span class='ok'>Yes</span>\n") : PSTR("<span class='fail'>No</span>\n"));
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("UTC time: %s\n"), timeClient.getFormattedTime().c_str());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
if (timeClient.isTimeSet()) {
|
||||||
|
unsigned long epochTime = timeClient.getEpochTime();
|
||||||
|
unsigned long localTime = epochTime + getTotalOffset(epochTime);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Local time: %02d:%02d:%02d\n"), (int)((localTime/3600)%24), (int)((localTime/60)%60), (int)(localTime%60));
|
||||||
|
server.sendContent(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Attempts: %d\nSuccesses: %d\nLast error: %s\n</pre>"), ntpAttempts, ntpSuccesses, lastError.c_str());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2>Timezone & DST</h2><pre>"));
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Base offset: %.1f hours (%ld seconds)\nDST enabled: %s\n"),
|
||||||
|
config.timezone_offset/3600.0, config.timezone_offset, config.dst_enabled ? "Yes" : "No");
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
if (config.dst_enabled && timeClient.isTimeSet()) {
|
||||||
|
unsigned long epochTime = timeClient.getEpochTime();
|
||||||
|
bool inDST = isDST(epochTime);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("DST active now: %s\nTotal offset: %.1f hours\n"),
|
||||||
|
inDST ? "<span class='ok'>Yes (+1 hour)</span>" : "No", getTotalOffset(epochTime)/3600.0);
|
||||||
|
server.sendContent(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Time format: %s\n</pre>"), config.hour_format_24 ? "24-hour" : "12-hour (AM/PM)");
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2>Weather</h2><pre>"));
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\nValid data: %s\n"),
|
||||||
|
config.weather_enabled ? "Yes" : "No",
|
||||||
|
weather.valid ? "<span class='ok'>Yes</span>" : "<span class='fail'>No</span>");
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
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, (millis() - weather.lastUpdate)/1000);
|
||||||
|
server.sendContent(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("City: %s\nLocation: %.6f, %.6f\nUpdate interval: %u seconds\n</pre>"),
|
||||||
|
config.city_name, config.latitude, config.longitude, config.weather_interval);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2>Sunrise/Sunset</h2><pre>"));
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\n"), config.show_sunrise_sunset ? "Yes" : "No");
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
if (sunTimes.lastDay != -1) {
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<span class='ok'>Data available</span>\nSunrise: %s (%d min)\nSunset: %s (%d min)\nLast update day: %d\n</pre>"),
|
||||||
|
sunTimes.sunrise, sunTimes.sunriseMinutes, sunTimes.sunset, sunTimes.sunsetMinutes, sunTimes.lastDay);
|
||||||
|
} else {
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<span class='fail'>No data</span>\n</pre>"));
|
||||||
|
}
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2>Display</h2><pre>"));
|
||||||
|
const char* modeStr = (displayMode == 0) ? " (Time)" : (displayMode == 1) ? " (Weather)" : " (Sun times)";
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Current mode: %d%s\nRotation interval: %u seconds\nBrightness: %d (0-7)\nShow weather: %s\nShow sun times: %s\nNTP synced: %s\n</pre>"),
|
||||||
|
displayMode, modeStr, config.display_rotation_sec, config.brightness,
|
||||||
|
config.show_weather ? "Yes" : "No", config.show_sunrise_sunset ? "Yes" : "No",
|
||||||
|
timeClient.isTimeSet() ? "<span class='ok'>Yes</span>" : "<span class='fail'>No</span>");
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2>System</h2><pre>"));
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("Uptime: %lu seconds\nFree heap: %u bytes\nChip ID: %X\nFlash size: %u bytes\nSDK version: %s\n</pre>"),
|
||||||
|
millis()/1000, ESP.getFreeHeap(), ESP.getChipId(), ESP.getFlashChipSize(), ESP.getSdkVersion());
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(DEBUG_HTML_FOOTER);
|
||||||
|
server.sendContent("");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleTestNTP() {
|
||||||
|
testInternetConnectivity();
|
||||||
|
updateNTPTime();
|
||||||
|
|
||||||
|
server.sendHeader("Location", "/debug");
|
||||||
|
server.send(303);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleTestDisplay() {
|
||||||
|
uint8_t data[] = {0xFF, 0xFF, 0xFF, 0xFF};
|
||||||
|
displaySegments(data);
|
||||||
|
delay(3000);
|
||||||
|
|
||||||
|
server.sendHeader("Location", "/debug");
|
||||||
|
server.send(303);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROGMEM templates for handleConfig()
|
||||||
|
const char CONFIG_HTML_HEADER[] PROGMEM =
|
||||||
|
"<!DOCTYPE html><html><head>"
|
||||||
|
"<meta charset='UTF-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||||
|
"<title>Configuration</title>"
|
||||||
|
"<style>"
|
||||||
|
"body{font-family:Arial;margin:20px;background:#f0f0f0;}"
|
||||||
|
".container{max-width:600px;margin:0 auto;background:white;padding:20px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,0.1);}"
|
||||||
|
"input,select{width:100%;padding:8px;margin:5px 0 15px 0;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;}"
|
||||||
|
"button{background:#0066cc;color:white;padding:12px 20px;border:none;border-radius:5px;cursor:pointer;width:100%;}"
|
||||||
|
"button:hover{background:#0052a3;}"
|
||||||
|
"label{font-weight:bold;}"
|
||||||
|
"</style>"
|
||||||
|
"</head><body>"
|
||||||
|
"<div class='container'>"
|
||||||
|
"<h1>Configuration</h1>"
|
||||||
|
"<form method='POST' action='/config'>";
|
||||||
|
|
||||||
|
const char CONFIG_HTML_FOOTER[] PROGMEM =
|
||||||
|
"<button type='submit'>Save & Reboot</button>"
|
||||||
|
"</form>"
|
||||||
|
"<p><a href='/'>Back to Home</a></p>"
|
||||||
|
"</div></body></html>";
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleConfig() {
|
||||||
|
char buf[150];
|
||||||
|
|
||||||
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "text/html", "");
|
||||||
|
|
||||||
|
server.sendContent_P(CONFIG_HTML_HEADER);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>WiFi SSID:</label><input type='text' name='ssid' value='%s' required>"), config.ssid);
|
||||||
|
server.sendContent(buf);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>WiFi Password:</label><input type='password' name='password' value='%s'>"), config.password);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>Timezone Offset (seconds):</label><input type='number' name='timezone' value='%ld'>"), config.timezone_offset);
|
||||||
|
server.sendContent(buf);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>Brightness (0-7):</label><input type='number' name='brightness' min='0' max='7' value='%d'>"), config.brightness);
|
||||||
|
server.sendContent(buf);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>Hostname:</label><input type='text' name='hostname' value='%s'>"), config.hostname);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2 style='margin-top:20px;'>Weather Settings</h2>"));
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>City Name:</label><input type='text' name='city_name' value='%s'>"), config.city_name);
|
||||||
|
server.sendContent(buf);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>Latitude:</label><input type='number' step='0.000001' name='latitude' value='%.6f'>"), config.latitude);
|
||||||
|
server.sendContent(buf);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>Longitude:</label><input type='number' step='0.000001' name='longitude' value='%.6f'>"), config.longitude);
|
||||||
|
server.sendContent(buf);
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>Weather Update Interval (seconds):</label><input type='number' name='weather_interval' value='%u'>"), config.weather_interval);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(PSTR("<h2 style='margin-top:20px;'>Display Settings</h2>"));
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<label>Screen Rotation Interval (seconds):</label><input type='number' name='display_rotation_sec' value='%u'>"), config.display_rotation_sec);
|
||||||
|
server.sendContent(buf);
|
||||||
|
|
||||||
|
server.sendContent_P(CONFIG_HTML_FOOTER);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return !allSame; // reject "AAAAA...", "BBBBB...", etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleConfigSave() {
|
||||||
|
// Validate before saving — reject obviously bad input rather than brick the device
|
||||||
|
if (server.hasArg("ssid")) {
|
||||||
|
String s = server.arg("ssid");
|
||||||
|
if (!isValidSSID(s)) {
|
||||||
|
server.send(400, "text/plain", "Invalid SSID (1-31 printable chars, not all-same)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeStringCopy(s, config.ssid, sizeof(config.ssid));
|
||||||
|
}
|
||||||
|
if (server.hasArg("password")) {
|
||||||
|
String p = server.arg("password");
|
||||||
|
if (p.length() > 63) {
|
||||||
|
server.send(400, "text/plain", "Password too long (max 63 chars)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeStringCopy(p, config.password, sizeof(config.password));
|
||||||
|
}
|
||||||
|
if (server.hasArg("timezone")) {
|
||||||
|
long tz = server.arg("timezone").toInt();
|
||||||
|
config.timezone_offset = constrain(tz, -43200L, 43200L); // ±12h
|
||||||
|
}
|
||||||
|
if (server.hasArg("brightness")) {
|
||||||
|
config.brightness = constrain(server.arg("brightness").toInt(), 0, 7);
|
||||||
|
}
|
||||||
|
if (server.hasArg("hostname")) {
|
||||||
|
String h = server.arg("hostname");
|
||||||
|
if (h.length() == 0 || h.length() > 31) {
|
||||||
|
server.send(400, "text/plain", "Invalid hostname length (1-31)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeStringCopy(h, config.hostname, sizeof(config.hostname));
|
||||||
|
}
|
||||||
|
if (server.hasArg("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")) {
|
||||||
|
float lat = server.arg("latitude").toFloat();
|
||||||
|
config.latitude = constrain(lat, -90.0f, 90.0f);
|
||||||
|
}
|
||||||
|
if (server.hasArg("longitude")) {
|
||||||
|
float lon = server.arg("longitude").toFloat();
|
||||||
|
config.longitude = constrain(lon, -180.0f, 180.0f);
|
||||||
|
}
|
||||||
|
if (server.hasArg("weather_interval")) {
|
||||||
|
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();
|
||||||
|
config.ntp_interval = constrain(ni, 60L, 86400L);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
safeStringCopy(n, config.ntp_server, sizeof(config.ntp_server));
|
||||||
|
}
|
||||||
|
if (server.hasArg("display_rotation_sec")) {
|
||||||
|
config.display_rotation_sec = constrain(server.arg("display_rotation_sec").toInt(), 1, 60);
|
||||||
|
}
|
||||||
|
if (server.hasArg("display_orientation")) {
|
||||||
|
config.display_orientation = constrain(server.arg("display_orientation").toInt(), 0, 3);
|
||||||
|
display.setRotation(config.display_orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
String html = F("<!DOCTYPE html><html><head>");
|
||||||
|
html += F("<meta charset='UTF-8'>");
|
||||||
|
html += F("<meta http-equiv='refresh' content='5;url=/'>");
|
||||||
|
html += F("<style>body{font-family:Arial;text-align:center;margin-top:50px;}</style>");
|
||||||
|
html += F("</head><body>");
|
||||||
|
html += F("<h1>Configuration Saved!</h1>");
|
||||||
|
html += F("<p>Device will reboot in 5 seconds...</p>");
|
||||||
|
html += F("</body></html>");
|
||||||
|
|
||||||
|
server.send(200, "text/html", html);
|
||||||
|
|
||||||
|
delay(1000);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleAPITime() {
|
||||||
|
char buf[256];
|
||||||
|
|
||||||
|
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() {
|
||||||
|
char buf[256];
|
||||||
|
|
||||||
|
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() {
|
||||||
|
char buf[256];
|
||||||
|
char errBuf[80];
|
||||||
|
|
||||||
|
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() {
|
||||||
|
char buf[256];
|
||||||
|
|
||||||
|
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() {
|
||||||
|
String json = "{";
|
||||||
|
json += "\"firmware_version\":\"" FIRMWARE_VERSION "\",";
|
||||||
|
json += "\"magic\":\"0x" + String(config.magic, HEX) + "\",";
|
||||||
|
json += "\"ssid\":\"" + String(config.ssid) + "\",";
|
||||||
|
json += "\"password\":\"" + String(config.password) + "\",";
|
||||||
|
json += "\"timezone_offset\":" + String(config.timezone_offset) + ",";
|
||||||
|
json += "\"dst_enabled\":" + String(config.dst_enabled ? "true" : "false") + ",";
|
||||||
|
json += "\"brightness\":" + String(config.brightness) + ",";
|
||||||
|
json += "\"ntp_server\":\"" + String(config.ntp_server) + "\",";
|
||||||
|
json += "\"ntp_interval\":" + String(config.ntp_interval) + ",";
|
||||||
|
json += "\"hour_format_24\":" + String(config.hour_format_24 ? "true" : "false") + ",";
|
||||||
|
json += "\"hostname\":\"" + String(config.hostname) + "\",";
|
||||||
|
json += "\"latitude\":" + String(config.latitude, 6) + ",";
|
||||||
|
json += "\"longitude\":" + String(config.longitude, 6) + ",";
|
||||||
|
json += "\"city_name\":\"" + String(config.city_name) + "\",";
|
||||||
|
json += "\"weather_enabled\":" + String(config.weather_enabled ? "true" : "false") + ",";
|
||||||
|
json += "\"weather_interval\":" + String(config.weather_interval) + ",";
|
||||||
|
json += "\"display_rotation_sec\":" + String(config.display_rotation_sec) + ",";
|
||||||
|
json += "\"show_weather\":" + String(config.show_weather ? "true" : "false") + ",";
|
||||||
|
json += "\"show_sunrise_sunset\":" + String(config.show_sunrise_sunset ? "true" : "false");
|
||||||
|
json += "}";
|
||||||
|
|
||||||
|
server.sendHeader("Content-Disposition", "attachment; filename=clock-config.json");
|
||||||
|
server.send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleAPIConfigImport() {
|
||||||
|
if (!server.hasArg("plain")) {
|
||||||
|
server.send(400, "text/plain", "No config data received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String body = server.arg("plain");
|
||||||
|
Serial.println("Received config: " + body);
|
||||||
|
|
||||||
|
// Parse various fields (simplified parsing)
|
||||||
|
int pos;
|
||||||
|
|
||||||
|
pos = body.indexOf("\"ssid\":\"");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 8;
|
||||||
|
int end = body.indexOf("\"", start);
|
||||||
|
if (end > start) {
|
||||||
|
safeStringCopy(body.substring(start, end), config.ssid, sizeof(config.ssid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = body.indexOf("\"password\":\"");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 12;
|
||||||
|
int end = body.indexOf("\"", start);
|
||||||
|
if (end > start) {
|
||||||
|
safeStringCopy(body.substring(start, end), config.password, sizeof(config.password));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = body.indexOf("\"timezone_offset\":");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 18;
|
||||||
|
int end = body.indexOf(",", start);
|
||||||
|
if (end < 0) end = body.indexOf("}", start);
|
||||||
|
if (end > start) {
|
||||||
|
config.timezone_offset = body.substring(start, end).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = body.indexOf("\"brightness\":");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 13;
|
||||||
|
int end = body.indexOf(",", start);
|
||||||
|
if (end < 0) end = body.indexOf("}", start);
|
||||||
|
if (end > start) {
|
||||||
|
config.brightness = body.substring(start, end).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = body.indexOf("\"hostname\":\"");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 12;
|
||||||
|
int end = body.indexOf("\"", start);
|
||||||
|
if (end > start) {
|
||||||
|
safeStringCopy(body.substring(start, end), config.hostname, sizeof(config.hostname));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = body.indexOf("\"city_name\":\"");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 13;
|
||||||
|
int end = body.indexOf("\"", start);
|
||||||
|
if (end > start) {
|
||||||
|
safeStringCopy(body.substring(start, end), config.city_name, sizeof(config.city_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = body.indexOf("\"latitude\":");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 11;
|
||||||
|
int end = body.indexOf(",", start);
|
||||||
|
if (end < 0) end = body.indexOf("}", start);
|
||||||
|
if (end > start) {
|
||||||
|
config.latitude = body.substring(start, end).toFloat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = body.indexOf("\"longitude\":");
|
||||||
|
if (pos >= 0) {
|
||||||
|
int start = pos + 12;
|
||||||
|
int end = body.indexOf(",", start);
|
||||||
|
if (end < 0) end = body.indexOf("}", start);
|
||||||
|
if (end > start) {
|
||||||
|
config.longitude = body.substring(start, end).toFloat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.magic = CONFIG_MAGIC;
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Config imported and saved. Reboot recommended.\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleEEPROMClear() {
|
||||||
|
EEPROM.begin(512);
|
||||||
|
for (int i = 0; i < 512; i++) {
|
||||||
|
EEPROM.write(i, 0xFF);
|
||||||
|
}
|
||||||
|
EEPROM.commit();
|
||||||
|
EEPROM.end();
|
||||||
|
|
||||||
|
Serial.println("EEPROM cleared!");
|
||||||
|
|
||||||
|
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"EEPROM cleared, device will reboot\"}");
|
||||||
|
|
||||||
|
delay(1000);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleReboot() {
|
||||||
|
Serial.println("Reboot requested via web interface");
|
||||||
|
|
||||||
|
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Device rebooting...\"}");
|
||||||
|
|
||||||
|
delay(1000);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR handleI2CScan() {
|
||||||
|
String json = "{\"i2c_scan\":{\"devices\":[";
|
||||||
|
|
||||||
|
int deviceCount = 0;
|
||||||
|
|
||||||
|
for (uint8_t address = 0x08; address <= 0x77; address++) {
|
||||||
|
Wire.beginTransmission(address);
|
||||||
|
uint8_t error = Wire.endTransmission();
|
||||||
|
|
||||||
|
if (error == 0) {
|
||||||
|
if (deviceCount > 0) json += ",";
|
||||||
|
json += "{\"address\":\"0x";
|
||||||
|
if (address < 16) json += "0";
|
||||||
|
json += String(address, HEX);
|
||||||
|
json += "\",\"decimal\":" + String(address) + "}";
|
||||||
|
deviceCount++;
|
||||||
|
}
|
||||||
|
delay(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
json += "],\"count\":" + String(deviceCount);
|
||||||
|
|
||||||
|
json += ",\"oled_test\":{";
|
||||||
|
Wire.beginTransmission(0x3C);
|
||||||
|
json += "\"0x3C\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\",";
|
||||||
|
Wire.beginTransmission(0x3D);
|
||||||
|
json += "\"0x3D\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\"";
|
||||||
|
json += "}}}";
|
||||||
|
|
||||||
|
Serial.println("I2C Scan results: " + json);
|
||||||
|
|
||||||
|
server.send(200, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup web server
|
||||||
|
void ICACHE_FLASH_ATTR setupWebServer() {
|
||||||
|
httpUpdater.setup(&server, "/update", "admin", "admin");
|
||||||
|
|
||||||
|
server.on("/", HTTP_GET, handleRoot);
|
||||||
|
server.on("/config", HTTP_GET, handleConfig);
|
||||||
|
server.on("/config", HTTP_POST, handleConfigSave);
|
||||||
|
server.on("/debug", HTTP_GET, handleDebug);
|
||||||
|
server.on("/test-ntp", HTTP_GET, handleTestNTP);
|
||||||
|
server.on("/test-display", HTTP_GET, handleTestDisplay);
|
||||||
|
server.on("/api/time", HTTP_GET, handleAPITime);
|
||||||
|
server.on("/api/status", HTTP_GET, handleAPIStatus);
|
||||||
|
server.on("/api/debug", HTTP_GET, handleAPIDebug);
|
||||||
|
server.on("/api/weather", HTTP_GET, handleAPIWeather);
|
||||||
|
server.on("/api/config", HTTP_GET, handleAPIConfigExport);
|
||||||
|
server.on("/api/config", HTTP_POST, handleAPIConfigImport);
|
||||||
|
server.on("/api/eeprom-clear", HTTP_POST, handleEEPROMClear);
|
||||||
|
server.on("/api/reboot", HTTP_POST, handleReboot);
|
||||||
|
server.on("/api/i2c-scan", HTTP_GET, handleI2CScan);
|
||||||
|
|
||||||
|
server.begin();
|
||||||
|
Serial.println("Web server started");
|
||||||
|
|
||||||
|
if (MDNS.begin(config.hostname)) {
|
||||||
|
Serial.printf("mDNS responder started: %s.local\n", config.hostname);
|
||||||
|
MDNS.addService("http", "tcp", 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/*
|
||||||
|
* wifi_manager.cpp - WiFi connection management
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "globals.h"
|
||||||
|
#include <WiFiManager.h>
|
||||||
|
|
||||||
|
// Async WiFi - Process connection (call in loop)
|
||||||
|
void ICACHE_FLASH_ATTR processWiFiConnection() {
|
||||||
|
if (wifiConnState != WIFI_CONN_CONNECTING) return;
|
||||||
|
|
||||||
|
// Check connection status
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
wifiRetry.reset();
|
||||||
|
internetConnected = true;
|
||||||
|
Serial.println("\nWiFi connected!");
|
||||||
|
Serial.print("SSID: ");
|
||||||
|
Serial.println(WiFi.SSID());
|
||||||
|
Serial.print("IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
|
||||||
|
// Disable fallback AP if it was enabled
|
||||||
|
if (WiFi.getMode() == WIFI_AP_STA) {
|
||||||
|
Serial.println("Disabling fallback AP (back to STA mode)");
|
||||||
|
WiFi.softAPdisconnect(true);
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync connected SSID to config
|
||||||
|
if (strlen(config.ssid) == 0) {
|
||||||
|
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
showIP();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check timeout for this attempt
|
||||||
|
if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) {
|
||||||
|
wifiRetry.scheduleRetry();
|
||||||
|
unsigned long nextRetryMs = wifiRetry.getBackoffDelay();
|
||||||
|
|
||||||
|
Serial.printf("\nWiFi connection failed. Retry in %lu seconds\n", nextRetryMs / 1000);
|
||||||
|
|
||||||
|
wifiConnState = WIFI_CONN_FAILED;
|
||||||
|
internetConnected = false;
|
||||||
|
|
||||||
|
showNoWiFi(nextRetryMs / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still connecting
|
||||||
|
static unsigned long lastDot = 0;
|
||||||
|
if (millis() - lastDot > 500) {
|
||||||
|
Serial.print(".");
|
||||||
|
lastDot = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi setup (SYNCHRONOUS in setup(), async reconnect in loop())
|
||||||
|
void ICACHE_FLASH_ATTR setupWiFi() {
|
||||||
|
Serial.println("WiFi Setup - Synchronous for initial connection");
|
||||||
|
|
||||||
|
WiFi.hostname(config.hostname);
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
|
||||||
|
// Try 1: Use WiFi.begin() without params - only when no SSID is configured.
|
||||||
|
// Skipped if user has a saved SSID: SDK-cached credentials may include open
|
||||||
|
// networks (e.g. public hotspots) that would be preferred over the user's
|
||||||
|
// network, and a successful connect would overwrite config.ssid (issue #3).
|
||||||
|
if (strlen(config.ssid) == 0) {
|
||||||
|
Serial.println("No SSID configured, trying SDK-stored credentials...");
|
||||||
|
WiFi.begin();
|
||||||
|
|
||||||
|
// SYNCHRONOUS wait for connection (max 10 seconds)
|
||||||
|
Serial.print("Connecting to WiFi");
|
||||||
|
int attempts = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||||
|
showWiFiConnecting(attempts);
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.println("\nWiFi connected!");
|
||||||
|
Serial.print("SSID: ");
|
||||||
|
Serial.println(WiFi.SSID());
|
||||||
|
Serial.print("IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
Serial.print("Gateway: ");
|
||||||
|
Serial.println(WiFi.gatewayIP());
|
||||||
|
Serial.print("DNS: ");
|
||||||
|
Serial.println(WiFi.dnsIP());
|
||||||
|
|
||||||
|
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
showIP();
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 2: If we have EEPROM credentials, try those
|
||||||
|
if (strlen(config.ssid) > 0 && strlen(config.password) > 0) {
|
||||||
|
Serial.println("\nTrying EEPROM credentials...");
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
|
||||||
|
int attempts = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||||
|
showWiFiConnecting(attempts);
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.println("\nWiFi connected via EEPROM credentials!");
|
||||||
|
showIP();
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No stored credentials - use WiFiManager
|
||||||
|
if (strlen(config.ssid) == 0) {
|
||||||
|
Serial.println("\nNo saved credentials, using WiFiManager...");
|
||||||
|
WiFiManager wifiManager;
|
||||||
|
wifiManager.setConfigPortalTimeout(180);
|
||||||
|
|
||||||
|
// Show AP mode indicator
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setCursor(25, 15);
|
||||||
|
display.print("Setup Mode");
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setCursor(10, 35);
|
||||||
|
display.print("Connect to WiFi:");
|
||||||
|
display.setCursor(10, 48);
|
||||||
|
display.print("TJ56654-Setup");
|
||||||
|
display.display();
|
||||||
|
|
||||||
|
Serial.println("Attempting WiFiManager auto-connect...");
|
||||||
|
if (!wifiManager.autoConnect("TJ56654-Setup", "12345678")) {
|
||||||
|
Serial.println("WiFi connection failed. Starting fallback AP...");
|
||||||
|
WiFi.mode(WIFI_AP);
|
||||||
|
WiFi.softAP("TJ56654-Clock", "12345678");
|
||||||
|
Serial.print("Fallback AP IP: ");
|
||||||
|
Serial.println(WiFi.softAPIP());
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("WiFi connected via WiFiManager!");
|
||||||
|
Serial.print("SSID: ");
|
||||||
|
Serial.println(WiFi.SSID());
|
||||||
|
Serial.print("IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
|
||||||
|
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
showIP();
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have credentials but WiFi is not available
|
||||||
|
Serial.println("\nWiFi not available. Will retry in background.");
|
||||||
|
wifiConnState = WIFI_CONN_FAILED;
|
||||||
|
wifiRetry.scheduleRetry();
|
||||||
|
showNoWiFi(wifiRetry.getBackoffDelay() / 1000);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
|||||||
|
#!/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 'current' field", "current" in data, str(data.keys()))
|
||||||
|
if "current" in data:
|
||||||
|
t = data["current"]
|
||||||
|
test("Time format HH:MM:SS", len(t) == 8 and t[2] == ":" and t[5] == ":",
|
||||||
|
f"got '{t}'")
|
||||||
|
h, m, s = int(t[:2]), int(t[3:5]), int(t[6:])
|
||||||
|
test("Hour in 0-23", 0 <= h <= 23, f"h={h}")
|
||||||
|
test("Minute in 0-59", 0 <= m <= 59, f"m={m}")
|
||||||
|
test("Second in 0-59", 0 <= s <= 59, f"s={s}")
|
||||||
|
test("Has 'timezone_offset'", "timezone_offset" in data)
|
||||||
|
test("Has 'ntp_synced'", "ntp_synced" in data)
|
||||||
|
|
||||||
|
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