ivãstival

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

Three lanes. The first sends the gzipped page straight out of flash with send_P: 47,047 bytes as authored, 14,313 gzipped, zero heap. The second serializes into a single static 1,536-byte buffer with a known Content-Length, never a String, and is flagged because the settings response now needs 1,554 bytes. The third leaves the Content-Length unknown so the body is chunked, building each item in a 192-byte stack buffer with a yield between them.

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:

FeatureWould blockInstead
Starting an audio test recordingA whole recordingReturns at once; capture continues in the background; client polls
Scanning for Wi-Fi networksSeconds, inside the scan itselfKicks off an async scan, returns immediately, client polls
Changing Wi-Fi credentialsA reconnectStores credentials, defers the reconnect
Sending text to the assistantA whole turnQueues 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, name and api TXT 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_alloc and caps.
  • caps always includes settings, panels, scenes, wifi, firmware; build flags add ota, ambient, voice, audio_test and battery.

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

CapabilityInputSuccessRejections
Serve the browser pageNone200, gzip HTML from flashAuth challenge
Report device identityNone; public200, identity and build capabilitiesService must be running
Report live statusNone200, live network/weather/battery/system snapshotAuth challenge
Read settingsNone200, all_saved and settings[] metadataAuth challenge
Write settingsFlat JSON object, key → integerPer-key results, all_saved; accepted keys apply at once400 invalid body/key/range; 409 would hide every panel
Reset settings to defaultsNo body200, snapshot after reset to compiled defaultsAuth, origin
Scan for Wi-Fi networksNone202 while scanning; 200 with networks[] when doneAuth
Change Wi-Fi credentials{"ssid":"…","password":"…"}200, {"ok":true,"reconnecting":true}; deferred400 empty SSID, SSID > 32 B, password > 64 B
Upload new firmwareMultipart .bin in field firmware200, {"ok":true,"rebooting":true}409 upload/origin conflict; 500 update error

Assistant

CapabilityInputSuccessRejections
List assistant providersNoneSelected provider, and each provider's model, configured, localAuth
Change assistant provider{"selected":"gemini"} or {"provider":"gemini","model":"…"}Saves and returns the same status400 invalid, or a key field; 409 unconfigured selection; body > 768 B
Read assistant statusNoneturn_id, busy, last reply, current action, event ringAuth
Send text to the assistant{"text":"…"}Runs a turn through the spoken path without a microphoneAuth, origin
Read voice diagnosticsNoneWake and transcription diagnostics; fields vary with build flagsAuth
Trigger a device action directly{"cmd":"happy"}Dispatches a real action with no model involved404 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

CapabilityContract
List scenes200 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 scenesorder[] is a permutation of every registry index; enabled[] is a boolean array indexed by scene id
Reset scene orderRestores 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_only returns 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

SymptomCause
A route answers 500 response does not fit the shared body bufferThe response builder refusing to truncate — that route needs streaming, as settings and scenes do
Device refuses allocations after days of uptime, free heap looks fineHeap fragmentation — check the largest allocatable block, not total free heap
UDP discovery never answersThe probe carried a trailing newline; the comparison includes the terminator
A route 404s that the documentation listsCompiled out by its feature flag; unknown routes authenticate first
Cross-site request rejected from a tool that worked beforeThe tool started sending an Origin header; it must match the request's own host
A provider update returns 400 on a valid modelA key field was included; keys live with the proxy, not the device
Frames stutter during a download or uploadExpected: both stream synchronously on the loop task
A scene reorder reports a permutation error after a valid orderValidation failure and storage-save failure share one result
The page loads but every API call 401sThe page is served before authentication runs; credentials are per-request
Firmware uploaded successfully, device now unreachableNo health rollback exists; recover over USB

Configuration reference

SettingDefaultEffect
Basic auth usernameadmin—
Basic auth password—Required; the web server refuses to start without one
Web port80Also what mDNS advertises
Discovery port47474UDP probe responder

Files referenced

ComponentResponsibility
Web server moduleRoute registrations, handlers, streaming, discovery
Web assets headerThe gzipped page, embedded in the firmware image
Web asset generator scriptRegenerates that header from the page's source HTML
Browser clientThe browser client and its request payloads
Settings moduleSetting keys, validation, the results contract
Scene-order storeOrder and rotation persistence
Network moduleDeferred Wi-Fi credentials and the live snapshot
OTA moduleUpdate exclusion and ArduinoOTA

See also