Unify cache polling on CACHE_STATUS and split API docs.

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>
This commit is contained in:
2026-05-29 21:23:09 +02:00
co-authored by Cursor
parent a85d48320e
commit 31e539052a
25 changed files with 1243 additions and 1269 deletions
+10 -241
View File
@@ -25,10 +25,8 @@ go run . -port /dev/ttyUSB0 clients
| `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 |
| `cache-status` | `0x1d` | Subscribed 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) |
@@ -80,257 +78,28 @@ Open [http://localhost:8080](http://localhost:8080) — shows master firmware in
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:
1. **`set_stream`** — this WebSocket connection: whether to receive `accel` JSON and at what poll rate (1 ms … 10 s per client; server UART poll uses the minimum among active subscribers).
2. **`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):
1. **`set_tap_notify`** — firmware: which tap kinds (single/double/triple) the slave sends to the master over ESP-NOW.
2. **`set_tap_stream`** — this WebSocket connection: poll `CACHE_STATUS` (tap slice) and push `tap` JSON. Events stay visible for **`tap_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`):
```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 registered slaves** (UART `CLIENT_INFO`; use before per-slave `set_accel_stream` / `set_tap_notify`):
```json
{"type":"list_clients"}
```
Reply:
```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}]}
```
**Receive accel on this connection** (optional `interval_ms`, default from `-accel-interval`):
```json
{"type":"set_stream","enable":true,"interval_ms":32}
{"type":"get_stream"}
```
Reply:
```json
{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true}
```
**Slave ESP-NOW stream** (per `client_id`):
```json
{"type":"set_accel_stream","client_id":16,"enable":true}
{"type":"get_accel_stream","client_id":16}
```
Reply:
```json
{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true}
```
**LED ring** (same JSON fields as `POST /api/led-ring`):
```json
{"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`):
```json
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
{"type":"get_tap_notify","client_id":16}
```
Reply:
```json
{"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`):
```json
{"type":"set_tap_stream","enable":true,"interval_ms":16}
{"type":"get_tap_stream"}
```
Reply:
```json
{"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):
```json
{"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):
```json
{"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):
```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}))
print(await ws.recv()) # accel_stream_status
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):
```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}))
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:
### HTTP / WebSocket API
| 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` |
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same UART as the dashboard.
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`):
```json
{"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` (0100), `digit` (010 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`):
```json
{"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):
```http
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):
```http
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`):
```http
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}]}`.
| Doc | Content |
|-----|---------|
| **[docs/API_WEBSOCKET.md](docs/API_WEBSOCKET.md)** | `ws://…:8081/ws` commands, **`accel` / `tap` push stream** format, dashboard `ws://…:8080/ws` |
| **[docs/API_REST.md](docs/API_REST.md)** | REST on `:8080` (dashboard) and `:8081` (battery, LED, service info) |
CLI:
```bash
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
go run . -port /dev/ttyUSB0 tap -client 16
go run . -port /dev/ttyUSB0 cache-status
```
| 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` |
| Firmware OTA card | Same as `ota` CLI; dashboard WebSocket `ota_progress` ([REST doc](docs/API_REST.md)) |
| `POST /api/ota` | Upload `.bin` to master — slaves updated by firmware over ESP-NOW after `OTA_END` |
```bash
go run . -port /dev/ttyUSB0 ota build/powerpod.bin