powerpods/goTool/docs/API_WEBSOCKET.md
simon 31e539052a 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>
2026-05-29 21:23:09 +02:00

10 KiB
Raw Blame History

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. HTTP endpoints: 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):

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

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

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

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

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

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

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

Response stream_status:

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

set_accel_stream / get_accel_stream (firmware, per slave)

client_id required (> 0).

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

Response accel_stream_status:

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

set_tap_stream / get_tap_stream (receive tap on this connection)

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

Response tap_stream_status:

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

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

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

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

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: DashboardStateupdated_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), not via this WebSocket.