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:
+257
-4
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user