Stream slave accel via ESP-NOW with master snapshot cache.
Slaves push BMA456 samples at 16ms when enabled; the master caches per client and exposes ACCEL_SNAPSHOT and ACCEL_STREAM over UART. goTool adds dashboard stream controls, HTTP accel-stream routes, and an external WebSocket API with per-connection receive/interval and slave stream commands. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+96
-2
@@ -25,7 +25,7 @@ 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` | Read current BMA456 XYZ (raw LSB, ±2g); alias `accel-read` |
|
||||
| `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) |
|
||||
@@ -62,11 +62,91 @@ Polls the master over UART and pushes state to the browser via WebSocket (Alpine
|
||||
```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`.
|
||||
|
||||
### 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`):
|
||||
|
||||
```json
|
||||
{"type":"hello","serial_port":"/dev/ttyUSB0","interval_ms":16,"commands":["set_stream","get_stream","set_accel_stream","get_accel_stream"]}
|
||||
```
|
||||
|
||||
**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}
|
||||
```
|
||||
|
||||
**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())
|
||||
```
|
||||
|
||||
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:
|
||||
@@ -78,7 +158,21 @@ The dashboard can configure nodes using the same UART commands as the CLI:
|
||||
| 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`, `POST /api/unicast-test`, `POST /api/find-me`, `POST /api/restart`, `POST /api/ota` (multipart field `firmware`, max 2 MiB).
|
||||
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`), `POST /api/unicast-test`, `POST /api/find-me`, `POST /api/restart`, `POST /api/ota` (multipart field `firmware`, max 2 MiB).
|
||||
|
||||
**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}`.
|
||||
|
||||
| UI / API | Behaviour |
|
||||
|----------|-----------|
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import "sync"
|
||||
|
||||
// accelStreamCtl tracks which slaves the host wants to poll for accel (mirrors firmware).
|
||||
type accelStreamCtl struct {
|
||||
mu sync.Mutex
|
||||
enabled map[uint32]struct{}
|
||||
}
|
||||
|
||||
func newAccelStreamCtl() *accelStreamCtl {
|
||||
return &accelStreamCtl{enabled: make(map[uint32]struct{})}
|
||||
}
|
||||
|
||||
func (c *accelStreamCtl) Set(clientID uint32, on bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if on {
|
||||
c.enabled[clientID] = struct{}{}
|
||||
} else {
|
||||
delete(c.enabled, clientID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *accelStreamCtl) Any() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.enabled) > 0
|
||||
}
|
||||
|
||||
func (c *accelStreamCtl) SyncFromClients(clients []ClientView) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.enabled = make(map[uint32]struct{})
|
||||
for _, cl := range clients {
|
||||
if cl.AccelStream {
|
||||
c.enabled[cl.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
type accelStreamAPIRequest struct {
|
||||
Write bool `json:"write"`
|
||||
Enable bool `json:"enable"`
|
||||
ClientID uint32 `json:"client_id"`
|
||||
AllClients bool `json:"all_clients"`
|
||||
}
|
||||
|
||||
type accelStreamAPIResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientID uint32 `json:"client_id"`
|
||||
Success bool `json:"success"`
|
||||
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type clientAccelStreamBody struct {
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
|
||||
func mountAccelStreamAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||
mux.HandleFunc("GET /api/clients/{clientID}/accel-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
clientID, err := parsePathClientID(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
serveAccelStreamGet(w, clientID, link, hub, ctl)
|
||||
})
|
||||
mux.HandleFunc("PUT /api/clients/{clientID}/accel-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
clientID, err := parsePathClientID(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
serveClientAccelStreamPut(w, r, clientID, link, hub, ctl)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/accel-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serveAccelStreamGetQuery(w, r, link, hub, ctl)
|
||||
case http.MethodPost:
|
||||
serveAccelStreamPost(w, r, link, hub, ctl)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func parsePathClientID(r *http.Request) (uint32, error) {
|
||||
s := r.PathValue("clientID")
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("client_id required")
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 10, 32)
|
||||
if err != nil || v == 0 {
|
||||
return 0, fmt.Errorf("invalid client_id")
|
||||
}
|
||||
return uint32(v), nil
|
||||
}
|
||||
|
||||
func applyAccelStreamClient(link *managedSerial, hub *wsHub, ctl *accelStreamCtl, clientID uint32, enable bool) accelStreamAPIResponse {
|
||||
resp, err := link.AccelStream(&pb.AccelStreamRequest{
|
||||
Write: true,
|
||||
Enable: enable,
|
||||
ClientId: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
return accelStreamAPIResponse{
|
||||
ClientID: clientID,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
out := accelStreamAPIResponse{
|
||||
Enabled: enable,
|
||||
ClientID: resp.GetClientId(),
|
||||
Success: resp.GetSuccess(),
|
||||
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||
}
|
||||
if resp.GetSuccess() {
|
||||
if ctl != nil {
|
||||
ctl.Set(clientID, enable)
|
||||
}
|
||||
if hub != nil {
|
||||
hub.patchClientAccelStream(clientID, enable)
|
||||
}
|
||||
} else {
|
||||
out.Enabled = resp.GetEnabled()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func serveAccelStreamGet(w http.ResponseWriter, clientID uint32, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
|
||||
Write: false,
|
||||
ClientId: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, accelStreamAPIResponse{
|
||||
ClientID: clientID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if ctl != nil {
|
||||
ctl.Set(clientID, resp.GetEnabled())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accelStreamAPIResponse{
|
||||
Enabled: resp.GetEnabled(),
|
||||
ClientID: resp.GetClientId(),
|
||||
Success: resp.GetSuccess(),
|
||||
})
|
||||
}
|
||||
|
||||
func serveAccelStreamGetQuery(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||
if err != nil || clientID == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
serveAccelStreamGet(w, clientID, link, hub, ctl)
|
||||
}
|
||||
|
||||
func serveClientAccelStreamPut(w http.ResponseWriter, r *http.Request, clientID uint32, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||
var body clientAccelStreamBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
out := applyAccelStreamClient(link, hub, ctl, clientID, body.Enable)
|
||||
status := http.StatusOK
|
||||
if out.Error != "" {
|
||||
status = http.StatusServiceUnavailable
|
||||
} else if !out.Success {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
}
|
||||
|
||||
func serveAccelStreamPost(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||
var body accelStreamAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.AllClients {
|
||||
updated, err := applyAccelStreamAll(link, body.Enable)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, accelStreamAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
clients, _ := link.listClientsPoll()
|
||||
for _, c := range clients {
|
||||
if ctl != nil {
|
||||
ctl.Set(c.GetId(), body.Enable)
|
||||
}
|
||||
if hub != nil {
|
||||
hub.patchClientAccelStream(c.GetId(), body.Enable)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accelStreamAPIResponse{
|
||||
Enabled: body.Enable,
|
||||
Success: updated > 0,
|
||||
SlavesUpdated: updated,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if body.ClientID == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
out := applyAccelStreamClient(link, hub, ctl, body.ClientID, body.Enable)
|
||||
status := http.StatusOK
|
||||
if out.Error != "" || !out.Success {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
}
|
||||
|
||||
func applyAccelStreamAll(link *managedSerial, enable bool) (uint32, error) {
|
||||
clients, err := link.listClients()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var updated uint32
|
||||
for _, c := range clients {
|
||||
resp, err := link.AccelStream(&pb.AccelStreamRequest{
|
||||
Write: true,
|
||||
Enable: enable,
|
||||
ClientId: c.GetId(),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if resp.GetSuccess() {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
return 0, fmt.Errorf("no slaves registered")
|
||||
}
|
||||
if updated == 0 {
|
||||
return 0, fmt.Errorf("accel stream not applied to any slave")
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
+2
-1
@@ -67,7 +67,8 @@ type otaAPIResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub) {
|
||||
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl) {
|
||||
mountAccelStreamAPI(mux, link, hub, streamCtl)
|
||||
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAccelStreamInterval = 16 * time.Millisecond
|
||||
minAPIStreamInterval = 1 * time.Millisecond
|
||||
maxAPIStreamInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
// AccelClientSample is one slave's cached accel on the master.
|
||||
type AccelClientSample struct {
|
||||
ClientID uint32 `json:"client_id"`
|
||||
Valid bool `json:"valid"`
|
||||
X int32 `json:"x,omitempty"`
|
||||
Y int32 `json:"y,omitempty"`
|
||||
Z int32 `json:"z,omitempty"`
|
||||
AgeMs uint32 `json:"age_ms,omitempty"`
|
||||
}
|
||||
|
||||
// AccelStreamMessage is sent to external WebSocket clients.
|
||||
type AccelStreamMessage struct {
|
||||
Type string `json:"type"` // "hello" | "accel"
|
||||
Serial string `json:"serial_port,omitempty"`
|
||||
IntervalMs int `json:"interval_ms,omitempty"`
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
|
||||
T int64 `json:"t,omitempty"` // Unix nanoseconds
|
||||
Success bool `json:"success,omitempty"`
|
||||
Clients []AccelClientSample `json:"clients,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StreamStatusMessage is the reply to set_stream / get_stream (this connection).
|
||||
type StreamStatusMessage struct {
|
||||
Type string `json:"type"` // "stream_status"
|
||||
ReceiveAccel bool `json:"receive_accel"`
|
||||
IntervalMs int `json:"interval_ms"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AccelStreamStatusMessage is the reply to set_accel_stream / get_accel_stream (slave).
|
||||
type AccelStreamStatusMessage struct {
|
||||
Type string `json:"type"` // "accel_stream_status"
|
||||
ClientID uint32 `json:"client_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Success bool `json:"success"`
|
||||
SlavesUpdated uint32 `json:"slaves_updated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type accelWSCommand struct {
|
||||
Type string `json:"type"`
|
||||
ClientID uint32 `json:"client_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IntervalMs *int `json:"interval_ms"`
|
||||
}
|
||||
|
||||
type APIInfoResponse struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
SerialPort string `json:"serial_port"`
|
||||
WebSocket string `json:"websocket"`
|
||||
DefaultIntervalMs int `json:"default_interval_ms"`
|
||||
MinIntervalMs int `json:"min_interval_ms"`
|
||||
MaxIntervalMs int `json:"max_interval_ms"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type wsSubscriber struct {
|
||||
conn *websocket.Conn
|
||||
receiveAccel bool
|
||||
interval time.Duration
|
||||
lastSent time.Time
|
||||
}
|
||||
|
||||
type accelStreamHub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*websocket.Conn]*wsSubscriber
|
||||
defaultInterval time.Duration
|
||||
configChanged chan struct{}
|
||||
}
|
||||
|
||||
func newAccelStreamHub(defaultInterval time.Duration) *accelStreamHub {
|
||||
return &accelStreamHub{
|
||||
clients: make(map[*websocket.Conn]*wsSubscriber),
|
||||
defaultInterval: defaultInterval,
|
||||
configChanged: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) notifyConfigChanged() {
|
||||
select {
|
||||
case h.configChanged <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func clampAPIInterval(d time.Duration) time.Duration {
|
||||
if d < minAPIStreamInterval {
|
||||
return minAPIStreamInterval
|
||||
}
|
||||
if d > maxAPIStreamInterval {
|
||||
return maxAPIStreamInterval
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber {
|
||||
sub := &wsSubscriber{
|
||||
conn: conn,
|
||||
receiveAccel: false,
|
||||
interval: h.defaultInterval,
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.clients[conn] = sub
|
||||
h.mu.Unlock()
|
||||
|
||||
hello := AccelStreamMessage{
|
||||
Type: "hello",
|
||||
Serial: portName,
|
||||
IntervalMs: int(h.defaultInterval / time.Millisecond),
|
||||
Commands: []string{"set_stream", "get_stream", "set_accel_stream", "get_accel_stream"},
|
||||
}
|
||||
if data, err := json.Marshal(hello); err == nil {
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) unregister(conn *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, conn)
|
||||
h.mu.Unlock()
|
||||
h.notifyConfigChanged()
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) anyWantsAccel() bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, sub := range h.clients {
|
||||
if sub.receiveAccel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) minWantedInterval() time.Duration {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
var min time.Duration
|
||||
for _, sub := range h.clients {
|
||||
if !sub.receiveAccel {
|
||||
continue
|
||||
}
|
||||
if min == 0 || sub.interval < min {
|
||||
min = sub.interval
|
||||
}
|
||||
}
|
||||
if min == 0 {
|
||||
return h.defaultInterval
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) setStream(sub *wsSubscriber, enable bool, intervalMs *int) StreamStatusMessage {
|
||||
h.mu.Lock()
|
||||
sub.receiveAccel = enable
|
||||
if intervalMs != nil {
|
||||
sub.interval = clampAPIInterval(time.Duration(*intervalMs) * time.Millisecond)
|
||||
}
|
||||
ms := int(sub.interval / time.Millisecond)
|
||||
h.mu.Unlock()
|
||||
h.notifyConfigChanged()
|
||||
|
||||
return StreamStatusMessage{
|
||||
Type: "stream_status",
|
||||
ReceiveAccel: enable,
|
||||
IntervalMs: ms,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) getStream(sub *wsSubscriber) StreamStatusMessage {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return StreamStatusMessage{
|
||||
Type: "stream_status",
|
||||
ReceiveAccel: sub.receiveAccel,
|
||||
IntervalMs: int(sub.interval / time.Millisecond),
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) deliver(msg AccelStreamMessage) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for conn, sub := range h.clients {
|
||||
if !sub.receiveAccel {
|
||||
continue
|
||||
}
|
||||
if !sub.lastSent.IsZero() && now.Sub(sub.lastSent) < sub.interval {
|
||||
continue
|
||||
}
|
||||
sub.lastSent = now
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
delete(h.clients, conn)
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, stop <-chan struct{}) {
|
||||
var ticker *time.Ticker
|
||||
var tick <-chan time.Time
|
||||
|
||||
resetTicker := func() {
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
interval := hub.minWantedInterval()
|
||||
ticker = time.NewTicker(interval)
|
||||
tick = ticker.C
|
||||
}
|
||||
resetTicker()
|
||||
defer func() {
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-hub.configChanged:
|
||||
resetTicker()
|
||||
case <-tick:
|
||||
if !hub.anyWantsAccel() {
|
||||
continue
|
||||
}
|
||||
if !accelStreamPollingActive(dash, ctl) {
|
||||
continue
|
||||
}
|
||||
now := time.Now().UnixNano()
|
||||
resp, err := link.readAccelSnapshotPoll(0)
|
||||
if errors.Is(err, errUARTBusy) {
|
||||
hub.deliver(AccelStreamMessage{
|
||||
Type: "accel",
|
||||
T: now,
|
||||
Success: false,
|
||||
Error: "uart busy",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
hub.deliver(AccelStreamMessage{
|
||||
Type: "accel",
|
||||
T: now,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
clients := make([]AccelClientSample, 0, len(resp.GetSamples()))
|
||||
for _, s := range resp.GetSamples() {
|
||||
clients = append(clients, AccelClientSample{
|
||||
ClientID: s.GetClientId(),
|
||||
Valid: s.GetValid(),
|
||||
X: s.GetX(),
|
||||
Y: s.GetY(),
|
||||
Z: s.GetZ(),
|
||||
AgeMs: s.GetAgeMs(),
|
||||
})
|
||||
}
|
||||
hub.deliver(AccelStreamMessage{
|
||||
Type: "accel",
|
||||
T: now,
|
||||
Success: true,
|
||||
Clients: clients,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accelStreamPollingActive(dash *wsHub, ctl *accelStreamCtl) bool {
|
||||
if ctl != nil && ctl.Any() {
|
||||
return true
|
||||
}
|
||||
return dash != nil && dash.anyAccelStreamEnabled()
|
||||
}
|
||||
|
||||
func writeStreamStatus(conn *websocket.Conn, msg StreamStatusMessage) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
func writeAccelStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
|
||||
msg := AccelStreamStatusMessage{
|
||||
Type: "accel_stream_status",
|
||||
ClientID: out.ClientID,
|
||||
Enabled: out.Enabled,
|
||||
Success: out.Success,
|
||||
SlavesUpdated: out.SlavesUpdated,
|
||||
Error: out.Error,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, hub *accelStreamHub) {
|
||||
var cmd accelWSCommand
|
||||
if err := json.Unmarshal(data, &cmd); err != nil {
|
||||
writeStreamStatus(conn, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
switch cmd.Type {
|
||||
case "set_stream":
|
||||
if cmd.Enable == nil {
|
||||
writeStreamStatus(conn, StreamStatusMessage{
|
||||
Type: "stream_status",
|
||||
Error: "enable required",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeStreamStatus(conn, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs))
|
||||
|
||||
case "get_stream":
|
||||
writeStreamStatus(conn, hub.getStream(sub))
|
||||
|
||||
case "set_accel_stream":
|
||||
if cmd.ClientID == 0 {
|
||||
writeAccelStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
if cmd.Enable == nil {
|
||||
writeAccelStreamStatus(conn, accelStreamAPIResponse{
|
||||
ClientID: cmd.ClientID,
|
||||
Error: "enable required",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeAccelStreamStatus(conn, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
|
||||
|
||||
case "get_accel_stream":
|
||||
if cmd.ClientID == 0 {
|
||||
writeAccelStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
|
||||
Write: false,
|
||||
ClientId: cmd.ClientID,
|
||||
})
|
||||
if err != nil {
|
||||
writeAccelStreamStatus(conn, accelStreamAPIResponse{
|
||||
ClientID: cmd.ClientID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if ctl != nil {
|
||||
ctl.Set(cmd.ClientID, resp.GetEnabled())
|
||||
}
|
||||
writeAccelStreamStatus(conn, accelStreamAPIResponse{
|
||||
Enabled: resp.GetEnabled(),
|
||||
ClientID: resp.GetClientId(),
|
||||
Success: resp.GetSuccess(),
|
||||
})
|
||||
|
||||
default:
|
||||
writeStreamStatus(conn, StreamStatusMessage{
|
||||
Type: "stream_status",
|
||||
Error: "unknown type (set_stream, get_stream, set_accel_stream, get_accel_stream)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, portName string, hub *accelStreamHub) {
|
||||
sub := hub.register(conn, portName)
|
||||
defer hub.unregister(conn)
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
handleAccelWSCommand(conn, sub, data, link, dash, ctl, hub)
|
||||
}
|
||||
}
|
||||
|
||||
func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl) {
|
||||
defMs := int(defaultInterval / time.Millisecond)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/api/v1" && r.URL.Path != "/api/v1/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, APIInfoResponse{
|
||||
Name: "powerpod-external-api",
|
||||
Version: "1",
|
||||
SerialPort: portName,
|
||||
WebSocket: "/ws",
|
||||
DefaultIntervalMs: defMs,
|
||||
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
|
||||
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
|
||||
Description: "WebSocket: per-connection accel receive + interval; slave stream via set_accel_stream",
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("api websocket upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
serveExternalWS(conn, link, dash, ctl, portName, hub)
|
||||
})
|
||||
}
|
||||
|
||||
func runAPIServer(portName string, link *managedSerial, addr string, defaultInterval time.Duration, dash *wsHub, ctl *accelStreamCtl, stop <-chan struct{}) *http.Server {
|
||||
hub := newAccelStreamHub(defaultInterval)
|
||||
go runAccelStreamer(link, hub, dash, ctl, stop)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mountExternalAPI(mux, portName, defaultInterval, hub, link, dash, ctl)
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: mux}
|
||||
go func() {
|
||||
log.Printf("external API http://localhost%s WebSocket ws://localhost%s/ws (default accel interval %s, per-client via set_stream)",
|
||||
addr, addr, defaultInterval.String())
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("external API server: %v", err)
|
||||
}
|
||||
}()
|
||||
return srv
|
||||
}
|
||||
|
||||
func shutdownAPIServer(srv *http.Server) {
|
||||
if srv == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
}
|
||||
+138
-7
@@ -40,6 +40,70 @@ 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 (m *managedSerial) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
return m.accelStreamVia(m.withPort, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelStreamPoll(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
return m.accelStreamVia(m.withPortPoll, req)
|
||||
}
|
||||
|
||||
// SetAccelStream enables or disables the ESP-NOW accel stream for one slave (master UART).
|
||||
func (m *managedSerial) SetAccelStream(clientID uint32, enable bool) (*pb.AccelStreamResponse, error) {
|
||||
return m.AccelStream(&pb.AccelStreamRequest{
|
||||
Write: true,
|
||||
Enable: enable,
|
||||
ClientId: clientID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAccelStream returns whether the accel stream is enabled for a slave on the master.
|
||||
func (m *managedSerial) GetAccelStream(clientID uint32) (bool, error) {
|
||||
resp, err := m.AccelStreamPoll(&pb.AccelStreamRequest{
|
||||
Write: false,
|
||||
ClientId: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !resp.GetSuccess() {
|
||||
return false, fmt.Errorf("accel stream read failed for client %d", clientID)
|
||||
}
|
||||
return resp.GetEnabled(), nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) accelStreamVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.AccelStreamRequest,
|
||||
) (*pb.AccelStreamResponse, error) {
|
||||
var resp *pb.AccelStreamResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.AccelStream(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
return m.accelDeadzoneVia(m.withPort, req)
|
||||
}
|
||||
@@ -101,25 +165,43 @@ func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) {
|
||||
return info.GetClients(), nil
|
||||
}
|
||||
|
||||
func (s *serialPort) readAccel() (*pb.AccelReadResponse, error) {
|
||||
payload, err := s.exchange(byte(pb.MessageType_ACCEL_READ), "ACCEL_READ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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_READ {
|
||||
if msg.GetType() != pb.MessageType_ACCEL_SNAPSHOT {
|
||||
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
|
||||
}
|
||||
r := msg.GetAccelReadResponse()
|
||||
r := msg.GetAccelSnapshotResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing accel_read_response")
|
||||
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 {
|
||||
@@ -136,6 +218,33 @@ func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
|
||||
return decodeClientsPayload(payload)
|
||||
}
|
||||
|
||||
func (s *serialPort) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ACCEL_STREAM,
|
||||
Payload: &pb.UartMessage_AccelStreamRequest{
|
||||
AccelStreamRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
|
||||
respPayload, err := s.exchangePayload(payload, "ACCEL_STREAM")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetAccelStreamResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing accel_stream_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ACCEL_DEADZONE,
|
||||
@@ -234,6 +343,28 @@ func (s *serialPort) GetVersion() (*pb.VersionResponse, error) { return s.getVer
|
||||
|
||||
func (s *serialPort) ListClients() ([]*pb.ClientInfo, error) { return s.listClients() }
|
||||
|
||||
func (s *serialPort) SetAccelStream(clientID uint32, enable bool) (*pb.AccelStreamResponse, error) {
|
||||
return s.AccelStream(&pb.AccelStreamRequest{
|
||||
Write: true,
|
||||
Enable: enable,
|
||||
ClientId: clientID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *serialPort) GetAccelStream(clientID uint32) (bool, error) {
|
||||
resp, err := s.AccelStream(&pb.AccelStreamRequest{
|
||||
Write: false,
|
||||
ClientId: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !resp.GetSuccess() {
|
||||
return false, fmt.Errorf("accel stream read failed for client %d", clientID)
|
||||
}
|
||||
return resp.GetEnabled(), nil
|
||||
}
|
||||
|
||||
func (s *serialPort) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
return s.accelDeadzone(req)
|
||||
}
|
||||
|
||||
+17
-4
@@ -5,13 +5,26 @@ import (
|
||||
)
|
||||
|
||||
func runAccel(sp *serialPort) error {
|
||||
r, err := sp.readAccel()
|
||||
return runAccelSnapshot(sp, 0)
|
||||
}
|
||||
|
||||
func runAccelSnapshot(sp *serialPort, clientID uint32) error {
|
||||
r, err := sp.readAccelSnapshot(clientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return fmt.Errorf("accel read failed (sensor not ready?)")
|
||||
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())
|
||||
}
|
||||
fmt.Printf("accel: x=%d y=%d z=%d (raw LSB, ±2g)\n", r.GetX(), r.GetY(), r.GetZ())
|
||||
return nil
|
||||
}
|
||||
|
||||
+18
-5
@@ -21,7 +21,9 @@ var wsUpgrader = websocket.Upgrader{
|
||||
|
||||
func runServe(portName string, baud int, args []string) error {
|
||||
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
addr := serveFlags.String("addr", ":8080", "HTTP listen address")
|
||||
addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address")
|
||||
apiAddr := serveFlags.String("api-addr", ":8081", "external API HTTP listen address (empty to disable)")
|
||||
accelInterval := serveFlags.Duration("accel-interval", defaultAccelStreamInterval, "accel WebSocket sample period on API server")
|
||||
interval := serveFlags.Duration("interval", 2*time.Second, "UART poll interval")
|
||||
if err := serveFlags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -35,12 +37,20 @@ func runServe(portName string, baud int, args []string) error {
|
||||
defer link.Close()
|
||||
|
||||
hub := newWSHub()
|
||||
streamCtl := newAccelStreamCtl()
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go runPoller(link, portName, hub, *interval, stop)
|
||||
go runPoller(link, portName, hub, streamCtl, *interval, stop)
|
||||
go runAccelDashboardPoller(link, hub, *accelInterval, stop)
|
||||
|
||||
var apiSrv *http.Server
|
||||
if *apiAddr != "" {
|
||||
apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, stop)
|
||||
defer shutdownAPIServer(apiSrv)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mountServeAPI(mux, link, hub)
|
||||
mountServeAPI(mux, link, hub, streamCtl)
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -64,7 +74,10 @@ func runServe(portName string, baud int, args []string) error {
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(ui)))
|
||||
|
||||
log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, auto-reconnect)",
|
||||
*addr, portName, baud, interval.String())
|
||||
log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, accel %s, auto-reconnect)",
|
||||
*addr, portName, baud, interval.String(), accelInterval.String())
|
||||
if *apiAddr == "" {
|
||||
log.Printf("external API disabled (-api-addr \"\")")
|
||||
}
|
||||
return http.ListenAndServe(*addr, mux)
|
||||
}
|
||||
|
||||
+193
-6
@@ -32,6 +32,12 @@ type ClientView struct {
|
||||
Used bool `json:"used"`
|
||||
LastPing uint32 `json:"last_ping"`
|
||||
LastSuccessPing uint32 `json:"last_success_ping"`
|
||||
AccelValid bool `json:"accel_valid"`
|
||||
AccelX int32 `json:"accel_x"`
|
||||
AccelY int32 `json:"accel_y"`
|
||||
AccelZ int32 `json:"accel_z"`
|
||||
AccelAgeMs uint32 `json:"accel_age_ms"`
|
||||
AccelStream bool `json:"accel_stream"`
|
||||
}
|
||||
|
||||
type DashboardState struct {
|
||||
@@ -56,6 +62,8 @@ func newWSHub() *wsHub {
|
||||
|
||||
func (h *wsHub) setState(st DashboardState) {
|
||||
h.mu.Lock()
|
||||
prev := h.state.Clients
|
||||
st.Clients = preserveClientAccel(st.Clients, prev)
|
||||
h.state = st
|
||||
conns := make([]*websocket.Conn, 0, len(h.clients))
|
||||
for c := range h.clients {
|
||||
@@ -89,6 +97,136 @@ func (h *wsHub) unregister(c *websocket.Conn) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func applyAccelSamples(clients []ClientView, samples []*pb.AccelSample) []ClientView {
|
||||
if len(samples) == 0 {
|
||||
return clients
|
||||
}
|
||||
byID := make(map[uint32]*pb.AccelSample, len(samples))
|
||||
for _, s := range samples {
|
||||
byID[s.GetClientId()] = s
|
||||
}
|
||||
out := make([]ClientView, len(clients))
|
||||
for i, c := range clients {
|
||||
out[i] = c
|
||||
if !c.AccelStream {
|
||||
out[i].AccelValid = false
|
||||
continue
|
||||
}
|
||||
s, ok := byID[c.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[i].AccelValid = s.GetValid()
|
||||
if s.GetValid() {
|
||||
out[i].AccelX = s.GetX()
|
||||
out[i].AccelY = s.GetY()
|
||||
out[i].AccelZ = s.GetZ()
|
||||
out[i].AccelAgeMs = s.GetAgeMs()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func preserveClientAccel(newClients, oldClients []ClientView) []ClientView {
|
||||
if len(oldClients) == 0 {
|
||||
return newClients
|
||||
}
|
||||
oldByID := make(map[uint32]ClientView, len(oldClients))
|
||||
for _, c := range oldClients {
|
||||
oldByID[c.ID] = c
|
||||
}
|
||||
out := make([]ClientView, len(newClients))
|
||||
for i, c := range newClients {
|
||||
out[i] = c
|
||||
if !c.AccelStream {
|
||||
continue
|
||||
}
|
||||
prev, ok := oldByID[c.ID]
|
||||
if !ok || !prev.AccelValid {
|
||||
continue
|
||||
}
|
||||
if !c.AccelValid {
|
||||
out[i].AccelValid = prev.AccelValid
|
||||
out[i].AccelX = prev.AccelX
|
||||
out[i].AccelY = prev.AccelY
|
||||
out[i].AccelZ = prev.AccelZ
|
||||
out[i].AccelAgeMs = prev.AccelAgeMs
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func anyClientAccelStream(clients []ClientView) bool {
|
||||
for _, c := range clients {
|
||||
if c.AccelStream {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// patchClientAccelStream updates stream flag immediately (e.g. after REST) and pushes WS.
|
||||
func (h *wsHub) patchClientAccelStream(clientID uint32, enabled bool) {
|
||||
h.mu.Lock()
|
||||
for i := range h.state.Clients {
|
||||
if h.state.Clients[i].ID != clientID {
|
||||
continue
|
||||
}
|
||||
h.state.Clients[i].AccelStream = enabled
|
||||
if !enabled {
|
||||
h.state.Clients[i].AccelValid = false
|
||||
h.state.Clients[i].AccelX = 0
|
||||
h.state.Clients[i].AccelY = 0
|
||||
h.state.Clients[i].AccelZ = 0
|
||||
h.state.Clients[i].AccelAgeMs = 0
|
||||
}
|
||||
break
|
||||
}
|
||||
st := h.state
|
||||
st.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
conns := make([]*websocket.Conn, 0, len(h.clients))
|
||||
for c := range h.clients {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
data, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range conns {
|
||||
_ = c.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) anyAccelStreamEnabled() bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return anyClientAccelStream(h.state.Clients)
|
||||
}
|
||||
|
||||
// mergeAccel updates cached accel on clients and pushes state to dashboard WebSockets.
|
||||
func (h *wsHub) mergeAccel(samples []*pb.AccelSample) {
|
||||
h.mu.Lock()
|
||||
st := h.state
|
||||
st.Clients = applyAccelSamples(st.Clients, samples)
|
||||
st.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
h.state = st
|
||||
conns := make([]*websocket.Conn, 0, len(h.clients))
|
||||
for c := range h.clients {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
data, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range conns {
|
||||
_ = c.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) broadcastRaw(v any) {
|
||||
h.mu.RLock()
|
||||
conns := make([]*websocket.Conn, 0, len(h.clients))
|
||||
@@ -106,7 +244,7 @@ func (h *wsHub) broadcastRaw(v any) {
|
||||
}
|
||||
}
|
||||
|
||||
func pollDashboard(link *managedSerial, portName string, last *DashboardState) DashboardState {
|
||||
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl) DashboardState {
|
||||
st := DashboardState{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: portName,
|
||||
@@ -152,15 +290,63 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState) D
|
||||
Used: c.GetUsed(),
|
||||
LastPing: c.GetLastPing(),
|
||||
LastSuccessPing: c.GetLastSuccessPing(),
|
||||
}
|
||||
if dz, err := readDeadzonePoll(link, c.GetId()); err == nil {
|
||||
cv.Deadzone = dz
|
||||
AccelStream: c.GetAccelStreamEnabled(),
|
||||
}
|
||||
st.Clients = append(st.Clients, cv)
|
||||
}
|
||||
if anyClientAccelStream(st.Clients) {
|
||||
for i := range st.Clients {
|
||||
if !st.Clients[i].AccelStream {
|
||||
continue
|
||||
}
|
||||
if dz, err := readDeadzonePoll(link, st.Clients[i].ID); err == nil {
|
||||
st.Clients[i].Deadzone = dz
|
||||
}
|
||||
}
|
||||
if snap, err := link.readAccelSnapshotPoll(0); err == nil {
|
||||
st.Clients = applyAccelSamples(st.Clients, snap.GetSamples())
|
||||
}
|
||||
} else {
|
||||
for i, c := range clients {
|
||||
if dz, err := readDeadzonePoll(link, c.GetId()); err == nil {
|
||||
st.Clients[i].Deadzone = dz
|
||||
}
|
||||
}
|
||||
}
|
||||
if streamCtl != nil {
|
||||
streamCtl.SyncFromClients(st.Clients)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func runAccelDashboardPoller(link *managedSerial, hub *wsHub, interval time.Duration, stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if hub.clientCount() == 0 || !hub.anyAccelStreamEnabled() {
|
||||
continue
|
||||
}
|
||||
snap, err := link.readAccelSnapshotPoll(0)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hub.mergeAccel(snap.GetSamples())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) clientCount() int {
|
||||
h.mu.RLock()
|
||||
n := len(h.clients)
|
||||
h.mu.RUnlock()
|
||||
return n
|
||||
}
|
||||
|
||||
func pausedPollState(portName string, last *DashboardState) DashboardState {
|
||||
if last != nil && last.UARTConnected {
|
||||
st := *last
|
||||
@@ -208,14 +394,15 @@ func formatMAC(mac []byte) string {
|
||||
return hex.EncodeToString(mac)
|
||||
}
|
||||
|
||||
func runPoller(link *managedSerial, portName string, hub *wsHub, interval time.Duration, stop <-chan struct{}) {
|
||||
func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *accelStreamCtl, interval time.Duration, stop <-chan struct{}) {
|
||||
// streamCtl kept for external API; dashboard uses hub.state AccelStream flags.
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
uartUp := false
|
||||
var lastGood DashboardState
|
||||
publish := func() {
|
||||
st := pollDashboard(link, portName, &lastGood)
|
||||
st := pollDashboard(link, portName, &lastGood, streamCtl)
|
||||
if st.UARTConnected && st.SerialOK {
|
||||
lastGood = st
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ func usage() {
|
||||
fmt.Fprintf(os.Stderr, " version firmware version and git hash\n")
|
||||
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, " accel read current accelerometer XYZ (raw LSB)\n")
|
||||
fmt.Fprintf(os.Stderr, " accel read cached slave accel snapshot from master\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")
|
||||
|
||||
+430
-151
File diff suppressed because it is too large
Load Diff
+76
-3
@@ -55,11 +55,12 @@
|
||||
.badge-offline { background: #5c6570; color: #f0f3f5; }
|
||||
.badge.bg-secondary { background: #4a5560 !important; color: #f0f3f5; }
|
||||
|
||||
.mac {
|
||||
.mac, .accel {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--pp-accent);
|
||||
}
|
||||
.accel-stale { color: var(--pp-text-muted); }
|
||||
|
||||
.pp-table {
|
||||
--bs-table-color: var(--pp-text);
|
||||
@@ -265,7 +266,9 @@
|
||||
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small px-3 pt-2 mb-0">Slaves per ESP-NOW — Master-Deadzone bleibt separat.</p>
|
||||
<p class="text-muted small px-3 pt-2 mb-0">
|
||||
Accel-Stream pro Slave per „Stream an“ aktivieren (~16 ms ESP-NOW). Ohne Aktivierung keine Werte.
|
||||
</p>
|
||||
<div class="card-body p-0 pt-2">
|
||||
<div class="table-responsive">
|
||||
<table class="table pp-table table-hover">
|
||||
@@ -276,12 +279,14 @@
|
||||
<th>Ver</th>
|
||||
<th>Status</th>
|
||||
<th>Deadzone</th>
|
||||
<th>Accel (LSB)</th>
|
||||
<th>Stream</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="!(state.clients || []).length">
|
||||
<tr><td colspan="6" class="text-muted text-center py-4">No clients</td></tr>
|
||||
<tr><td colspan="8" class="text-muted text-center py-4">No clients</td></tr>
|
||||
</template>
|
||||
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
|
||||
<tr>
|
||||
@@ -294,6 +299,20 @@
|
||||
x-text="c.available ? 'available' : 'inactive'"></span>
|
||||
</td>
|
||||
<td x-text="c.deadzone != null ? c.deadzone : '—'"></td>
|
||||
<td>
|
||||
<span class="accel"
|
||||
:class="accelCellClass(c)"
|
||||
x-text="formatAccel(c)"
|
||||
:title="accelTitle(c)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button"
|
||||
class="btn btn-sm"
|
||||
:class="c.accel_stream ? 'btn-warning' : 'btn-outline-success'"
|
||||
@click="setAccelStream(c.id, !c.accel_stream)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
x-text="c.accel_stream ? 'Aus' : 'An'"></button>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="number" class="form-control form-control-sm dz-input"
|
||||
@@ -496,6 +515,22 @@
|
||||
if (!hex || hex.length !== 12) return hex || '';
|
||||
return hex.match(/.{2}/g).join(':');
|
||||
},
|
||||
formatAccel(c) {
|
||||
if (!c?.accel_stream) return '—';
|
||||
if (!c?.accel_valid) return '…';
|
||||
return `${c.accel_x} / ${c.accel_y} / ${c.accel_z}`;
|
||||
},
|
||||
accelTitle(c) {
|
||||
if (!c?.accel_stream) return 'Accel-Stream nicht aktiviert';
|
||||
if (!c?.accel_valid) return 'Warte auf erste ESP-NOW Samples…';
|
||||
const age = c.accel_age_ms != null ? `${c.accel_age_ms} ms alt` : '';
|
||||
return `x=${c.accel_x} y=${c.accel_y} z=${c.accel_z} (raw LSB, ±2g)${age ? ' · ' + age : ''}`;
|
||||
},
|
||||
accelCellClass(c) {
|
||||
if (!c?.accel_valid) return 'accel-stale';
|
||||
if (c.accel_age_ms != null && c.accel_age_ms > 200) return 'accel-stale';
|
||||
return '';
|
||||
},
|
||||
formatSize(n) {
|
||||
if (n == null) return '';
|
||||
if (n < 1024) return n + ' B';
|
||||
@@ -732,6 +767,44 @@
|
||||
async setMasterDeadzone() {
|
||||
await this.setDeadzone(0, this.masterDz);
|
||||
},
|
||||
patchClientAccelStream(clientId, enabled) {
|
||||
const clients = (this.state.clients || []).map((c) => {
|
||||
if (c.id !== clientId) {
|
||||
return c;
|
||||
}
|
||||
const next = { ...c, accel_stream: enabled };
|
||||
if (!enabled) {
|
||||
next.accel_valid = false;
|
||||
next.accel_x = 0;
|
||||
next.accel_y = 0;
|
||||
next.accel_z = 0;
|
||||
next.accel_age_ms = 0;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
this.state = { ...this.state, clients };
|
||||
},
|
||||
async setAccelStream(clientId, enable) {
|
||||
this.busy = true;
|
||||
try {
|
||||
const r = await fetch(`/api/clients/${clientId}/accel-stream`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enable: enable })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
this.flash(data.error || `Accel-Stream Slave ${clientId} fehlgeschlagen`, false);
|
||||
return;
|
||||
}
|
||||
this.patchClientAccelStream(clientId, !!data.enabled);
|
||||
this.flash(`Slave ${clientId}: Accel-Stream ${data.enabled ? 'an' : 'aus'}`, true);
|
||||
} catch (e) {
|
||||
this.flash(String(e), false);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async setDeadzoneAll(deadzone) {
|
||||
if (deadzone == null || deadzone < 0) {
|
||||
this.flash('Ungültiger Deadzone-Wert', false);
|
||||
|
||||
Reference in New Issue
Block a user