Combine cached accel and tap in one low-overhead master command for ~16 ms host polling. The dashboard uses a single live-stream toggle plus per-slave accel-stream controls; fix live_stream state so polling is not cleared every slow client refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
14 KiB
goTool
Host-side UART client for the Powerpod master ESP.
Full system documentation (roles, ESP-NOW, framing, protobuf): ../main/README.md.
Usage
cd goTool
go mod tidy
go run . -port /dev/ttyUSB0 version
go run . -port /dev/ttyUSB0 clients
| Flag | Default | Description |
|---|---|---|
-port |
(required) | Serial port on master UART (GPIO2/3 adapter) |
-baud |
921600 |
Must match firmware UART_BAUD_RATE |
Commands
| Command | UART payload | Description |
|---|---|---|
version |
0x03 |
Prints version and git_hash from firmware |
clients |
0x04 |
Lists slaves registered on the master via ESP-NOW |
deadzone |
0x06 |
Get/set accelerometer deadzone LSB (-set, -value, -client, -all) |
accel |
0x18 |
Cached slave accel snapshot from master (ACCEL_SNAPSHOT); alias accel-read |
tap-notify |
0x1b |
Get/set which tap kinds (single/double/triple) notify via ESP-NOW (-set, -client, -all, -single, -double, -triple) |
tap |
0x1c |
Cached tap snapshot from master (TAP_SNAPSHOT); events ≤16 ms old |
cache-status |
0x1d |
Combined accel + tap cache (CACHE_STATUS); one UART round-trip for 16 ms polling |
unicast-test |
0x07 |
Sends ESP-NOW unicast test to one slave (-client, -seq) |
test |
— | Run an automated scenario (JSON configs under testdata/) |
serve |
— | Web dashboard at http://localhost:8080 (WebSocket live updates) |
ota |
16–19 | UART firmware upload to master; firmware then pushes to slaves via ESP-NOW |
ota-progress |
21 | Query per-slave ESP-NOW OTA progress on the master (-client N, default all) |
led-ring |
8 | LED ring: -mode clear|color|progress|digit|blink|find-me, -client, -all |
find-me |
22 | Locate pod (-client 0 master, >0 slave via ESP-NOW) |
restart |
23 | Reboot master or slave (-client 0 / >0) |
clients requires slaves to have responded to master discover broadcasts first.
For adding commands end-to-end (UART, ESP-NOW, CLI, dashboard), see docs/adding-a-feature.md (Find me example).
Automated tests
Bench configs (testdata/configs/) list network, MACs, and serial ports (uart.master for commands, *_console for esptool reset). Scenarios run UART commands plus optional reset steps.
go run . test -list-configs
go run . test -list-scenarios
go run . test -config example-lab -scenario smoke
go run . test -config example-lab -scenario uart_cmds
go run . test -config my-lab -scenario smoke -port /dev/ttyUSB1 -v
With a complete bench config, -port is optional for test (uses uart.master from JSON).
See testdata/README.md for the JSON schema.
Web dashboard
Polls the master over UART and pushes state to the browser via WebSocket (Alpine.js + Bootstrap 5).
go run . -port /dev/ttyUSB0 serve
go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s
go run . -port /dev/ttyUSB0 serve -api-addr :8081 -accel-interval 16ms
make gotool-serve PORT=/dev/ttyUSB0
Open http://localhost:8080 — shows master firmware info and the ESP-NOW client table from CLIENT_INFO.
Tap (dashboard): two independent controls per slave:
| Column | Meaning |
|---|---|
| Tap-Notify (S/D/T) | Which tap kinds the slave sends to the master over ESP-NOW (UART TAP_NOTIFY) — does not poll UART |
| Tap (An/Aus) | Host receive: poll master tap cache (~16 ms) and show last tap for ≥2 s |
Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (set_tap_notify vs set_tap_stream).
External API (second HTTP server)
serve starts a separate listener (default :8081, disable with -api-addr "") for external programs. It shares the same UART connection as the dashboard.
| Endpoint | Description |
|---|---|
GET / or GET /api/v1/ |
JSON service info (default_interval_ms, min/max, serial_port, tap_display_min_ms) |
WebSocket /ws |
Per-connection accel/tap receive + interval; slave ESP-NOW accel/tap control |
Accel — two layers:
set_stream— this WebSocket connection: whether to receiveaccelJSON and at what poll rate (1 ms … 10 s per client; server UART poll uses the minimum among active subscribers).set_accel_stream— firmware: whether a slave sends accel to the master over ESP-NOW (16 ms on the pod).
Accel polling runs only when at least one connection has receive_accel: true and at least one slave streams (via set_accel_stream or dashboard :8080).
Tap — also two layers (notify alone does not poll UART):
set_tap_notify— firmware: which tap kinds (single/double/triple) the slave sends to the master over ESP-NOW.set_tap_stream— this WebSocket connection: pollCACHE_STATUS(tap slice) and pushtapJSON. Events stay visible fortap_display_min_ms(2000 ms) after first sight.
Tap polling runs only when at least one connection has receive_tap: true (via set_tap_stream).
Hello (on connect; accel/tap receive off until set_stream / set_tap_stream):
{"type":"hello","serial_port":"/dev/ttyUSB0","interval_ms":16,"tap_display_min_ms":2000,"note":"set_tap_notify configures slave S/D/T only; set_tap_stream enables tap polling/push","commands":["set_stream","get_stream","set_accel_stream","get_accel_stream","set_tap_stream","get_tap_stream","set_tap_notify","get_tap_notify","set_led_ring","get_battery"]}
Receive accel on this connection (optional interval_ms, default from -accel-interval):
{"type":"set_stream","enable":true,"interval_ms":32}
{"type":"get_stream"}
Reply:
{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true}
Slave ESP-NOW stream (per client_id):
{"type":"set_accel_stream","client_id":16,"enable":true}
{"type":"get_accel_stream","client_id":16}
Reply:
{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true}
LED ring (same JSON fields as POST /api/led-ring):
{"type":"set_led_ring","mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":200}
{"type":"set_led_ring","mode":"digit","all_clients":true,"slaves_only":true,"digit":3,"g":255}
Reply: {"type":"led_ring_status","success":true,"slaves_updated":2,...}
Tap notify (slave ESP-NOW config; per client_id, or all_clients):
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
{"type":"get_tap_notify","client_id":16}
Reply:
{"type":"tap_notify_status","client_id":16,"success":true,"single":true,"double_tap":false,"triple":false}
Receive tap on this connection (optional interval_ms; default from -accel-interval):
{"type":"set_tap_stream","enable":true,"interval_ms":16}
{"type":"get_tap_stream"}
Reply:
{"type":"tap_stream_status","receive_tap":true,"interval_ms":16,"success":true}
Tap events (only to connections with receive_tap: true; each event shown ≥2 s):
{"type":"tap","port":"/dev/ttyUSB0","success":true,"events":[{"client_id":16,"kind":"single","age_ms":3,"shown_at_ms":1717000000123}]}
Accel (only to connections with receive_accel: true, and only while slaves stream):
{"type":"accel","t":1716900123456789012,"success":true,"clients":[{"client_id":16,"valid":true,"x":12,"y":-34,"z":16384,"age_ms":8}]}
t is Unix time in nanoseconds. Each clients[] entry is one slave's latest cached sample (raw LSB, ±2g).
Example (Python):
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8081/ws") as ws:
print(await ws.recv()) # hello
await ws.send(json.dumps({"type": "set_stream", "enable": True, "interval_ms": 16}))
print(await ws.recv()) # stream_status
await ws.send(json.dumps({"type": "set_accel_stream", "client_id": 16, "enable": True}))
print(await ws.recv()) # accel_stream_status
while True:
msg = json.loads(await ws.recv())
if msg.get("type") != "accel" or not msg.get("success"):
continue
for c in msg.get("clients", []):
if c.get("valid"):
print(c["client_id"], c["x"], c["y"], c["z"])
asyncio.run(main())
Tap example (notify first, then enable stream on this connection):
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8081/ws") as ws:
print(await ws.recv()) # hello
await ws.send(json.dumps({"type": "set_tap_notify", "client_id": 16,
"single": True, "double_tap": False, "triple": False}))
print(await ws.recv()) # tap_notify_status
await ws.send(json.dumps({"type": "set_tap_stream", "enable": True, "interval_ms": 16}))
print(await ws.recv()) # tap_stream_status
while True:
msg = json.loads(await ws.recv())
if msg.get("type") == "tap" and msg.get("events"):
for e in msg["events"]:
print(e["client_id"], e["kind"], "age", e.get("age_ms"))
asyncio.run(main())
If the UART device is unplugged or the port disappears, serve keeps running and retries on each poll interval; the UI shows UART off until the port is available again.
The dashboard can configure nodes using the same UART commands as the CLI:
| UI action | CLI equivalent |
|---|---|
| Nur Master | deadzone -set -value N -client 0 |
| Einzelner Slave | deadzone -set -value N -client ID |
| Alle Slaves | per-slave ESP-NOW (Master bleibt unverändert; CLI -all setzt auch den Master) |
| Unicast test | unicast-test -client ID |
HTTP API (used by the web UI): GET/PUT /api/live-stream, GET/POST /api/deadzone, GET/PUT /api/clients/{id}/accel-stream, POST /api/accel-stream (legacy / all_clients), GET/PUT /api/clients/{id}/tap-notify, GET/POST /api/tap-notify, GET /api/tap-snapshot, GET/POST /api/battery, POST /api/led-ring, POST /api/unicast-test, POST /api/find-me, POST /api/restart, POST /api/ota (multipart field firmware, max 2 MiB).
LED ring (POST /api/led-ring and WebSocket set_led_ring on :8081):
{"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}
Modes: clear, color (full ring), progress (0–100), digit (0–10 symbols), blink, find-me. Use client_id (0 = master), or all_clients (+ optional slaves_only) for broadcast.
Battery (GET/POST /api/battery, WebSocket get_battery on :8081):
{"all_clients":true}
{"client_id":0}
{"client_id":16}
Response: samples[] with client_id, lipo1/lipo2 (valid, voltage_mv, percent), age_ms. Slaves push to the master every 30 s; UART reads the cache (fast). Dashboard polls with all_clients.
Accel stream per slave (must be enabled before values appear; goTool polls only while at least one slave has stream on):
GET /api/clients/16/accel-stream
→ {"enabled":false,"client_id":16,"success":true}
PUT /api/clients/16/accel-stream
Content-Type: application/json
{"enable": true}
→ {"enabled":true,"client_id":16,"success":true}
Enable all slaves: POST /api/accel-stream with {"write":true,"enable":true,"all_clients":true}.
Tap notify per slave (slave → master ESP-NOW; does not start host polling):
GET /api/clients/16/tap-notify
→ {"client_id":16,"success":true,"single":false,"double_tap":false,"triple":false}
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 with {"single":true,"double_tap":false,"triple":false,"all_clients":true}.
Live stream (dashboard: host-side CACHE_STATUS poll ~16 ms; per-slave accel via accel-stream):
PUT /api/live-stream
Content-Type: application/json
{"enable": true}
→ {"enabled":true,"success":true}
One-shot read: GET /api/tap-snapshot?client_id=16 → {"events":[{"client_id":16,"kind":"single","age_ms":4}]}.
CLI:
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
go run . -port /dev/ttyUSB0 tap -client 16
| UI / API | Behaviour |
|---|---|
| Firmware OTA card | Same as ota CLI; WebSocket ota_progress with step master (UART) then slaves (ESP-NOW) |
POST /api/ota |
Upload .bin to master only — slaves are updated by firmware over ESP-NOW after OTA_END |
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
Waits for ready after start (~30 s erase), sends 200-byte OTA_PAYLOAD frames, reads block_ack every 4 KiB, then OTA_END. The master then distributes to all available slaves (no extra host traffic); success is reported only when that finishes. Allow several minutes for large images. Reboot master and slaves to boot the new firmware.
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
On success the slave serial log should show UNICAST TEST OK from master … seq=42.
Example output:
clients (2):
[0] id=42 mac=aabbccddeeff ver=1 available=true used=false last_ping=250 last_success_ping=250
Regenerate protobuf
From repo root (needs protoc, protoc-gen-go, and for C also pip install protobuf):
make gotool-proto # Go: goTool/pb/uart_messages.pb.go
make proto_generate # C: main/proto/*.pb.h, *.pb.c