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
+419 -77
View File
@@ -17,6 +17,8 @@ const (
defaultAccelStreamInterval = 16 * time.Millisecond
minAPIStreamInterval = 1 * time.Millisecond
maxAPIStreamInterval = 10 * time.Second
// How long tap events stay in API push/cache after first sight (matches dashboard).
apiTapDisplayMinMs = 2000
)
// AccelClientSample is one slave's cached accel on the master.
@@ -29,12 +31,14 @@ type AccelClientSample struct {
AgeMs uint32 `json:"age_ms,omitempty"`
}
// AccelStreamMessage is sent to external WebSocket clients.
// AccelStreamMessage is sent to external WebSocket clients (hello + accel samples).
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"`
Type string `json:"type"` // "hello" | "accel"
Serial string `json:"serial_port,omitempty"`
IntervalMs int `json:"interval_ms,omitempty"`
TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"`
Commands []string `json:"commands,omitempty"`
Note string `json:"note,omitempty"`
T int64 `json:"t,omitempty"` // Unix nanoseconds
Success bool `json:"success,omitempty"`
@@ -61,36 +65,88 @@ type AccelStreamStatusMessage struct {
Error string `json:"error,omitempty"`
}
// TapClientEvent is one tap visible to API clients (fresh or within tap_display_min_ms).
type TapClientEvent struct {
ClientID uint32 `json:"client_id"`
Valid bool `json:"valid"`
Kind string `json:"kind,omitempty"` // single | double | triple
AgeMs uint32 `json:"age_ms,omitempty"`
ShownAtMs int64 `json:"shown_at_ms,omitempty"` // Unix ms when API first saw this tap
}
// TapStreamMessage is pushed to external WebSocket clients when receive_tap is on.
type TapStreamMessage struct {
Type string `json:"type"` // "tap"
T int64 `json:"t,omitempty"`
Success bool `json:"success,omitempty"`
Events []TapClientEvent `json:"events,omitempty"`
Error string `json:"error,omitempty"`
}
// TapStreamStatusMessage is the reply to set_tap_stream / get_tap_stream (this connection).
type TapStreamStatusMessage struct {
Type string `json:"type"` // "tap_stream_status"
ReceiveTap bool `json:"receive_tap"`
IntervalMs int `json:"interval_ms"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// TapNotifyStatusMessage is the reply to set_tap_notify / get_tap_notify (slave).
type TapNotifyStatusMessage struct {
Type string `json:"type"` // "tap_notify_status"
ClientID uint32 `json:"client_id"`
Single bool `json:"single"`
DoubleTap bool `json:"double_tap"`
Triple bool `json:"triple"`
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"`
Single *bool `json:"single"`
DoubleTap *bool `json:"double_tap"`
Triple *bool `json:"triple"`
AllClients bool `json:"all_clients"`
}
type APIInfoResponse struct {
Name string `json:"name"`
Version string `json:"version"`
SerialPort string `json:"serial_port"`
WebSocket string `json:"websocket"`
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"`
MinIntervalMs int `json:"min_interval_ms"`
MaxIntervalMs int `json:"max_interval_ms"`
TapDisplayMinMs int `json:"tap_display_min_ms"`
Description string `json:"description"`
}
type cachedTapEvent struct {
kind string
shownAt time.Time
}
type wsSubscriber struct {
conn *websocket.Conn
receiveAccel bool
interval time.Duration
lastSent time.Time
conn *websocket.Conn
receiveAccel bool
receiveTap bool
interval time.Duration
lastAccelSent time.Time
lastTapSent time.Time
}
type accelStreamHub struct {
mu sync.RWMutex
clients map[*websocket.Conn]*wsSubscriber
mu sync.RWMutex
clients map[*websocket.Conn]*wsSubscriber
defaultInterval time.Duration
configChanged chan struct{}
configChanged chan struct{}
recentTaps map[uint32]cachedTapEvent
}
func newAccelStreamHub(defaultInterval time.Duration) *accelStreamHub {
@@ -129,11 +185,14 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
h.mu.Unlock()
hello := AccelStreamMessage{
Type: "hello",
Serial: portName,
IntervalMs: int(h.defaultInterval / time.Millisecond),
Type: "hello",
Serial: portName,
IntervalMs: int(h.defaultInterval / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs,
Note: "set_tap_notify configures slave S/D/T only; set_tap_stream enables tap polling/push",
Commands: []string{
"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",
},
}
@@ -146,6 +205,16 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
func (h *accelStreamHub) unregister(conn *websocket.Conn) {
h.mu.Lock()
delete(h.clients, conn)
anyTap := false
for _, sub := range h.clients {
if sub.receiveTap {
anyTap = true
break
}
}
if !anyTap {
h.recentTaps = nil
}
h.mu.Unlock()
h.notifyConfigChanged()
}
@@ -161,12 +230,23 @@ func (h *accelStreamHub) anyWantsAccel() bool {
return false
}
func (h *accelStreamHub) anyWantsTap() bool {
h.mu.RLock()
defer h.mu.RUnlock()
for _, sub := range h.clients {
if sub.receiveTap {
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 {
if !sub.receiveAccel && !sub.receiveTap {
continue
}
if min == 0 || sub.interval < min {
@@ -208,6 +288,78 @@ func (h *accelStreamHub) getStream(sub *wsSubscriber) StreamStatusMessage {
}
}
func (h *accelStreamHub) setTapStream(sub *wsSubscriber, enable bool, intervalMs *int) TapStreamStatusMessage {
h.mu.Lock()
sub.receiveTap = enable
if !enable {
h.recentTaps = nil
}
if intervalMs != nil {
sub.interval = clampAPIInterval(time.Duration(*intervalMs) * time.Millisecond)
}
ms := int(sub.interval / time.Millisecond)
h.mu.Unlock()
h.notifyConfigChanged()
return TapStreamStatusMessage{
Type: "tap_stream_status",
ReceiveTap: enable,
IntervalMs: ms,
Success: true,
}
}
func (h *accelStreamHub) getTapStream(sub *wsSubscriber) TapStreamStatusMessage {
h.mu.RLock()
defer h.mu.RUnlock()
return TapStreamStatusMessage{
Type: "tap_stream_status",
ReceiveTap: sub.receiveTap,
IntervalMs: int(sub.interval / time.Millisecond),
Success: true,
}
}
func (h *accelStreamHub) ingestTapEvents(incoming []TapClientEvent) []TapClientEvent {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
if h.recentTaps == nil {
h.recentTaps = make(map[uint32]cachedTapEvent)
}
for _, e := range incoming {
if !e.Valid || e.Kind == "" {
continue
}
h.recentTaps[e.ClientID] = cachedTapEvent{kind: e.Kind, shownAt: now}
}
return h.activeTapEventsLocked(now)
}
func (h *accelStreamHub) activeTapEventsLocked(now time.Time) []TapClientEvent {
if len(h.recentTaps) == 0 {
return nil
}
cutoff := now.Add(-apiTapDisplayMinMs * time.Millisecond)
out := make([]TapClientEvent, 0, len(h.recentTaps))
for id, ev := range h.recentTaps {
if ev.shownAt.Before(cutoff) {
delete(h.recentTaps, id)
continue
}
shownAtMs := ev.shownAt.UnixMilli()
out = append(out, TapClientEvent{
ClientID: id,
Valid: true,
Kind: ev.kind,
AgeMs: uint32(now.Sub(ev.shownAt).Milliseconds()),
ShownAtMs: shownAtMs,
})
}
return out
}
func (h *accelStreamHub) deliver(msg AccelStreamMessage) {
data, err := json.Marshal(msg)
if err != nil {
@@ -221,10 +373,10 @@ func (h *accelStreamHub) deliver(msg AccelStreamMessage) {
if !sub.receiveAccel {
continue
}
if !sub.lastSent.IsZero() && now.Sub(sub.lastSent) < sub.interval {
if !sub.lastAccelSent.IsZero() && now.Sub(sub.lastAccelSent) < sub.interval {
continue
}
sub.lastSent = now
sub.lastAccelSent = now
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
delete(h.clients, conn)
_ = conn.Close()
@@ -232,7 +384,31 @@ func (h *accelStreamHub) deliver(msg AccelStreamMessage) {
}
}
func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, stop <-chan struct{}) {
func (h *accelStreamHub) deliverTap(msg TapStreamMessage) {
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.receiveTap {
continue
}
if !sub.lastTapSent.IsZero() && now.Sub(sub.lastTapSent) < sub.interval {
continue
}
sub.lastTapSent = 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, tapCtl *tapNotifyCtl, stop <-chan struct{}) {
var ticker *time.Ticker
var tick <-chan time.Time
@@ -258,49 +434,85 @@ func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
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 hub.anyWantsAccel() && accelStreamPollingActive(dash, ctl) {
resp, err := link.readAccelSnapshotPoll(0)
if errors.Is(err, errUARTBusy) {
hub.deliver(AccelStreamMessage{
Type: "accel",
T: now,
Success: false,
Error: "uart busy",
})
} else if err != nil {
hub.deliver(AccelStreamMessage{
Type: "accel",
T: now,
Success: false,
Error: err.Error(),
})
} else {
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,
})
}
}
if err != nil {
hub.deliver(AccelStreamMessage{
Type: "accel",
T: now,
Success: false,
Error: err.Error(),
})
continue
if hub.anyWantsTap() {
nowMs := time.Now().UnixNano()
resp, err := link.readTapSnapshotPoll(0)
if errors.Is(err, errUARTBusy) {
hub.deliverTap(TapStreamMessage{
Type: "tap",
T: nowMs,
Success: false,
Error: "uart busy",
})
} else if err != nil {
hub.deliverTap(TapStreamMessage{
Type: "tap",
T: nowMs,
Success: false,
Error: err.Error(),
})
} else {
fresh := make([]TapClientEvent, 0, len(resp.GetEvents()))
for _, e := range resp.GetEvents() {
if !e.GetValid() {
continue
}
fresh = append(fresh, TapClientEvent{
ClientID: e.GetClientId(),
Valid: true,
Kind: tapKindLabelPB(e.GetKind()),
AgeMs: e.GetAgeMs(),
})
}
events := hub.ingestTapEvents(fresh)
if len(events) == 0 {
continue
}
hub.deliverTap(TapStreamMessage{
Type: "tap",
T: nowMs,
Success: true,
Events: events,
})
}
}
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,
})
}
}
}
@@ -354,7 +566,63 @@ func writeAccelStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
_ = conn.WriteMessage(websocket.TextMessage, data)
}
func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, hub *accelStreamHub) {
func writeTapStreamStatus(conn *websocket.Conn, msg TapStreamStatusMessage) {
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
}
func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
msg := TapNotifyStatusMessage{
Type: "tap_notify_status",
ClientID: out.ClientID,
Single: out.Single,
DoubleTap: out.DoubleTap,
Triple: out.Triple,
Success: out.Success,
SlavesUpdated: out.SlavesUpdated,
Error: out.Error,
}
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
}
func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyCtl, clientID uint32, single, doubleTap, triple bool) tapNotifyAPIResponse {
resp, err := link.TapNotify(&pb.TapNotifyRequest{
Write: true,
ClientId: clientID,
Single: single,
DoubleTap: doubleTap,
Triple: triple,
})
if err != nil {
return tapNotifyAPIResponse{ClientID: clientID, Error: err.Error()}
}
out := tapNotifyAPIResponse{
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
SlavesUpdated: resp.GetSlavesUpdated(),
Single: resp.GetSingle(),
DoubleTap: resp.GetDoubleTap(),
Triple: resp.GetTriple(),
}
if resp.GetSuccess() {
if tapCtl != nil {
tapCtl.Set(clientID, single, doubleTap, triple)
}
if dash != nil {
dash.patchClientTapNotify(clientID, single, doubleTap, triple)
}
}
return out
}
func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
var cmd accelWSCommand
if err := json.Unmarshal(data, &cmd); err != nil {
writeStreamStatus(conn, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
@@ -414,6 +682,79 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
Success: resp.GetSuccess(),
})
case "set_tap_stream":
if cmd.Enable == nil {
writeTapStreamStatus(conn, TapStreamStatusMessage{
Type: "tap_stream_status",
Error: "enable required",
})
return
}
writeTapStreamStatus(conn, hub.setTapStream(sub, *cmd.Enable, cmd.IntervalMs))
case "get_tap_stream":
writeTapStreamStatus(conn, hub.getTapStream(sub))
case "set_tap_notify":
if cmd.AllClients {
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "single, double_tap, triple required"})
return
}
updated, err := applyTapNotifyAll(link, dash, tapCtl, *cmd.Single, *cmd.DoubleTap, *cmd.Triple)
if err != nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: err.Error()})
return
}
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
Success: updated > 0,
SlavesUpdated: updated,
Single: *cmd.Single,
DoubleTap: *cmd.DoubleTap,
Triple: *cmd.Triple,
})
return
}
if cmd.ClientID == 0 {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
return
}
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Error: "single, double_tap, triple required",
})
return
}
writeTapNotifyStatus(conn, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple))
case "get_tap_notify":
if cmd.ClientID == 0 {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
return
}
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
Write: false,
ClientId: cmd.ClientID,
})
if err != nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Error: err.Error(),
})
return
}
if tapCtl != nil {
tapCtl.Set(cmd.ClientID, resp.GetSingle(), resp.GetDoubleTap(), resp.GetTriple())
}
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Success: resp.GetSuccess(),
Single: resp.GetSingle(),
DoubleTap: resp.GetDoubleTap(),
Triple: resp.GetTriple(),
})
case "set_led_ring":
var body ledRingAPIRequest
if err := json.Unmarshal(data, &body); err != nil {
@@ -440,12 +781,12 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
default:
writeStreamStatus(conn, StreamStatusMessage{
Type: "stream_status",
Error: "unknown type (set_stream, get_stream, set_accel_stream, get_accel_stream, set_led_ring, get_battery)",
Error: "unknown type (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)",
})
}
}
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, portName string, hub *accelStreamHub) {
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, portName string, hub *accelStreamHub) {
sub := hub.register(conn, portName)
defer hub.unregister(conn)
defer conn.Close()
@@ -455,11 +796,11 @@ func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl
if err != nil {
return
}
handleAccelWSCommand(conn, sub, data, link, dash, ctl, hub)
handleAccelWSCommand(conn, sub, data, link, dash, ctl, tapCtl, hub)
}
}
func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl) {
func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl) {
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/" {
@@ -478,7 +819,8 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
DefaultIntervalMs: defMs,
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
Description: "WebSocket: accel stream + set_led_ring (modes: clear, color, progress, digit, blink, find-me)",
TapDisplayMinMs: apiTapDisplayMinMs,
Description: "WebSocket: set_accel_stream + set_stream for accel; set_tap_notify (slave S/D/T) then set_tap_stream for tap events (shown ≥2s)",
})
})
@@ -488,22 +830,22 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
log.Printf("api websocket upgrade: %v", err)
return
}
serveExternalWS(conn, link, dash, ctl, portName, hub)
serveExternalWS(conn, link, dash, ctl, tapCtl, portName, hub)
})
}
func runAPIServer(portName string, link *managedSerial, addr string, defaultInterval time.Duration, dash *wsHub, ctl *accelStreamCtl, stop <-chan struct{}) *http.Server {
func runAPIServer(portName string, link *managedSerial, addr string, defaultInterval time.Duration, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) *http.Server {
hub := newAccelStreamHub(defaultInterval)
go runAccelStreamer(link, hub, dash, ctl, stop)
go runAccelStreamer(link, hub, dash, ctl, tapCtl, stop)
mux := http.NewServeMux()
mountExternalAPI(mux, portName, defaultInterval, hub, link, dash, ctl)
mountExternalAPI(mux, portName, defaultInterval, hub, link, dash, ctl, tapCtl)
mountLedRingAPI(mux, link)
mountBatteryAPI(mux, link)
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)",
log.Printf("external API http://localhost%s WebSocket ws://localhost%s/ws (default stream interval %s, per-client via set_stream / set_tap_stream)",
addr, addr, defaultInterval.String())
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("external API server: %v", err)