ivãstival

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.

DeadlineValueCovers
Connection6 sTCP establishment, after DNS already succeeded
Request8 sHeaders and body
Refresh600 sTime 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.

StateMeaningNext
DisabledNo configured SSID and fallback disabledNetwork features stay unavailable
ConnectingAssociation or reconnect in progressConnects, or fails after the timeout
ConnectedAssociatedRecords RSSI, SSID, IP; enables NTP, weather, OTA
FailedInitial association timed outAfter a retry wait, disconnects and begins again
Setup hotspotNo station connectionWeb 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

Three horizontal bars, four megabytes of flash each, drawn to scale. The core's default scheme has two app slots of 1,310,720 bytes; a dashed line marking the current firmware at 1,399,376 bytes falls past the end of that slot. min_spiffs has two slots of 1,966,080 bytes, 71 percent used, with the second slot intact. huge_app has one slot of 3,145,728 bytes and no second slot, and therefore no OTA.

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_spiffshuge_app
Application slot1,966,080 B3,145,728 B
Second slot1,966,080 Bnone
SPIFFS131,072 B917,504 B
Can flash over Wi-Fiyesno

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

Four panels showing an update. Running from app0 with otadata pointing at app0; uploading into app1 while app0 keeps running; finalising, where Update.end(true) rewrites otadata to app1; and rebooting, where the bootloader reads otadata and runs app1 while the old app0 image stays intact.

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

SymptomCause
Clock panel shows a placeholder long after bootNTP has not answered; the clock-validity flag is still false. Check DNS, not the clock
Clock is right but hours outThe 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 doesGateway or DNS problem; the boot log prints both
Weather age keeps growing, reading unchangedFetches are failing; the old reading is kept deliberately
Device on the LAN but not reachable by hostnamemDNS, not this layer — see the web API
A misconfigured network never falls back to the hotspotShould not happen — the fallback measures from the offline clock, which retries do not reset. Check the fallback-enabled setting
OTA upload rejected immediatelyA busy flag — a browser upload is in progress, or vice versa
OTA acknowledgements time outOTA servicing is being called at frame rate instead of loop rate
Upload succeeds, device never comes backNo health rollback exists; recover over USB
ota absent from device-identity capabilitiesOver-the-air support disabled at compile time, or the build was not compiled with it
Bootloader refuses the image after a manual flashCompile and upload disagreed about the partition scheme

Configuration reference

SettingDefaultEffect
Wi-Fi association timeout20 sAssociation attempt before it is marked failed
Wi-Fi retry wait30 sWait before retrying a failed association
Setup hotspot fallbackEnabledStart the setup hotspot when there is no usable network
Setup hotspot fallback delay60 sHow long to fail before falling back
Setup hotspot lifetime30 sHotspot 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 PauloSent to Open-Meteo
Weather unitsMetricCelsius and km/h; imperial gives Fahrenheit and mph
Weather refresh interval10 minTime between successful fetches
OTAEnabledCompiles over-the-air updates in
OTA hostnameDevice hostnameWhat the upload tooling targets
OTA passwordEmptyEmpty accepts any LAN upload

Weather units and labels still read the compiled default rather than the stored runtime setting.


Files referenced

ComponentResponsibility
Network moduleThe shared snapshot contract and the network-state vocabulary
Network moduleWi-Fi polling, NTP validity, the core-0 weather task, snapshot locking
OTA moduleOTA lifecycle surface
OTA moduleDeferred startup, transfer callbacks, progress rendering
Main sketchFull-rate OTA servicing ahead of the frame gate
Configuration templateNetwork, NTP, weather and OTA template
Credential sync scriptGenerates the local credentials header from the ignored environment file
Build scriptPins the partition scheme on both compile and upload
Screens moduleHonest unavailable and in-progress presentation

See also