powerpods/goTool/README.md
simon a85d48320e Add list_clients WebSocket command to external API.
Lets API clients discover slave IDs and stream/notify flags before configuring per-slave accel or tap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 21:02:41 +02:00

362 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# goTool
Host-side UART client for the Powerpod **master** ESP.
Full system documentation (roles, ESP-NOW, framing, protobuf): [`../main/README.md`](../main/README.md).
## Usage
```bash
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` | 1619 | 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](../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.
```bash
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`](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).
```bash
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](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:
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:
| 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`):
```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}]}`.
CLI:
```bash
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` |
```bash
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.
```bash
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`):
```bash
make gotool-proto # Go: goTool/pb/uart_messages.pb.go
make proto_generate # C: main/proto/*.pb.h, *.pb.c
```