# 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 | | `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](../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 `TAP_SNAPSHOT` 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":["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`): ```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": "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/POST /api/deadzone`, `GET/PUT /api/clients/{id}/accel-stream`, `POST /api/accel-stream` (legacy / `all_clients`), `GET/PUT /api/clients/{id}/tap-notify`, `PUT /api/clients/{id}/tap-receive`, `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` (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`): ```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}`. **Tap receive** (host-side; dashboard polls `TAP_SNAPSHOT` while enabled): ```http PUT /api/clients/16/tap-receive Content-Type: application/json {"enable": true} → {"client_id":16,"enabled":true,"success":true} ``` One-shot read (no receive flag): `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 ```