ESP32 · 05-network
Architecture: Web Management and HTTP API
Note: This document was generated by an AI agent.
Overview
The device serves its own management UI and a 26-route HTTP API, using the ESP32 Arduino web server library. There is no filesystem and no room to buffer a response, and those two absences shape every route on the page.
The web server refuses to start without a configured password, and starts once the device is online — either connected to a network or running its own setup hotspot. It runs ahead of the render loop's 33 ms frame gate, on the same task as rendering — so handlers share a core with the renderer, and anything slow is the renderer's problem too.
Serving a UI with no filesystem
An ESP32 sketch has three places to put a web page: SPIFFS/LittleFS, an SD card, or the application image itself. This project uses the third, and the reasoning is about deployment rather than bytes:
The UI and the API it calls ship together, roll back together, and there is no second flash step to forget.
A page on a filesystem is a second artefact with its own version, and an OTA update that replaces the firmware but not the filesystem leaves a page calling routes that changed underneath it. Embedding makes that unrepresentable.
A generator script gzips the page's HTML at maximum compression into a byte
array baked into the firmware image, and the server sends it directly from
flash with a Content-Encoding: gzip header — so the browser decompresses it
and the firmware never copies the page into RAM at all.
web/index.html 47,047 bytes
gzip -9 14,313 bytes (69.6 % smaller)
WebAssets.generated.h 85,878 bytes of C source
The last row is worth understanding before anyone worries about it: each byte
becomes six characters of source (0xNN, ), so the generated header is roughly
6× the data it carries. What reaches flash is the 14,313 bytes, not the 85.9 KB
the file browser shows.
Two conventions keep that header honest. It is checked in and regenerated by hand, following the project's convention for other generated data files, rather than adding a build-time Python dependency. And it is compressed with a fixed modification time, so regenerating an unchanged page produces a byte-identical header instead of a spurious diff.
Three ways to answer, all of them free
The board's largest contiguous block is about 53 KB. Nothing here may assemble a response before sending it.
Why not String
The web server library's API is String-heavy, and building responses that way fragments the heap over a long uptime. The symptom is specific and unpleasant: a shrinking largest-free-block while total free heap still looks fine — a device that has been up for days and starts refusing allocations it made happily at boot.
So responses are serialized into one shared, fixed-size buffer — 1,536 bytes — declared once at file scope rather than built up dynamically.
The status endpoint reports the largest allocatable block alongside total free heap precisely so that regression is visible from outside if it ever creeps back in. See the contiguous-heap ledger for what else competes for that block.
Truncation is refused, not silently emitted. The JSON serializer stops at
the buffer end and returns what it wrote, which is indistinguishable from a
document that happened to fit — so an oversized response used to go out as a
Content-Length matching a body that ended mid-object, and the client saw
well-formed HTTP wrapping invalid JSON. The response path now measures the
exact size first, and answers 500 rather than corrupt the body.
The settings endpoint is the route that found this. At seventeen settings its response reached about 1,554 bytes against the 1,536-byte buffer — it fitted at sixteen, and one more setting crossed the line. It is now streamed instead, so its cost is one entry rather than the whole table and no future setting can overrun anything.
Chunked transfer, for the responses that cannot be counted
Some responses are not bounded by anything the firmware controls. The scene list is 55 entries and growing; a Wi-Fi scan is however many networks are in range.
HTTP's answer is chunked transfer encoding, and the web server library emits it when the length is declared unknown: the response declares its length as unknown up front, then sends the JSON array in repeated pieces followed by the closing bracket.
Each scene is serialized into a 192-byte stack buffer and pushed straight out:
55 scenes x up to 192 bytes = up to ~10.5 KB of response
actual buffer = 192 bytes
The cost is one item, not the collection — which is what makes the response cost independent of the registry's size. A brief yield between entries is cheap insurance against starving the watchdog on a slow link, since this is all happening on the loop task.
The WAV download uses the same idea with a fixed length instead: the duration is known, so the header's fields can be computed before the first byte, and the PCM streams in 2,048-sample (4 KB) pieces. See audio diagnostics.
The handler budget
Handlers run inline on the loop task between rendered frames, so a handler may not block for more than about 50 ms without the renderer showing it. That single constraint explains the shape of several routes:
| Feature | Would block | Instead |
|---|---|---|
| Starting an audio test recording | A whole recording | Returns at once; capture continues in the background; client polls |
| Scanning for Wi-Fi networks | Seconds, inside the scan itself | Kicks off an async scan, returns immediately, client polls |
| Changing Wi-Fi credentials | A reconnect | Stores credentials, defers the reconnect |
| Sending text to the assistant | A whole turn | Queues to the local agent's worker |
Two routes deliberately break the rule, because there is no alternative: firmware upload takes over the loop and draws its own progress on the OLED, and the recording download streams synchronously. Both can delay frames, and both are operator actions rather than background traffic.
Authentication and discovery
Every registered route requires HTTP Basic authentication except one public
device-identity endpoint. The default username is admin; the password is set
at build time from local configuration. The transport is plain HTTP, so
Basic auth does not encrypt anything — the password is base64 on the wire,
which is encoding, not protection. There is no user database, no token, and no
role system. The open setup hotspot still requires the same password.
State-changing handlers check Origin when present: it must equal http:// plus
the request's Host. Requests without Origin — ordinary curl calls — are
accepted after authentication. This is CSRF protection, not an access control:
the browser attaches cached credentials to a same-origin request automatically,
which makes a cross-site form or fetch a real risk anywhere a GET is not the
whole story. Firmware upload applies the check at upload start and reports 409
rather than the 403 every other route uses.
Discovery is deliberately public metadata:
- mDNS advertises two service types on port 80, one of them with
id,fw,nameandapiTXT fields. - A UDP discovery port answers the exact probe
ROBOTFACE?with the same JSON identity the device-identity endpoint returns. Send it without a newline: the comparison includes the string terminator too, so trailing characters matter. - Identity carries
id,name,hostname,model,board,fw,api,mac,ip,mode,uptime_s,free_heap,max_allocandcaps. capsalways includessettings,panels,scenes,wifi,firmware; build flags addota,ambient,voice,audio_testandbattery.
Capabilities describe the build, not the hardware. voice in caps means
the firmware was compiled with it, not that a microphone is attached, working, or
that the LAN services are running. If the web service refuses to start or is
compiled out, HTTP identity and UDP discovery go with it; ArduinoOTA is a
separate service.
Route catalogue
JSON examples use representative values; read metadata from the device rather than hardcoding it.
Core
| Capability | Input | Success | Rejections |
|---|---|---|---|
| Serve the browser page | None | 200, gzip HTML from flash | Auth challenge |
| Report device identity | None; public | 200, identity and build capabilities | Service must be running |
| Report live status | None | 200, live network/weather/battery/system snapshot | Auth challenge |
| Read settings | None | 200, all_saved and settings[] metadata | Auth challenge |
| Write settings | Flat JSON object, key → integer | Per-key results, all_saved; accepted keys apply at once | 400 invalid body/key/range; 409 would hide every panel |
| Reset settings to defaults | No body | 200, snapshot after reset to compiled defaults | Auth, origin |
| Scan for Wi-Fi networks | None | 202 while scanning; 200 with networks[] when done | Auth |
| Change Wi-Fi credentials | {"ssid":"…","password":"…"} | 200, {"ok":true,"reconnecting":true}; deferred | 400 empty SSID, SSID > 32 B, password > 64 B |
| Upload new firmware | Multipart .bin in field firmware | 200, {"ok":true,"rebooting":true} | 409 upload/origin conflict; 500 update error |
Assistant
| Capability | Input | Success | Rejections |
|---|---|---|---|
| List assistant providers | None | Selected provider, and each provider's model, configured, local | Auth |
| Change assistant provider | {"selected":"gemini"} or {"provider":"gemini","model":"…"} | Saves and returns the same status | 400 invalid, or a key field; 409 unconfigured selection; body > 768 B |
| Read assistant status | None | turn_id, busy, last reply, current action, event ring | Auth |
| Send text to the assistant | {"text":"…"} | Runs a turn through the spoken path without a microphone | Auth, origin |
| Read voice diagnostics | None | Wake and transcription diagnostics; fields vary with build flags | Auth |
| Trigger a device action directly | {"cmd":"happy"} | Dispatches a real action with no model involved | 404 unknown or unavailable command |
API keys are not accepted here and are not stored on the device. Sending a
key field is an explicit 400 — "keys belong with the proxy, not on the
device" — because a secret has no business on a board that accepts firmware
uploads from the local network. Cloud replies go through a separate proxy
process over plain HTTP on the LAN, which owns the credentials; see
the local pipeline.
Model names allow ASCII letters, digits, -, _, . and :.
Scenes
| Capability | Contract |
|---|---|
| List scenes | 200 with count and scenes[], streamed chunked; 409 in Classic style |
| Play a scene | {"id":2} selects that registry scene; 404 outside the registry; 409 in Classic |
| Reorder scenes | order[] is a permutation of every registry index; enabled[] is a boolean array indexed by scene id |
| Reset scene order | Restores default order and rotation mask; {"ok":true} |
Each entry carries id, name, duration_ms, loop, enabled, auto_rotate
and order. enabled in a read means the scene exists and is playable;
enabled[] in a write changes auto-rotation instead — it does not make an
empty user slot playable. It stays indexed by id even when order[] has been
rearranged. Send both arrays complete, using the count the device returned; the
registry currently has 55 entries including eight user slots.
The whole table is one call rather than a per-scene write, so a reorder is a single atomic write — a partial or malformed table must never reach the registry.
Audio and diagnostics
Registered only when the firmware is built with voice audio support; a compiled-out route reaches the authenticated 404 handler. These are catalogued with their behaviour in audio diagnostics — status, tone test, level monitor, recording, playback download, discard, and speak.
Malformed requests generally return {"error":"reason"}. Unknown routes
authenticate first, then 404. Authentication challenges come from the web
server library and are not JSON.
Requests authenticate with HTTP Basic auth; providing the password interactively rather than embedding it in the command keeps it out of shell history.
What the responses promise, and what they do not
The status response always includes net.state and system. Station
metadata appears only while connected; AP metadata uses ap_ssid and ap_ip.
time, weather and battery appear only when their validity or presence
flags allow it — missing weather is weather_fetching, never a fabricated
zero reading. The time string is local, with no UTC offset.
Reading settings returns key, value, min, max, default, saved and
panel per entry. A write returns one of ok, session_only, unknown_key,
out_of_range or would_hide_all per key, and:
- A batch is sequential, not a transaction. Earlier valid writes stay applied when a later key fails.
- The HTTP status is the numerically greatest per-key status, which is a summary, not a description.
session_onlyreturns 200 because RAM did change — the value is live and will not survive a reboot.- Values are coerced to integers by the JSON library; send actual integers.
scenes_played, reported in the voice diagnostics, counts dispatcher calls,
not pixels: Classic mode ignores scene selection, and another UI mode may
cover the face. underruns climbs during ordinary idle silence. Judge both
against known audio, never the absolute counter.
Downloading the recorded test clip is destructive — it frees the clip, and frees it even when the client disconnects mid-download. It is not a repeatable media URL.
The browser page
The page has Network, Display, Animations, Audio Test, Assistant and Firmware tabs, and it may reference no external script, font or stylesheet: a board in access-point mode is sometimes the only thing reachable.
It discovers rather than assumes. Settings metadata is loaded dynamically, and the scene and audio tabs reveal themselves by probing their routes and hiding on failure — rather than inferring from a firmware version string what a build contains. Polling is scoped: status every 5 s, audio status about every 100 ms while that tab is open, an in-progress Wi-Fi scan every second. Audio polling schedules the next request after the last one returns, so requests cannot accumulate under a slow link.
The recorded clip is fetched into a Blob before being handed to the <audio>
element, because a media element's own request is a different credential context
and does not reliably reuse the page's cached Basic-Auth credentials — it fails
silently when it does not. Firmware upload uses XMLHttpRequest for progress
events and reloads after success. There is no bundler and no package manager.
Firmware upload
Upload the compiled application image, not a merged bootloader/partition image. That output is produced by the build and is not tracked. Browser updating does not depend on whether over-the-air updates are compiled in, but both update paths need an application partition layout with a second slot — see data and OTA.
A pair of busy flags stop the two update mechanisms starting together. A failed transfer preserves the running image, because the inactive slot is what was being written. There is no health rollback after successfully flashing a bad application: the image validated, so the bootloader will keep choosing it.
Failure modes
| Symptom | Cause |
|---|---|
A route answers 500 response does not fit the shared body buffer | The response builder refusing to truncate — that route needs streaming, as settings and scenes do |
| Device refuses allocations after days of uptime, free heap looks fine | Heap fragmentation — check the largest allocatable block, not total free heap |
| UDP discovery never answers | The probe carried a trailing newline; the comparison includes the terminator |
| A route 404s that the documentation lists | Compiled out by its feature flag; unknown routes authenticate first |
| Cross-site request rejected from a tool that worked before | The tool started sending an Origin header; it must match the request's own host |
| A provider update returns 400 on a valid model | A key field was included; keys live with the proxy, not the device |
| Frames stutter during a download or upload | Expected: both stream synchronously on the loop task |
| A scene reorder reports a permutation error after a valid order | Validation failure and storage-save failure share one result |
| The page loads but every API call 401s | The page is served before authentication runs; credentials are per-request |
| Firmware uploaded successfully, device now unreachable | No health rollback exists; recover over USB |
Configuration reference
| Setting | Default | Effect |
|---|---|---|
| Basic auth username | admin | — |
| Basic auth password | — | Required; the web server refuses to start without one |
| Web port | 80 | Also what mDNS advertises |
| Discovery port | 47474 | UDP probe responder |
Files referenced
| Component | Responsibility |
|---|---|
| Web server module | Route registrations, handlers, streaming, discovery |
| Web assets header | The gzipped page, embedded in the firmware image |
| Web asset generator script | Regenerates that header from the page's source HTML |
| Browser client | The browser client and its request payloads |
| Settings module | Setting keys, validation, the results contract |
| Scene-order store | Order and rotation persistence |
| Network module | Deferred Wi-Fi credentials and the live snapshot |
| OTA module | Update exclusion and ArduinoOTA |
See also
- Data, network and weather and OTA — the partition layout both update paths need
- Settings, persistence and assets — what a settings write updates
- Audio diagnostics — the audio test routes in detail
- Local agent pipeline — why no key reaches the device