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>
349 lines
10 KiB
Markdown
349 lines
10 KiB
Markdown
# 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`](../README.md). HTTP endpoints: [`API_REST.md`](API_REST.md).
|
||
|
||
---
|
||
|
||
## External API (`:8081/ws`)
|
||
|
||
### Connection flow
|
||
|
||
1. Connect → server sends **`hello`** (receive off; lists available commands).
|
||
2. Send JSON commands → server replies with a matching `*_status` or `client_list` message (one reply per command).
|
||
3. After `set_stream` / `set_tap_stream` with `enable: true`, the server may send **`accel`** and/or **`tap`** messages **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: true` **and** at least one slave streams accel (`set_accel_stream` or dashboard).
|
||
- **Tap UART polling** runs only if at least one connection has `receive_tap: true` (`set_tap_stream`). `set_tap_notify` alone does **not** poll.
|
||
|
||
Typical sequence:
|
||
|
||
1. `list_clients` → slave IDs
|
||
2. Per slave: `set_accel_stream` / `set_tap_notify` as needed
|
||
3. `set_stream` and/or `set_tap_stream` with `"enable": true`
|
||
4. 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`):
|
||
|
||
```json
|
||
{
|
||
"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):
|
||
|
||
```json
|
||
{
|
||
"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**:
|
||
|
||
```json
|
||
{
|
||
"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**:
|
||
|
||
```json
|
||
{
|
||
"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)
|
||
|
||
```json
|
||
{
|
||
"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`:
|
||
|
||
```json
|
||
{
|
||
"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)
|
||
|
||
```json
|
||
{"type":"set_stream","enable":true,"interval_ms":32}
|
||
{"type":"get_stream"}
|
||
```
|
||
|
||
Response `stream_status`:
|
||
|
||
```json
|
||
{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true}
|
||
```
|
||
|
||
### `set_accel_stream` / `get_accel_stream` (firmware, per slave)
|
||
|
||
`client_id` required (> 0).
|
||
|
||
```json
|
||
{"type":"set_accel_stream","client_id":16,"enable":true}
|
||
{"type":"get_accel_stream","client_id":16}
|
||
```
|
||
|
||
Response `accel_stream_status`:
|
||
|
||
```json
|
||
{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true}
|
||
```
|
||
|
||
### `set_tap_stream` / `get_tap_stream` (receive tap on this connection)
|
||
|
||
```json
|
||
{"type":"set_tap_stream","enable":true,"interval_ms":16}
|
||
{"type":"get_tap_stream"}
|
||
```
|
||
|
||
Response `tap_stream_status`:
|
||
|
||
```json
|
||
{"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.
|
||
|
||
```json
|
||
{"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`:
|
||
|
||
```json
|
||
{
|
||
"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`](API_REST.md#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
|
||
|
||
```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": "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
|
||
|
||
```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_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`](API_REST.md)), not via this WebSocket.
|