REST + WebSocket control surface served by the ESP32-S3 firmware
(src/net/WebInterface.cpp). The browser UI in
data/ is the primary client; tools/mock_server.py
reimplements this contract for UI development off-device.
- Base URL:
http://<device-ip>/(port 80, no mDNS — get the IP from the serial boot log) - Auth: none (LAN device)
- CORS: every response sends
Access-Control-Allow-Origin: * - Content type: all bodies are JSON; all POSTs with a body must send
Content-Type: application/json
Command model — async ack. Mutating endpoints (POST) do not apply the
change inline. The async handler validates the request, queues a pending command
under a mutex, and returns immediately. The command is drained and applied in the
main loop (applyPending()), and every applied command is echoed to serial as
[CMD] …. Callers observe the effect by polling GET /api/v1/status or via the
/ws telemetry feed — not from the POST response.
Success envelope:
{ "ok": true }Read endpoints (GET /status, /runs, /calibration, /wifi/scan) return their
payload directly instead of {ok:true}. File downloads return the raw file.
Error envelope (HTTP 400/404/503):
{ "ok": false, "error": { "code": "out_of_range", "message": "rpm must be 0..30" } }| Code | When |
|---|---|
invalid_request |
missing/invalid action or field |
out_of_range |
value outside accepted bounds |
not_found |
unknown endpoint or run id |
no_log |
SD not mounted / no log file |
wifi_ssid_required |
connect without ssid |
feature_disabled |
endpoint's feature toggled off at build time (HTTP 503) |
Returns the cached status document (rebuilt in the loop ~10 Hz). This is the single source of truth for all live state. Shape:
Alarm codes the firmware emits: sensor_fault, heater_probe_fault,
safety_tripped, driver_ot, driver_otpw, driver_stall, driver_open_load
(severity warn or critical).
Gap: the UI's pause logic reads
run.pause.{motor,heater}(runstate.js), but the firmwarerunobject does not emit apauseobject. See Connection audit.
On connect, the server sends one status snapshot immediately, then broadcasts the
same status JSON as GET /api/v1/status at wsPushPeriodMs (250 ms ≈ 4 Hz). The
socket is push-only; the client sends nothing.
Start or stop a run. Body field action selects the operation.
Start:
{ "action": "start", "targetC": 36.0, "rpm": 8.0, "durationMin": 0, "name": "Ethanol distillation" }targetC0–55 (default 36),rpm0–30 (default 8),durationMin≥0 (default 0 = until stopped)nameoptional, ≤32 chars; firmware sanitizes (strips control chars, trims). Empty ⇒ unnamed.- On success the reactor starts and, only if
reactor.running()becomes true, a per-run CSV/runs/NNNNN.csvis created (plus/runs/NNNNN.namewhen named). If the pre-flightthermal.enable()refuses (faulted/missing probe), the run stays idle and no run file is created — the{ok:true}ack only means the command was queued.
Stop:
{ "action": "stop", "data": "save" } // "save" (default) | "discard"discard deletes the in-progress CSV + sidecar; save keeps them.
- ❌
action: "pause"andaction: "resume"are rejected (HTTP 400invalid_request, "action must be start|stop"). The UI sends these — see Connection audit.
Saved-run index, served from a loop-built cache (rebuilt when the run-file set
changes — start/stop/delete/erase — not on a timer). bytes for the current
run is its size at open time, not live:
{ "runs": [ { "id": 7, "label": "Ethanol distillation", "bytes": 10240, "current": true } ] }label is the sidecar name or "Run <id>". current:true marks the in-progress run.
Downloads that run's CSV (text/csv, attachment). 404 not_found if absent. The
server names the attachment after the on-disk file (00007.csv); the UI overrides
the saved filename with the session name (jimbo.csv, or run_<id>.csv when
unnamed) via the download link, since UI and API are same-origin.
?tail=N — returns the CSV header line plus roughly the last N rows
(chunked, no attachment sizing). Rows are located by a 96-bytes-per-row estimate
from the end of file, so expect slightly more than N. The response carries
X-Tail-Truncated: 1|0 — 0 means the window covered the whole file. The
History UI loads ?tail=1000 by default and fetches the full file only on
explicit request; multi-week runs produce multi-MB CSVs.
Queues deletion of a run's CSV + sidecar. (The open run is discarded if targeted.)
Both
{id}routes are regex routes — the firmware build needs-DASYNCWEBSERVER_REGEX=1(set inplatformio.ini).
Change target temperature and/or speed of a running process without restarting.
{ "targetC": 37.5, "rpm": 10 } // either field optionaltargetC 0–55. Out-of-range targetC ⇒ 400.
Drive parameters (any subset):
{ "rpm": 8, "currentMa": 600, "microsteps": 16, "direction": "cw", "enabled": true }direction: "cw" | "ccw". currentMa is clamped to 100–1500 in firmware.
Brief jog (~8 rpm, 3 s) to confirm the drive turns. No body. Ignored while a run is active.
Gains and/or mode:
{ "kp": 0.08, "ki": 0.0015, "kd": 0.4, "mode": "auto" }Gains apply only when all three of kp,ki,kd are present. mode is a string
(e.g. "auto" | "manual").
Adaptive gains contract: when
kEnableAdaptiveThermalis enabled (default), a gains POST writes the schedule's HOLD set; the HEAT set is derived from it (kHeatKpScale, ×1.8). Both persist, andstatus.thermal.pid.tunedbecomestrue(manual gains count as commissioning — the auto-tune-on-first-start won't run). The livepid.kp/ki/kdin status remain the scheduler's per-sample blended output, not the stored sets — readpid.schedulefor those. With the adaptive feature disabled (fixed mode), gains apply directly to the live PID as before.
{ "action": "start" } // "start" | "cancel"Progress/phase/result surface in status.thermal.pid.autotune.
startrequires an active run — otherwise409 run_not_active(the control loop only advances the tune while a run is enabled).- The relay tune oscillates
kTuneMarginC(3 °C) below the current setpoint so the batch never overshoots; derived gains land in the schedule (adaptive) or the live PID (fixed mode). - Commissioning runs at most once on first Start: a failed tune latches
(
atTriedin NVS) and the firmware keeps the configured defaults instead of re-running a 30-minute tune on every Start. Explicitstartalways re-tunes.
{ "method": "beta", "calibrated": false,
"points": [ { "referenceC": 25.0, "resistanceOhms": 10000 } ] }Capture a calibration point at the known reference temperature:
{ "referenceC": 25.0 } // requiredNo body. Fits offset/Beta/Steinhart by point count; result via GET /calibration.
No body. Reverts to factory Beta.
The reactor can be paired with a WiFi-less HUB companion device over ESP-NOW.
The binary wire protocol is documented in docs/PROTOCOL_ESPNOW.md.
ESP-NOW telemetry is a compact binary projection of the GET /api/v1/status document,
pushed to the HUB at ~4 Hz.
HUB-initiated commands. The HUB control surface is monitor + Start / Pause / Stop
only (all parameter tuning stays on this web API). Because the HUB has no adjust screen,
its START sends a RunStart command with the kCmdFlagUseCurrent flag: the reactor
begins a run using its currently configured setpoint and disc rpm, run-until-stopped —
no target/rpm/duration is chosen on the HUB. (Web/REST RunStart is unchanged and still
carries explicit values.) There is no new REST endpoint for this.
Opens a 60-second pairing window. The reactor will accept the next PairRequest
from a HUB that is simultaneously scanning. Trigger it from the web UI
(Settings → HUB LINK → Pair HUB, shown only when the feature is enabled) at
the same time as tapping PAIR on the HUB screen.
Response: { "ok": true } — window is now open.
503 feature_disabled when AppConfig::Features::kEnableEspNow = false.
Clears the stored HUB binding (MAC + channel). The HUB will detect the link loss and re-sweep channels to re-pair. Exposed in the web UI as Settings → HUB LINK → Forget HUB.
Response: { "ok": true }
503 feature_disabled when AppConfig::Features::kEnableEspNow = false.
Asks the bound HUB to re-run its first-boot touch-calibration wizard (tap 4 targets; the HUB re-solves and persists its swap/mirror transform). The binding is kept. Exposed in the web UI as Settings → HUB LINK → Recalibrate (per-row, next to Forget). No-op if no HUB is bound.
Response: { "ok": true } — request sent (best-effort over ESP-NOW).
503 feature_disabled when AppConfig::Features::kEnableEspNow = false.
GET /api/v1/status includes "espnow" in the features object to advertise
the compile-time flag:
"features": { "sdLogging": true, "oledUi": true, "autotune": true, "espnow": true }GET /api/v1/status also includes a top-level espnow object describing the
current binding, so the web UI can list paired HUB(s) with per-row Recalibrate +
Forget (Settings → HUB LINK). peers is empty when no HUB is bound (Phase 2 binds
one HUB, so it holds 0 or 1 entry):
"espnow": {
"enabled": true,
"bound": true,
"peers": [ { "mac": "A4:CF:12:34:56:78", "channel": 6, "name": "hub" } ]
}Four compile-time flags in include/app_config.hpp
(AppConfig::Features::kEnableSdLogging, kEnableOledUi, kEnableAutotune,
kEnableEspNow, all default true) gate optional features. GET /api/v1/status
advertises their state so the UI can react:
"features": { "sdLogging": true, "oledUi": true, "autotune": true, "espnow": true }When a flag is false, that feature's control endpoints return HTTP 503
with code: "feature_disabled":
| Flag | Endpoints that return 503 when off |
|---|---|
kEnableSdLogging |
GET /log, POST /log/interval, POST /sd/erase, GET /runs, GET /runs/{id}, POST /runs/{id}/delete |
kEnableAutotune |
POST /pid/autotune |
kEnableOledUi |
none (front-panel display only; no HTTP surface) |
kEnableEspNow |
POST /espnow/pair, POST /espnow/forget, POST /espnow/recalibrate |
POST /api/v1/run is never gated — running the reactor is core control; SD
logging only records the run.
Run-only logging: the card holds nothing but per-run files. While a run is
open, one CSV row is appended to /runs/NNNNN.csv every logIntervalSec; when no
run is open, nothing is written. There is no always-on /reactor_log.csv. Header:
t_ms,running,liquid_c,heater_c,setpoint_c,heater_pct,rpm,load,fault,safety.
t_ms is milliseconds since run start (first row ≈ 0). Files written before
2026-07-10 used boot-relative millis. Fault rows zero-fill the temperatures and
set fault=1 — filter on the flag before plotting.
A run file is opened on every start path: web and HUB starts open it (with the session name) via the command queue; panel (OLED) starts are caught by the loop's running-edge and open an unnamed file.
Convenience alias that downloads the latest run's CSV (the newest /runs
file). The newest run id is resolved from a loop-built cache, so this handler does
not enumerate the card from the async task. 503 no_log when the card isn't
mounted or no runs exist yet. The UI's "Download latest run" button resolves the
newest run from GET /runs and saves it under the session name (jimbo.csv).
Equivalent to GET /api/v1/runs/{latestId}.
Set the SD row cadence (persisted in NVS, namespace sdlog):
{ "seconds": 10 } // required, 1..3600Out-of-range ⇒ 400 out_of_range. Current value is reported in
status.storage.logIntervalSec.
No body. Destructive — deletes every file/dir on the card (all runs).
Refused with 409 run_active while a run is active (it would delete the live
run's file and leave the rest of the run unlogged).
Triggers a fresh scan and returns the last cached scan:
{ "scanning": false, "networks": [ { "ssid": "LAB-NET", "rssi": -55, "secure": true } ] }Poll until scanning:false for fresh results.
{ "ssid": "LAB-NET", "password": "secret" } // ssid required; password optional (open net)No body. Clears stored credentials and returns to the setup AP.
GET / and any non-/api/ path serve the UI from SPIFFS (index.html,
app.css, app.js, wordmark.svg, …) with index.html as the SPA fallback.
Unknown /api/ paths return 404 not_found.
UI client = data/core/api.js + direct calls in
data/screens/settings.js /
data/app.js. FW = src/net/WebInterface.cpp.
Mock = tools/mock_server.py.
| Endpoint / action | UI calls | Firmware | Mock | Status |
|---|---|---|---|---|
GET /status |
✅ | ✅ | ✅ | OK |
WS /ws |
✅ | ✅ | ✅ | OK |
POST /run start |
✅ | ✅ | ✅ | OK |
POST /run stop |
✅ | ✅ | ✅ | OK |
POST /run pause |
✅ runPause() |
❌ 400 | ✅ | MISSING in FW |
POST /run resume |
✅ runResume() |
❌ 400 | ✅ | MISSING in FW |
status.run.pause field |
✅ reads it | ❌ not emitted | ✅ | MISSING in FW |
GET /runs |
✅ | ✅ | ✅ | OK |
GET /runs/{id} |
✅ | ✅ | ✅ | OK |
POST /runs/{id}/delete |
✅ | ✅ | ✅ | OK |
POST /setpoint |
✅ | ✅ | ✅ | OK |
POST /disc |
✅ | ✅ | ✅ | OK |
POST /disc/test |
✅ | ✅ | ✅ | OK |
POST /pid |
✅ | ✅ | ✅ | OK |
POST /pid/autotune |
✅ | ✅ | ✅ | OK |
GET /calibration |
✅ | ✅ | ✅ | OK |
POST /calibration/{point,compute,reset} |
✅ | ✅ | ✅ | OK |
GET /log (download latest run) |
✅ | ✅ | ✅ | OK |
POST /log/interval |
✅ | ✅ | ✅ | OK |
POST /sd/erase |
✅ | ✅ | ✅ | OK |
GET /wifi/scan |
✅ | ✅ | ✅ | OK |
POST /wifi/connect |
✅ | ✅ | ✅ | OK |
POST /wifi/forget |
✅ | ✅ | ✅ | OK |
POST /debug/probe-fault |
❌ | ❌ | ✅ | Mock-only test affordance |
POST /espnow/pair |
✅ Settings | ✅ | ✅ | ESP-NOW pairing window |
POST /espnow/forget |
✅ Settings | ✅ | ✅ | Clears HUB binding |
Only gap: run pause/resume (the deferred "Plan B"). The UI ships Pause ▾ /
Resume controls and derives a paused state from status.run.pause, but the
firmware neither accepts the actions nor emits the field. To close it the firmware
needs: (1) action:"pause" with target ∈ motor|heater|all, (2) action:"resume",
and (3) a run.pause:{motor,heater} object in the status document.
ESPAsyncWebServer matches handlers in registration order, and a plain handler for
/api/v1/x also matches any URL starting with /api/v1/x/ (built-in prefix
match; the JSON body handler does the same and has no regex support). So a
parent route registered before a same-method child swallows the child. The
firmware must register the specific child first. Affected pairs (all fixed —
children registered first):
| Child (must be first) | Parent that would shadow it |
|---|---|
GET /api/v1/runs/{id} (regex) |
GET /api/v1/runs |
POST /api/v1/pid/autotune |
POST /api/v1/pid |
POST /api/v1/disc/test |
POST /api/v1/disc |
/api/v1/calibration/* is not affected because its parent /api/v1/calibration
is GET while the children are POST (method mismatch ⇒ no shadowing).
Logging is run-only: the card is written only while a run is open, into
/runs/NNNNN.csv. If a run appears to have no data, check in order:
- Card mounted?
status.storage.sdMounted/system.sdMountedmust betrue. The boot log prints the mount result and a write/read self-test (read/write test: PASS). If it failed, nothing is written. - Is a run actually open? A row is written only when
reactor.running()is true andstartRun()opened a file (status.run.idis non-null).start()is refused — and the UI disables Start — when the heater NTC is faulted (status.thermal.safety.probe.resistanceOhms == null). - Row cadence is
status.storage.logIntervalSec(NVS-persisted, 1..3600 s). A large value (e.g. left high from testing/log/interval) means rows appear rarely. - Just started? A freshly opened run file has only the header until the first interval elapses. The Monitor → HISTORY card does not auto-refresh; click Refresh to re-read a live run (known minor UI limitation).
mounted_latches false on the first failed write and does not re-mount until reboot — a transient SPI/FS error stops logging for the session.
Both download endpoints serve the same per-run files:
GET /runs/{id}(a chosen run) andGET /log(the latest run). Neither enumerates the card from the async task —/loguses a loop-built cache of the newest run id.
{ "apiVersion": "1.0", "uptimeSec": 1234, "system": { "firmware": "1.0.0", "freeHeap": 210000, "minFreeHeap": 168000, "largestBlock": 110000, "freeDma": 90000, "minFreeDma": 42000, "vbus": "12V", "sdMounted": true }, // heap diagnostics (all bytes, internal RAM — board has no PSRAM): // freeHeap current free heap // minFreeHeap lowest free heap since boot (true-leak floor — keeps dropping on a leak) // largestBlock biggest contiguous free block (fragmentation signal) // freeDma free in the DMA-capable pool that lwIP/WiFi pbufs draw from // minFreeDma lowest-ever DMA-pool free (its floor is what starves the radio) "thermal": { "tempC": 35.81, // null when sensorFault "errorC": 0.19, // setpoint - temp; null when sensorFault "setpointC": 36.0, "heaterPct": 42.5, "fault": false, "safety": { "tripped": false, "heaterTempC": 48.2, // null when heater NTC faulted "heaterMaxC": 80.0, "processMaxC": 55.0, "probe": { // heater NTC safety probe "adcRaw": 1820, "resistanceOhms": 9120, // null when faulted -> UI blocks Start "calibrated": false, "method": "beta" } }, "pid": { "kp": 0.08, "ki": 0.0015, "kd": 0.4, "p": 0.0, "i": 0.0, "d": 0.0, "out": 0.425, "mode": "auto", "regime": "hold", // "heat" | "approach" | "hold" | "fixed"; active gain-scheduling regime; "fixed" when kEnableAdaptiveThermal is false "dutyCeil": 0.6, // current max-duty ceiling (soft-landing taper near setpoint) "tuned": true, // gains commissioned — by a completed relay tune OR a manual gains POST "schedule": { // always present; active/derived gain sets (fixed mode shows configured defaults) "heat": { "kp": 0.144, "ki": 0.003, "kd": 0.2 }, "hold": { "kp": 0.08, "ki": 0.0015, "kd": 0.4 } }, "autotune": { "active": false, "progress": 0, "phase": null, "result": null } // autotune.phase: "ramp" (heating to the tune point; progress reads 0) | // "cycling" (relay oscillation; progress = completed cycles) | null when inactive } }, "disc": { "running": true, "rpm": 8.0, "rpmSetpoint": 8.0, "direction": "cw", "currentMa": 600, "microsteps": 16, "enabled": true, "load": 120, // StallGuard; null when idle or driver offline "driver": { "version": "0x21", "connected": true, "flags": { "otpw": false, "ot": false, "stall": false, "openLoadA": false, "openLoadB": false, "shortA": false, "shortB": false } } }, "run": { "active": true, "elapsedSec": 65, "remainingSec": null, // null when durationMin == 0 (run-until-stopped) "durationMin": 0, "id": 7, // current run id; null when idle "name": "Ethanol distillation" // null when unnamed or idle }, "wifi": { "mode": "sta", "connected": true, "ssid": "LAB-NET", "ip": "192.168.1.42", "rssi": -55, "recoveries": 0 }, // recoveries: count of WiFi-stack self-heals since boot. The driver's TX-buffer // pool can wedge under load (TCP + ESP-NOW stop transmitting while still // associated); the watchdog restarts just the WiFi stack (never the chip, so a // run keeps going). A rising count means it caught + healed a wedge. // Detection watches the ESP-NOW send counters: HUB telemetry when bound, a 1 Hz // broadcast Probe when unbound. kEnableEspNow=false leaves the watchdog blind // (no observable TX) — the toggle dependency is logged at boot. // Shown in the UI as "· N self-heals" on the Settings WIFI line when > 0. "storage": { "sdMounted": true, "logBytes": null, // not yet reported "logging": true, // rows are being written NOW (run open + writes OK) — not just "card mounted" "logDegraded": false, // a row write failed mid-run (yanked/dying card): rows are being lost; latched until next run "logIntervalSec": 10 // current SD row cadence (see /log/interval) }, "alarms": [ { "code": "sensor_fault", "severity": "warn", "since": 1200 } ] }