simon a8d4d42920 Add BMA456 tap detection with ESP-NOW notify and host snapshot API.
Slaves forward configured tap kinds to the master; goTool exposes CLI, dashboard, REST, and WebSocket with separate notify vs receive and 2s display cache.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:42:57 +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
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 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.

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

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

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

Tap notify (slave ESP-NOW config; per client_id, or all_clients):

{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
{"type":"get_tap_notify","client_id":16}

Reply:

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

{"type":"set_tap_stream","enable":true,"interval_ms":16}
{"type":"get_tap_stream"}

Reply:

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

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

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

Tap example (notify first, then enable stream on this connection):

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

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

Tap notify per slave (slave → master ESP-NOW; does not start host polling):

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

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:

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