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>
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.
- Non-blocking HTTP requests
- Callback-based response handling
- Exponential backoff on failures (1s → 2s → 4s)
- Maximum 3 retries before giving up
For architecture details (async state machines, memory budget, EEPROM layout, factory reset), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
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).
Custom manual NTP implementation:
- Builds raw UDP packets (48 bytes)
- Non-blocking `parsePacket()` checks
- 5-second timeout
- Independent epoch tracking for accuracy between syncs
| `globals.h` | Shared state and extern declarations |
| `display.cpp` | OLED rendering |
| `ntp_client.cpp` | Async NTP sync |
| `weather.cpp` | Open-Meteo API fetch |
| `web_server.cpp` | Web UI and REST API |
| `wifi_manager.cpp` | WiFi connection and resilience |
### v1.9.4: Community Bug Fixes 🐛
Community-reported bugs fixed:
- **Date timezone** (#5): Date now changes at local midnight, not UTC midnight
- **Weather refresh** (#7): Periodic weather updates no longer blocked after first fetch
- **WiFi hotspot** (#3): Device no longer connects to open SDK-cached networks (e.g. public hotspots) when a saved SSID exists, preventing config corruption
- **ArduinoJson v7**: Updated `StaticJsonDocument` → `JsonDocument` for library compatibility
@@ -1066,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: €5 hardware + a weekend of tinkering.
If you have one of these devices, **flash this firmware**. If you're buying IoT gadgets, **always audit them first**. And if something seems insecure, **fix it yourself** - that's the hacker spirit.
Now go make something cool. 🚀
---
**P.S.**: If you found this useful, consider starring the repo. If you found a bug, open an issue. If you want to add Home Assistant screens, let's collaborate - I'm planning that next!
**Author**: apetrochenko
**Date**: 2026-01-06
**Firmware Version**: v1.9.8 (Production Ready)
**Author**: apetrochenko · **License**: MIT · **Firmware**: v1.9.10
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
- **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.
| **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
voidICACHE_FLASH_ATTRhandleConfig(){
// 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.
| 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:
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`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.