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

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

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

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

Fixes #12

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:25:32 +01:00
Alex Petrochenko e9dc158a4e chore: bump version to 1.9.6, update CHANGELOG 2026-05-18 16:02:53 +01:00
Alex Petrochenko 7ebe341c06 fix(web): clamp lastError before snprintf to prevent malformed JSON (Gemini review) 2026-05-18 15:57:41 +01:00
Alex PetrochenkoandClaude Sonnet 4.6 78bbd96f45 fix: C1 C2 H1 H2 H3 — stability fixes for long-running operation
C1 (critical): Add 15s watchdog for WEATHER_REQUESTING state — resets to
IDLE if TCP connection hangs silently, preventing permanent weather death

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Applied manually due to repo layout mismatch in original PR.

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

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

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

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

Co-authored-by: Mysteoa <mysteoa@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:20:08 +01:00
Alex PetrochenkoandClaude Opus 4.5 9c0f269ee5 chore: bump version to 1.9.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:44:57 +00:00
Alex PetrochenkoandClaude Opus 4.5 abf3a513c9 docs: update documentation for v1.9.2
- CHANGELOG.md: Add v1.9.2 WiFi resilience changes
- README.md: Update journey section, memory stats, project structure
- docs/v1.9.1_HYBRID_FIX.md: Minor formatting fixes
- docs/v1.9.2_WIFI_RESILIENCE.md: New detailed documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:19:19 +00:00
Alex PetrochenkoandClaude Opus 4.5 4a7e889052 refactor: split monolithic .ino into modular structure + improve boot UX
## Code Structure
- Split 2172-line .ino into 7 modules:
  - config.h - structs, enums, constants
  - globals.h - extern declarations, function prototypes
  - display.cpp - OLED display functions, Thanos dissolve effect
  - ntp_client.cpp - NTP sync, DST calculation
  - wifi_manager.cpp - WiFi connection management
  - weather.cpp - Open-Meteo API (async)
  - web_server.cpp - Web interface handlers
  - clock_ntp_ota_v1.9.ino - setup/loop, EEPROM, OTA (153 lines)

## Boot UX Improvements
- Remove meaningless "READY" screen
- Replace cryptic counter (0-7) with "WiFi..." + animated dots
- Show SSID + IP on connected screen
- Use yellow zone (Y 48-63) for status text
- Fix text centering on all boot screens
- Initialize lastBlinkTime/lastModeSwitch to prevent first-frame glitch

