Skip to main content

HTTP API

The remote firmware serves an HTTP interface on port 80 over any active link (Ethernet, USB-NCM tether, WiFi, or Tailscale). There are two route groups on the same server:

  • Diagnostic / config routes — unauthenticated.
  • Admin routes (/admin/...) — HTTP Basic auth (admin : CONFIG_ML_ADMIN_PASSWORD). An image built with the public default password (microlink) or an empty one refuses admin requests that arrive over Tailscale (401); they work on the USB tether, Ethernet and the SoftAP. Build with your own password to administer over Tailscale.

Query parameters are shown where required. Unless noted, POST bodies are empty.

Diagnostic / config (unauthenticated)​

MethodRoutePurpose
GET/Diagnostic HTML page
GET/state.jsonTelemetry snapshot (uptime, ml_state, pstop counters, heap, E-stop channels, public_ip/derp_region for management-side geolocation (derp_region = the effective DERP home region in use; derp_region_locked = the configured region lock, 0 = auto, else the pinned region — set via POST /admin/api/settings derp_region), local_ip = active-uplink LAN address, fw_ver/fw_sha = running build identity — version tag + truncated ELF SHA-256 — so test harnesses can verify the DUT runs the build under test. Crash/stall visibility (2026-08-19): ctrl_reset_cause (one-shot controlled-reset crumb: 1=clock-fault 2=XCHECK 3=padcfg), xcheck_last_detail ((code<<4)|stalled_core, rides an XCHECK boot), crash_present/crash_pc/crash_task/crash_sha (boot summary of the coredump in flash — sha dates it to a build), log_lines_s/log_lines_s_peak/log_console_skipped (log-rate gauges; console output is budgeted to 40 lines/s, the RTC ring gets everything))
GET/api/last_logTail of the previous boot's log (RTC ring)
GET/api/coredumpStream the raw coredump image from flash (admin auth — a coredump is a RAM snapshot; 404 when none; persists until the next crash). Decode host-side: espcoredump.py info_corefile -t raw -c core.bin <matching ELF from ops/elf-archive> — state.json crash_sha identifies the build
POST/api/derpToggle the DERP TX worker
POST/api/derp_delay?ms=NSet the DERP loop yield (ms)
POST/api/wgRemoved — returns 410 Gone. The runtime suspend could park ml_wg_mgr while it held the lwIP core lock, wedging all networking until a task-WDT panic (bench-proven 2026-08-09). Use /api/ts_boot (persistent, safe boot-path pause) or /api/derp instead. The /state.json wg_paused field still reports the boot-path suspend state
POST/api/wifi_tx_power?q=NSet WiFi max TX power (quarter-dBm)
POST/api/iface/ethToggle the Ethernet driver on/off (not a selector; no GET). Reply {"enabled":0|1} is the resulting state. Runtime only, not persisted. Uplink preference is automatic: Ethernet > USB-NCM > WiFi (active_iface in /state.json)
POST/api/iface/wifiToggle the WiFi STA driver on/off. Same semantics
POST/api/iface/usbToggle the USB-NCM tether on/off (installs/removes TinyUSB; the host sees a DHCP Release and a USB disconnect). Same semantics. Bench-verified 2026-09-04: Eth->USB fallback costs one Tailscale re-register (~5 s), USB->Eth is seamless; a bonded machine with the default 2 s timeout stays armed through both
POST/api/usb_enableFlip the USB-NCM NVS flag and reboot
POST/api/ts_bootFlip the Tailscale-on-boot NVS flag (effective next reboot)
POST/api/pstop_peer?ip=A.B.C.D&port=NSet + persist the pstop machine target (= peer slot 0, legacy single-machine call)
POST/api/pstop_peers?slot=N&ip=A.B.C.D&port=P[&id=HEX]Multi-machine peer table: set slot 0..3 (id = the machine's machine_device_id, default 0x01020304). ?slot=N&clear=1 empties a slot. ?slot=N&rebond=1 restarts that slot's bond — the only way out of REJECTED (state 3: the machine refused the BOND with UNBOND; the remote does not retry by itself). Applies live within one 100 ms tick, persists to NVS
POST/api/pstop_num?n=NSet the USB "PSTOPxx" unit number (0 = auto)
POST/api/ring_offset?n=NSet + persist the LED-ring rotation offset (0..15) — which physical pixel is "LED 1". Applies immediately, survives reboots and firmware updates (NVS ring_off)
POST/api/ring_led1?on=0|1Locate mode: light ONLY LED 1 solid white (overrides state colours) so the offset can be verified during install; auto-expires after 5 min
POST/api/enter_download?confirm=1Prepare for USB flashing (admin auth). The unit drops off USB and re-enumerates as USB-Serial-JTAG (303a:1001) about a second later; esptool's default reset then puts it into download mode. If no flasher takes over within 60 s the unit restarts normally
GET/api/roleRemote self-role (admin auth): {"ok":true,"role":"stop_only"|"operator"}. Announced in every pstop frame; this alone decides whether the remote may re-arm a machine (the machine has no operator list). Default stop_only. Remote only
POST/api/role?role=stop_only|operatorPersist the self-role to NVS and apply it live (admin auth, no reboot). An armed machine keeps running when its operator demotes itself, but refuses the next re-arm until a remote announcing operator performs STOP → OK. Remote only
GET/api/healthLifetime wear/health counters (see Health)
POST/api/health/reset?what=button|all&confirm=1Zero the button counters after a switch replacement (button, bumps button_swaps) or everything (all, refurbished unit). Admin auth

Health: lifetime counters and warnings​

GET /api/health returns counters that persist across reboots, idf.py flash and OTA (NVS blob dcs_app/health, flushed at most once per minute and at least every 10 minutes, plus on controlled restart). They do not survive erase-flash or an NVS auto-erase (ESP_ERR_NVS_NO_FREE_PAGES / NEW_VERSION_FOUND at boot).

{"ok":true,"level":0,"presses":1234,"mismatch_events":2,"uptime_s":864000,
"boots":41,"flashes":7,"otas":5,"button_swaps":0,
"button_rated_ops":100000,"button_wear_pct":1,
"fw_sha":"a1b2c3d4e5f60718","nvs_flushes":3,"last_flush_age_s":17,
"warnings":[{"src":"button_wear","level":1,"msg":"82% of rated 100000 ops","count":4,"age_s":120}],
"dropped":0}
FieldMeaning
levelWorst active warning: 0 ok, 1 warn, 2 critical. Also mirrored as health in /state.json
pressesButton operations: rising edges of both lockstep cores reading STOP. A single-loop STOP (wiring fault) is not a press. Remote only
mismatch_eventsRising edges of the two cores disagreeing on the same tick (one loop open, the other closed): contact bounce or a degrading loop. Remote only
uptime_sCumulative powered time, accumulated from the monotonic clock; loses at most the last flush interval on power pull
bootsEvery boot (lifetime; unlike the crash-ladder boot_count in /state.json, never auto-cleared)
flashesBoots where the running image's ELF SHA differs from the last one seen: cable and OTA updates
otasBoots where the image was PENDING_VERIFY, i.e. delivered by OTA. Cable flashes = flashes - otas
button_swapsTimes reset?what=button was used
button_rated_opsCONFIG_DCS_BUTTON_RATED_OPS, default 100000 (NKK FF01 datasheet: mechanical and electrical life, 100,000 operations minimum)
warnings[]Every active warning published through dcs_health_publish() from anywhere in the firmware, one entry per source. Built-in sources: button_wear (warn at CONFIG_DCS_BUTTON_WARN_PCT %, critical at 100 %), loop_mismatch (warn when mismatch_events exceeds CONFIG_DCS_MISMATCH_WARN_PER_1000 per 1000 presses after at least CONFIG_DCS_MISMATCH_WARN_MIN events)
droppedPublishes refused because the 8-entry table was full

Multi-machine (one remote, up to 4 machines)​

The remote heartbeats every configured peer slot independently: per-machine socket (local ports 8891..8894, slot order), per-machine bond/counter state and reply-loss watchdog, so one dead machine never stalls the heartbeats to the others. The lockstep comparator still gates ALL transmissions: a core mismatch silences every session at once. A STOP (and the arming press-and-release) is broadcast — one operator gesture arms every bonded machine; each machine keeps enforcing its own min_stop_ms veto.

The machine governs each session's publish rate: pstop_c advertises the machine's per-operator heartbeat_ms (machine.toml) in every reply's heartbeat_timeout field, and the remote transmits at half that window (clamped 100..1000 ms), advancing its counter only on transmitting ticks so the machine sees contiguous counters. The E-stop sampling stays at the fixed 10 Hz lockstep tick regardless.

/state.json carries the per-machine detail in pstop_machines: an array of all 4 slots (stable indices) with cfg, ip, port, id, state (0 idle / 1 bonding / 2 bonded), sent, replies, send_fail, rebonds, rtt_ms, hb_ms (the machine-requested heartbeat window), last_msg, last_reply_ms. The legacy scalar pstop_* fields remain as aggregates (worst-of last_msg, summed counters, most recent reply) for old tooling.

The LED ring divides evenly among the configured slots, in slot order starting at LED 1 (one machine = whole ring, matching the old display); each segment shows its machine's state with the usual colours. HIL validation: tools/pstop_multi_machine_test.py.

LED-ring rotation (provisioning)​

The 16-LED ring can be installed in any of 16 orientations, so "LED 1" is a per-device setting. To calibrate from your provisioning tooling:

  1. POST /api/ring_led1?on=1 — one white pixel shows where the current offset puts LED 1 (current offset and state are in /state.json as ring_offset / ring_led1).
  2. POST /api/ring_offset?n=N — the white pixel moves on the next repaint (≤250 ms); iterate until it sits on the bezel's LED-1 position. Pixel indices run in WS2812 data order (physical pixel (logical + n) mod 16).
  3. POST /api/ring_led1?on=0 — return the ring to the normal state display (it also auto-expires after 5 minutes as a safety backstop).

Admin (admin : CONFIG_ML_ADMIN_PASSWORD)​

MethodRoutePurpose
GET/admin/Config panel (HTML)
GET/admin/api/statusNode status
GET/admin/api/settingsRead settings
POST/admin/api/settingsUpdate settings. The derp_region field (0..4095, 0 = auto) locks the DERP home region: it persists to NVS and applies live (updates the runtime override + kicks a slot-0 re-home, no reboot). Locking guards the priority path against a region misroute (e.g. region-9/dfw). Example: {"derp_region":2} locks to sfo; {"derp_region":0} clears to auto
GET/admin/api/monitorHeap / DERP / per-task monitor + DERP re-home diagnostics (derp_home_region, fleet_peer_region, rehome_* counters) + same-LAN direct-path diagnostics (advert_lan_ip, pp_has_direct, pp_best_ip/pp_best_port, pp_endpoints) + DISCO observability (probe_tbl_hw = pending-probe table occupancy high water since boot, 64 = saturated; cmm_rx_count = CallMeMaybe messages received; regains_safety = direct-path regains on SAFETY peers only — direct_regains also counts bulk tailnet peers) + Tailscale enrollment (ts_enrolled = node holds an enrolled identity; coord_authkey_regs = registrations since boot that sent the auth key, 0 on an enrolled node unless its node key expired)
GET/admin/api/peersActive WireGuard peer table
GET/admin/api/peers/allowedRead the peer allowlist
POST/admin/api/peers/allowedAdd an allowed peer (the configured management server, if any, is non-removable)
DELETE/admin/api/peers/allowedRemove an allowed peer
POST/admin/api/restartReboot the device
GET/admin/api/wifiWiFi scan / status
POST/admin/api/wifiSet WiFi credentials
POST/admin/api/otaDirect firmware upload (OTA)
GET/admin/api/ota/statusRunning version + OTA image state
GET/admin/api/fleet-ota/statusManaged-OTA status
POST/admin/api/fleet-ota/checkTrigger a check-in against the OTA backend (pull if an update is assigned)
POST/admin/api/fleet-ota/toggleToggle auto-update
POST/admin/api/fleet-ota/intervalSet the check-in interval
GET/admin/api/verboseVerbose-logging status
POST/admin/api/verbose/toggleToggle verbose logging

The /admin/api/fleet-ota/* endpoints and the periodic check-in are intended to pair with a centralized configuration management and OTA system of your own (configured via CONFIG_ML_FLEET_SERVER_IP / CONFIG_ML_OTA_BACKEND_URL); no such backend is included in this project. With no backend configured, the direct /admin/api/ota upload is the update path.

Machine (machn) — operator authorization​

These routes and /state.json fields belong to the ESP32 machine unit (machn/), not the remote documented above. They are grouped here as the single HTTP reference. (Endpoints are being added alongside this doc.)

A machine accepts every remote that bonds but by default treats it as stop-only (stop_only=true): the remote may command STOP and is heartbeat-monitored (its silence forces a fail-safe STOP), but it can never re-arm the machine (STOP→OK). Only remotes on the machine's operator allowlist (stop_only=false) may re-arm. The operator allowlist is empty by default — out of the box every remote is stop-only (maximally safe); operator remote IDs are added during commissioning. Enforced in pstop_c (machine.c:135-142). Safety case: HARA H-13, SR-SYS-09, FMEA H02-4.

All routes require admin Basic-auth (enforced in-handler, same credential as /api/enter_download).

MethodRoutePurpose
GET/api/admissionAdmission lists (admin auth): {"ok":true,"allowlist":[..],"denylist":[..],"pinlist":[..]}. Allow/deny both empty (default) = every remote may bond
POST/api/admission?allow=ID / ?unallow=IDAdd/remove a remote device ID (hex 0x.. or decimal) on the allowlist. Non-empty allowlist = only listed ids may bond ("paranoid" mode). Persisted to NVS
POST/api/admission?deny=ID / ?undeny=IDAdd/remove an ID on the denylist — never admitted, wins over the allowlist. Persisted to NVS
POST/api/admission?pin=ID / ?unpin=IDAdd/remove an ID on the pin list: keep that remote's WireGuard peer across netmap trims on a large tailnet so it can always reach this machine. No admission effect. Allowlisted IDs are pinned implicitly; denylisted IDs never. Seeded once at upgrade from the pre-admission operator list

Admission decides only whether a remote may bond; a refused BOND is answered with UNBOND and the remote parks itself (REJECTED) until manually rebonded. Re-arm authority is not configured on the machine: each remote announces its own stop_only/operator role (/api/role) and the machine honours it live. The machine's /state.json exposes allowlist/denylist and, per bonded remote, its current stop_only — true = may STOP, cannot re-arm; false = may also re-arm.

Machine arming/restart telemetry (/state.json)​

Published once per comparator tick from the pstop_c library state (core 0's lockstep instance; both fields read 0 on remotes):

  • remote_stop_id — pstop_c robot_state.remote_stop_id: the remote device ID that stopped the robot, or that owns the in-progress stop/OK arming cycle. 0 = none (fresh boot, or robot running with no cycle open). This is the arming owner. Only a remote currently announcing operator can hold it; a remote that demotes itself releases it (and the machine keeps running until the next STOP).
  • restart_state — pstop_c ROBOT_RESTART_STATE_*: 0 = OK (an OK message may complete re-arming), 1 = NEED_STOP (the machine refuses OK until a STOP gesture arrives first — e.g. after a heartbeat loss or boot), 2 = STOP_RECEIVED (stop half of the gesture seen; awaiting the OK half after min_stop_ms).
  • bonded_remotes[].state — pstop_c pstop_remote_state_t for that remote: 0 INITING (bond handshake in progress), 1 BONDED (bonded, no verdict yet), 2 OK (commanding RUN), 3 STOPPED (commanding STOP), 255 UNKNOWN.

A robot that is actually disarmed but shown "green" by fleet tooling, or an arming gesture that never completes, is diagnosable from these three fields alone: who owns the cycle, what the machine is waiting for, and what each bonded remote is commanding.

Source of truth​

  • Diagnostic/config routes: firmware/components/dcs_support/src/dcs_admin_pages.c
  • Health counters and the publish API: firmware/components/dcs_support/src/dcs_health.c, firmware/components/dcs_support/include/dcs_health.h
  • Admin routes: components/microlink/src/ml_config_httpd.c (/admin prefix) and components/microlink/src/ml_app.c (managed OTA, verbose).

The app server's user-handler budget is set in dcs_support.c (cfg.max_user_uri_handlers); adding routes beyond it fails silently, so bump it when adding new /api handlers.