simon 3cb0b5bbe9 Add LiPo battery monitoring with ESP-NOW cache and dashboard API.
Slaves report pack voltages every 30s; the master caches them for fast
BATTERY_STATUS reads. goTool exposes REST/WebSocket and shows values in
the dashboard, with a nanopb fix so optional lipo submessages encode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:14:28 +02:00
..

goTool

Host-side UART client for the Powerpod master ESP.

Full system documentation (roles, ESP-NOW, framing, protobuf): ../main/README.md.

Usage

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
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 (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.

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 for the JSON schema.

Web dashboard

Polls the master over UART and pushes state to the browser via WebSocket (Alpine.js + Bootstrap 5).

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 — shows master firmware info and the ESP-NOW client table from CLIENT_INFO.

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)
WebSocket /ws Per-connection accel receive + interval; slave ESP-NOW stream control

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).

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).

Hello (on connect; accel is off until set_stream):

{"type":"hello","serial_port":"/dev/ttyUSB0","interval_ms":16,"commands":["set_stream","get_stream","set_accel_stream","get_accel_stream","set_led_ring","get_battery"]}

Receive accel on this connection (optional interval_ms, default from -accel-interval):

{"type":"set_stream","enable":true,"interval_ms":32}
{"type":"get_stream"}

Reply:

{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true}

Slave ESP-NOW stream (per client_id):

{"type":"set_accel_stream","client_id":16,"enable":true}
{"type":"get_accel_stream","client_id":16}

Reply:

{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true}

LED ring (same JSON fields as POST /api/led-ring):

{"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,...}

Accel (only to connections with receive_accel: true, and only while slaves stream):

{"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):

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())

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/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):

{"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):

{"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):

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}.

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
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.

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):

make gotool-proto      # Go: goTool/pb/uart_messages.pb.go
make proto_generate    # C: main/proto/*.pb.h, *.pb.c