Add BMA456 tap detection with ESP-NOW notify and host snapshot API.

Slaves forward configured tap kinds to the master; goTool exposes CLI, dashboard, REST, and WebSocket with separate notify vs receive and 2s display cache.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 20:42:57 +02:00
co-authored by Cursor
parent 3cb0b5bbe9
commit a8d4d42920
34 changed files with 3138 additions and 241 deletions
+257 -4
View File
@@ -41,6 +41,13 @@ type ClientView struct {
AccelZ int32 `json:"accel_z"`
AccelAgeMs uint32 `json:"accel_age_ms"`
AccelStream bool `json:"accel_stream"`
TapNotifySingle bool `json:"tap_notify_single"`
TapNotifyDouble bool `json:"tap_notify_double"`
TapNotifyTriple bool `json:"tap_notify_triple"`
/** Host-side: poll master tap cache for this slave (~16 ms). */
TapReceive bool `json:"tap_receive"`
LastTap string `json:"last_tap,omitempty"`
LastTapAt int64 `json:"last_tap_at,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"`
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
@@ -71,6 +78,8 @@ func (h *wsHub) setState(st DashboardState) {
prev := h.state
st.Clients = preserveClientAccel(st.Clients, prev.Clients)
st.Clients = preserveClientBattery(st.Clients, prev.Clients)
st.Clients = preserveClientTapReceive(st.Clients, prev.Clients)
st.Clients = preserveClientTap(st.Clients, prev.Clients)
if !st.Master.Lipo1.Valid && !st.Master.Lipo2.Valid {
if prev.Master.Lipo1.Valid || prev.Master.Lipo2.Valid {
st.Master.Lipo1 = prev.Master.Lipo1
@@ -206,6 +215,124 @@ func anyClientAccelStream(clients []ClientView) bool {
return false
}
func anyClientTapNotify(clients []ClientView) bool {
for _, c := range clients {
if c.TapNotifySingle || c.TapNotifyDouble || c.TapNotifyTriple {
return true
}
}
return false
}
func anyClientTapReceive(clients []ClientView) bool {
for _, c := range clients {
if c.TapReceive {
return true
}
}
return false
}
func preserveClientTapReceive(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
prev, ok := oldByID[c.ID]
if !ok {
continue
}
out[i].TapReceive = prev.TapReceive
if !prev.TapReceive {
continue
}
if c.LastTap == "" && prev.LastTap != "" {
cutoff := time.Now().Add(-clientTapDisplayMinMs * time.Millisecond).UnixMilli()
if prev.LastTapAt >= cutoff {
out[i].LastTap = prev.LastTap
out[i].LastTapAt = prev.LastTapAt
}
}
}
return out
}
func tapKindLabelPB(k pb.TapKind) string {
switch k {
case pb.TapKind_TAP_SINGLE:
return "single"
case pb.TapKind_TAP_DOUBLE:
return "double"
case pb.TapKind_TAP_TRIPLE:
return "triple"
default:
return ""
}
}
func applyTapEvents(clients []ClientView, events []*pb.TapEvent) []ClientView {
if len(events) == 0 {
return clients
}
byID := make(map[uint32]*pb.TapEvent, len(events))
for _, e := range events {
if e.GetValid() {
byID[e.GetClientId()] = e
}
}
if len(byID) == 0 {
return clients
}
now := time.Now().UnixMilli()
out := make([]ClientView, len(clients))
for i, c := range clients {
out[i] = c
if !c.TapReceive {
continue
}
e, ok := byID[c.ID]
if !ok {
continue
}
out[i].LastTap = tapKindLabelPB(e.GetKind())
out[i].LastTapAt = now
}
return out
}
const clientTapDisplayMinMs = 2000
func preserveClientTap(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
}
cutoff := time.Now().Add(-clientTapDisplayMinMs * time.Millisecond).UnixMilli()
out := make([]ClientView, len(newClients))
for i, c := range newClients {
out[i] = c
if c.LastTap != "" {
continue
}
prev, ok := oldByID[c.ID]
if !ok || prev.LastTap == "" || prev.LastTapAt < cutoff {
continue
}
out[i].LastTap = prev.LastTap
out[i].LastTapAt = prev.LastTapAt
}
return out
}
// patchClientAccelStream updates stream flag immediately (e.g. after REST) and pushes WS.
func (h *wsHub) patchClientAccelStream(clientID uint32, enabled bool) {
h.mu.Lock()
@@ -246,6 +373,51 @@ func (h *wsHub) anyAccelStreamEnabled() bool {
return anyClientAccelStream(h.state.Clients)
}
func (h *wsHub) anyTapNotifyEnabled() bool {
h.mu.RLock()
defer h.mu.RUnlock()
return anyClientTapNotify(h.state.Clients)
}
func (h *wsHub) anyTapReceiveEnabled() bool {
h.mu.RLock()
defer h.mu.RUnlock()
return anyClientTapReceive(h.state.Clients)
}
// patchClientTapNotify updates tap notify flags immediately (e.g. after REST) and pushes WS.
func (h *wsHub) patchClientTapNotify(clientID uint32, single, doubleTap, triple bool) {
h.mu.Lock()
for i := range h.state.Clients {
if h.state.Clients[i].ID != clientID {
continue
}
h.state.Clients[i].TapNotifySingle = single
h.state.Clients[i].TapNotifyDouble = doubleTap
h.state.Clients[i].TapNotifyTriple = triple
if !single && !doubleTap && !triple {
h.state.Clients[i].LastTap = ""
h.state.Clients[i].LastTapAt = 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)
}
}
// mergeAccel updates cached accel on clients and pushes state to dashboard WebSockets.
func (h *wsHub) mergeAccel(samples []*pb.AccelSample) {
h.mu.Lock()
@@ -268,6 +440,60 @@ func (h *wsHub) mergeAccel(samples []*pb.AccelSample) {
}
}
func (h *wsHub) patchClientTapReceive(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].TapReceive = enabled
if !enabled {
h.state.Clients[i].LastTap = ""
h.state.Clients[i].LastTapAt = 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) mergeTap(events []*pb.TapEvent) {
if len(events) == 0 {
return
}
h.mu.Lock()
st := h.state
st.Clients = applyTapEvents(st.Clients, events)
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))
@@ -285,7 +511,7 @@ func (h *wsHub) broadcastRaw(v any) {
}
}
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl) DashboardState {
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState {
st := DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
@@ -332,6 +558,9 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
LastPing: c.GetLastPing(),
LastSuccessPing: c.GetLastSuccessPing(),
AccelStream: c.GetAccelStreamEnabled(),
TapNotifySingle: c.GetTapNotifySingle(),
TapNotifyDouble: c.GetTapNotifyDouble(),
TapNotifyTriple: c.GetTapNotifyTriple(),
}
st.Clients = append(st.Clients, cv)
}
@@ -358,6 +587,9 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
if streamCtl != nil {
streamCtl.SyncFromClients(st.Clients)
}
if tapCtl != nil {
tapCtl.SyncFromClients(st.Clients)
}
return st
}
@@ -436,6 +668,27 @@ func runAccelDashboardPoller(link *managedSerial, hub *wsHub, interval time.Dura
}
}
func runTapDashboardPoller(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.anyTapReceiveEnabled() {
continue
}
snap, err := link.readTapSnapshotPoll(0)
if err != nil {
continue
}
hub.mergeTap(snap.GetEvents())
}
}
}
func (h *wsHub) clientCount() int {
h.mu.RLock()
n := len(h.clients)
@@ -490,15 +743,15 @@ func formatMAC(mac []byte) string {
return hex.EncodeToString(mac)
}
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.
func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl, interval time.Duration, stop <-chan struct{}) {
// streamCtl / tapCtl kept for external API; dashboard uses hub.state flags.
ticker := time.NewTicker(interval)
defer ticker.Stop()
uartUp := false
var lastGood DashboardState
publish := func() {
st := pollDashboard(link, portName, &lastGood, streamCtl)
st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl)
if st.UARTConnected && st.SerialOK {
lastGood = st
}