Per-pod NVS enable (default off) via UART/ESP-NOW/dashboard, and skip UV latch when LiPo ADC reads ~0 mV so USB/external supply still boots. Co-authored-by: Cursor <cursoragent@cursor.com>
13 KiB
REST API
go run . serve (optional -port / -ble) starts two HTTP servers on the same managedHost (UART or BLE):
| Base URL | Flag | Used by |
|---|---|---|
http://localhost:8080 |
-addr (default :8080) |
Web dashboard + automation on the UI routes |
http://localhost:8081 |
-api-addr (default :8081, "" disables) |
External programs; subset of routes + service info |
WebSocket streaming (accel/tap push): API_WEBSOCKET.md.
All JSON responses use Content-Type: application/json. On UART errors many routes return 503 with "error" in the body.
External API (:8081)
Service info
GET /
GET /api/v1/
{
"name": "powerpod-external-api",
"version": "1",
"serial_port": "/dev/ttyUSB0",
"transport": "uart",
"endpoint": "/dev/ttyUSB0",
"connected": true,
"websocket": "/ws",
"default_interval_ms": 16,
"min_interval_ms": 1,
"max_interval_ms": 10000,
"tap_display_min_ms": 2000,
"description": "..."
}
Battery
GET /api/battery?all_clients=true
GET /api/battery?client_id=16
POST /api/battery
Content-Type: application/json
POST body:
{"all_clients": true}
{"client_id": 0}
{"client_id": 16}
Response:
{
"success": true,
"samples": [
{
"client_id": 16,
"lipo1": {"valid": true, "voltage_mv": 3850, "percent": 71},
"lipo2": {"valid": false},
"age_ms": 1200
}
]
}
Slaves push battery to the master every 30 s; these routes read the master cache.
WebSocket equivalent: get_battery on ws://localhost:8081/ws (reply type battery_status).
LED ring
POST /api/led-ring
Content-Type: application/json
Body:
{"mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":128}
{"mode":"digit","client_id":0,"digit":3,"r":0,"g":255,"b":0}
{"mode":"find-me","all_clients":true,"slaves_only":true}
mode |
Notes |
|---|---|
clear |
Turn off |
color |
Full ring RGB + intensity |
progress |
progress 0–100 |
digit |
digit 0–10 |
blink |
blink_ms, blink_count |
find-me |
Locate pod |
battery-low |
First 4 LEDs red at ~10 % for 5 s (UV indicator test) |
Use client_id (0 = master) or all_clients (+ optional slaves_only) for broadcast.
Response: success, slaves_updated, optional error.
WebSocket: set_led_ring with the same fields plus "type":"set_led_ring" → led_ring_status.
Dashboard API (:8080)
Used by the web UI; safe for scripts that drive the same features.
Host link (UART / BLE)
GET /api/host
→ {"transport":"none|uart|ble","endpoint":"…","connected":false,"ble_name":""}
GET /api/serial/ports
→ {"ports":["/dev/ttyUSB0",…]}
POST /api/host/connect
Content-Type: application/json
{"transport":"uart","port":"/dev/ttyUSB0"}
{"transport":"ble","address":"AA:BB:…","name":"Powerpod"}
POST /api/host/disconnect
POST /api/ble/scan
{"timeout_s":5,"name_filter":"Powerpod"}
→ {"devices":[{"address":"…","name":"Powerpod","rssi":-55}]}
name_filter is optional substring match (case-insensitive); omit to list all LE advertisements. Active BLE is disconnected before scan (BlueZ adapter).
Dashboard WebSocket state includes host_connected, host_transport, host_endpoint (aliases uart_connected / serial_port kept).
OTA (POST /api/ota) returns 400 unless a host (UART or BLE) is connected.
BLE latency tests (BLE host only)
POST /api/ble-rtt
{"count":50,"warmup":5,"settle_s":0}
→ {"success":true,"count":50,"warmup":5,"stats":{"n":50,"min_ms":…,"median_ms":…,"mean_ms":…,"max_ms":…}}
POST /api/ble-stream
{"count":50,"period_ms":8,"tol_ms":2,"timeout_s":15,"client_id":0}
→ {"success":true,"received":50,"requested":50,"window_ok":true,"dt_esp_ms":{…},"dr_pc_ms":{…},…}
Pauses dashboard/API polling while running. Requires active BLE host (DIP8=1).
Live stream (host CACHE_STATUS poll ~8 ms; BLE uses BLE_STREAM_TICK)
GET /api/live-stream
PUT /api/live-stream
Content-Type: application/json
{"enable": true}
{"enabled": true, "success": true}
Enables fast UART polling for dashboard accel/tap display. Per-slave accel still requires accel-stream (below).
Accel stream (firmware ESP-NOW, per slave)
GET /api/clients/16/accel-stream
PUT /api/clients/16/accel-stream
Content-Type: application/json
{"enable": true}
{"enabled": true, "client_id": 16, "success": true}
All slaves:
POST /api/accel-stream
Content-Type: application/json
{"write": true, "enable": true, "all_clients": true}
Polling on the host runs only while at least one slave has streaming enabled (here or via external WebSocket / dashboard).
Tap notify (firmware; does not start host tap polling)
GET /api/clients/16/tap-notify
PUT /api/clients/16/tap-notify
Content-Type: application/json
{"single": true, "double_tap": false, "triple": false}
{
"client_id": 16,
"success": true,
"slaves_updated": 1,
"single": true,
"double_tap": false,
"triple": false
}
All slaves:
POST /api/tap-notify
Content-Type: application/json
{"single": true, "double_tap": false, "triple": false, "all_clients": true}
Host tap display / external set_tap_stream is separate.
Tap snapshot (one-shot, via CACHE_STATUS)
GET /api/tap-snapshot?client_id=16
Reads the combined cache (CACHE_STATUS); optional client_id filters pending tap events. Pending taps are consumed on read.
{
"events": [
{"client_id": 16, "kind": "single", "age_ms": 4}
]
}
Deadzone
GET /api/deadzone?client_id=0
POST /api/deadzone
Content-Type: application/json
{"write": true, "deadzone": 128, "client_id": 0}
With all_clients + slaves_only: push to ESP-NOW slaves only (master BMA456 unchanged).
{"deadzone": 128, "client_id": 0, "success": true, "slaves_updated": 2}
Tap config (BMA456 multitap)
GET /api/tap-config?client_id=0
POST /api/tap-config
Content-Type: application/json
{
"write": true,
"client_id": 0,
"tap_sens_thres": 5,
"max_gest_dur": 100,
"tap_shock_dur": 6,
"quite_time_after_gest": 60,
"wait_for_timeout": 0,
"axis_sel": 2,
"enable_single": true,
"enable_double": true,
"enable_triple": true
}
Same client_id / all_clients / slaves_only semantics as deadzone. Values are applied to the sensor and persisted in NVS on each target node.
{
"client_id": 0,
"success": true,
"slaves_updated": 0,
"tap_sens_thres": 5,
"max_gest_dur": 100,
"tap_shock_dur": 6,
"quite_time_after_gest": 60,
"wait_for_timeout": 0,
"axis_sel": 2,
"enable_single": true,
"enable_double": true,
"enable_triple": true
}
Unicast test
POST /api/unicast-test
Content-Type: application/json
{"client_id": 16, "seq": 42}
Echo ping (ESP-NOW round-trip latency)
POST /api/echo-ping
Content-Type: application/json
{"client_id": 16}
client_id must be a registered slave id (> 0). The host sends a microsecond timestamp; the master forwards it over ESP-NOW and the slave echoes it back unchanged.
Flow: Host → UART → cmd_espnow_echo_ping → ESPNOW_ECHO_PING (with master_time_us from esp_timer_get_time()) → Slave → ESPNOW_ECHO_PONG → Master recv_cb → UART response.
Response fields:
| Field | Unit | Meaning |
|---|---|---|
success |
— | true if pong received within 500 ms |
client_id |
— | Echo of request |
timestamp_us |
µs (Unix) | Echoed host timestamp; must match request on success |
rtt_ms |
ms | Host-side round-trip: goTool send → UART response (full chain incl. USB serial) |
esp_rtt_us |
µs | Master-side ESP-NOW only: esp_timer_get_time() delta from ping send to pong recv |
esp_rtt_us is the raw firmware value (microseconds, not converted). The web dashboard displays it converted to milliseconds for readability (esp_rtt_us / 1000, 3 decimal places). The success banner stays visible for at least 5 seconds.
{
"success": true,
"client_id": 16,
"timestamp_us": 1717654321123456,
"rtt_ms": 49.729,
"esp_rtt_us": 18234
}
On failure (success: false), esp_rtt_us is omitted; rtt_ms still reflects the host round-trip. HTTP 503 if UART exchange fails (e.g. timeout, client not in registry).
CLI:
go run . -port /dev/ttyUSB0 echo-ping -client 16
Example output:
echo ping: success=true client_id=16 rtt_ms=49.729 esp_rtt_us=18234
Find me
POST /api/find-me
Content-Type: application/json
{"client_id": 16}
client_id 0 = master LED ring.
Restart
POST /api/restart
Content-Type: application/json
{"client_id": 16}
Log level (master ESP-IDF console)
Runtime get/set of the global log filter on the master (esp_log_level_set("*", …)). Output goes to UART0 (USB debug, 115200), not the host protocol UART (GPIO 2/3, 921600).
GET /api/log-level
POST /api/log-level
Content-Type: application/json
{"write": true, "level": 3}
level |
Meaning |
|---|---|
| 0 | None (no log output) |
| 1 | Error |
| 2 | Warn |
| 3 | Info |
| 4 | Debug |
| 5 | Verbose |
{"success": true, "level": 3}
CLI:
go run . -port /dev/ttyUSB0 log-level
go run . -port /dev/ttyUSB0 log-level -set -level 3
Dashboard: Master card → dropdown ESP Log-Level with Lesen / Setzen.
Battery UV protection
Runtime enable for software LiPo under-voltage protection (NVS uv_en per pod). Disable also clears uv_latch. all_clients applies to master + every registered slave; slaves_only with all_clients skips the master.
GET /api/battery-uv?client_id=0
POST /api/battery-uv
Content-Type: application/json
{"write": true, "enabled": false, "client_id": 0}
{"write": true, "enabled": false, "all_clients": true}
{"write": true, "enabled": false, "all_clients": true, "slaves_only": true}
{"success": true, "client_id": 0, "enabled": false, "latched": false, "slaves_updated": 0}
go run . -port /dev/ttyUSB0 battery-uv
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -client 0
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -all
Dashboard: Master UV-Schutz checkbox + Alle an/aus; per-slave UV column.
Input send mode (slave accel/tap ESP-NOW TX path)
Debug A/B for how slaves transmit ACCEL_SAMPLE / TAP_EVENT. Default on slaves is best_effort (0). Master remembers the last mode for GET; write pushes via ESP-NOW.
GET /api/input-send-mode
PUT /api/input-send-mode
Content-Type: application/json
{"mode": 2, "all_clients": true}
mode |
Path |
|---|---|
| 0 | best_effort (fire-and-forget, quiet) |
| 1 | fast (fire-and-forget, logs failures) |
| 2 | wait (send-done + retries) |
| 3 | reliable (OTA-style retries; can stall high-rate accel) |
{"success": true, "mode": 2, "mode_name": "wait", "slaves_updated": 2}
CLI:
go run . -port /dev/ttyUSB0 input-send-mode
go run . -port /dev/ttyUSB0 input-send-mode -set -mode 2 -all
Dashboard: ESP-NOW Slaves toolbar → Input-Send dropdown + Setzen / Lesen.
OTA (master host upload, UART or BLE)
POST /api/ota
Content-Type: multipart/form-data
Form field firmware: binary image, max 2 MiB. Requires connected host (transport uart or ble). BLE needs ATT MTU ≥ 219 for 200 B chunks.
{"success": true, "bytes_written": 123456, "target_slot": 1}
Firmware distributes to slaves over ESP-NOW after OTA_END. Progress also appears on dashboard WebSocket as ota_progress messages.
CLI equivalent:
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
go run . -ble ota build/powerpod.bin
LED ring and battery
Same as external API:
POST /api/led-ringGET/POST/api/battery
Dashboard vs external
| Feature | Dashboard :8080 |
External :8081 |
|---|---|---|
| Client list | Via dashboard WebSocket state / CLI clients |
WebSocket list_clients |
| Accel/tap push stream | WebSocket state when live-stream on | WebSocket set_stream / set_tap_stream |
| Accel stream enable | REST PUT .../accel-stream |
WebSocket set_accel_stream |
| Tap notify | REST PUT .../tap-notify |
WebSocket set_tap_notify |
| LED / battery | REST | REST + WebSocket on :8081 |
UI mapping
| UI action | REST / CLI |
|---|---|
| Nur Master deadzone | POST /api/deadzone client_id: 0 or CLI deadzone -set -client 0 |
| Einzelner Slave | client_id: <id> |
| Alle Slaves deadzone | all_clients + slaves_only on POST |
| Master tap config | GET / POST /api/tap-config client_id: 0 or CLI tap-config |
| Tap-Cfg alle Slaves | POST /api/tap-config with all_clients + slaves_only |
| Unicast test | POST /api/unicast-test |
| Echo ping | POST /api/echo-ping (per-slave latency; UI shows Host ms + ESP ms) |
| Master log level | GET / POST /api/log-level or CLI log-level |
| UV protection | GET / POST /api/battery-uv or CLI battery-uv |
| Input-Send mode | GET / PUT /api/input-send-mode or CLI input-send-mode |
| Tap notify S/D/T | PUT /api/clients/{id}/tap-notify |
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |