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>
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>
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>
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>
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>
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>
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>