Replace separate accel/tap snapshot UART commands with one clients[] response that omits unsubscribed fields; remove snapshot handlers and CLI commands. Add goTool/docs for WebSocket streams and REST; tap-snapshot REST uses CACHE_STATUS. Co-authored-by: Cursor <cursoragent@cursor.com>
10 KiB
WebSocket API
go run . -port /dev/ttyUSB0 serve exposes two WebSocket endpoints. They share the same UART link but serve different purposes.
| URL | Port (default) | Role |
|---|---|---|
ws://localhost:8080/ws |
Dashboard (-addr) |
Server → client only: full DashboardState JSON (~2 s poll + live-stream accel/tap) |
ws://localhost:8081/ws |
External API (-api-addr) |
Request/response commands + optional accel / tap push streams |
Disable the external server with -api-addr "".
CLI overview and UART commands: ../README.md. HTTP endpoints: API_REST.md.
External API (:8081/ws)
Connection flow
- Connect → server sends
hello(receive off; lists available commands). - Send JSON commands → server replies with a matching
*_statusorclient_listmessage (one reply per command). - After
set_stream/set_tap_streamwithenable: true, the server may sendacceland/ortapmessages without a prior command (push stream).
Commands and stream pushes are multiplexed on one socket. While streaming, always parse type and branch (status vs sample vs error).
Two layers (accel and tap)
| Layer | Commands | Effect |
|---|---|---|
| Firmware (ESP-NOW) | set_accel_stream, set_tap_notify |
Per client_id: slave sends accel or tap kinds to the master |
| This connection (host) | set_stream, set_tap_stream |
Whether you receive push JSON and at what rate (interval_ms, 1 ms … 10 s) |
- Accel UART polling runs only if at least one connection has
receive_accel: trueand at least one slave streams accel (set_accel_streamor dashboard). - Tap UART polling runs only if at least one connection has
receive_tap: true(set_tap_stream).set_tap_notifyalone does not poll.
Typical sequence:
list_clients→ slave IDs- Per slave:
set_accel_stream/set_tap_notifyas needed set_streamand/orset_tap_streamwith"enable": true- Read push messages in a loop
There is no per-slave filter on push messages: each accel contains all cached slaves; each tap contains all visible events. Filter by client_id in your app.
Push stream messages
These are the samples you get after enabling receive. Interval is per WebSocket connection; the server UART poll uses the minimum interval_ms among all subscribers that want accel or tap.
accel (type "accel")
Sent only when set_stream has enable: true, a slave streams accel, and the poll tick fires for this connection.
Success — all slaves with a cache entry on the master (not only those with valid: true):
{
"type": "accel",
"t": 1716900123456789012,
"success": true,
"clients": [
{
"client_id": 16,
"valid": true,
"x": 12,
"y": -34,
"z": 16384,
"age_ms": 8
},
{
"client_id": 42,
"valid": false
}
]
}
| Field | Meaning |
|---|---|
t |
Unix timestamp in nanoseconds when the host read the cache |
success |
true if CACHE_STATUS succeeded |
clients[] |
One entry per slave slot in the master cache |
client_id |
ESP-NOW client id (same as list_clients) |
valid |
false if no sample yet or stale; omit x/y/z when false |
x, y, z |
Raw accelerometer LSB (BMA456, ±2 g scale on the pod) |
age_ms |
Milliseconds since the master received this sample |
Failure (e.g. UART busy):
{
"type": "accel",
"t": 1716900123456789012,
"success": false,
"error": "uart busy"
}
No clients array on failure.
tap (type "tap")
Sent only when set_tap_stream has enable: true and there is at least one event to show.
Events appear when the master cache reports a new tap. Each event stays in push payloads for tap_display_min_ms (2000 ms, also in hello) after the API first saw it, even if the hardware age grows.
Success:
{
"type": "tap",
"t": 1716900123456789012,
"success": true,
"events": [
{
"client_id": 16,
"valid": true,
"kind": "single",
"age_ms": 3,
"shown_at_ms": 1717000000123
}
]
}
| Field | Meaning |
|---|---|
t |
Unix timestamp in nanoseconds (poll time) |
events[] |
All taps currently “on screen” for the API |
client_id |
Slave that tapped |
kind |
"single", "double", or "triple" |
age_ms |
Age in the master cache when read |
shown_at_ms |
Unix milliseconds when this host first included the event |
If no events are visible, no tap message is sent on that tick (unlike accel, which can send empty clients only on success with cache data).
Failure:
{
"type": "tap",
"t": 1716900123456789012,
"success": false,
"error": "uart busy"
}
Commands (request → response)
Send one JSON object per message. Field type selects the command.
hello (server → client, on connect)
{
"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": [
"list_clients",
"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"
]
}
list_clients
Request: {"type":"list_clients"}
Response client_list:
{
"type": "client_list",
"success": true,
"clients": [
{
"id": 16,
"mac": "aa:bb:cc:dd:ee:10",
"version": 1,
"available": true,
"used": true,
"last_ping": 1234,
"last_success_ping": 1200,
"accel_stream": false,
"tap_notify_single": false,
"tap_notify_double": false,
"tap_notify_triple": false
}
]
}
set_stream / get_stream (receive accel on this connection)
{"type":"set_stream","enable":true,"interval_ms":32}
{"type":"get_stream"}
Response stream_status:
{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true}
set_accel_stream / get_accel_stream (firmware, per slave)
client_id required (> 0).
{"type":"set_accel_stream","client_id":16,"enable":true}
{"type":"get_accel_stream","client_id":16}
Response accel_stream_status:
{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true}
set_tap_stream / get_tap_stream (receive tap on this connection)
{"type":"set_tap_stream","enable":true,"interval_ms":16}
{"type":"get_tap_stream"}
Response tap_stream_status:
{"type":"tap_stream_status","receive_tap":true,"interval_ms":16,"success":true}
set_tap_notify / get_tap_notify (firmware, per slave)
Per client: single, double_tap, triple required on set.
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
Broadcast: "all_clients": true with the three booleans.
Response tap_notify_status:
{
"type": "tap_notify_status",
"client_id": 16,
"success": true,
"single": true,
"double_tap": false,
"triple": false
}
set_led_ring
Same JSON body as POST /api/led-ring with "type":"set_led_ring" added. Reply: led_ring_status.
get_battery
Body: {"type":"get_battery","all_clients":true} or "client_id":16. Default if omitted: all clients.
Reply: battery_status with samples[] (see REST doc).
Examples
Accel stream
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": "list_clients"}))
clients = json.loads(await ws.recv())["clients"]
for c in clients:
if not c.get("available"):
continue
await ws.send(json.dumps({
"type": "set_accel_stream", "client_id": c["id"], "enable": True
}))
await ws.recv() # accel_stream_status
await ws.send(json.dumps({"type": "set_stream", "enable": True, "interval_ms": 16}))
await ws.recv() # stream_status
while True:
msg = json.loads(await ws.recv())
if msg.get("type") != "accel":
continue
if not msg.get("success"):
print("error:", msg.get("error"))
continue
for c in msg.get("clients", []):
if c.get("valid"):
print(c["client_id"], c["x"], c["y"], c["z"], "age", c.get("age_ms"))
asyncio.run(main())
Tap stream
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
}))
await ws.recv() # tap_notify_status
await ws.send(json.dumps({"type": "set_tap_stream", "enable": True, "interval_ms": 16}))
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())
Dashboard WebSocket (:8080/ws)
Read-only from the browser’s perspective: the server pushes JSON whenever state changes. Clients do not send commands on this socket (messages are ignored).
Payload shape: DashboardState — updated_at, serial_port, uart_connected, live_stream, master, clients[] (id, mac, accel, tap notify flags, battery, etc.). Accel/tap samples appear here when Live stream is enabled in the UI (PUT /api/live-stream).
During OTA, additional messages with "type":"ota_progress" may appear on the same socket.
Configure slaves via REST on :8080 (API_REST.md), not via this WebSocket.