Files
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

5.1 KiB
Raw Permalink Blame History

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:

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

  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:

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.