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
+9 -7
View File
@@ -506,8 +506,9 @@ func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
}
if wantAccel {
clients := make([]AccelClientSample, 0, len(cache.GetAccel()))
for _, s := range cache.GetAccel() {
samples := accelSamplesFromCacheStatus(cache)
clients := make([]AccelClientSample, 0, len(samples))
for _, s := range samples {
clients = append(clients, AccelClientSample{
ClientID: s.GetClientId(),
Valid: s.GetValid(),
@@ -525,8 +526,9 @@ func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
})
}
if wantTap {
fresh := make([]TapClientEvent, 0, len(cache.GetTaps()))
for _, e := range cache.GetTaps() {
events := tapEventsFromCacheStatus(cache)
fresh := make([]TapClientEvent, 0, len(events))
for _, e := range events {
if !e.GetValid() {
continue
}
@@ -537,13 +539,13 @@ func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
AgeMs: e.GetAgeMs(),
})
}
events := hub.ingestTapEvents(fresh)
if len(events) > 0 {
visible := hub.ingestTapEvents(fresh)
if len(visible) > 0 {
hub.deliverTap(TapStreamMessage{
Type: "tap",
T: now,
Success: true,
Events: events,
Events: visible,
})
}
}
+4 -4
View File
@@ -199,14 +199,14 @@ func serveTapSnapshotGet(w http.ResponseWriter, r *http.Request, link *managedSe
writeJSON(w, http.StatusBadRequest, tapSnapshotAPIResponse{Error: err.Error()})
return
}
resp, err := link.readTapSnapshotPoll(clientID)
cache, err := link.readCacheStatusPoll()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, tapSnapshotAPIResponse{Error: err.Error()})
return
}
out := tapSnapshotAPIResponse{Events: make([]tapEventView, 0, len(resp.GetEvents()))}
for _, e := range resp.GetEvents() {
if !e.GetValid() {
out := tapSnapshotAPIResponse{Events: make([]tapEventView, 0)}
for _, e := range tapEventsFromCacheStatus(cache) {
if clientID != 0 && e.GetClientId() != clientID {
continue
}
out.Events = append(out.Events, tapEventView{
+49
View File
@@ -0,0 +1,49 @@
package main
import "powerpod/gotool/pb"
// accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample
// (for dashboard / WebSocket accel push).
func accelSamplesFromCacheStatus(r *pb.CacheStatusResponse) []*pb.AccelSample {
if r == nil {
return nil
}
out := make([]*pb.AccelSample, 0, len(r.GetClients()))
for _, c := range r.GetClients() {
if c.GetAccel() == nil {
continue
}
a := c.GetAccel()
out = append(out, &pb.AccelSample{
ClientId: c.GetClientId(),
Valid: a.GetValid(),
X: a.GetX(),
Y: a.GetY(),
Z: a.GetZ(),
AgeMs: a.GetAgeMs(),
})
}
return out
}
// tapEventsFromCacheStatus maps combined CACHE_STATUS entries to TapEvent
// (only clients with a consumed pending tap).
func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
if r == nil {
return nil
}
out := make([]*pb.TapEvent, 0, len(r.GetClients()))
for _, c := range r.GetClients() {
if c.GetTap() == nil {
continue
}
t := c.GetTap()
out = append(out, &pb.TapEvent{
ClientId: c.GetClientId(),
Valid: true,
Kind: t.GetKind(),
AgeMs: t.GetAgeMs(),
})
}
return out
}
-112
View File
@@ -40,25 +40,6 @@ func (m *managedSerial) listClientsPoll() ([]*pb.ClientInfo, error) {
return decodeClientsPayload(payload)
}
func (m *managedSerial) readAccelSnapshotPoll(clientID uint32) (*pb.AccelSnapshotResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_SNAPSHOT,
Payload: &pb.UartMessage_AccelSnapshotRequest{
AccelSnapshotRequest: &pb.AccelSnapshotRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ACCEL_SNAPSHOT)}, body...)
respPayload, err := m.exchangePayloadPoll(payload, "ACCEL_SNAPSHOT")
if err != nil {
return nil, err
}
return decodeAccelSnapshotPayload(respPayload)
}
func decodeBatteryStatusPayload(payload []byte) (*pb.BatteryStatusResponse, error) {
if len(payload) < 2 {
return nil, fmt.Errorf("short battery response")
@@ -222,43 +203,6 @@ func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
return r, nil
}
func (m *managedSerial) readTapSnapshotPoll(clientID uint32) (*pb.TapSnapshotResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_SNAPSHOT,
Payload: &pb.UartMessage_TapSnapshotRequest{
TapSnapshotRequest: &pb.TapSnapshotRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_SNAPSHOT)}, body...)
respPayload, err := m.exchangePayloadPoll(payload, "TAP_SNAPSHOT")
if err != nil {
return nil, err
}
return decodeTapSnapshotPayload(respPayload)
}
func decodeTapSnapshotPayload(payload []byte) (*pb.TapSnapshotResponse, error) {
if len(payload) < 1 {
return nil, fmt.Errorf("empty response payload")
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
if msg.GetType() != pb.MessageType_TAP_SNAPSHOT {
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
}
r := msg.GetTapSnapshotResponse()
if r == nil {
return nil, fmt.Errorf("missing tap_snapshot_response")
}
return r, nil
}
func (m *managedSerial) accelStreamVia(
portFn func(func(*serialPort) error) error,
req *pb.AccelStreamRequest,
@@ -333,43 +277,6 @@ func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) {
return info.GetClients(), nil
}
func decodeAccelSnapshotPayload(payload []byte) (*pb.AccelSnapshotResponse, error) {
if len(payload) < 1 {
return nil, fmt.Errorf("empty response payload")
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
if msg.GetType() != pb.MessageType_ACCEL_SNAPSHOT {
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
}
r := msg.GetAccelSnapshotResponse()
if r == nil {
return nil, fmt.Errorf("missing accel_snapshot_response")
}
return r, nil
}
func (s *serialPort) readAccelSnapshot(clientID uint32) (*pb.AccelSnapshotResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_SNAPSHOT,
Payload: &pb.UartMessage_AccelSnapshotRequest{
AccelSnapshotRequest: &pb.AccelSnapshotRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ACCEL_SNAPSHOT)}, body...)
respPayload, err := s.exchangePayload(payload, "ACCEL_SNAPSHOT")
if err != nil {
return nil, err
}
return decodeAccelSnapshotPayload(respPayload)
}
func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION")
if err != nil {
@@ -448,25 +355,6 @@ func (s *serialPort) readCacheStatus() (*pb.CacheStatusResponse, error) {
return decodeCacheStatusPayload(payload)
}
func (s *serialPort) readTapSnapshot(clientID uint32) (*pb.TapSnapshotResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_SNAPSHOT,
Payload: &pb.UartMessage_TapSnapshotRequest{
TapSnapshotRequest: &pb.TapSnapshotRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_SNAPSHOT)}, body...)
respPayload, err := s.exchangePayload(payload, "TAP_SNAPSHOT")
if err != nil {
return nil, err
}
return decodeTapSnapshotPayload(respPayload)
}
func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_DEADZONE,
-30
View File
@@ -1,30 +0,0 @@
package main
import (
"fmt"
)
func runAccel(sp *serialPort) error {
return runAccelSnapshot(sp, 0)
}
func runAccelSnapshot(sp *serialPort, clientID uint32) error {
r, err := sp.readAccelSnapshot(clientID)
if err != nil {
return err
}
samples := r.GetSamples()
if len(samples) == 0 {
fmt.Println("no accel samples (no slaves or no ESP-NOW stream yet)")
return nil
}
for _, s := range samples {
if !s.GetValid() {
fmt.Printf("client %d: no sample yet\n", s.GetClientId())
continue
}
fmt.Printf("client %d: x=%d y=%d z=%d (age %d ms, raw LSB ±2g)\n",
s.GetClientId(), s.GetX(), s.GetY(), s.GetZ(), s.GetAgeMs())
}
return nil
}
+17 -19
View File
@@ -9,26 +9,24 @@ func runCacheStatus(sp *serialPort) error {
if err != nil {
return err
}
accel := r.GetAccel()
if len(accel) == 0 {
fmt.Println("accel: (none — no slaves with accel stream enabled)")
} else {
for _, s := range accel {
if !s.GetValid() {
fmt.Printf("accel client %d: no sample yet\n", s.GetClientId())
continue
}
fmt.Printf("accel client %d: x=%d y=%d z=%d (age %d ms)\n",
s.GetClientId(), s.GetX(), s.GetY(), s.GetZ(), s.GetAgeMs())
}
clients := r.GetClients()
if len(clients) == 0 {
fmt.Println("(no slaves with accel stream or tap notify enabled)")
return nil
}
taps := r.GetTaps()
if len(taps) == 0 {
fmt.Println("tap: (none pending)")
} else {
for _, e := range taps {
fmt.Printf("tap client %d: %s (age %d ms)\n",
e.GetClientId(), tapKindLabel(e.GetKind()), e.GetAgeMs())
for _, c := range clients {
id := c.GetClientId()
if a := c.GetAccel(); a != nil {
if !a.GetValid() {
fmt.Printf("client %d accel: no sample yet\n", id)
} else {
fmt.Printf("client %d accel: x=%d y=%d z=%d (age %d ms)\n",
id, a.GetX(), a.GetY(), a.GetZ(), a.GetAgeMs())
}
}
if t := c.GetTap(); t != nil {
fmt.Printf("client %d tap: %s (age %d ms)\n",
id, tapKindLabel(t.GetKind()), t.GetAgeMs())
}
}
return nil
-26
View File
@@ -44,32 +44,6 @@ func runTapNotify(sp *serialPort, args []string) error {
return nil
}
func runTapSnapshot(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("tap", flag.ExitOnError)
clientID := fs.Uint("client", 0, "client id (0 = all slaves with tap notify)")
if err := fs.Parse(args); err != nil {
return err
}
return runTapSnapshotForClient(sp, uint32(*clientID))
}
func runTapSnapshotForClient(sp *serialPort, clientID uint32) error {
r, err := sp.readTapSnapshot(clientID)
if err != nil {
return err
}
events := r.GetEvents()
if len(events) == 0 {
fmt.Println("no tap events (none pending or older than 16 ms)")
return nil
}
for _, e := range events {
fmt.Printf("client %d: %s (age %d ms)\n",
e.GetClientId(), tapKindLabel(e.GetKind()), e.GetAgeMs())
}
return nil
}
func tapKindLabel(k pb.TapKind) string {
switch k {
case pb.TapKind_TAP_SINGLE:
+2 -2
View File
@@ -643,8 +643,8 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
if err != nil {
continue
}
hub.mergeAccel(cache.GetAccel())
hub.mergeTap(cache.GetTaps())
hub.mergeAccel(accelSamplesFromCacheStatus(cache))
hub.mergeTap(tapEventsFromCacheStatus(cache))
}
}
}
+284
View File
@@ -0,0 +1,284 @@
# REST API
`go run . -port /dev/ttyUSB0 serve` starts two HTTP servers on the same UART link:
| Base URL | Flag | Used by |
|----------|------|---------|
| `http://localhost:8080` | `-addr` (default `:8080`) | Web dashboard + automation on the UI routes |
| `http://localhost:8081` | `-api-addr` (default `:8081`, `""` disables) | External programs; subset of routes + service info |
WebSocket streaming (accel/tap push): [`API_WEBSOCKET.md`](API_WEBSOCKET.md).
All JSON responses use `Content-Type: application/json`. On UART errors many routes return **503** with `"error"` in the body.
---
## External API (`:8081`)
### Service info
```http
GET /
GET /api/v1/
```
```json
{
"name": "powerpod-external-api",
"version": "1",
"serial_port": "/dev/ttyUSB0",
"websocket": "/ws",
"default_interval_ms": 16,
"min_interval_ms": 1,
"max_interval_ms": 10000,
"tap_display_min_ms": 2000,
"description": "..."
}
```
### Battery
```http
GET /api/battery?all_clients=true
GET /api/battery?client_id=16
POST /api/battery
Content-Type: application/json
```
POST body:
```json
{"all_clients": true}
{"client_id": 0}
{"client_id": 16}
```
Response:
```json
{
"success": true,
"samples": [
{
"client_id": 16,
"lipo1": {"valid": true, "voltage_mv": 3850, "percent": 71},
"lipo2": {"valid": false},
"age_ms": 1200
}
]
}
```
Slaves push battery to the master every **30 s**; these routes read the master cache.
WebSocket equivalent: `get_battery` on `ws://localhost:8081/ws` (reply type `battery_status`).
### LED ring
```http
POST /api/led-ring
Content-Type: application/json
```
Body:
```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}
```
| `mode` | Notes |
|--------|--------|
| `clear` | Turn off |
| `color` | Full ring RGB + `intensity` |
| `progress` | `progress` 0100 |
| `digit` | `digit` 010 |
| `blink` | `blink_ms`, `blink_count` |
| `find-me` | Locate pod |
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
Response: `success`, `slaves_updated`, optional `error`.
WebSocket: `set_led_ring` with the same fields plus `"type":"set_led_ring"``led_ring_status`.
---
## Dashboard API (`:8080`)
Used by the web UI; safe for scripts that drive the same features.
### Live stream (host `CACHE_STATUS` poll ~16 ms)
```http
GET /api/live-stream
PUT /api/live-stream
Content-Type: application/json
{"enable": true}
```
```json
{"enabled": true, "success": true}
```
Enables fast UART polling for dashboard accel/tap display. Per-slave accel still requires accel-stream (below).
### Accel stream (firmware ESP-NOW, per slave)
```http
GET /api/clients/16/accel-stream
PUT /api/clients/16/accel-stream
Content-Type: application/json
{"enable": true}
```
```json
{"enabled": true, "client_id": 16, "success": true}
```
All slaves:
```http
POST /api/accel-stream
Content-Type: application/json
{"write": true, "enable": true, "all_clients": true}
```
Polling on the host runs only while at least one slave has streaming enabled (here or via external WebSocket / dashboard).
### Tap notify (firmware; does not start host tap polling)
```http
GET /api/clients/16/tap-notify
PUT /api/clients/16/tap-notify
Content-Type: application/json
{"single": true, "double_tap": false, "triple": false}
```
```json
{
"client_id": 16,
"success": true,
"slaves_updated": 1,
"single": true,
"double_tap": false,
"triple": false
}
```
All slaves:
```http
POST /api/tap-notify
Content-Type: application/json
{"single": true, "double_tap": false, "triple": false, "all_clients": true}
```
Host tap display / external `set_tap_stream` is separate.
### Tap snapshot (one-shot, via `CACHE_STATUS`)
```http
GET /api/tap-snapshot?client_id=16
```
Reads the combined cache (`CACHE_STATUS`); optional `client_id` filters pending tap events. Pending taps are consumed on read.
```json
{
"events": [
{"client_id": 16, "kind": "single", "age_ms": 4}
]
}
```
### Deadzone
```http
GET /api/deadzone?client_id=0
POST /api/deadzone
Content-Type: application/json
{"write": true, "deadzone": 128, "client_id": 0}
```
With `all_clients` + `slaves_only`: push to ESP-NOW slaves only (master BMA456 unchanged).
```json
{"deadzone": 128, "client_id": 0, "success": true, "slaves_updated": 2}
```
### Unicast test
```http
POST /api/unicast-test
Content-Type: application/json
{"client_id": 16, "seq": 42}
```
### Find me
```http
POST /api/find-me
Content-Type: application/json
{"client_id": 16}
```
`client_id` `0` = master LED ring.
### Restart
```http
POST /api/restart
Content-Type: application/json
{"client_id": 16}
```
### OTA (master UART upload)
```http
POST /api/ota
Content-Type: multipart/form-data
```
Form field **`firmware`**: binary image, max **2 MiB**.
```json
{"success": true, "bytes_written": 123456, "target_slot": 1}
```
Firmware distributes to slaves over ESP-NOW after `OTA_END`. Progress also appears on dashboard WebSocket as `ota_progress` messages.
CLI equivalent: `go run . -port /dev/ttyUSB0 ota build/powerpod.bin`
### LED ring and battery
Same as external API:
- `POST /api/led-ring`
- `GET` / `POST` `/api/battery`
---
## Dashboard vs external
| Feature | Dashboard `:8080` | External `:8081` |
|---------|-------------------|------------------|
| Client list | Via dashboard WebSocket state / CLI `clients` | WebSocket `list_clients` |
| Accel/tap **push stream** | WebSocket state when live-stream on | WebSocket `set_stream` / `set_tap_stream` |
| Accel stream enable | REST `PUT .../accel-stream` | WebSocket `set_accel_stream` |
| Tap notify | REST `PUT .../tap-notify` | WebSocket `set_tap_notify` |
| LED / battery | REST | REST + WebSocket on `:8081` |
---
## UI mapping
| UI action | REST / CLI |
|-----------|------------|
| Nur Master deadzone | `POST /api/deadzone` `client_id: 0` or CLI `deadzone -set -client 0` |
| Einzelner Slave | `client_id: <id>` |
| Alle Slaves deadzone | `all_clients` + `slaves_only` on POST |
| Unicast test | `POST /api/unicast-test` |
| Tap notify S/D/T | `PUT /api/clients/{id}/tap-notify` |
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |
+348
View File
@@ -0,0 +1,348 @@
# WebSocket API
`go run . -port /dev/ttyUSB0 serve` exposes two WebSocket endpoints. They share the same UART link but serve different purposes.
| URL | Port (default) | Role |
|-----|----------------|------|
| `ws://localhost:8080/ws` | Dashboard (`-addr`) | Server → client only: full `DashboardState` JSON (~2 s poll + live-stream accel/tap) |
| `ws://localhost:8081/ws` | External API (`-api-addr`) | Request/response commands + optional **accel** / **tap** push streams |
Disable the external server with `-api-addr ""`.
CLI overview and UART commands: [`../README.md`](../README.md). HTTP endpoints: [`API_REST.md`](API_REST.md).
---
## External API (`:8081/ws`)
### Connection flow
1. Connect → server sends **`hello`** (receive off; lists available commands).
2. Send JSON commands → server replies with a matching `*_status` or `client_list` message (one reply per command).
3. After `set_stream` / `set_tap_stream` with `enable: true`, the server may send **`accel`** and/or **`tap`** messages **without** a prior command (push stream).
Commands and stream pushes are multiplexed on one socket. While streaming, always parse `type` and branch (status vs sample vs error).
### Two layers (accel and tap)
| Layer | Commands | Effect |
|-------|----------|--------|
| **Firmware (ESP-NOW)** | `set_accel_stream`, `set_tap_notify` | Per `client_id`: slave sends accel or tap kinds to the master |
| **This connection (host)** | `set_stream`, `set_tap_stream` | Whether **you** receive push JSON and at what rate (`interval_ms`, 1 ms … 10 s) |
- **Accel UART polling** runs only if at least one connection has `receive_accel: true` **and** at least one slave streams accel (`set_accel_stream` or dashboard).
- **Tap UART polling** runs only if at least one connection has `receive_tap: true` (`set_tap_stream`). `set_tap_notify` alone does **not** poll.
Typical sequence:
1. `list_clients` → slave IDs
2. Per slave: `set_accel_stream` / `set_tap_notify` as needed
3. `set_stream` and/or `set_tap_stream` with `"enable": true`
4. Read push messages in a loop
There is **no per-slave filter** on push messages: each `accel` contains all cached slaves; each `tap` contains all visible events. Filter by `client_id` in your app.
---
## Push stream messages
These are the samples you get after enabling receive. Interval is per WebSocket connection; the server UART poll uses the **minimum** `interval_ms` among all subscribers that want accel or tap.
### `accel` (type `"accel"`)
Sent only when `set_stream` has `enable: true`, a slave streams accel, and the poll tick fires for this connection.
**Success** — all slaves with a cache entry on the master (not only those with `valid: true`):
```json
{
"type": "accel",
"t": 1716900123456789012,
"success": true,
"clients": [
{
"client_id": 16,
"valid": true,
"x": 12,
"y": -34,
"z": 16384,
"age_ms": 8
},
{
"client_id": 42,
"valid": false
}
]
}
```
| Field | Meaning |
|-------|---------|
| `t` | Unix timestamp in **nanoseconds** when the host read the cache |
| `success` | `true` if `CACHE_STATUS` succeeded |
| `clients[]` | One entry per slave slot in the master cache |
| `client_id` | ESP-NOW client id (same as `list_clients`) |
| `valid` | `false` if no sample yet or stale; omit `x`/`y`/`z` when false |
| `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g scale on the pod) |
| `age_ms` | Milliseconds since the master received this sample |
**Failure** (e.g. UART busy):
```json
{
"type": "accel",
"t": 1716900123456789012,
"success": false,
"error": "uart busy"
}
```
No `clients` array on failure.
### `tap` (type `"tap"`)
Sent only when `set_tap_stream` has `enable: true` and there is at least one event to show.
Events appear when the master cache reports a new tap. Each event stays in push payloads for **`tap_display_min_ms`** (2000 ms, also in `hello`) after the API first saw it, even if the hardware age grows.
**Success**:
```json
{
"type": "tap",
"t": 1716900123456789012,
"success": true,
"events": [
{
"client_id": 16,
"valid": true,
"kind": "single",
"age_ms": 3,
"shown_at_ms": 1717000000123
}
]
}
```
| Field | Meaning |
|-------|---------|
| `t` | Unix timestamp in **nanoseconds** (poll time) |
| `events[]` | All taps currently “on screen” for the API |
| `client_id` | Slave that tapped |
| `kind` | `"single"`, `"double"`, or `"triple"` |
| `age_ms` | Age in the master cache when read |
| `shown_at_ms` | Unix **milliseconds** when this host first included the event |
If no events are visible, **no** `tap` message is sent on that tick (unlike accel, which can send empty `clients` only on success with cache data).
**Failure**:
```json
{
"type": "tap",
"t": 1716900123456789012,
"success": false,
"error": "uart busy"
}
```
---
## Commands (request → response)
Send one JSON object per message. Field `type` selects the command.
### `hello` (server → client, on connect)
```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_clients`
Request: `{"type":"list_clients"}`
Response `client_list`:
```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
}
]
}
```
### `set_stream` / `get_stream` (receive accel on this connection)
```json
{"type":"set_stream","enable":true,"interval_ms":32}
{"type":"get_stream"}
```
Response `stream_status`:
```json
{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true}
```
### `set_accel_stream` / `get_accel_stream` (firmware, per slave)
`client_id` required (> 0).
```json
{"type":"set_accel_stream","client_id":16,"enable":true}
{"type":"get_accel_stream","client_id":16}
```
Response `accel_stream_status`:
```json
{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true}
```
### `set_tap_stream` / `get_tap_stream` (receive tap on this connection)
```json
{"type":"set_tap_stream","enable":true,"interval_ms":16}
{"type":"get_tap_stream"}
```
Response `tap_stream_status`:
```json
{"type":"tap_stream_status","receive_tap":true,"interval_ms":16,"success":true}
```
### `set_tap_notify` / `get_tap_notify` (firmware, per slave)
Per client: `single`, `double_tap`, `triple` required on set.
```json
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
```
Broadcast: `"all_clients": true` with the three booleans.
Response `tap_notify_status`:
```json
{
"type": "tap_notify_status",
"client_id": 16,
"success": true,
"single": true,
"double_tap": false,
"triple": false
}
```
### `set_led_ring`
Same JSON body as [`POST /api/led-ring`](API_REST.md#led-ring) with `"type":"set_led_ring"` added. Reply: `led_ring_status`.
### `get_battery`
Body: `{"type":"get_battery","all_clients":true}` or `"client_id":16`. Default if omitted: all clients.
Reply: `battery_status` with `samples[]` (see REST doc).
---
## Examples
### Accel stream
```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
}))
await ws.recv() # accel_stream_status
await ws.send(json.dumps({"type": "set_stream", "enable": True, "interval_ms": 16}))
await ws.recv() # stream_status
while True:
msg = json.loads(await ws.recv())
if msg.get("type") != "accel":
continue
if not msg.get("success"):
print("error:", msg.get("error"))
continue
for c in msg.get("clients", []):
if c.get("valid"):
print(c["client_id"], c["x"], c["y"], c["z"], "age", c.get("age_ms"))
asyncio.run(main())
```
### Tap stream
```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
}))
await ws.recv() # tap_notify_status
await ws.send(json.dumps({"type": "set_tap_stream", "enable": True, "interval_ms": 16}))
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())
```
---
## Dashboard WebSocket (`:8080/ws`)
Read-only from the browsers perspective: the server pushes JSON whenever state changes. Clients do not send commands on this socket (messages are ignored).
Payload shape: `DashboardState``updated_at`, `serial_port`, `uart_connected`, `live_stream`, `master`, `clients[]` (id, mac, accel, tap notify flags, battery, etc.). Accel/tap samples appear here when **Live stream** is enabled in the UI (`PUT /api/live-stream`).
During OTA, additional messages with `"type":"ota_progress"` may appear on the same socket.
Configure slaves via REST on `:8080` ([`API_REST.md`](API_REST.md)), not via this WebSocket.
+2 -8
View File
@@ -17,9 +17,7 @@ func usage() {
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n")
fmt.Fprintf(os.Stderr, " tap-notify get/set which tap kinds notify via ESP-NOW\n")
fmt.Fprintf(os.Stderr, " tap read cached tap events from master\n")
fmt.Fprintf(os.Stderr, " accel read cached slave accel snapshot from master\n")
fmt.Fprintf(os.Stderr, " cache-status combined accel + tap cache (one UART round-trip)\n")
fmt.Fprintf(os.Stderr, " cache-status subscribed accel + tap cache (one UART round-trip)\n")
fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n")
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
fmt.Fprintf(os.Stderr, " serve web dashboard (Bootstrap + WebSocket)\n")
@@ -54,7 +52,7 @@ func main() {
os.Exit(2)
}
runErr = runServe(*portName, *baud, flag.Args()[1:])
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "tap-notify", "tap_notify", "tap", "accel", "accel-read", "accel_read", "cache-status", "cache_status", "unicast-test", "unicast_test", "led-ring", "led_ring", "find-me", "find_me", "restart", "ota", "ota-progress", "ota_progress":
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "tap-notify", "tap_notify", "cache-status", "cache_status", "unicast-test", "unicast_test", "led-ring", "led_ring", "find-me", "find_me", "restart", "ota", "ota-progress", "ota_progress":
if *portName == "" {
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
usage()
@@ -74,10 +72,6 @@ func main() {
runErr = runDeadzone(sp, flag.Args()[1:])
case "tap-notify", "tap_notify":
runErr = runTapNotify(sp, flag.Args()[1:])
case "tap":
runErr = runTapSnapshot(sp, flag.Args()[1:])
case "accel", "accel-read", "accel_read":
runErr = runAccel(sp)
case "cache-status", "cache_status":
runErr = runCacheStatus(sp)
case "unicast-test", "unicast_test":
File diff suppressed because it is too large Load Diff