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:
2026-05-29 19:11:36 +02:00
co-authored by Cursor
parent ba20544762
commit 47c75110c9
35 changed files with 2409 additions and 300 deletions
+193 -6
View File
@@ -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
}