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.
5.1 KiB
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:
AsyncHTTPRequestcallback updatesweatherState(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 inloop()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:
// 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:
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:
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:
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:
- Each boot increments the counter at EEPROM offset 480
- If the device runs for 10s, the counter clears back to 0
- 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:
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
- Check IRAM usage after build — if growing, add
ICACHE_FLASH_ATTRto your new functions - Don't use
Stringin handlers or callbacks — usechar buf[]+snprintf - New shared state between callbacks and loop → declare
volatile - New timer logic → use the subtraction-safe
millis()pattern - New API endpoints → run
tests/test_device.pyagainst the device to verify heap stability
For more context on past design decisions, see CHANGELOG.md.