ESP32 · 05-network
Architecture: Data, Network, Weather, and OTA
Note: This document was generated by an AI agent.
Overview
The network layer is the firmware's boundary for Wi-Fi, NTP, Open-Meteo and battery sampling. It publishes a mutex-protected snapshot that the controller and every renderer read. A separate OTA boundary waits for that published connection state and then services ArduinoOTA outside the frame limiter.
At startup, Wi-Fi association begins in the background and a weather task starts on the second core, while the OTA service simply records that it is enabled without making any network call yet. On every pass through the main loop, OTA and web servicing run first, and the rendering update — about every 33 ms — reads a fresh network/time/weather snapshot down into the screens, ambient mode and face.
Two rules run through everything below, and they are the same rule seen from different sides: nothing here may block a frame, and nothing here may be believed before it has been checked. The first produces a core-0 task and a poll rate; the second produces the clock's validity flag and the weather's freshness stamp.
Why the clock cannot be trusted at boot
An ESP32 has no battery-backed real-time clock. At power-on its system time is whatever the clock API returns from an uninitialised counter — conventionally the Unix epoch, 1 January 1970 — and reading the local time will happily format that into a perfectly plausible-looking string. A clock panel rendered from it is not blank. It is confidently wrong.
NTP fixes that asynchronously: an SNTP client sends a request and, some hundreds of milliseconds to some seconds later, steps the system clock. Nothing tells the caller when. So the firmware needs a predicate for has the clock been set yet, and the one it uses is deliberately crude: it asks whether the local clock reports a year at or after 2000, which is only true once NTP has replaced the epoch-zero boot clock.
Two details matter. The check passes a zero timeout, so it never waits — it asks what the clock says right now and returns. And the year test works because the failure mode is not a slightly wrong clock but a clock thirty years out: any plausible NTP answer is on one side of 2000 and the un-set boot clock is on the other.
A validity flag is that predicate's answer, and the panels draw a placeholder rather than a time when it is false. That is the project's never-invent-data rule at the point where it is easiest to break, because a wrong clock looks exactly like a right one.
The configured timezone ("<-03>3" by default) is a POSIX TZ specification,
not a zone name — the board carries no timezone database. Its offset is
applied to the synced UTC; the string a panel shows is local time with no
offset printed.
A fetch that must not stall the face
The weather comes from Open-Meteo over HTTP, and the request involves a DNS lookup, a TCP connection, a response body and a JSON parse. Every one of those can take seconds. The render loop has 33 ms.
So the fetch runs on a FreeRTOS task pinned to the second core, given an 8 KB stack that in practice peaks around 2.9 KB with the HTTP client and its buffered response, while the render loop keeps running on the first core.
The task's structure is a poll, not a queue: it wakes every 200 ms, and does something only when a fetch has been requested and Wi-Fi is up. A request is raised by the frame side the moment the device first connects, and again whenever the refresh interval (10 minutes) has elapsed. The frame side never waits for the answer; it reads whatever the snapshot currently holds.
DNS is resolved separately, before the HTTP client is involved at all. The firmware resolves the weather host's IP address up front, rather than letting the HTTP client do it internally.
The reason is specific and is the kind of thing that costs an afternoon: the connection timeout does not bound the underlying DNS wait. A lookup that never gets an answer parks the task indefinitely, and the panel sits on "Fetching…" with nothing on the console to say why. Resolving first turns an unbounded hang into a logged failure.
| Deadline | Value | Covers |
|---|---|---|
| Connection | 6 s | TCP establishment, after DNS already succeeded |
| Request | 8 s | Headers and body |
| Refresh | 600 s | Time between successful fetches |
Open-Meteo sends chunked responses, so the HTTP client reads the whole body before the filtered JSON parse. Plain HTTP is deliberate: this is a public, unauthenticated feed, and using it avoids bundling a TLS root certificate — the same reasoning that moved cloud replies to a LAN proxy. Do not extend that choice to anything carrying secrets or personal data.
What a failed fetch does, and does not, do
A response carrying weather data sets the valid flag and stamps the fetch time. Any failure — DNS, connection, HTTP status, parse, or missing data — preserves the previous valid reading and leaves the valid flag alone. There is no expiry that invalidates it.
That is a deliberate choice with a consequence worth stating: old weather is better than no weather, but only if the age travels with it. It does — the reading's stamp is what lets the panels, and the agent's spoken answers, report a stale reading as stale rather than as current. A separate flag distinguishes a fetch in progress from a failure, so the screens can tell apart four different states that would otherwise all render as "no weather": Wi-Fi disabled, no link, fetch in progress, and fetch failed.
Field-level validation is not strict: individual missing measurements fall back to parser defaults, so a valid reading means the response had weather data in it, not every field arrived.
Wi-Fi lifecycle and snapshots
Station-mode association with automatic reconnect starts in the background and returns immediately. The network update is called from the normal frame path but polls the link only twice per second, so it never waits for association and never spends a frame on a status read.
| State | Meaning | Next |
|---|---|---|
| Disabled | No configured SSID and fallback disabled | Network features stay unavailable |
| Connecting | Association or reconnect in progress | Connects, or fails after the timeout |
| Connected | Associated | Records RSSI, SSID, IP; enables NTP, weather, OTA |
| Failed | Initial association timed out | After a retry wait, disconnects and begins again |
| Setup hotspot | No station connection | Web setup reachable with the configured password |
On a new connection the network layer configures NTP and requests weather at once, and logs IP, subnet mask, gateway and DNS — because LAN traffic can work perfectly while a gateway or DNS problem blocks everything on the internet, and those two failures are indistinguishable from the panel.
Credentials, and which source wins
Stored Wi-Fi credentials take precedence over compiled ones. An empty SSID is a supported state, not an error:
The hotspot is named RobotFace- plus the final three MAC bytes and runs
wildcard DNS on port 53. Open the page at the address shown on the screen —
there is no dedicated captive-portal probe handler, so automatic detection is
not guaranteed. The AP stops after 30 s of connected station operation.
Two clocks, deliberately. One clock records when the current attempt began — it is what the 20 s association timeout and the 30 s retry measure against, and every retry restarts it. A second, separate clock records when the device went offline, is untouched by retries, and is cleared only by a successful connection. The 60 s fallback measures against that second clock.
They were once the same variable, and that was a lockout: the retry restarted the clock every 30 s, so a 60 s threshold could never be reached, and a device whose stored password had gone stale retried forever without offering the hotspot. Recovering it needed a USB cable. A credential change mid-outage does not restart the clock either, so entering a second wrong password does not strand you again.
Changing Wi-Fi credentials over the web API acknowledges, waits about 500 ms, then stages the new credentials. The next network update handles it before the half-second poll gate — so a change takes effect within one frame rather than waiting up to 500 ms — persists the strings, disconnects and re-associates. Persistence errors are not currently reported back to the browser.
The snapshot discipline
The shared snapshot carries network metadata, time validity, weather values with their freshness, and battery values. The weather task writes it from core 0 while the loop renders from core 1, so every writer and reader locks a shared mutex.
Take one snapshot per frame and pass the copy down. Reading shared state repeatedly inside a frame means locking repeatedly, and worse, means two renderers can disagree about what the same frame is showing.
The partition layout, and why it is pinned
Drawn to scale, against the image this repository currently builds.
The build script pins the partition scheme to min_spiffs on both compile
and upload, because the bootloader cannot place the image if the two
disagree. The choice is forced by two numbers:
Boto.ino.bin 1,399,376 bytes
default app slot 1,310,720 overruns by 88,656 bytes - does not fit
min_spiffs app slot 1,966,080 71 % used, 566,704 free - fits, keeps app1
huge_app app slot 3,145,728 44 % used - fits, no app1
The default scheme stopped being an option some time ago; the firmware simply
does not fit in 1.25 MB any more. That leaves a real choice between min_spiffs
and huge_app, and it is not about space:
min_spiffs | huge_app | |
|---|---|---|
| Application slot | 1,966,080 B | 3,145,728 B |
| Second slot | 1,966,080 B | none |
| SPIFFS | 131,072 B | 917,504 B |
| Can flash over Wi-Fi | yes | no |
There is no filesystem in use — the web page lives in flash, embedded in the
firmware image and settings
live in NVS — so huge_app's extra SPIFFS is worthless here, and its extra
megabyte of application space is worth less than the second slot it costs. The
sketch is nowhere near needing 3 MB.
OTA, and what two slots actually buy
The second slot is not redundancy. It is the mechanism.
An update is written into the slot that is not running. otadata is an 8 KB
partition whose only job is to record which slot the bootloader should choose,
and finalizing the update rewrites it as the last step. So the ordering gives
the guarantee for free: a transfer that fails at any point before that rewrite
leaves the pointer untouched, and the running image is still the one that boots.
This is transfer safety, not health rollback. An image that uploads perfectly and then crashes on boot is not covered — it validated, so the bootloader keeps choosing it. The firmware implements no post-boot health check and no automatic reversion. USB remains the recovery path, which is why keeping USB upload working matters even though updating over the air is faster.
Starting it late, on purpose
Startup deliberately makes no network call. The OTA service starts ArduinoOTA once, after the network snapshot first reports a successful connection. A board that joins the network late becomes reachable when it does; a board with no network never blocks at boot waiting to be.
ArduinoOTA is serviced on every loop pass, ahead of the ~30 fps frame gate, and the reason is not politeness: limiting it to the frame rate lets upload acknowledgements time out. During a transfer that servicing does not return to the regular loop at all.
That makes the OTA callbacks the deliberate exception to the project's rendering ownership rule — they draw progress to the display themselves, as the browser upload callbacks do. Progress is redrawn only when the integer percentage changes, which keeps the I2C bus mostly free during the transfer.
A pair of busy flags cross-gate the two upload mechanisms, because both target the same inactive slot and neither would survive the other writing into it.
An OTA password is optional. Empty accepts uploads from anyone on the local network; setting one in the local configuration and passing the same value to the upload tooling enables ArduinoOTA authentication.
Local configuration and credentials
A committed template defines the compiled defaults. The ignored local configuration file includes locally generated credentials when they exist, and otherwise defines empty Wi-Fi credentials — which is why the project builds and runs without any. A local environment file is the source for the Wi-Fi network name and password, the web password, and optional OTA password and username; a sync step regenerates the local credentials header after changing it.
Keep real credentials out of tracked files and out of documentation. The generated credentials header holds them locally and is ignored.
Failure modes
| Symptom | Cause |
|---|---|
| Clock panel shows a placeholder long after boot | NTP has not answered; the clock-validity flag is still false. Check DNS, not the clock |
| Clock is right but hours out | The configured timezone is a POSIX spec, not a zone name — check its sign |
| Weather stuck on "Fetching…" | A DNS lookup with no answer. This is what resolving first was meant to bound |
| Weather works, nothing else on the internet does | Gateway or DNS problem; the boot log prints both |
| Weather age keeps growing, reading unchanged | Fetches are failing; the old reading is kept deliberately |
| Device on the LAN but not reachable by hostname | mDNS, not this layer — see the web API |
| A misconfigured network never falls back to the hotspot | Should not happen — the fallback measures from the offline clock, which retries do not reset. Check the fallback-enabled setting |
| OTA upload rejected immediately | A busy flag — a browser upload is in progress, or vice versa |
| OTA acknowledgements time out | OTA servicing is being called at frame rate instead of loop rate |
| Upload succeeds, device never comes back | No health rollback exists; recover over USB |
ota absent from device-identity capabilities | Over-the-air support disabled at compile time, or the build was not compiled with it |
| Bootloader refuses the image after a manual flash | Compile and upload disagreed about the partition scheme |
Configuration reference
| Setting | Default | Effect |
|---|---|---|
| Wi-Fi association timeout | 20 s | Association attempt before it is marked failed |
| Wi-Fi retry wait | 30 s | Wait before retrying a failed association |
| Setup hotspot fallback | Enabled | Start the setup hotspot when there is no usable network |
| Setup hotspot fallback delay | 60 s | How long to fail before falling back |
| Setup hotspot lifetime | 30 s | Hotspot lifetime after a station connects |
| NTP servers (primary / secondary) | pool.ntp.org, time.nist.gov | |
| Timezone string | "<-03>3" | POSIX TZ spec, not a zone name |
| Weather coordinates (lat / lon) | São Paulo | Sent to Open-Meteo |
| Weather units | Metric | Celsius and km/h; imperial gives Fahrenheit and mph |
| Weather refresh interval | 10 min | Time between successful fetches |
| OTA | Enabled | Compiles over-the-air updates in |
| OTA hostname | Device hostname | What the upload tooling targets |
| OTA password | Empty | Empty accepts any LAN upload |
Weather units and labels still read the compiled default rather than the stored runtime setting.
Files referenced
| Component | Responsibility |
|---|---|
| Network module | The shared snapshot contract and the network-state vocabulary |
| Network module | Wi-Fi polling, NTP validity, the core-0 weather task, snapshot locking |
| OTA module | OTA lifecycle surface |
| OTA module | Deferred startup, transfer callbacks, progress rendering |
| Main sketch | Full-rate OTA servicing ahead of the frame gate |
| Configuration template | Network, NTP, weather and OTA template |
| Credential sync script | Generates the local credentials header from the ignored environment file |
| Build script | Pins the partition scheme on both compile and upload |
| Screens module | Honest unavailable and in-progress presentation |
See also
- Web management and HTTP API — the browser upload path into the same slot
- Settings, persistence and assets — what NVS holds, and what a write costs
- Runtime — the frame budget this layer must not spend