## Layout (dual-color display)
- Blue zone (Y 0-47): main content
- Yellow zone (Y 48-63): status/version
- Centering formula: width = n×5 + (n-1)×1 for size 1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:05:45 +00:00
Alex PetrochenkoandAlex Petrochenko c3bfaa26bc feat(wifi): v1.9.2 - WiFi resilience and credential persistence (#1)
## Problem
Previous versions would clear WiFi credentials on connection failure,
requiring manual reconfiguration via captive portal after each WiFi outage.

## Solution
- Never clear credentials on connection failure
- Infinite retry with exponential backoff (5s → 10s → 20s → ... → 5min max)
- Fallback AP ("TJ56654-Setup") enabled after ~5 min of failed attempts
- Dual STA+AP mode allows configuration while still retrying connection
- AP automatically disabled when WiFi reconnects

## Changes

### WiFi Credential Handling
- Try SDK-stored credentials first (from WiFiManager)
- Fall back to EEPROM config if SDK credentials fail
- Fixes OTA update credential loss issue

### User Experience
- Display shows "No WiFi" with retry countdown instead of cryptic numbers
- "!" indicator in date line when WiFi is disconnected
- Clock continues running with last synced time during outage

### Network Activity (~50 requests/day)
| Service | Interval | Endpoint |
|---------|----------|----------|
| NTP | 1 hour | pool.ntp.org:123 (UDP) |
| Weather | 30 min | api.open-meteo.com (HTTP) |
| mDNS | continuous | 224.0.0.251 (multicast) |

Co-authored-by: Alex Petrochenko <petrochenko@life-pay.ru>
2026-01-06 20:51:42 +00:00
Alex Petrochenko 0f2860c25e Add comprehensive project context documentation
Complete context file with:
- Full development history (v1.5 → v1.9.1)
- All technical details and architecture
- Security issues and fixes
- Hardware specifications and pinout
- Current device configuration
- Next steps (Home Assistant integration)
- Key learnings from project
- Quick reference commands

This file serves as complete knowledge base for:
- Resuming work after long breaks
- Onboarding new contributors
- Understanding project evolution
- Reference for future features
2026-01-03 15:59:24 +00:00
Alex Petrochenko 220544583a Update price: $12 → €5
The device actually costs €5 on AliExpress, not $12.
Updated all price mentions in README.
2026-01-03 15:26:50 +00:00
Alex Petrochenko f2dc2fb961 Add dynamic GitHub badges to README
- Release version badge (auto-updates)
- License badge (MIT)
- Build status badge (CI/CD)
- Issues count badge
- Hardware and status badges
2026-01-03 15:25:16 +00:00
23 changed files with 3424 additions and 3623 deletions
-73
View File
@@ -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
+4
View File
@@ -1,5 +1,6 @@
# Arduino build artifacts
build/
.pio/
*.bin
*.elf
*.map
@@ -25,3 +26,6 @@ build/
# Secrets (in case someone accidentally commits credentials)
secrets.h
config_local.h
# Internal backlogs (not for public repo)
BACKLOG_*.md
+169 -6
View File
@@ -5,25 +5,181 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.9.10] - 2026-09-20
### Fixed
- **WiFi password lost after captive-portal setup, no reconnect after reboot** (#12):
after a successful connect via SDK-cached credentials (Try 1) or the WiFiManager
portal, only the SSID was written to EEPROM. On the next boot `config.password`
was empty, so `setupWiFi()` and the reconnect loop called `WiFi.begin(ssid)` as
if the network were open and failed with `WL_WRONG_PASSWORD`. v1.9.6 masked this
through the bare `WiFi.begin()` fallback in the retry loop, which 02834ba (v1.9.7)
replaced with `WiFi.begin(config.ssid)`. Now the password is read back with
`WiFi.psk()` and saved next to the SSID in all three places that sync the SSID.
Devices already stuck recover by entering the password once in the web UI.
## [1.9.9] - 2026-05-19
### Fixed
- **Factory reset atomicity**: cleared WiFi credentials are now committed BEFORE
the reset counter is zeroed. If power fails between the two writes, the device
still boots into WiFiManager AP on next start (cleared creds win over stale counter)
instead of being stuck in an inconsistent state
- **`isValidSSID` rejected single-char SSIDs**: length-1 networks like "A" were
incorrectly flagged as all-same garbage. Now allowed (per 802.11 spec)
- **`ntp_interval` config changes ignored until reboot**: `NTPClient` is constructed
in `setup()` with this value and never re-initialized. Now triggers `needsRestart`
when the interval actually changes
- **Open WiFi (no password) failed to connect at setup** (M2): Try-2 block in
`setupWiFi()` required both ssid and password; now falls back to `WiFi.begin(ssid)`
when password is empty
- **Unseeded PRNG, identical dissolve pattern every boot** (M4): `randomSeed()`
now called with `ESP.getChipId() ^ micros()` — varies between devices and boots
### Removed
- **Dead `NTPState` enum values** (M3): `NTP_WAITING`, `NTP_SUCCESS`, `NTP_FAILED`
were defined but never assigned. Removed to reduce code surface area
## [1.9.8] - 2026-05-19
### Fixed
- **handleConfigSave always rebooted device on save**: only reboots now if WiFi/network
fields changed (`ssid`, `password`, `hostname`, `ntp_server`). Other settings
(brightness, timezone, intervals, coordinates, display options) apply live without
restart. Eliminates reboot cascades during config tweaks and makes the test suite
safe to run repeatedly.
## [1.9.7] - 2026-05-19
### Fixed
- **Config endpoint accepted garbage values, bricking device** (M1): `/config` form handler
now validates all inputs. SSID rejected if empty, >31 chars, non-printable, or all-same-char
(fuzz garbage like "AAAA..."). Numeric fields are `constrain()`-ed to safe ranges
(`ntp_interval`/`weather_interval`: 60–86400, `brightness`: 0–7, `timezone`: ±12h,
`latitude`/`longitude`: physical ranges, `display_orientation`: 0–3). Invalid input
returns HTTP 400 instead of silently saving and rebooting.
## [1.9.6] - 2026-05-18
### Fixed
- **Weather hangs permanently after TCP timeout** (C1): `WEATHER_REQUESTING` state now has a
15-second watchdog — if the HTTP callback never fires (NAT timeout, server half-close),
state resets to IDLE and retry logic resumes
- **All retry timers freeze at ~49 days** (C2): Replaced unsafe `millis() >= nextRetryTime`
with subtraction-safe `(millis() - nextRetryTime) < 0x80000000UL` in RetryConfig and
WiFiRetryConfig; fixed boot guard with static flag
- **DST switches on wrong day** (H1): Last-Sunday formula was using year-only heuristic;
now computes weekday of the 31st from current `tm_wday`: verified correct for 2026–2030
- **Heap fragmentation from web API polling** (H2): Replaced String+= concatenation with
`snprintf`+`sendContent` in `handleAPITime`, `handleAPIStatus`, `handleAPIDebug`,
`handleAPIWeather` — eliminates permanent heap fragmentation from 1s JS polling
- **Race condition on shared state** (H3): Added `volatile` to `weatherState` and `ntpState`
— prevents compiler from caching stale values across ESPAsyncTCP callback boundaries
- **Malformed JSON on long error messages**: `lastError` clamped to 79 chars before snprintf
### Changed
- RAM usage: 37,268 bytes (46%) — down from 37,560 due to String elimination in API handlers
## [1.9.5] - 2026-05-18
### Fixed
- **Temperature disappears permanently** (#9): After 3 consecutive API failures,
the weather state machine was permanently locked — `weatherState` stayed `WEATHER_FAILED`
and no new requests were ever made even after the API recovered. Fix: reset retry
counter and state after max retries so periodic refresh resumes after next interval (30 min).
## [1.9.4] - 2026-05-14
### Fixed
- **Date timezone** (#5): Date string now uses local time, so date rolls over at local midnight instead of UTC midnight
- **Weather periodic refresh** (#7): Removed `WEATHER_SUCCESS` state that permanently blocked periodic weather updates after first fetch; also fixed "Last update" counter showing raw timestamp instead of elapsed seconds
- **WiFi connects to wrong network** (#3): `WiFi.begin()` without params is now skipped when a saved SSID exists, preventing connection to SDK-cached open hotspots and config corruption
### Changed
- WiFi startup: SDK-cached credentials only attempted on first boot (no saved SSID); subsequent boots go directly to saved credentials
- ArduinoJson: `StaticJsonDocument` → `JsonDocument` for compatibility with ArduinoJson v7
### Removed
- Unused `weekday` variable in NTP DST calculation (compiler warning)
### Internal
- Split `clock_ntp_ota_v1.9/` directory renamed to `weather_clock/` — version no longer baked into path
- Removed internal development documents from repository
## [1.9.3] - 2026-01-06
### Changed
- Refactored monolithic 2,100-line `.ino` into modular structure:
`display.cpp`, `ntp_client.cpp`, `weather.cpp`, `web_server.cpp`, `wifi_manager.cpp`
## [1.9.2] - 2026-01-06
### Fixed
- **CRITICAL**: WiFi credentials no longer cleared on connection failure
- Previous behavior erased SSID/password after failed connection attempts
- Now credentials persist indefinitely through WiFi outages
- OTA update credential loss issue (SDK credentials now tried first, then EEPROM)
### Added
- **WiFi Resilience**: Infinite retry with exponential backoff (5s → 10s → 20s → ... → 5min max)
- **Fallback AP**: "TJ56654-Setup" enabled after ~5 min of failed attempts
- Device continues retry attempts while AP is active (dual STA+AP mode)
- 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
### Fixed
- **CRITICAL**: WiFi startup sequence - synchronous connection in setup() to ensure proper initialization order
- Display blank screen for 10+ seconds on boot (now shows time after ~15 seconds)
- "DNS resolution failed" errors during startup
- Sunrise/sunset labels cut off on 128px screen (removed labels, arrows are self-explanatory)
### Changed
- Hybrid WiFi model: synchronous in setup(), async reconnect in loop()
- Display formatting: superscript degree symbol and lowercase 'c' for temperature
- Sunrise/sunset screen now shows daylight duration (e.g., "Day 9h 41m") instead of static "Sun Times" text
### Documentation
- Added detailed v1.9.1_HYBRID_FIX.md explaining startup sequence problem and solution
## [1.9.0] - 2026-01-02
### Added
- Fully async architecture (zero blocking operations in loop)
- Custom async NTP implementation (manual UDP packet handling)
- Async HTTP weather fetch (AsyncHTTPRequest library)
@@ -31,12 +187,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Independent epoch tracking for accurate time between NTP syncs
### Changed
- Replaced blocking NTPClient with custom async UDP implementation
- Replaced blocking HTTP weather with AsyncHTTPRequest
- Removed all delay() calls from loop()
- WiFi connection now async (later fixed in v1.9.1)
### Performance
- Loop time: 10ms → <1ms (10x improvement)
- Weather fetch: 1-10s blocking → 0ms
- NTP sync: 5-20s blocking → 0ms
@@ -44,6 +202,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- OTA updates now work during active weather fetching
### Technical
- RAM usage: +536 bytes (36,980 → 37,516)
- Flash usage: +1040 bytes (407,500 → 408,540)
- IRAM: 61,987 bytes (94% - stable)
@@ -51,12 +210,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.8.0] - 2026-01-01
### Security
- **CRITICAL**: Removed hardcoded WiFi credentials
- Integrated WiFiManager for secure captive portal setup
- Added config validation (magic number check)
- Input sanitization to prevent buffer overflows
### Fixed
- IRAM overflow crisis (94% → 70% via ICACHE_FLASH_ATTR)
- NTP interval bug (config value was ignored, always used hardcoded 1 hour)
- Boolean parsing errors in JSON config import/export
@@ -64,17 +225,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Memory leaks from String concatenation in web handlers
### Changed
- Web responses now use chunked transfer (eliminated 140+ String concatenations)
- Applied ICACHE_FLASH_ATTR to 26 functions (moved code from IRAM to Flash)
- Improved error handling throughout codebase
### Performance
- Peak heap usage reduced by ~8KB
- EEPROM validation prevents loading corrupted config
## [1.7.0] - 2025-12-31
### Added
- Initial working firmware with correct display support
- NTP time synchronization
- Weather data from Open-Meteo API (free, no API key required)
@@ -86,11 +250,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- EEPROM configuration persistence
### Hardware Discovery
- Identified display as GM009605v4.3 (not TM1637 or TM1650)
- Discovered swapped I2C pins: SDA=GPIO0, SCL=GPIO2
- Switched to Adafruit_SSD1306 library
### Replaced
- QWeather API → Open-Meteo (no registration required)
- Proprietary firmware → Open source custom firmware
- Insecure WiFi handling → WiFiManager with timeout
@@ -98,11 +264,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.6.0] - 2025-12-30 (unreleased)
### Attempted
- TM1650 LED driver support (incorrect - device has OLED)
## [1.5.0] - 2025-12-29 (unreleased)
### Attempted
- TM1637 7-segment display support (incorrect - device has OLED)
---
@@ -113,11 +281,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Minor version** (1.X.0): New features, backward-compatible
- **Patch version** (1.9.X): Bug fixes, no new features
## Links
- [Full v1.9 Release Notes](docs/v1.9_RELEASE_NOTES.md)
- [v1.9.1 Hybrid Fix Details](docs/v1.9.1_HYBRID_FIX.md)
---
**Status**: v1.9.1 is production-ready and actively used 24/7.
**Status**: v1.9.3 is production-ready and actively used 24/7.
+43 -7
View File
@@ -7,6 +7,7 @@ Thank you for your interest in contributing! This project welcomes improvements,
### Reporting Bugs
If you find a bug, please open an issue with:
- Clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
@@ -16,6 +17,7 @@ If you find a bug, please open an issue with:
### Suggesting Features
Feature requests are welcome! Please include:
- Use case description
- Why this would be useful
- Any implementation ideas
@@ -38,16 +40,19 @@ Feature requests are welcome! Please include:
### Code Guidelines
**Memory Safety:**
- Check IRAM usage after adding code
- Use fixed-size buffers instead of dynamic allocation where possible
- Prefer `snprintf` over String concatenation
**Async Architecture:**
- Keep loop() non-blocking (no delay() calls)
- Use state machines for multi-step operations
- Add exponential backoff to network operations
**Testing:**
- Test on ESP-01S hardware (1MB flash, 80KB RAM)
- Verify OTA updates work
- Check 24h stability
@@ -55,31 +60,62 @@ Feature requests are welcome! Please include:
## Development Setup
### Requirements
- Arduino IDE 1.8.x or 2.x
- ESP8266 board support (v3.0.0+)
- Libraries (see README)
### Building
```bash
# Arduino IDE: Sketch → Verify/Compile
# Or use arduino-cli:
arduino-cli compile --fqbn esp8266:esp8266:generic src/clock_ntp_ota_v1.9.ino
arduino-cli compile --fqbn esp8266:esp8266:generic firmware/weather_clock
```
### Testing
```bash
# Upload via FTDI (first time)
arduino-cli upload -p /dev/cu.usbserial* --fqbn esp8266:esp8266:generic
### Flashing
# Upload via OTA (subsequent)
```bash
# OTA upload (preferred, when device is on the network)
curl -u admin:admin -F "file=@build/*.bin" http://192.168.x.x/update
# Initial flash via FTDI (3.3V! ESP-01S in socket — no soldering)
# 1. Pull ESP-01S from socket on TJ-56-654 PCB
# 2. Connect: FTDI 3V3→3V3, GND→GND, TX↔RX crossed, GND→GPIO0
# 3. Power on with GPIO0 grounded → bootloader mode
esptool.py --port /dev/cu.usbserial-0001 --baud 115200 write_flash \
--flash_size 1MB --flash_mode dout 0x0 firmware.bin
```
### Running the test suite
Hardware-in-the-loop tests verify functionality and resilience:
```bash
python3 tests/test_device.py <device-ip>
```
73 test cases cover REST API, config validation, fuzz testing, and heap stability.
Safe to run repeatedly — validation rejects garbage, only WiFi changes reboot the device.
### Recovery (bricked device)
Triple power-cycle (≤10s apart, 3 times) triggers factory reset — clears WiFi
credentials and shows AP info on the OLED. Connect to `TJ56654-Setup` /
`12345678` and reconfigure via `http://192.168.4.1/config`.
If that fails, full reset via FTDI:
```bash
esptool.py --port /dev/cu.usbserial-0001 erase_flash
esptool.py --port /dev/cu.usbserial-0001 write_flash 0x0 firmware.bin
```
## Project Structure
```
esp8266-weather-clock-opensource/
├── src/ # Main firmware source
├── firmware/ # Main firmware source (weather_clock/)
├── docs/ # Documentation
├── images/ # Photos and screenshots
├── README.md # Main documentation
-249
View File
@@ -1,249 +0,0 @@
# Project Structure
This document describes the organization of the ESP8266 Weather Clock firmware repository.
## Directory Layout
```
esp8266-weather-clock-opensource/
│
├── README.md # Main documentation (start here!)
├── LICENSE # MIT License
├── CHANGELOG.md # Version history
├── CONTRIBUTING.md # Contribution guidelines
├── PROJECT_STRUCTURE.md # This file
├── .gitignore # Git exclusions
│
├── src/ # Source code
│ └── clock_ntp_ota_v1.9.ino # Main firmware (2,096 lines)
│
├── docs/ # Documentation
│ ├── INSTALLATION.md # Complete installation guide
│ ├── HARDWARE.md # Hardware specs and pinout
│ ├── v1.9_RELEASE_NOTES.md # v1.9.0 release notes
│ └── v1.9.1_HYBRID_FIX.md # v1.9.1 WiFi startup fix
│
├── images/ # Photos and screenshots
│ ├── product/ # AliExpress product photos
│ │ ├── 01-main-product.webp # Main product shot
│ │ ├── 02-components.webp # Kit components
│ │ ├── 03-weather-forecast.webp
│ │ ├── 04-temperature-display.webp
│ │ ├── 05-details.webp # Transparent case details
│ │ └── 06-size.webp # Dimensions (40x40x43mm)
│ │
│ └── build/ # Custom firmware screenshots
│ ├── display-time.png # Time display mode
│ ├── display-temperature.png # Weather display mode
│ └── display-sunrise-sunset.png # Solar display mode
│
└── .github/ # GitHub-specific files
├── workflows/
│ └── build.yml # CI: Auto-build on push
│
└── ISSUE_TEMPLATE/
├── bug_report.md # Bug report template
└── feature_request.md # Feature request template
```
## Key Files
### Root Level
**README.md** (11KB)
- Main project documentation
- Blog-style narrative about reverse engineering
- Security issues discovered
- Complete feature list
- Installation quickstart
- API documentation
**LICENSE** (MIT)
- Permissive open source license
- Use freely, modify, distribute
**CHANGELOG.md**
- Version history: v1.5 → v1.9.1
- Features, fixes, breaking changes
- Migration notes
**CONTRIBUTING.md**
- How to contribute
- Code style guidelines
- Testing requirements
### Source Code (`/src`)
**clock_ntp_ota_v1.9.ino**
- Main firmware file (2,096 lines)
- ESP8266 Arduino sketch
- Requires libraries:
- Adafruit GFX & SSD1306
- NTPClient
- WiFiManager
- AsyncHTTPRequest_Generic
- ESPAsyncTCP
**Architecture:**
- Fully async (zero blocking in loop)
- State machines: WiFi, NTP, Weather
- Hybrid model: sync WiFi in setup(), async in loop()
- Memory-optimized: ICACHE_FLASH_ATTR on 26 functions
**Configuration:**
- 26-field struct stored in EEPROM
- Magic number validation
- Web-based config UI
### Documentation (`/docs`)
**INSTALLATION.md** (18KB)
- Complete step-by-step installation guide
- Arduino IDE setup
- FTDI wiring diagrams
- OTA update instructions
- Comprehensive troubleshooting
**HARDWARE.md** (8KB)
- ESP-01S specifications
- Pin mapping (SDA=GPIO0, SCL=GPIO2)
- Display module details (GM009605v4.3)
- Power requirements
- Memory layout
- Safety warnings
**v1.9_RELEASE_NOTES.md** (7KB)
- Detailed v1.9.0 changelog
- Performance improvements
- Async architecture explanation
- Memory usage comparison
- Testing checklist
**v1.9.1_HYBRID_FIX.md** (Russian, 6KB)
- Critical startup fix documentation
- WiFi synchronous vs async tradeoffs
- Timeline diagrams
- Before/after comparison
### Images (`/images`)
**Product Photos** (`/product`)
- Original AliExpress product images
- DIY kit components
- Transparent acrylic case
- Size reference (40mm cube)
**Build Photos** (`/build`)
- Custom firmware screenshots
- Three display modes:
1. Time mode (10:34 + date)
2. Weather mode (15.4°c + city)
3. Sunrise/sunset mode (times + daylight duration)
### GitHub Config (`/.github`)
**Workflows**
- `build.yml`: CI pipeline
- Auto-compile on push
- Check firmware size < 470KB
- Upload build artifacts
- Attach binaries to releases
**Issue Templates**
- `bug_report.md`: Structured bug reports
- `feature_request.md`: Feature suggestions
## Build Artifacts (ignored by git)
When you compile locally, these are created:
```
build/
├── clock_ntp_ota_v1.9.ino.bin # Flash this via OTA
├── clock_ntp_ota_v1.9.ino.elf # Debug symbols
└── clock_ntp_ota_v1.9.ino.map # Memory map
```
**Note**: `build/` is in `.gitignore` - artifacts not committed to repo.
## File Sizes
| File | Size | Description |
|------|------|-------------|
| `src/*.ino` | 65KB | Main source code |
| `build/*.bin` | 409KB | Compiled firmware |
| `README.md` | 45KB | Main docs |
| `docs/INSTALLATION.md` | 18KB | Install guide |
| `docs/HARDWARE.md` | 8KB | Hardware specs |
## Memory Usage
**Compiled firmware (v1.9.1):**
- Flash: 408,844 / 1,048,576 bytes (38%)
- RAM: 37,644 / 80,192 bytes (46%)
- IRAM: 61,987 / 65,536 bytes (94%) ⚠️
**Why 94% IRAM is acceptable:**
- ICACHE_FLASH_ATTR applied to all web handlers
- Stable across versions v1.8-v1.9.1
- No IRAM growth observed in testing
## Version Control
**Branches:**
- `main`: Stable releases (v1.9.1)
- `develop`: Work-in-progress features
- `feature/*`: New feature branches
**Tags:**
- `v1.9.1`: Current production release
- `v1.9.0`: Async refactoring
- `v1.8.0`: Security + stability fixes
- `v1.7.0`: Initial working firmware
## Not Included (Why)
**What's NOT in this repo:**
- Build artifacts (`.bin`, `.elf`, `.map`) - generated locally
- Backup files (`.bak`, `.bak2`) - development artifacts
- IDE configs (`.vscode/`, `.idea/`) - personal preferences
- macOS metadata (`.DS_Store`) - system files
- Secrets (`config_local.h`) - would leak credentials
These are excluded via `.gitignore`.
## How to Navigate
**For users:**
1. Start with `README.md` (overview + quickstart)
2. Follow `docs/INSTALLATION.md` (step-by-step setup)
3. Check `CHANGELOG.md` (version history)
**For developers:**
1. Read `CONTRIBUTING.md` (guidelines)
2. Study `src/clock_ntp_ota_v1.9.ino` (source code)
3. Review `docs/HARDWARE.md` (hardware constraints)
4. Check `.github/workflows/build.yml` (CI setup)
**For hardware hackers:**
1. Check `docs/HARDWARE.md` (pinout, specs)
2. View `images/product/` (original device photos)
3. Read `README.md` section "Hardware Discovery"
**For troubleshooters:**
1. Open `docs/INSTALLATION.md`
2. Jump to "Troubleshooting" section
3. Check `images/build/` for reference screenshots
## Quick Links
- **Main docs**: [README.md](README.md)
- **Install guide**: [docs/INSTALLATION.md](docs/INSTALLATION.md)
- **Hardware specs**: [docs/HARDWARE.md](docs/HARDWARE.md)
- **Changelog**: [CHANGELOG.md](CHANGELOG.md)
- **Contributing**: [CONTRIBUTING.md](CONTRIBUTING.md)
---
**Last updated**: 2026-01-03
**Repository**: https://github.com/your-username/esp8266-weather-clock-opensource
-440
View File
@@ -1,440 +0,0 @@
# Publishing to GitHub - Step by Step Guide
This file contains instructions for publishing this project to GitHub.
## Prerequisites
1. **GitHub account** - Sign up at https://github.com if you don't have one
2. **Git installed** - Check with `git --version` in terminal
3. **GitHub CLI (optional)** - Makes repository creation easier: https://cli.github.com/
---
## Option 1: Using GitHub Web Interface (Easiest)
### Step 1: Create Repository on GitHub
1. Go to https://github.com/new
2. Fill in:
- **Repository name**: `esp8266-weather-clock-opensource`
- **Description**: `Secure open-source firmware for ESP8266 weather clock - reverse engineered from AliExpress DIY kit`
- **Visibility**: Public ✅
- **Initialize**: ❌ Do NOT check "Add README" (we have one)
3. Click: **Create repository**
### Step 2: Initialize Local Git Repository
Open terminal and navigate to project directory:
```bash
cd "/Users/apetrochenko/Library/Mobile Documents/com~apple~CloudDocs/src/arduino/clock/esp8266-weather-clock-opensource"
```
Initialize git and add files:
```bash
# Initialize git
git init
# Add all files
git add .
# Create first commit
git commit -m "Initial commit: v1.9.1 production firmware
- Complete reverse engineering of TJ-56-654 weather clock
- Fixes security issues (WiFi password leak)
- Fully async architecture (zero blocking)
- OTA updates, web interface, REST API
- Open-Meteo weather (free, no API key)
- Comprehensive documentation"
```
### Step 3: Connect to GitHub
Replace `YOUR_USERNAME` with your actual GitHub username:
```bash
# Add remote
git remote add origin https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource.git
# Set main branch
git branch -M main
# Push to GitHub
git push -u origin main
```
**If prompted for credentials:**
- Username: Your GitHub username
- Password: Use **Personal Access Token** (not your password!)
- Create token at: https://github.com/settings/tokens
- Select scopes: `repo` (full control of private repositories)
### Step 4: Verify Upload
1. Browse to: `https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource`
2. You should see:
- README.md rendered nicely
- All directories and files
- First commit visible
---
## Option 2: Using GitHub CLI (Faster)
If you have GitHub CLI installed:
```bash
# Navigate to project
cd "/Users/apetrochenko/Library/Mobile Documents/com~apple~CloudDocs/src/arduino/clock/esp8266-weather-clock-opensource"
# Authenticate (one-time)
gh auth login
# Create repo and push in one command
gh repo create esp8266-weather-clock-opensource \
--public \
--source=. \
--description="Secure open-source firmware for ESP8266 weather clock" \
--push
```
Done! Repository is created and pushed.
---
## Step 5: Configure Repository Settings
### Add Topics (Tags)
1. Go to your repo on GitHub
2. Click: **⚙️ Settings** (top right near About)
3. Under "Topics", add:
- `esp8266`
- `arduino`
- `iot`
- `weather-station`
- `reverse-engineering`
- `security`
- `oled-display`
- `ntp`
- `ota-updates`
- `open-meteo`
### Update About Section
1. Go to repo main page
2. Click: **⚙️** (gear icon) next to "About"
3. Set:
- **Description**: `Secure open-source firmware for ESP8266 weather clock - reverse engineered from AliExpress DIY kit to fix security flaws`
- **Website**: `https://open-meteo.com` (or your personal site if you blog about it)
- **Topics**: Should already be set from above
### Enable Features
In **Settings → General**:
**Features**:
- ✅ Issues (for bug reports)
- ✅ Discussions (for questions)
- ❌ Wiki (not needed, we have docs/)
- ❌ Projects (not needed yet)
**Pull Requests**:
- ✅ Allow squash merging
- ✅ Automatically delete head branches
### Set Up GitHub Actions
The CI workflow should activate automatically on first push. Check:
1. Go to: **Actions** tab
2. You should see: "Build Firmware" workflow
3. It should run and ✅ pass (compiles firmware)
If it fails:
- Check library names in `.github/workflows/build.yml`
- Some libraries may need exact version pinning
---
## Step 6: Create First Release
### Tag the Release Locally
```bash
# Create annotated tag
git tag -a v1.9.1 -m "Release v1.9.1: Production-ready firmware
Features:
- Hybrid WiFi model (sync on boot, async in loop)
- Daylight duration display
- Fully async NTP, weather, WiFi reconnect
- OTA updates, web interface, REST API
- Open-Meteo weather (free API)
- Security fixes (no WiFi password leak)
Fixes:
- Startup display blank for 10+ seconds
- DNS resolution failed errors
- Sunrise/sunset label cutoff"
# Push tag to GitHub
git push origin v1.9.1
```
### Create Release on GitHub
1. Go to: **Releases** (right sidebar)
2. Click: **Draft a new release**
3. Fill in:
- **Tag**: `v1.9.1` (should appear in dropdown)
- **Release title**: `v1.9.1 - Production Ready`
- **Description**:
```markdown
## 🎉 First Public Release
Secure, open-source replacement firmware for ESP8266 weather clocks.
### ✨ Highlights
- **Security**: Fixes WiFi password leak in original firmware
- **Performance**: Fully async architecture, <1ms loop time
- **Features**: OTA updates, web UI, REST API, NTP time, weather
- **Free API**: Uses Open-Meteo (no registration required)
### 📦 Downloads
- `esp8266-weather-clock-v1.9.1.bin` - Flash this via OTA or FTDI
### 📖 Documentation
- [Installation Guide](docs/INSTALLATION.md)
- [Hardware Specs](docs/HARDWARE.md)
- [Full Changelog](CHANGELOG.md)
### 🚀 Quick Start
1. Download `.bin` file
2. Flash via FTDI (first time) or OTA (updates)
3. Connect to `TJ56654-Setup` WiFi
4. Configure your network
5. Access web UI at `http://tj56654-clock.local`
See [README](README.md) for complete instructions.
### 🐛 Known Issues
None! This release is production-ready and tested 24/7.
```
4. **Attach binary** (if you have it locally):
- Compile firmware first: Arduino IDE → Sketch → Export Compiled Binary
- Or use GitHub Actions artifact
- Drag `build/clock_ntp_ota_v1.9.ino.bin` to release assets
- Rename to: `esp8266-weather-clock-v1.9.1.bin`
5. Click: **Publish release**
---
## Step 7: Add Shields/Badges to README
Edit `README.md` and add at the top (after title):
```markdown
<p align="center">
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/releases">
<img src="https://img.shields.io/github/v/release/YOUR_USERNAME/esp8266-weather-clock-opensource?style=flat-square" alt="Release">
</a>
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/YOUR_USERNAME/esp8266-weather-clock-opensource?style=flat-square" alt="License">
</a>
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/actions">
<img src="https://img.shields.io/github/actions/workflow/status/YOUR_USERNAME/esp8266-weather-clock-opensource/build.yml?style=flat-square" alt="Build">
</a>
<a href="https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource/issues">
<img src="https://img.shields.io/github/issues/YOUR_USERNAME/esp8266-weather-clock-opensource?style=flat-square" alt="Issues">
</a>
</p>
```
Replace `YOUR_USERNAME` with actual username.
Commit and push:
```bash
git add README.md
git commit -m "Add badges to README"
git push
```
---
## Step 8: Share Your Project
### Post on Social Media
**Reddit:**
- r/esp8266
- r/arduino
- r/selfhosted
- r/homeassistant (when you add HA integration)
**Hackaday:**
- Submit project tip: https://hackaday.com/submit-a-tip/
**Hackster.io:**
- Create project page: https://www.hackster.io/
**Twitter/X:**
```
Just reverse-engineered a $12 AliExpress weather clock and found it was leaking WiFi passwords!
Replaced the firmware with secure open-source version:
- ✅ No password leak
- ✅ OTA updates
- ✅ Free weather API
- ✅ Full async arch
Check it out: [your-repo-link]
#ESP8266 #IoTSecurity #Arduino
```
### Add to Awesome Lists
Search for "awesome ESP8266" and submit PR to add your project.
---
## Maintenance Tips
### Keep README Updated
When you add features:
1. Update README.md
2. Update CHANGELOG.md
3. Create new git tag
4. Create GitHub release
### Respond to Issues
Enable email notifications:
1. Go to: repo → **Watch** → **Custom**
2. Check: ✅ Issues, ✅ Pull requests, ✅ Discussions
### Version Numbering
Use semantic versioning (semver.org):
- `v2.0.0`: Breaking changes (incompatible config)
- `v1.10.0`: New features (backward-compatible)
- `v1.9.2`: Bug fixes only
### Automated Releases
GitHub Actions can auto-build on new tags. Check `.github/workflows/build.yml`.
---
## Troubleshooting
### "Permission denied" when pushing
**Solution**: Use Personal Access Token instead of password
1. Generate: https://github.com/settings/tokens
2. Scopes: `repo` (full control)
3. Use token as password when prompted
Or configure SSH keys:
```bash
# Generate SSH key
ssh-keygen -t ed25519 -C "your_email@example.com"
# Add to GitHub: Settings → SSH Keys → New SSH key
# Paste contents of ~/.ssh/id_ed25519.pub
# Change remote to SSH
git remote set-url origin git@github.com:YOUR_USERNAME/esp8266-weather-clock-opensource.git
```
### "This repository is empty"
You forgot to push:
```bash
git push -u origin main
```
### Files too large
GitHub has 100MB file size limit. If you accidentally added build artifacts:
```bash
# Remove from staging
git reset HEAD build/
# Add to .gitignore
echo "build/" >> .gitignore
# Commit
git commit -m "Ignore build artifacts"
```
### CI build fails
Check:
- Library names are correct in `build.yml`
- All libraries are available via Arduino Library Manager
- Firmware compiles locally first
---
## Next Steps After Publishing
1. **Star your own repo** (to make it discoverable)
2. **Watch releases** (be notified of activity)
3. **Enable Discussions** (for community Q&A)
4. **Create SECURITY.md** (if you want responsible disclosure process)
5. **Add funding links** (GitHub Sponsors, Buy Me a Coffee, etc.)
---
## GitHub Repository Best Practices
### Essential Files (✅ You have these!)
- ✅ README.md
- ✅ LICENSE
- ✅ CONTRIBUTING.md
- ✅ CHANGELOG.md
- ✅ .gitignore
- ✅ Issue templates
### Nice-to-Have
- CODE_OF_CONDUCT.md (for community standards)
- SECURITY.md (vulnerability disclosure policy)
- FUNDING.yml (donation links)
### Pin Important Files
On your repo page, pin:
1. README.md (auto-pinned)
2. INSTALLATION.md (pin in About section)
3. Latest release (pin in sidebar)
---
## Success Checklist
After publishing, verify:
- [ ] Repository is public and accessible
- [ ] README renders correctly (images, links work)
- [ ] All documentation files are present
- [ ] CI/CD pipeline passes (green checkmark)
- [ ] First release is tagged and published
- [ ] Binary is attached to release
- [ ] Topics/tags are set
- [ ] License is visible
- [ ] Issues and Discussions are enabled
---
**Congratulations!** Your project is now public and ready to help the world build secure IoT devices. 🚀
---
**Repository URL**: https://github.com/YOUR_USERNAME/esp8266-weather-clock-opensource
Don't forget to replace `YOUR_USERNAME` with your actual GitHub username!
+113 -347
View File
@@ -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">
<img src="https://img.shields.io/badge/ESP8266-ESP--01S-blue?style=flat-square" />
<img src="https://img.shields.io/badge/Firmware-v1.9.1-green?style=flat-square" />
<img src="https://img.shields.io/badge/OTA-Enabled-orange?style=flat-square" />
<img src="https://img.shields.io/badge/Status-Production%20Ready-brightgreen?style=flat-square" />
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/releases">
<img src="https://img.shields.io/github/v/release/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=Release&color=green" alt="Release">
</a>
<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>
## TL;DR
@@ -15,19 +22,25 @@ I bought a cute weather clock kit from AliExpress ([TJ-56-654](https://pt.aliexp
## Table of Contents
**Story**
- [The Discovery: When "Smart" Means "Insecure"](#the-discovery-when-smart-means-insecure)
- [The Device](#the-device)
- [The Investigation](#the-investigation)
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
- [Technical Deep Dive](#technical-deep-dive)
- [The Journey: v1.7 → v1.9.1](#the-journey-v17--v191)
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
- [Hardware Quirk: Swapped I2C Pins](#hardware-quirk-swapped-i2c-pins)
- [Version History](#version-history)
**Use it**
- [How to Flash This Firmware](#how-to-flash-this-firmware)
- [Web Interface](#web-interface)
- [API Documentation](#api-documentation)
- [Testing](#testing)
- [Security Improvements](#security-improvements)
- [Lessons Learned](#lessons-learned)
- [Credits](#credits)
- [Project Structure](#project-structure)
For internal architecture notes (state machines, memory, EEPROM layout), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
---
@@ -53,6 +66,7 @@ When you first set up the device, it creates an access point with a default pass
5. **Your WiFi password is displayed in plaintext on the config page**
Anyone within WiFi range could:
- Connect to the device's AP (weak default password)
- Browse to 192.168.4.1
- Read your WiFi password in plaintext
@@ -66,18 +80,18 @@ This is a textbook example of poor IoT security design. No thanks.
**Product**: ESP8266 Mini Weather Clock Kit
**Model**: TJ-56-654
**Price**: ~$12 USD
**Price**: ~€5 EUR
**Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
### Original Hardware Specifications
| Component | Details |
|-----------|---------|
| **MCU** | ESP-01S (ESP8266EX, 1MB flash, 80KB RAM) |
| **Display** | GM009605v4.3 OLED (128x64, I2C) |
| **Power** | 5V USB (Micro-USB) |
| **Case** | Transparent acrylic (40x40x43mm) |
| **PCB** | TJ-56-654 main board |
| Component | Details |
| ----------- | ---------------------------------------- |
| **MCU** | ESP-01S (ESP8266EX, 1MB flash, 80KB RAM) |
| **Display** | GM009605v4.3 OLED (128x64, I2C) |
| **Power** | 5V USB (Micro-USB) |
| **Case** | Transparent acrylic (40x40x43mm) |
| **PCB** | TJ-56-654 main board |
### What It Came With
@@ -112,6 +126,7 @@ The transparent case made inspection easy - just unscrew the brass standoffs. In
- **No additional sensors** (temperature/humidity were from weather API, not local)
The ESP-01S pinout is printed right on the PCB:
```
3V3 | GND
TX | GPIO0 (I2C SDA)
@@ -128,6 +143,7 @@ To flash custom firmware, you need:
3. **Steady hands**
**Wiring:**
```
FTDI ESP-01S
────────────────────
@@ -139,12 +155,14 @@ FTDI ESP-01S
```
**Boot into flash mode:**
1. Connect GPIO0 to GND
2. Power on the device
3. Remove GPIO0 to GND connection after boot
4. Device is now in programming mode
**Programming:**
- Use Arduino IDE with ESP8266 board support
- Select board: "Generic ESP8266 Module"
- Flash size: 1MB (FS:64KB OTA:~470KB)
@@ -169,6 +187,7 @@ I decided to write a complete replacement firmware with:
### Features Implemented
#### 🌐 Network & Time
- **WiFiManager** captive portal for secure first-time setup
- **Hybrid WiFi**: Synchronous on boot (ensures proper init), async reconnect during operation
- **NTP time sync** with configurable server and interval
@@ -176,12 +195,14 @@ I decided to write a complete replacement firmware with:
- **mDNS**: Access via `http://tj56654-clock.local/`
#### 🌦️ Weather Data
- **Open-Meteo API**: Free, no registration, no API key
- **Configurable location**: Latitude/longitude + city name
- **Data**: Temperature, sunrise, sunset, daylight duration
- **Smart updates**: Async fetch every 30 minutes (configurable)
#### 🔄 OTA Updates
- **Web-based OTA**: Upload .bin files via browser at `/update`
- **ArduinoOTA**: Update directly from Arduino IDE
- **Non-blocking**: System stays responsive during updates
@@ -231,266 +252,23 @@ All endpoints return JSON:
---
## Technical Deep Dive
## Hardware Quirk: Swapped I2C Pins
### Architecture: Fully Async State Machines
The firmware uses **zero blocking operations** in the main loop. Everything is state-machine-based:
#### Weather State Machine
```cpp
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
```
Uses `AsyncHTTPRequest` library:
- Non-blocking HTTP requests
- Callback-based response handling
- Exponential backoff on failures (1s → 2s → 4s)
- Maximum 3 retries before giving up
#### NTP State Machine
```cpp
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
```
Custom manual NTP implementation:
- Builds raw UDP packets (48 bytes)
- Non-blocking `parsePacket()` checks
- 5-second timeout
- Independent epoch tracking for accuracy between syncs
#### WiFi State Machine
```cpp
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
```
**Hybrid model** (this was critical!):
- **Setup phase**: Synchronous connection (waits up to 10 seconds)
- Why? OTA, web server, NTP all need WiFi ready
- Without this, device shows blank display for 10+ seconds
- **Loop phase**: Async reconnection (checks every 5 seconds)
- Why? Don't freeze the entire system if WiFi drops
### Memory Optimization
ESP8266 has strict memory limits:
| Memory Type | Total | Used | Usage | Status |
|-------------|-------|------|-------|--------|
| **Flash** | 1,048,576 | 408,844 | 38% | ✅ Plenty |
| **RAM** | 80,192 | 37,644 | 46% | ✅ Safe |
| **IRAM** | 65,536 | 61,987 | **94%** | ⚠️ Critical |
**IRAM Crisis Solution:**
IRAM (Instruction RAM) is limited and fills fast. The solution: `ICACHE_FLASH_ATTR` macro.
ESP-01S exposes only GPIO0 and GPIO2. The TJ-56-654 board designer used them as I2C — but **swapped from typical breakouts**:
```cpp
void ICACHE_FLASH_ATTR handleConfig() {
// This function's code lives in Flash, not IRAM
// Saves precious IRAM at cost of slightly slower execution
}
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2 (non-standard!)
```
Applied to 26 functions (web handlers, display, config utilities), reducing IRAM pressure from **overflow risk** to **sustainable 94%**.
Standard ESP8266 boards use GPIO4=SDA, GPIO5=SCL. Easy to miss if you're porting code from a different ESP8266 setup. The display is a GM009605v4.3 OLED (SSD1306-compatible) at I2C address 0x3C.
**String Safety:**
For architecture details (async state machines, memory budget, EEPROM layout, factory reset), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
Avoid String concatenation in loops (causes heap fragmentation):
```cpp
// ❌ BAD - 140+ concatenations
String html = "";
html += F("<!DOCTYPE html>");
html += F("<head>..."); // x138 more times
## Version History
// ✅ GOOD - Chunked responses
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
server.sendContent_P(HTML_HEADER);
server.sendContent_P(HTML_FOOTER);
server.sendContent(""); // End
```
The clock has gone through many iterations — display hardware discovery (v1.5–v1.7), stability and security fixes (v1.8), full async refactor (v1.9.0), and a long series of bug fixes informed by community reports and AI-assisted code review (v1.9.1–v1.9.10).
### Configuration Storage
26-field struct stored in EEPROM (512 bytes):
```cpp
struct Config {
char ssid[32];
char password[64];
int timezone_offset;
bool dst_enabled;
uint8_t brightness;
char ntp_server[64];
unsigned long ntp_interval;
bool hour_format_24;
char hostname[32];
float latitude;
float longitude;
char city_name[32];
bool weather_enabled;
unsigned long weather_interval;
unsigned long display_rotation_sec;
bool show_weather;
bool show_sunrise_sunset;
uint8_t display_orientation;
uint32_t magic; // 0xC10CC10C - validation
};
```
**EEPROM validation**: Magic number check prevents loading corrupted data. On validation failure, gracefully defaults to safe config.
### Display Hardware Discovery
This took **3 firmware iterations** to get right:
**v1.5**: Assumed TM1637 (7-segment LED driver)
- ❌ Wrong - device has OLED, not 7-segment LEDs
**v1.6**: Tried TM1650 (another LED driver)
- ❌ Wrong - I2C addresses didn't match
**v1.7**: Identified GM009605v4.3 (SSD1306-compatible OLED)
- ✅ Correct! Used Adafruit_SSD1306 library
- ✅ Discovered swapped pins: SDA on GPIO0, SCL on GPIO2
**Pin mapping quirk:**
Standard ESP8266 I2C uses GPIO4 (SDA) and GPIO5 (SCL), but ESP-01S only exposes GPIO0 and GPIO2. The board designer mapped:
- GPIO0 → SDA (unusual)
- GPIO2 → SCL (unusual)
This is **backwards** from typical breakout boards, but works perfectly once configured:
```cpp
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
```
---
## The Journey: v1.7 → v1.9.1
### v1.7: Display Discovery ✅
- Identified correct display hardware
- Basic time display working
- WiFiManager integration
- First OTA deployment
### v1.8: Stability & Security 🔒
**Goals**: Fix memory issues, eliminate security holes
**Changes:**
- IRAM optimization (added `ICACHE_FLASH_ATTR` to 26 functions)
- Removed hardcoded WiFi credentials
- Fixed NTP interval bug (config value was ignored)
- Fixed boolean parsing in JSON import
- Added input validation (buffer overflow protection)
- Chunked HTTP responses (eliminated 140+ String concatenations)
**Result**: IRAM usage 94% → 70%, no memory leaks, secure config storage
### v1.9.0: Full Async Refactoring ⚡
**Goals**: Eliminate all blocking operations
**Changes:**
- Async HTTP weather fetch (AsyncHTTPRequest library)
- Custom async NTP implementation (manual UDP packets)
- Async WiFi connection (state machine)
- Removed all `delay()` calls from `loop()`
- Exponential backoff retry logic
**Performance:**
| Operation | Before (v1.8) | After (v1.9.0) | Improvement |
|-----------|---------------|----------------|-------------|
| Weather fetch | 1-10s blocking | 0ms | ✅ Async callback |
| NTP sync | 5-20s blocking | 0ms | ✅ Non-blocking UDP |
| WiFi reconnect | 15s blocking | 0ms | ✅ State machine |
| Loop time | 10ms minimum | <1ms | ✅ 10x faster |
**Result**: Device stays responsive during OTA updates while weather is fetching!
### v1.9.1: Hybrid Fix (Current) 🎯
**Problem Discovered:**
After deploying v1.9.0, the display showed **blank screen for 10 seconds** after boot, with `DNS resolution failed` errors in logs.
**Root Cause:**
Making WiFi fully async broke the **initialization order**:
```cpp
void setup() {
setupWiFi(); // Returns immediately (async)
setupOTA(); // WiFi NOT ready! ❌
setupWebServer(); // WiFi NOT ready! ❌
testInternetConnectivity(); // WiFi NOT ready! → DNS error
}
```
**Solution: Hybrid Model**
| Phase | WiFi Mode | Blocking? | Why? |
|-------|-----------|-----------|------|
| `setup()` | Synchronous | 10s max | OTA/web/NTP need WiFi ready |
| `loop()` | Asynchronous | 0s | Don't freeze on reconnect |
**Results:**
- ✅ Display shows time immediately after WiFi connects (~15 sec boot)
- ✅ No "DNS resolution failed" errors
- ✅ Proper initialization order guaranteed
- ✅ Device never freezes on WiFi loss during operation
**Startup Timeline:**
```
[0-5s] Display init, startup animation
[5-15s] WiFi connection (SYNCHRONOUS in setup())
✅ WiFi connected! IP assigned
[15-20s] OTA init, web server start, NTP client ready
✅ Internet test: PASSED
[20-30s] First async NTP sync
✅ Time synced and displayed
```
### Memory Evolution
| Version | RAM Usage | IRAM Usage | Flash Usage | Notes |
|---------|-----------|------------|-------------|-------|
| v1.7 | 34,980 (43%) | **61,987 (94%)** | 407,500 (38%) | IRAM crisis |
| v1.8 | 36,980 (46%) | **45,120 (68%)** | 407,800 (38%) | ICACHE_FLASH_ATTR fix |
| v1.9.0 | 37,516 (46%) | **61,987 (94%)** | 408,540 (38%) | Async libs added |
| v1.9.1 | 37,644 (46%) | **61,987 (94%)** | 408,844 (38%) | Production ready |
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
---
## What's Next: Home Assistant Integration
The firmware is designed to be extensible. Next planned features:
### Custom Display Screens
Pull data from Home Assistant via REST API:
- **Smart home stats**: Energy usage, room temperatures
- **Sensor data**: Air quality, CO2 levels
- **Automation states**: Alarm status, door locks
### MQTT Integration
- Publish time/weather data to MQTT broker
- Subscribe to topics for display content
- Enable automation triggers (e.g., display alert when door opens)
### WebSocket Live Updates
Replace polling with WebSocket for:
- Real-time config changes without page refresh
- Live display preview in web UI
- Push notifications for firmware updates
See [CHANGELOG.md](CHANGELOG.md) for the full version history with technical details.
---
@@ -516,8 +294,9 @@ Replace polling with WebSocket for:
- `Adafruit SSD1306`
- `NTPClient`
- `WiFiManager` (by tzapu)
- `AsyncHTTPRequest_Generic`
- `asyncHTTPrequest` (by Bob Lemaire)
- `ESPAsyncTCP`
- `ArduinoJson` (by Benoit Blanchon)
3. **Board Configuration**
- Board: "Generic ESP8266 Module"
@@ -530,6 +309,7 @@ Replace polling with WebSocket for:
### First Flash (via FTDI)
1. **Wire the ESP-01S**:
```
FTDI 3.3V → ESP-01S 3V3
FTDI GND → ESP-01S GND
@@ -539,7 +319,7 @@ Replace polling with WebSocket for:
```
2. **Compile and Upload**:
- Open `clock_ntp_ota_v1.9.ino`
- Open `weather_clock.ino`
- Sketch → Upload
- Wait for "Done uploading"
- Remove GPIO0-to-GND jumper
@@ -572,6 +352,7 @@ Replace polling with WebSocket for:
## Web Interface
### Home Page (`/`)
Current time display with live updates via JavaScript (fetches `/api/time` every second).
### Configuration Page (`/config`)
@@ -579,11 +360,13 @@ Current time display with live updates via JavaScript (fetches `/api/time` every
Comprehensive settings form:
**WiFi Settings**
- SSID
- Password
- Hostname (for mDNS)
**Time Settings**
- Timezone offset (seconds from UTC)
- DST enabled (European rules)
- NTP server address
@@ -591,6 +374,7 @@ Comprehensive settings form:
- Hour format (12h/24h)
**Weather Settings**
- Enabled/disabled toggle
- Latitude
- Longitude
@@ -598,6 +382,7 @@ Comprehensive settings form:
- Update interval (seconds)
**Display Settings**
- Brightness (0-7)
- Rotation (0°, 90°, 180°, 270°)
- Display rotation interval (seconds)
@@ -631,6 +416,7 @@ All endpoints return JSON (except `/update` which is for file upload).
Current time information.
**Response:**
```json
{
"current": "14:23:45",
@@ -646,6 +432,7 @@ Current time information.
System status overview.
**Response:**
```json
{
"wifi": {
@@ -672,6 +459,7 @@ System status overview.
Current weather data.
**Response:**
```json
{
"temperature": 15.4,
@@ -690,6 +478,7 @@ Current weather data.
Export full configuration as JSON.
**Response:**
```json
{
"ssid": "MyNetwork",
@@ -719,6 +508,7 @@ Import configuration from JSON.
**Request Body**: Same structure as export response (password field optional for security).
**Response:**
```json
{
"status": "ok"
@@ -732,6 +522,7 @@ Device automatically reboots after import.
Factory reset (clears EEPROM).
**Response:**
```json
{
"status": "cleared"
@@ -745,6 +536,7 @@ Device reboots to WiFiManager captive portal.
Remote reboot.
**Response:**
```json
{
"status": "rebooting"
@@ -759,15 +551,15 @@ Device reboots immediately.
### What Changed from Original Firmware
| Issue | Original | Custom Firmware |
|-------|----------|-----------------|
| **WiFi Password Leak** | Plaintext in open AP | No open AP after setup |
| **Persistent AP** | Always active | Only on first boot or failure |
| **API Keys** | QWeather requires registration | Open-Meteo (no key needed) |
| **Cloud Dependency** | Chinese servers | Direct API calls, no intermediary |
| **Firmware Updates** | Manual FTDI only | OTA via WiFi (password-protected) |
| **Config Access** | No authentication | Admin password required |
| **Code Transparency** | Closed source | Open source (you're reading it!) |
| Issue | Original | Custom Firmware |
| ---------------------- | ------------------------------ | --------------------------------- |
| **WiFi Password Leak** | Plaintext in open AP | No open AP after setup |
| **Persistent AP** | Always active | Only on first boot or failure |
| **API Keys** | QWeather requires registration | Open-Meteo (no key needed) |
| **Cloud Dependency** | Chinese servers | Direct API calls, no intermediary |
| **Firmware Updates** | Manual FTDI only | OTA via WiFi (password-protected) |
| **Config Access** | No authentication | Admin password required |
| **Code Transparency** | Closed source | Open source (you're reading it!) |
### Best Practices Implemented
@@ -781,16 +573,19 @@ Device reboots immediately.
### Recommended Post-Flash Steps
1. **Change OTA password**: Edit line ~60 in `.ino` file:
```cpp
ArduinoOTA.setPassword("admin"); // Change this!
```
2. **Change web admin password**: Edit line ~430:
```cpp
if (!server.authenticate("admin", "admin")) { // Change this!
```
3. **Set strong WiFi AP fallback password**: Edit line ~780:
```cpp
WiFi.softAP("TJ56654-Clock", "12345678"); // Change this!
```
@@ -799,39 +594,6 @@ Device reboots immediately.
---
## Lessons Learned
### Hardware
1. **Always check pinouts**: Don't assume standard pin mappings - this device swaps SDA/SCL
2. **FTDI is your friend**: A $2 adapter unlocks any ESP8266 device
3. **Transparent cases are great**: Made debugging and identification trivial
4. **Read the PCB silk screen**: Model numbers and pin labels save hours of guessing
### Software
1. **Async is hard but worth it**: Fully non-blocking architecture eliminates user-facing freezes
2. **IRAM is precious**: On ESP8266, use `ICACHE_FLASH_ATTR` liberally
3. **Hybrid approaches work**: Don't be dogmatic - synchronous WiFi in setup() solved a critical UX issue
4. **State machines scale**: Better than callback hell for complex async operations
5. **Test on real hardware**: Emulators can't catch pin mapping errors or memory constraints
### Security
1. **IoT security is often terrible**: Always audit devices before trusting them on your network
2. **Open source is safer**: Closed firmware is a black box - you have no idea what it's doing
3. **Defaults matter**: Insecure defaults (open AP, plaintext passwords) lead to real vulnerabilities
4. **Defense in depth**: Multiple layers (WiFiManager timeout, password protection, validation) catch mistakes
### Development
1. **OTA from day 1**: Flashing via FTDI gets old fast - build OTA support early
2. **Version your work**: Backup files (.bak, .bak2) saved me multiple times
3. **Document as you go**: Release notes and architecture docs prevent "what was I thinking?" moments
4. **Incremental improvements**: v1.7 → v1.8 → v1.9.x made debugging manageable
---
## Credits
**Hardware**: TJ-56-654 Weather Clock Kit ([AliExpress](https://pt.aliexpress.com/item/1005008333782531.html))
@@ -839,16 +601,19 @@ Device reboots immediately.
**Firmware**: Written from scratch with love and frustration
**Libraries Used**:
- [ESP8266 Arduino Core](https://github.com/esp8266/Arduino)
- [Adafruit SSD1306](https://github.com/adafruit/Adafruit_SSD1306)
- [WiFiManager](https://github.com/tzapu/WiFiManager)
- [AsyncHTTPRequest_Generic](https://github.com/khoih-prog/AsyncHTTPRequest_Generic)
- [asyncHTTPrequest](https://github.com/boblemaire/asyncHTTPrequest)
- [NTPClient](https://github.com/arduino-libraries/NTPClient)
**APIs**:
- [Open-Meteo](https://open-meteo.com/) - Free weather API, no registration required
**Tools**:
- Arduino IDE 2.x
- FTDI FT232RL USB-to-Serial adapter
- Lots of coffee ☕
@@ -858,17 +623,41 @@ Device reboots immediately.
## Project Structure
```
clock_ntp_ota_v1.9/
├── clock_ntp_ota_v1.9.ino # Main firmware (2,096 lines)
├── v1.9_RELEASE_NOTES.md # Detailed changelog
├── v1.9.1_HYBRID_FIX.md # WiFi startup fix documentation
├── README.md # This file
└── build/
├── clock_ntp_ota_v1.9.ino.bin # Compiled firmware
├── clock_ntp_ota_v1.9.ino.elf # Debug symbols
└── clock_ntp_ota_v1.9.ino.map # Memory map
esp8266-weather-clock/
├── firmware/
│ └── weather_clock/ # Modular firmware
│ ├── weather_clock.ino # setup() and loop()
│ ├── config.h # Config struct, EEPROM layout
│ ├── globals.h # Shared state
│ ├── display.cpp # OLED rendering
│ ├── ntp_client.cpp # Async NTP + DST
│ ├── weather.cpp # Open-Meteo API
│ ├── web_server.cpp # HTTP UI + REST API
│ └── wifi_manager.cpp # WiFi resilience
├── tests/
│ └── test_device.py # HW-in-the-loop test suite (73 cases)
├── docs/
│ ├── HARDWARE.md # Hardware specifications
│ └── INSTALLATION.md # Flashing guide
├── CHANGELOG.md # Version history
└── README.md # This file
```
## Testing
```bash
# Hardware-in-the-loop test suite — verifies API, fuzzes config endpoint,
# checks heap stability over 5 samples
python3 tests/test_device.py 192.168.x.x
```
The test suite covers:
- All REST API endpoints (functional validation)
- Boundary fuzz against `/config` (oversized SSID, invalid intervals, etc.)
- Malformed JSON fuzz against `/api/config` import
- Heap stability check (must stay >20KB, drift <2KB across runs)
---
## License
@@ -877,29 +666,6 @@ This project is released into the public domain. Do whatever you want with it. I
---
## Final Thoughts
This project started as "I don't trust this device" and ended as "I built something better."
The original firmware had security holes you could drive a truck through. The custom replacement:
- ✅ Doesn't leak WiFi passwords
- ✅ Uses free, open APIs
- ✅ Updates over WiFi
- ✅ Runs fully async (no freezing)
- ✅ Integrates with Home Assistant (coming soon)
- ✅ Is completely auditable (you're reading the source)
Total cost: $12 hardware + a weekend of tinkering.
If you have one of these devices, **flash this firmware**. If you're buying IoT gadgets, **always audit them first**. And if something seems insecure, **fix it yourself** - that's the hacker spirit.
Now go make something cool. 🚀
---
**P.S.**: If you found this useful, consider starring the repo. If you found a bug, open an issue. If you want to add Home Assistant screens, let's collaborate - I'm planning that next!
**Built with**: Claude Code (Opus 4.5)
**Author**: apetrochenko
**Date**: 2026-01-03
**Firmware Version**: v1.9.1 (Production Ready)
**Author**: apetrochenko · **License**: MIT · **Firmware**: v1.9.10
+118
View File
@@ -0,0 +1,118 @@
# Architecture Notes
Internal notes for contributors modifying the firmware. End users don't need this.
## Async model
The main loop performs zero blocking operations. All network I/O runs through callback-based state machines:
- **Weather**: `AsyncHTTPRequest` callback updates `weatherState` (IDLE → REQUESTING → IDLE on success/failure), with a 15s watchdog in the loop to recover from TCP hangs
- **NTP**: Manual UDP packet build + non-blocking `parsePacket()` with 5s timeout
- **WiFi**: Synchronous in `setup()` (so OTA / web / NTP have a working network at start), async reconnect in `loop()` with exponential backoff
State variables shared between callbacks and the main loop are declared `volatile` (`weatherState`, `ntpState`) since ESPAsyncTCP callbacks fire from within `yield()`/TCP poll and can interrupt the loop at any point.
## Timer correctness
`millis()` is `uint32_t` and rolls over every ~49.7 days. All deadline comparisons use the subtraction-safe pattern:
```cpp
// Unsafe: fails at rollover
if (millis() >= nextRetryTime) { ... }
// Safe: works across rollover
if ((millis() - nextRetryTime) < 0x80000000UL) { ... }
```
See `RetryConfig::isRetryTime()` in `config.h` for the canonical implementation.
## Memory budget (ESP-01S, 1MB flash)
| Pool | Total | Used | % | Notes |
| --------- | --------- | -------- | --- | -------------------------------------------------------- |
| **Flash** | 1,048,576 | ~411,000 | 39% | Headroom for features |
| **RAM** | 80,192 | ~37,700 | 46% | Stable across all 1.9.x releases |
| **IRAM** | 65,536 | 61,987 | 94% | **Critical** — `ICACHE_FLASH_ATTR` required on new funcs |
### IRAM discipline
IRAM is the tightest constraint. Apply `ICACHE_FLASH_ATTR` to any non-critical function so its code lives in flash instead of IRAM:
```cpp
void ICACHE_FLASH_ATTR handleConfig() {
// code in flash, slightly slower but saves IRAM
}
```
Currently 26 functions use this attribute (web handlers, display routines, config helpers). Functions called from interrupt context or hot loops should stay in IRAM.
### Heap fragmentation
ESP8266 heap is never compacted. Repeated `String` concatenation in long-running handlers (especially `/api/*` endpoints polled by the web UI) permanently fragments the heap until reboot.
Pattern to follow for HTTP responses:
```cpp
char buf[256];
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
snprintf_P(buf, sizeof(buf), PSTR("{\"...\":%d}"), value);
server.sendContent(buf);
server.sendContent(""); // close chunked transfer
```
Never:
```cpp
String html = "";
html += "..."; // <- heap fragmentation
```
## EEPROM layout
512 bytes mapped via `EEPROM.begin(512)`:
| Offset | Size | Content |
| ------ | ------ | ------------------------------------------------- |
| 0 | ~260 B | `Config` struct (validated by magic `0xC10CC10C`) |
| 480 | 2 B | `ResetCounter` for triple power-cycle reset |
Writes are flash-sector-erase operations (4KB), so the entire 512-byte image is rewritten on each `EEPROM.commit()`. Expect ~100k cycles before wear matters.
The `Config` struct includes a magic number — on validation failure, the firmware falls back to defaults rather than loading garbage.
## Factory reset
Three quick power cycles within 10 seconds (controlled by `RESET_COUNTER_WINDOW`) triggers a factory reset:
1. Each boot increments the counter at EEPROM offset 480
2. If the device runs for 10s, the counter clears back to 0
3. If count reaches 3 first, WiFi credentials are wiped and the device reboots into WiFiManager AP mode
For atomicity, credentials are committed BEFORE the counter is zeroed. Power loss between the two writes still results in a recoverable state (cleared creds → AP boot).
## Display
128×64 OLED, SSD1306-compatible (GM009605v4.3 panel). The ESP-01S exposes only GPIO0 and GPIO2, and the board designer mapped them as I2C:
```cpp
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2 (non-standard!)
```
This is the inverse of typical breakout boards — easy to get wrong if you're porting code.
## Web server
`ESP8266WebServer` (not async, but fast enough for low-frequency requests). All API responses use chunked transfer with `snprintf` + `sendContent`. Form save (`/config`) only triggers a reboot when WiFi/network fields actually change; other settings apply live.
Input validation in `handleConfigSave` clamps numeric fields and rejects garbage SSIDs (HTTP 400) — see `isValidSSID()` in `web_server.cpp`.
## Adding new features
1. Check IRAM usage after build — if growing, add `ICACHE_FLASH_ATTR` to your new functions
2. Don't use `String` in handlers or callbacks — use `char buf[]` + `snprintf`
3. New shared state between callbacks and loop → declare `volatile`
4. New timer logic → use the subtraction-safe `millis()` pattern
5. New API endpoints → run `tests/test_device.py` against the device to verify heap stability
For more context on past design decisions, see `CHANGELOG.md`.
+91 -28
View File
@@ -64,16 +64,17 @@ Go to: **Sketch → Include Library → Manage Libraries**
Install the following libraries (search by name):
| Library | Author | Min Version | Purpose |
|---------|--------|-------------|---------|
| **Adafruit GFX Library** | Adafruit | 1.11.0 | Graphics primitives |
| **Adafruit SSD1306** | Adafruit | 2.5.0 | OLED display driver |
| **NTPClient** | Fabrice Weinberg | 3.2.0 | NTP time sync (base) |
| **WiFiManager** | tzapu | 2.0.0 | Captive portal setup |
| **AsyncHTTPRequest_Generic** | Khoi Hoang | 1.13.0 | Async weather fetch |
| **ESPAsyncTCP** | me-no-dev | 1.2.2 | Async TCP (required by above) |
| Library | Author | Min Version | Purpose |
| ---------------------------- | ---------------- | ----------- | ----------------------------- |
| **Adafruit GFX Library** | Adafruit | 1.11.0 | Graphics primitives |
| **Adafruit SSD1306** | Adafruit | 2.5.0 | OLED display driver |
| **NTPClient** | Fabrice Weinberg | 3.2.0 | NTP time sync (base) |
| **WiFiManager** | tzapu | 2.0.0 | Captive portal setup |
| **AsyncHTTPRequest_Generic** | Khoi Hoang | 1.13.0 | Async weather fetch |
| **ESPAsyncTCP** | me-no-dev | 1.2.2 | Async TCP (required by above) |
**Installation steps for each library:**
1. Search library name in Library Manager
2. Click **Install**
3. Wait for "INSTALLED" badge
@@ -87,17 +88,17 @@ Install the following libraries (search by name):
2. Select: **Generic ESP8266 Module**
3. Configure settings:
| Setting | Value | Why |
|---------|-------|-----|
| Flash Size | `1MB (FS:64KB OTA:~470KB)` | Enables OTA with 470KB max firmware |
| Flash Mode | `DIO` | Compatible with most ESP-01S modules |
| Flash Frequency | `40MHz` | Safe default for all ESP8266 |
| CPU Frequency | `80MHz` | Standard (can use 160MHz for more speed) |
| Crystal Frequency | `26MHz` | Default for ESP-01S |
| Upload Speed | `115200` | Balance between speed and reliability |
| Debug Level | `None` | Reduces firmware size |
| IwIP Variant | `v2 Lower Memory` | Better for 1MB flash devices |
| Erase Flash | `Only Sketch` | Preserves config on re-flash |
| Setting | Value | Why |
| ----------------- | -------------------------- | ---------------------------------------- |
| Flash Size | `1MB (FS:64KB OTA:~470KB)` | Enables OTA with 470KB max firmware |
| Flash Mode | `DIO` | Compatible with most ESP-01S modules |
| Flash Frequency | `40MHz` | Safe default for all ESP8266 |
| CPU Frequency | `80MHz` | Standard (can use 160MHz for more speed) |
| Crystal Frequency | `26MHz` | Default for ESP-01S |
| Upload Speed | `115200` | Balance between speed and reliability |
| Debug Level | `None` | Reduces firmware size |
| IwIP Variant | `v2 Lower Memory` | Better for 1MB flash devices |
| Erase Flash | `Only Sketch` | Preserves config on re-flash |
---
@@ -106,6 +107,7 @@ Install the following libraries (search by name):
### Step 1: Identify Pins
ESP-01S pinout (looking at module from top, antenna up):
```
┌─────────────┐
│ │
@@ -123,15 +125,16 @@ ESP-01S pinout (looking at module from top, antenna up):
**Connections:**
| FTDI Pin | ESP-01S Pin | Wire Color | Notes |
|----------|-------------|------------|-------|
| 3.3V | 3V3 | Red | Power (NOT 5V!) |
| GND | GND | Black | Ground |
| TX | RX | Yellow | Data: FTDI transmit → ESP receive |
| RX | TX | Green | Data: FTDI receive → ESP transmit |
| GND | GPIO0 | Blue | **Programming mode** (temporary) |
| FTDI Pin | ESP-01S Pin | Wire Color | Notes |
| -------- | ----------- | ---------- | --------------------------------- |
| 3.3V | 3V3 | Red | Power (NOT 5V!) |
| GND | GND | Black | Ground |
| TX | RX | Yellow | Data: FTDI transmit → ESP receive |
| RX | TX | Green | Data: FTDI receive → ESP transmit |
| GND | GPIO0 | Blue | **Programming mode** (temporary) |
**⚠️ CRITICAL**:
- **Never connect 5V to ESP-01S** - it's not 5V tolerant!
- Double-check polarity before powering on
- GPIO0-to-GND connection is **temporary** (only for programming mode)
@@ -152,8 +155,8 @@ ESP-01S is now in programming mode, ready to receive firmware.
### Step 1: Open Project
1. Download or clone this repository
2. Navigate to: `esp8266-weather-clock-opensource/src/`
3. Open: `clock_ntp_ota_v1.9.ino` in Arduino IDE
2. Navigate to: `esp8266-weather-clock-opensource/firmware/weather_clock/`
3. Open: `weather_clock.ino` in Arduino IDE
### Step 2: Verify Board Settings
@@ -166,6 +169,7 @@ ESP-01S is now in programming mode, ready to receive firmware.
- Windows: `COM3`, `COM4`, etc.
If port doesn't appear:
- Check USB cable is data-capable (not charge-only)
- Install FTDI drivers
- Try different USB port
@@ -222,10 +226,12 @@ If port doesn't appear:
### Step 2: Captive Portal
**Automatic (iOS/Android):**
- Captive portal should pop up automatically
- If not, manually browse to: http://192.168.4.1
**Manual (laptop):**
- Browse to: http://192.168.4.1
### Step 3: Configure WiFi
@@ -240,16 +246,19 @@ If port doesn't appear:
### Step 4: Find Device IP
**Method 1: Router Admin Panel**
- Log into your router
- Look for device: "tj56654-clock"
- Note its IP address (e.g., 192.168.1.47)
**Method 2: mDNS (if your OS supports it)**
- Browse to: http://tj56654-clock.local/
- Works on macOS, Linux, iOS out-of-box
- Windows: Install [Bonjour Print Services](https://support.apple.com/kb/DL999)
**Method 3: Serial Monitor**
1. Keep FTDI connected (no GPIO0 to GND!)
2. Open: **Tools → Serial Monitor**
3. Set baud rate: **115200**
@@ -261,6 +270,7 @@ If port doesn't appear:
Browse to: `http://<device-ip>/` or `http://tj56654-clock.local/`
You should see:
- Current time display
- Navigation links (Config, Debug, Update)
@@ -276,6 +286,7 @@ You should see:
4. Device reboots with new settings
**Timezone examples:**
- UTC+0 (London winter): `0`
- UTC+1 (Paris winter): `3600`
- UTC-5 (New York winter): `-18000`
@@ -319,6 +330,7 @@ curl -u admin:admin -F "file=@/path/to/firmware.bin" http://192.168.x.x/update
```
Replace:
- `192.168.x.x` with your device IP
- `/path/to/firmware.bin` with actual path to .bin file
@@ -329,43 +341,51 @@ Replace:
### Upload Fails
**Error: "espcomm_open failed"**
- Check: GPIO0 was grounded during power-on
- Check: FTDI driver installed
- Try: Different USB port
- Try: Lower upload speed (57600 instead of 115200)
**Error: "espcomm_upload_mem failed"**
- Check: Wire connections (especially RX↔TX swap)
- Check: FTDI is 3.3V (not 5V)
- Try: Power ESP-01S from external 3.3V supply (FTDI may not provide enough current)
**Error: "Chip sync error"**
- GPIO0 must be LOW during boot
- Try: Hold GPIO0 to GND, reset ESP, then release GPIO0
### Compilation Fails
**Error: "library not found"**
- Install missing library via Library Manager
- Restart Arduino IDE after installing
**Error: "Sketch too big"**
- Flash size must be set to 1MB
- Reduce features if necessary (disable weather, etc.)
**IRAM overflow error**
- Some functions missing `ICACHE_FLASH_ATTR`
- Use version from this repo (already optimized)
### WiFi Connection Fails
**Device creates AP but won't connect to home WiFi**
- ESP8266 only supports 2.4GHz (not 5GHz)
- Try: Different WiFi channel (1, 6, or 11)
- Check: WiFi password is correct
- Check: Router supports 802.11n
**Device reboots in a loop**
- Likely: Power supply too weak (brownout)
- Solution: Use powered USB hub or different power adapter
- Minimum: 500mA @ 5V
@@ -373,28 +393,33 @@ Replace:
### Display Issues
**Display is blank**
- Check: I2C wiring (SDA=GPIO0, SCL=GPIO2)
- Check: Display I2C address (try 0x3C and 0x3D in code)
- Test: Use `/api/i2c-scan` endpoint to detect display
**Display shows garbage**
- Wrong display library or initialization
- This firmware is for SSD1306-compatible OLED
- Verify display model is GM009605v4.3 or similar
**Display is upside down**
- Change `display_orientation` in `/config`
- Values: 0 (normal), 1 (90°), 2 (180°), 3 (270°)
### Time Not Syncing
**Time shows 00:00:00**
- Check: WiFi is connected (`/api/status`)
- Check: NTP server is reachable (default: pool.ntp.org)
- Check: Router firewall allows UDP port 123
- Try: Different NTP server (e.g., time.google.com)
**Time is wrong by hours**
- Check: Timezone offset in `/config`
- Remember: Offset is in **seconds**, not hours
- Example: UTC+1 = 3600 seconds
@@ -402,6 +427,7 @@ Replace:
### Weather Not Updating
**Temperature shows 0.0°C**
- Check: Internet connectivity (`/api/debug`)
- Check: Latitude/longitude are correct
- Check: Open-Meteo API is accessible (visit https://open-meteo.com/ in browser)
@@ -410,11 +436,13 @@ Replace:
### OTA Update Fails
**Web upload hangs at 0%**
- Check: Device is online and responsive
- Try: Smaller firmware (disable features)
- Try: Upload via Arduino IDE instead
**Upload completes but device doesn't reboot**
- Wait 30 seconds (sometimes slow)
- Manually power cycle device
- Check serial output for errors
@@ -422,10 +450,12 @@ Replace:
### Serial Monitor Shows Errors
**"DNS resolution failed"**
- In v1.9.0 (fixed in v1.9.1)
- Upgrade to v1.9.1 or later
**Watchdog reset / exception**
- Likely: Code bug or memory corruption
- Check: IRAM usage < 95%
- Report: Open issue with serial log
@@ -437,6 +467,7 @@ Replace:
### Change OTA Password
Edit in source code (line ~60):
```cpp
ArduinoOTA.setPassword("your-secret-password");
```
@@ -444,6 +475,7 @@ ArduinoOTA.setPassword("your-secret-password");
### Change Web Admin Password
Edit in source code (line ~430):
```cpp
if (!server.authenticate("admin", "your-secret-password")) {
```
@@ -453,17 +485,48 @@ if (!server.authenticate("admin", "your-secret-password")) {
To save memory, disable unused features:
**Disable weather:**
- Set `weather_enabled = false` in `/config`
- Or remove weather code from source
**Disable sunrise/sunset:**
- Set `show_sunrise_sunset = false` in `/config`
**Disable display rotation:**
- Set `display_rotation_sec = 0` (manual switch only)
---
## Factory Reset (no tools needed)
Since v1.9.6, three quick power cycles within 10 seconds trigger a factory reset:
1. Power off the device
2. Power on (briefly, < 10 sec)
3. Power off, power on
4. Power off, power on
On the third boot the OLED shows `FACTORY RESET! WiFi: TJ56654-Setup Pass: 12345678`.
Connect your phone to that AP and reconfigure WiFi at `http://192.168.4.1/config`.
Use this if:
- Forgot configured WiFi credentials
- Device stuck in "No WiFi" loop after router change
- Tests/fuzzing corrupted config
## Verifying Installation
Run the test suite against your device:
```bash
python3 tests/test_device.py 192.168.x.x
```
A healthy device passes all 73 tests, with heap drift under 1 KB across the run.
## Getting Help
If you're still stuck:
-218
View File
@@ -1,218 +0,0 @@
# v1.9.1 - Hybrid Async Fix
## Проблема в 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 🚀
-160
View File
@@ -1,160 +0,0 @@
# TJ-56-654 Weather Clock - v1.9.0 Release Notes
## Release Date
2026-01-03
## Overview
Version 1.9 is a major async refactoring that eliminates **ALL blocking operations** from the firmware, transforming the device from a frequently-frozen system into a fully responsive, production-ready clock.
## Performance Improvements
### Before (v1.8):
- **WiFi connection**: 10 seconds blocking (v1.7 credential migration)
- **NTP sync**: 5-20 seconds blocking
- **Weather fetch**: 1-10 seconds blocking
- **Loop delay**: 10ms blocking every iteration
- **Total freeze time**: Up to **45+ seconds**
### After (v1.9):
- **WiFi connection**: 0ms blocking (async state machine)
- **NTP sync**: 0ms blocking (async UDP)
- **Weather fetch**: 0ms blocking (AsyncHTTPRequest)
- **Loop delay**: 0ms (removed)
- **Total freeze time**: **0 seconds** ✅
**Loop responsiveness**: <1ms typical (was 10ms minimum)
## New Features
### 1. Async HTTP Weather Fetch (v1.9.2)
- **Library**: AsyncHTTPRequest_Generic v1.13.0
- **State machine**: IDLE → REQUESTING → SUCCESS/FAILED
- **Callback**: `onWeatherResponse()` processes data non-blocking
- **Result**: OTA updates work during weather fetch
### 2. Async NTP Implementation (v1.9.3)
- **Manual NTP**: Custom UDP packet building/parsing
- **Independent epoch tracking**: `syncedEpoch`, `syncedMillis`, `timeIsSynced`
- **Workaround**: NTPClient library is inherently blocking, so we bypass it
- **State machine**: IDLE → REQUEST_SENT → WAITING → SUCCESS/FAILED
- **Timeout**: 5 seconds non-blocking
### 3. Async WiFi Connection (v1.9.4)
- **v1.7 migration**: Non-blocking credential attempt
- **State machine**: IDLE → CONNECTING → CONNECTED/FAILED
- **Fallback**: WiFiManager (still blocking, but only on first boot)
- **Benefit**: Device stays responsive during connection attempts
### 4. Zero Blocking Delays (v1.9.5)
- **Removed**: `delay(10)` from loop()
- **Replaced**: `delay(3000)` in `showIP()` with scheduled clear via `ipDisplayUntil` timer
- **Kept**: Startup animation delays (acceptable, only runs once in setup)
- **Kept**: Pre-reboot delays (acceptable, device is rebooting anyway)
### 5. Exponential Backoff Retries (v1.9.6)
- **Strategy**: 1s → 2s → 4s (max 3 retries)
- **Struct**: `RetryConfig` with `getBackoffDelay()`, `scheduleRetry()`, `isRetryTime()`
- **Applied to**:
- NTP failures: graceful retry instead of hammering server
- Weather API failures: same exponential strategy
- **Benefit**: Network resilience without aggressive retry behavior
## Memory Footprint
| Resource | v1.8 (baseline) | v1.9.0 (final) | Increase |
|----------|----------------|----------------|----------|
| RAM | 36,980 bytes | 37,516 bytes | +536 bytes (1.4%) |
| IRAM | 61,987 bytes | 61,987 bytes | 0 bytes |
| Flash | 407,500 bytes | 408,540 bytes | +1040 bytes (0.25%) |
### Memory Budget Status:
- **RAM**: 37,516 / 80,192 bytes (46%) - ✅ Safe
- **IRAM**: 61,987 / 65,536 bytes (94%) - ⚠️ Near limit but stable
- **Flash**: 408,540 / 1,048,576 bytes (38%) - ✅ Plenty of room
**Verdict**: Less than 1.5% RAM increase for fully async operation - excellent ROI!
## Code Quality Improvements
### Line Count:
- **v1.8**: ~1,950 lines
- **v1.9**: 2,026 lines (+76 lines for async infrastructure)
### New Data Structures:
```cpp
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED, SKIP_ASYNC };
struct RetryConfig {
uint8_t maxRetries = 3;
uint8_t currentRetry = 0;
unsigned long nextRetryTime = 0;
unsigned long getBackoffDelay();
void scheduleRetry();
bool isRetryTime();
void reset();
bool maxRetriesReached();
};
```
### Key Functions Added:
1. `onWeatherResponse()` - AsyncHTTPRequest callback
2. `fetchWeatherAsync()` - Non-blocking weather fetch
3. `sendNTPRequestAsync()` - Manual NTP packet send
4. `processNTPResponse()` - Non-blocking NTP response check
5. `processWiFiConnection()` - Async WiFi state handler
6. `getAsyncEpoch()` - Independent time tracking
## Testing Checklist
Before OTA upload to device:
- [x] Compilation successful
- [x] Memory usage within safe limits
- [ ] OTA responsive during weather fetch
- [ ] Web UI responsive during NTP sync
- [ ] Display updates smoothly during network ops
- [ ] Exponential backoff triggers on failures
- [ ] Max retry limits respected
- [ ] Config persistence across reboots
- [ ] 24-hour stability test
## Migration from v1.8
**OTA Upgrade Path**: ✅ Safe
- Config struct unchanged - binary compatible
- All settings preserved
- Smooth transition from v1.7 credentials
**Rollback**: Keep v1.8.bin for emergency rollback via web upload
## Known Limitations
1. **WiFiManager**: Still blocking on first boot (acceptable)
2. **IRAM**: At 94% - future features must use `ICACHE_FLASH_ATTR`
3. **Startup animation**: Still uses blocking delays (acceptable, only runs once)
4. **Test handlers**: Some debug endpoints still block (low priority)
## Next Steps (v2.0)
Future improvements planned for v2.0:
1. **Modular architecture**: Split into separate files
2. **ArduinoJson**: Replace manual JSON parsing
3. **Constants**: Eliminate remaining magic numbers
4. **Code deduplication**: Display helper refactoring
5. **Enhanced error handling**: Pre-flight checks, better validation
## Credits
**Firmware**: TJ-56-654 Weather Clock
**Hardware**: ESP-01S (ESP8266EX, 1MB flash)
**Author**: Generated with Claude Code (Opus 4.5)
**Repository**: clock/firmware/clock_ntp_ota_v1.9
## Conclusion
v1.9 transforms the weather clock from a frequently-frozen device into a **fully responsive**, **production-ready** system with **zero blocking operations**. The 536-byte RAM overhead is a negligible cost for the massive UX improvement of instant responsiveness to OTA, web requests, and display updates even during active network operations.
**Status**: ✅ Ready for OTA deployment
+3
View File
@@ -0,0 +1,3 @@
build/
*.bak
*.bak2
+180
View File
@@ -0,0 +1,180 @@
/*
* 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.10"
// 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 — only IDLE and REQUEST_SENT are used
// (response handler transitions back to IDLE directly on success or timeout)
enum NTPState {
NTP_IDLE,
NTP_REQUEST_SENT
};
// 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
+476
View File
@@ -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
}
+144
View File
@@ -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
+207
View File
@@ -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);
}
}
+175
View File
@@ -0,0 +1,175 @@
/*
* weather.cpp - Weather API functions
* TJ-56-654 Weather Clock v1.9.3
*/
// Include asyncHTTPrequest before globals.h to provide the full type definition
#include <ESPAsyncTCP.h>
#include <asyncHTTPrequest.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 += "&current_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"));
}
+452
View File
@@ -0,0 +1,452 @@
/*
* 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 !!!");
// Atomicity: clear credentials FIRST (saveConfig commits), then zero counter.
// If power fails between the two commits, the device boots into AP mode next
// time (creds are already cleared) — instead of being in a "counter zeroed but
// creds still valid" inconsistent state.
memset(config.ssid, 0, sizeof(config.ssid));
memset(config.password, 0, sizeof(config.password));
EEPROM.end(); // close current handle before saveConfig opens its own
saveConfig(); // commits cleared credentials to flash
EEPROM.begin(512);
rc.count = 0;
EEPROM.put(RESET_COUNTER_ADDR, rc);
EEPROM.commit();
EEPROM.end();
// Show reset screen
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(8, 4);
display.println("FACTORY");
display.setCursor(8, 24);
display.println("RESET!");
display.setTextSize(1);
display.setCursor(2, 48);
display.println("WiFi: TJ56654-Setup");
display.setCursor(2, 57);
display.println("Pass: 12345678");
display.display();
delay(4000);
ESP.restart();
return;
}
EEPROM.put(RESET_COUNTER_ADDR, rc);
EEPROM.commit();
EEPROM.end();
}
// ============ EEPROM functions ============
void ICACHE_FLASH_ATTR loadConfig() {
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);
// Seed PRNG with hardware entropy: chip ID is unique per device,
// micros() varies on each boot due to power-on timing jitter
randomSeed(ESP.getChipId() ^ micros());
Serial.println("\n\nTJ-56-654 NTP Clock with OTA v" FIRMWARE_VERSION);
Serial.println("==========================================");
Serial.println("Display: GM009605v4.3 OLED 128x64 (SSD1306 I2C)");
// 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();
}
}
+710
View File
@@ -0,0 +1,710 @@
/*
* 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;
}
// Single-char SSIDs ("A") are valid per 802.11. Only reject all-same for length>1.
return n == 1 || !allSame;
}
void ICACHE_FLASH_ATTR handleConfigSave() {
// Track if reboot is required (only WiFi/network changes need it)
bool needsRestart = false;
// Validate before saving — reject obviously bad input rather than brick the device
if (server.hasArg("ssid")) {
String s = server.arg("ssid");
if (!isValidSSID(s)) {
server.send(400, "text/plain", "Invalid SSID (1-31 printable chars, not all-same)");
return;
}
if (s != String(config.ssid)) needsRestart = true;
safeStringCopy(s, config.ssid, sizeof(config.ssid));
}
if (server.hasArg("password")) {
String p = server.arg("password");
if (p.length() > 63) {
server.send(400, "text/plain", "Password too long (max 63 chars)");
return;
}
if (p != String(config.password)) needsRestart = true;
safeStringCopy(p, config.password, sizeof(config.password));
}
if (server.hasArg("timezone")) {
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;
}
if (h != String(config.hostname)) needsRestart = true; // mDNS bind on boot
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();
unsigned long newInterval = constrain(ni, 60L, 86400L);
if (newInterval != config.ntp_interval) needsRestart = true; // NTPClient constructed at boot with this
config.ntp_interval = newInterval;
}
if (server.hasArg("ntp_server")) {
String n = server.arg("ntp_server");
if (n.length() == 0 || n.length() > 63) {
server.send(400, "text/plain", "Invalid NTP server (1-63 chars)");
return;
}
if (n != String(config.ntp_server)) needsRestart = true; // NTPClient re-init
safeStringCopy(n, config.ntp_server, sizeof(config.ntp_server));
}
if (server.hasArg("display_rotation_sec")) {
config.display_rotation_sec = 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();
// Only restart for WiFi/network changes; other settings apply live
if (needsRestart) {
server.send(200, "text/html",
F("<!DOCTYPE html><meta charset='UTF-8'>"
"<meta http-equiv='refresh' content='5;url=/'>"
"<h1>Configuration Saved!</h1>"
"<p>WiFi/network changed — device will reboot in 5 seconds...</p>"));
delay(1000);
ESP.restart();
} else {
server.send(200, "text/html",
F("<!DOCTYPE html><meta charset='UTF-8'>"
"<meta http-equiv='refresh' content='2;url=/'>"
"<h1>Configuration Saved</h1>"
"<p>Applied without reboot. Returning to main page...</p>"));
}
}
void ICACHE_FLASH_ATTR handleAPITime() {
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);
}
}
+191
View File
@@ -0,0 +1,191 @@
/*
* 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 + password to config
if (strlen(config.ssid) == 0) {
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
saveConfig();
}
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());
// Persist the password too, not just the SSID. The SDK keeps the
// password in its own flash area, but every later boot reads
// config.password: an empty one is treated as an open network
// (WiFi.begin(ssid)) and fails with WL_WRONG_PASSWORD (issue #12).
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
saveConfig();
showIP();
wifiConnState = WIFI_CONN_CONNECTED;
return;
}
}
// Try 2: If we have EEPROM credentials, try those (M2: support open networks)
if (strlen(config.ssid) > 0) {
Serial.println("\nTrying EEPROM credentials...");
if (strlen(config.password) > 0) {
WiFi.begin(config.ssid, config.password);
} else {
WiFi.begin(config.ssid); // open network — no password
}
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());
// Save both SSID and password (issue #12): WiFiManager stores them in the
// SDK flash, but the next boot connects from config.* only.
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
safeStringCopy(WiFi.psk(), config.password, sizeof(config.password));
saveConfig();
showIP();
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);
}
+19
View File
@@ -0,0 +1,19 @@
[platformio]
src_dir = firmware/weather_clock
[env:esp01_1m]
platform = espressif8266
board = esp01_1m
framework = arduino
board_build.ldscript = eagle.flash.1m64.ld
board_build.flash_mode = dio
upload_speed = 115200
upload_resetmethod = nodemcu
lib_deps =
adafruit/Adafruit GFX Library
adafruit/Adafruit SSD1306
arduino-libraries/NTPClient
tzapu/WiFiManager
boblemaire/asyncHTTPrequest @ 1.2.2
me-no-dev/ESPAsyncTCP
bblanchon/ArduinoJson @ ^7
File diff suppressed because it is too large Load Diff
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
Hardware-in-the-loop test suite for ESP8266 Weather Clock firmware.
Runs against a live device via HTTP API.
Usage:
python3 tests/test_device.py [device_ip]
python3 tests/test_device.py 192.168.2.47
"""
import sys
import json
import time
import urllib.request
import urllib.parse
import urllib.error
from dataclasses import dataclass, field
from typing import Any
DEVICE_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.2.47"
BASE_URL = f"http://{DEVICE_IP}"
TIMEOUT = 10
# Safe config values to restore after fuzz tests
SAFE_CONFIG = {
"ssid": "", # keep empty — don't overwrite real credentials
"ntp_interval": "3600",
"weather_interval": "1800",
"brightness": "4",
"timezone_offset": "0",
"latitude": "37.19",
"longitude": "-8.54",
"hostname": "tj56654-clock",
"ntp_server": "pool.ntp.org",
"city_name": "Portimao",
}
# Saved before fuzz, restored after
_config_backup: dict = {}
# ─── HTTP helpers ────────────────────────────────────────────────────────────
def get(path) -> tuple[int, str]:
try:
r = urllib.request.urlopen(f"{BASE_URL}{path}", timeout=TIMEOUT)
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
except Exception as e:
return 0, str(e)
def get_json(path) -> tuple[int, dict | None]:
status, body = get(path)
try:
return status, json.loads(body)
except Exception:
return status, None
def post_form(path, data: dict) -> tuple[int, str]:
encoded = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(f"{BASE_URL}{path}", data=encoded, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
r = urllib.request.urlopen(req, timeout=TIMEOUT)
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
except Exception as e:
return 0, str(e)
def post_raw(path, body: bytes, content_type="application/json") -> tuple[int, str]:
req = urllib.request.Request(f"{BASE_URL}{path}", data=body, method="POST")
req.add_header("Content-Type", content_type)
try:
r = urllib.request.urlopen(req, timeout=TIMEOUT)
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
except Exception as e:
return 0, str(e)
# ─── Test runner ─────────────────────────────────────────────────────────────
@dataclass
class Result:
name: str
passed: bool
detail: str = ""
results: list[Result] = []
def test(name: str, passed: bool, detail: str = ""):
r = Result(name, passed, detail)
results.append(r)
icon = "✅" if passed else "❌"
print(f" {icon} {name}" + (f" — {detail}" if detail else ""))
return passed
# ─── Test suites ─────────────────────────────────────────────────────────────
def suite_connectivity():
print("\n📡 Connectivity")
status, body = get("/")
test("Device reachable", status == 200, f"HTTP {status}")
test("Returns HTML", "<html" in body.lower() or "<!DOCTYPE" in body.lower(),
f"{len(body)} bytes")
test("Version present", "v1.9" in body, body[:80] if body else "empty")
def suite_api_time():
print("\n🕐 /api/time")
status, data = get_json("/api/time")
test("HTTP 200", status == 200, f"got {status}")
if data is None:
test("Valid JSON", False, "parse error"); return
test("Valid JSON", True)
test("Has 'time' field", "time" in data, str(data.keys()))
if "time" in data:
t = data["time"]
test("Time format HH:MM:SS", len(t) == 8 and t[2] == ":" and t[5] == ":",
f"got '{t}'")
test("Has 'hours'", "hours" in data)
test("Has 'minutes'", "minutes" in data)
test("Has 'epoch'", "epoch" in data and data["epoch"] > 1700000000)
def suite_api_weather():
print("\n🌤 /api/weather")
status, data = get_json("/api/weather")
test("HTTP 200", status == 200, f"got {status}")
if data is None:
test("Valid JSON", False, "parse error"); return
test("Valid JSON", True)
test("enabled=true", data.get("enabled") == True)
test("valid=true", data.get("valid") == True, "weather data may not be fetched yet")
if data.get("valid"):
t = data.get("temperature", 0)
test("Temperature sane [-50, 70]", -50 <= t <= 70, f"{t}°C")
w = data.get("windspeed", 0)
test("Windspeed ≥ 0", w >= 0, f"{w} km/h")
wc = data.get("weathercode", -1)
test("Weathercode ≥ 0", wc >= 0, f"code={wc}")
test("Has sunrise", "sunrise" in data, str(data.get("sunrise")))
test("Has sunset", "sunset" in data, str(data.get("sunset")))
def suite_api_status():
print("\n📊 /api/status")
status, data = get_json("/api/status")
test("HTTP 200", status == 200)
if data is None:
test("Valid JSON", False, "parse error"); return
test("Valid JSON", True)
wifi = data.get("wifi", {})
test("Has wifi.ssid", "ssid" in wifi, str(wifi))
test("Has wifi.ip", "ip" in wifi)
test("Has wifi.rssi", "rssi" in wifi)
if "rssi" in wifi:
test("RSSI in realistic range [-100, 0]", -100 <= wifi["rssi"] <= 0,
f"{wifi['rssi']} dBm")
sys_ = data.get("system", {})
test("Has system.uptime", "uptime" in sys_)
test("Has system.free_heap", "free_heap" in sys_)
if "free_heap" in sys_:
heap = sys_["free_heap"]
test("Heap > 8KB (not fragmented)", heap > 8192, f"{heap} bytes free")
test("Heap > 20KB (healthy)", heap > 20480, f"{heap} bytes free")
def suite_api_debug():
print("\n🔍 /api/debug")
status, data = get_json("/api/debug")
test("HTTP 200", status == 200)
if data is None:
test("Valid JSON", False, "parse error"); return
test("Valid JSON", True)
test("Has internet_connected", "internet_connected" in data)
test("internet_connected=true", data.get("internet_connected") == True)
test("Has ntp_attempts", "ntp_attempts" in data)
test("Has ntp_successes", "ntp_successes" in data)
attempts = data.get("ntp_attempts", 0)
successes = data.get("ntp_successes", 0)
test("NTP attempted at least once", attempts >= 1, f"{attempts} attempts")
test("NTP success rate > 0", successes >= 1, f"{successes}/{attempts}")
test("last_error field present", "last_error" in data)
test("last_error is string", isinstance(data.get("last_error"), str))
def backup_config():
"""Save safe fields before fuzzing."""
global _config_backup
_, data = get_json("/api/status")
_config_backup = {
"ntp_interval": "3600",
"weather_interval": "1800",
"brightness": "4",
"timezone_offset": "0",
"ntp_server": "pool.ntp.org",
"hostname": "tj56654-clock",
}
print(" 💾 Config backup saved")
def restore_config():
"""Restore safe config after fuzzing — prevents bricking on bad values."""
status, _ = post_form("/config", SAFE_CONFIG)
time.sleep(1)
ok = status in (200, 302)
print(f" 🔄 Config restored {'✅' if ok else '❌'} (HTTP {status})")
return ok
def suite_fuzz_config():
print("\n🧨 Fuzz: /config boundary values")
backup_config()
# Save current config to restore later
_, before = get_json("/api/status")
cases = [
# (description, field, value, expect_no_crash)
("ntp_interval=0 (DoS risk)", "ntp_interval", "0", True),
("ntp_interval=86401 (over max day)", "ntp_interval", "86401", True),
("weather_interval=0", "weather_interval", "0", True),
("brightness=255 (uint8 overflow)", "brightness", "255", True),
("brightness=-1", "brightness", "-1", True),
("timezone_offset=99999999", "timezone_offset", "99999999", True),
("timezone_offset=-99999999", "timezone_offset", "-99999999", True),
("latitude=999 (invalid)", "latitude", "999", True),
("latitude=-999", "latitude", "-999", True),
("longitude=999", "longitude", "999", True),
("ssid=A*100 (overflow char[32])", "ssid", "A" * 100, True),
("password=B*200 (overflow char[64])","password", "B" * 200, True),
("hostname=C*100 (overflow char[32])","hostname", "C" * 100, True),
("ntp_server=D*200 (overflow char[64])","ntp_server", "D" * 200, True),
]
for desc, field_name, value, expect_survive in cases:
status, body = post_form("/config", {field_name: value})
survived = status in (200, 302, 400) # any response = not crashed
test(desc, survived, f"HTTP {status}")
# Always restore safe config after fuzzing — critical to prevent bricking
restore_config()
# Verify device still alive after fuzzing
time.sleep(1)
status, data = get_json("/api/status")
test("Device alive after fuzz", status == 200 and data is not None,
f"HTTP {status}")
def suite_fuzz_config_import():
print("\n🧨 Fuzz: /api/config import (JSON endpoint)")
cases = [
("Empty body", b""),
("Not JSON", b"this is not json at all!!!"),
("Partial JSON", b'{"ssid": "test"'),
("Null JSON", b"null"),
("Array JSON", b"[]"),
("Nested bomb", b'{"a":{"b":{"c":{"d":{"e":"f"}}}}}'),
("Very large string", json.dumps({"ssid": "X" * 5000}).encode()),
("Unicode", '{"ssid": "тест-сеть"}'.encode("utf-8")),
("Zero bytes field", json.dumps({"ntp_interval": 0}).encode()),
("Negative interval", json.dumps({"weather_interval": -1}).encode()),
("NaN float", b'{"latitude": "NaN"}'),
("Inf float", b'{"latitude": "Infinity"}'),
("SQL-like injection", b'{"ssid": "\'; DROP TABLE config; --"}'),
("HTML injection", b'{"ssid": "<script>alert(1)</script>"}'),
("Null bytes", b'{"ssid": "test\x00hidden"}'),
]
for desc, body in cases:
status, resp = post_raw("/api/config", body)
survived = status != 0 # got any response = not crashed/hung
test(desc, survived, f"HTTP {status}")
restore_config()
time.sleep(1)
status, _ = get_json("/api/status")
test("Device alive after import fuzz", status == 200, f"HTTP {status}")
def suite_stability():
print("\n⏱ Stability: heap trend over 5 requests")
heaps = []
for i in range(5):
_, data = get_json("/api/status")
if data and "system" in data:
heaps.append(data["system"].get("free_heap", 0))
time.sleep(0.5)
if heaps:
test("Got heap samples", len(heaps) == 5, f"{len(heaps)}/5")
min_heap = min(heaps)
max_heap = max(heaps)
drift = max_heap - min_heap
test("Heap stable (drift < 2KB)", drift < 2048,
f"min={min_heap} max={max_heap} drift={drift}")
test("Min heap > 20KB", min_heap > 20480, f"min={min_heap}")
print(f" Heap samples: {heaps}")
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
print(f"🔌 Testing device at {BASE_URL}")
print("=" * 55)
suite_connectivity()
suite_api_time()
suite_api_weather()
suite_api_status()
suite_api_debug()
suite_fuzz_config()
suite_fuzz_config_import()
suite_stability()
print("\n" + "=" * 55)
passed = sum(1 for r in results if r.passed)
failed = sum(1 for r in results if not r.passed)
total = len(results)
print(f"Results: {passed}/{total} passed", end="")
if failed:
print(f" ({failed} failed)")
print("\nFailed tests:")
for r in results:
if not r.passed:
print(f" ❌ {r.name}" + (f" — {r.detail}" if r.detail else ""))
else:
print(" ✅ All passed!")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())