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:
simon 2026-05-29 20:42:57 +02:00
parent 3cb0b5bbe9
commit a8d4d42920
34 changed files with 3138 additions and 241 deletions

View File

@ -26,6 +26,8 @@ go run . -port /dev/ttyUSB0 clients
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
| `deadzone` | `0x06` | Get/set accelerometer deadzone LSB (`-set`, `-value`, `-client`, `-all`) |
| `accel` | `0x18` | Cached slave accel snapshot from master (`ACCEL_SNAPSHOT`); alias `accel-read` |
| `tap-notify` | `0x1b` | Get/set which tap kinds (single/double/triple) notify via ESP-NOW (`-set`, `-client`, `-all`, `-single`, `-double`, `-triple`) |
| `tap` | `0x1c` | Cached tap snapshot from master (`TAP_SNAPSHOT`); events ≤16 ms old |
| `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) |
@ -68,26 +70,42 @@ 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`.
**Tap (dashboard):** two independent controls per slave:
| Column | Meaning |
|--------|---------|
| Tap-Notify (S/D/T) | Which tap kinds the **slave** sends to the master over ESP-NOW (UART `TAP_NOTIFY`) — does **not** poll UART |
| Tap (An/Aus) | Host **receive**: poll master tap cache (~16 ms) and show last tap for **≥2 s** |
Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (`set_tap_notify` vs `set_tap_stream`).
### 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 |
| `GET /` or `GET /api/v1/` | JSON service info (`default_interval_ms`, min/max, `serial_port`, `tap_display_min_ms`) |
| `WebSocket /ws` | Per-connection accel/tap receive + interval; slave ESP-NOW accel/tap control |
Two layers:
**Accel** — 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`).
Accel 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`):
**Tap** — also two layers (notify alone does **not** poll UART):
1. **`set_tap_notify`** — firmware: which tap kinds (single/double/triple) the slave sends to the master over ESP-NOW.
2. **`set_tap_stream`** — this WebSocket connection: poll `TAP_SNAPSHOT` and push `tap` JSON. Events stay visible for **`tap_display_min_ms`** (2000 ms) after first sight.
Tap polling runs only when at least one connection has `receive_tap: true` (via `set_tap_stream`).
**Hello** (on connect; accel/tap receive off until `set_stream` / `set_tap_stream`):
```json
{"type":"hello","serial_port":"/dev/ttyUSB0","interval_ms":16,"commands":["set_stream","get_stream","set_accel_stream","get_accel_stream","set_led_ring","get_battery"]}
{"type":"hello","serial_port":"/dev/ttyUSB0","interval_ms":16,"tap_display_min_ms":2000,"note":"set_tap_notify configures slave S/D/T only; set_tap_stream enables tap polling/push","commands":["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"]}
```
**Receive accel on this connection** (optional `interval_ms`, default from `-accel-interval`):
@ -125,6 +143,38 @@ Reply:
Reply: `{"type":"led_ring_status","success":true,"slaves_updated":2,...}`
**Tap notify** (slave ESP-NOW config; per `client_id`, or `all_clients`):
```json
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
{"type":"get_tap_notify","client_id":16}
```
Reply:
```json
{"type":"tap_notify_status","client_id":16,"success":true,"single":true,"double_tap":false,"triple":false}
```
**Receive tap on this connection** (optional `interval_ms`; default from `-accel-interval`):
```json
{"type":"set_tap_stream","enable":true,"interval_ms":16}
{"type":"get_tap_stream"}
```
Reply:
```json
{"type":"tap_stream_status","receive_tap":true,"interval_ms":16,"success":true}
```
**Tap events** (only to connections with `receive_tap: true`; each event shown ≥2 s):
```json
{"type":"tap","port":"/dev/ttyUSB0","success":true,"events":[{"client_id":16,"kind":"single","age_ms":3,"shown_at_ms":1717000000123}]}
```
**Accel** (only to connections with `receive_accel: true`, and only while slaves stream):
```json
@ -156,6 +206,28 @@ async def main():
asyncio.run(main())
```
Tap example (notify first, then enable stream on this connection):
```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_tap_notify", "client_id": 16,
"single": True, "double_tap": False, "triple": False}))
print(await ws.recv()) # tap_notify_status
await ws.send(json.dumps({"type": "set_tap_stream", "enable": True, "interval_ms": 16}))
print(await ws.recv()) # tap_stream_status
while True:
msg = json.loads(await ws.recv())
if msg.get("type") == "tap" and msg.get("events"):
for e in msg["events"]:
print(e["client_id"], e["kind"], "age", e.get("age_ms"))
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:
@ -167,7 +239,7 @@ 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`, `GET/PUT /api/clients/{id}/accel-stream`, `POST /api/accel-stream` (legacy / `all_clients`), `GET/POST /api/battery`, `POST /api/led-ring`, `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`), `GET/PUT /api/clients/{id}/tap-notify`, `PUT /api/clients/{id}/tap-receive`, `GET/POST /api/tap-notify`, `GET /api/tap-snapshot`, `GET/POST /api/battery`, `POST /api/led-ring`, `POST /api/unicast-test`, `POST /api/find-me`, `POST /api/restart`, `POST /api/ota` (multipart field `firmware`, max 2 MiB).
**LED ring** (`POST /api/led-ring` and WebSocket `set_led_ring` on `:8081`):
@ -203,6 +275,38 @@ Content-Type: application/json
Enable all slaves: `POST /api/accel-stream` with `{"write":true,"enable":true,"all_clients":true}`.
**Tap notify per slave** (slave → master ESP-NOW; does not start host polling):
```http
GET /api/clients/16/tap-notify
→ {"client_id":16,"success":true,"single":false,"double_tap":false,"triple":false}
PUT /api/clients/16/tap-notify
Content-Type: application/json
{"single": true, "double_tap": false, "triple": false}
→ {"client_id":16,"success":true,"slaves_updated":1,"single":true,"double_tap":false,"triple":false}
```
All slaves: `POST /api/tap-notify` with `{"single":true,"double_tap":false,"triple":false,"all_clients":true}`.
**Tap receive** (host-side; dashboard polls `TAP_SNAPSHOT` while enabled):
```http
PUT /api/clients/16/tap-receive
Content-Type: application/json
{"enable": true}
→ {"client_id":16,"enabled":true,"success":true}
```
One-shot read (no receive flag): `GET /api/tap-snapshot?client_id=16``{"events":[{"client_id":16,"kind":"single","age_ms":4}]}`.
CLI:
```bash
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
go run . -port /dev/ttyUSB0 tap -client 16
```
| UI / API | Behaviour |
|----------|-----------|
| Firmware OTA card | Same as `ota` CLI; WebSocket `ota_progress` with `step` `master` (UART) then `slaves` (ESP-NOW) |

View File

@ -67,8 +67,9 @@ type otaAPIResponse struct {
Error string `json:"error,omitempty"`
}
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl) {
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
mountAccelStreamAPI(mux, link, hub, streamCtl)
mountTapAPI(mux, link, hub, tapCtl)
mountLedRingAPI(mux, link)
mountBatteryAPI(mux, link)
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {

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)

253
goTool/api_tap.go Normal file
View File

@ -0,0 +1,253 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"powerpod/gotool/pb"
)
type tapNotifyAPIRequest struct {
Write bool `json:"write"`
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
Single bool `json:"single"`
DoubleTap bool `json:"double_tap"`
Triple bool `json:"triple"`
}
type tapNotifyAPIResponse struct {
ClientID uint32 `json:"client_id"`
Success bool `json:"success"`
SlavesUpdated uint32 `json:"slaves_updated"`
Single bool `json:"single"`
DoubleTap bool `json:"double_tap"`
Triple bool `json:"triple"`
Error string `json:"error,omitempty"`
}
type tapSnapshotAPIResponse struct {
Events []tapEventView `json:"events"`
Error string `json:"error,omitempty"`
}
type tapEventView struct {
ClientID uint32 `json:"client_id"`
Kind string `json:"kind"`
AgeMs uint32 `json:"age_ms"`
}
type tapReceiveAPIResponse struct {
ClientID uint32 `json:"client_id"`
Enabled bool `json:"enabled"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func mountTapAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl) {
mux.HandleFunc("GET /api/clients/{clientID}/tap-notify", func(w http.ResponseWriter, r *http.Request) {
clientID, err := parsePathClientID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: err.Error()})
return
}
serveTapNotifyGet(w, clientID, link)
})
mux.HandleFunc("PUT /api/clients/{clientID}/tap-notify", func(w http.ResponseWriter, r *http.Request) {
clientID, err := parsePathClientID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: err.Error()})
return
}
serveClientTapNotifyPut(w, r, clientID, link, hub, tapCtl)
})
mux.HandleFunc("PUT /api/clients/{clientID}/tap-receive", func(w http.ResponseWriter, r *http.Request) {
clientID, err := parsePathClientID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, tapReceiveAPIResponse{Error: err.Error()})
return
}
serveClientTapReceivePut(w, r, clientID, hub)
})
mux.HandleFunc("/api/tap-notify", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
serveTapNotifyGetQuery(w, r, link)
case http.MethodPost:
serveTapNotifyPost(w, r, link, hub, tapCtl)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/api/tap-snapshot", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveTapSnapshotGet(w, r, link)
})
}
func applyTapNotifyClient(link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl, clientID uint32, single, doubleTap, triple bool) tapNotifyAPIResponse {
return applyTapNotifyClientWS(link, hub, tapCtl, clientID, single, doubleTap, triple)
}
func serveTapNotifyGet(w http.ResponseWriter, clientID uint32, link *managedSerial) {
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, tapNotifyAPIResponse{
ClientID: clientID,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, tapNotifyAPIResponse{
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
Single: resp.GetSingle(),
DoubleTap: resp.GetDoubleTap(),
Triple: resp.GetTriple(),
})
}
func serveTapNotifyGetQuery(w http.ResponseWriter, r *http.Request, link *managedSerial) {
clientID, err := parseUintQuery(r, "client_id", 0)
if err != nil || clientID == 0 {
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "client_id required"})
return
}
serveTapNotifyGet(w, clientID, link)
}
type clientTapNotifyBody struct {
Single bool `json:"single"`
DoubleTap bool `json:"double_tap"`
Triple bool `json:"triple"`
}
func serveClientTapNotifyPut(w http.ResponseWriter, r *http.Request, clientID uint32, link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl) {
var body clientTapNotifyBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "invalid JSON"})
return
}
out := applyTapNotifyClient(link, hub, tapCtl, clientID, body.Single, body.DoubleTap, body.Triple)
status := http.StatusOK
if out.Error != "" || !out.Success {
status = http.StatusServiceUnavailable
}
writeJSON(w, status, out)
}
func serveTapNotifyPost(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl) {
var body tapNotifyAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "invalid JSON"})
return
}
if body.AllClients {
updated, err := applyTapNotifyAll(link, hub, tapCtl, body.Single, body.DoubleTap, body.Triple)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, tapNotifyAPIResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, tapNotifyAPIResponse{
Success: updated > 0,
SlavesUpdated: updated,
Single: body.Single,
DoubleTap: body.DoubleTap,
Triple: body.Triple,
})
return
}
if body.ClientID == 0 {
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "client_id required"})
return
}
out := applyTapNotifyClient(link, hub, tapCtl, body.ClientID, body.Single, body.DoubleTap, body.Triple)
status := http.StatusOK
if out.Error != "" || !out.Success {
status = http.StatusServiceUnavailable
}
writeJSON(w, status, out)
}
func serveClientTapReceivePut(w http.ResponseWriter, r *http.Request, clientID uint32, hub *wsHub) {
var body struct {
Enable bool `json:"enable"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, tapReceiveAPIResponse{Error: "invalid JSON"})
return
}
if hub != nil {
hub.patchClientTapReceive(clientID, body.Enable)
}
writeJSON(w, http.StatusOK, tapReceiveAPIResponse{
ClientID: clientID,
Enabled: body.Enable,
Success: true,
})
}
func applyTapNotifyAll(link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl, single, doubleTap, triple bool) (uint32, error) {
resp, err := link.TapNotify(&pb.TapNotifyRequest{
Write: true,
AllClients: true,
Single: single,
DoubleTap: doubleTap,
Triple: triple,
})
if err != nil {
return 0, err
}
if !resp.GetSuccess() {
return 0, fmt.Errorf("tap notify not applied to any slave")
}
if hub != nil || tapCtl != nil {
clients, _ := link.listClientsPoll()
for _, c := range clients {
if tapCtl != nil {
tapCtl.Set(c.GetId(), single, doubleTap, triple)
}
if hub != nil {
hub.patchClientTapNotify(c.GetId(), single, doubleTap, triple)
}
}
}
return resp.GetSlavesUpdated(), nil
}
func serveTapSnapshotGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
clientID, err := parseUintQuery(r, "client_id", 0)
if err != nil {
writeJSON(w, http.StatusBadRequest, tapSnapshotAPIResponse{Error: err.Error()})
return
}
resp, err := link.readTapSnapshotPoll(clientID)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, tapSnapshotAPIResponse{Error: err.Error()})
return
}
out := tapSnapshotAPIResponse{Events: make([]tapEventView, 0, len(resp.GetEvents()))}
for _, e := range resp.GetEvents() {
if !e.GetValid() {
continue
}
out.Events = append(out.Events, tapEventView{
ClientID: e.GetClientId(),
Kind: tapKindLabel(e.GetKind()),
AgeMs: e.GetAgeMs(),
})
}
writeJSON(w, http.StatusOK, out)
}

View File

@ -171,6 +171,64 @@ func (m *managedSerial) GetAccelStream(clientID uint32) (bool, error) {
return resp.GetEnabled(), nil
}
func (m *managedSerial) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
return m.tapNotifyVia(m.withPort, req)
}
func (m *managedSerial) TapNotifyPoll(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
return m.tapNotifyVia(m.withPortPoll, req)
}
func (m *managedSerial) tapNotifyVia(
portFn func(func(*serialPort) error) error,
req *pb.TapNotifyRequest,
) (*pb.TapNotifyResponse, error) {
var resp *pb.TapNotifyResponse
err := portFn(func(sp *serialPort) error {
var e error
resp, e = sp.TapNotify(req)
return e
})
return resp, err
}
func (m *managedSerial) readTapSnapshotPoll(clientID uint32) (*pb.TapSnapshotResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_SNAPSHOT,
Payload: &pb.UartMessage_TapSnapshotRequest{
TapSnapshotRequest: &pb.TapSnapshotRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_SNAPSHOT)}, body...)
respPayload, err := m.exchangePayloadPoll(payload, "TAP_SNAPSHOT")
if err != nil {
return nil, err
}
return decodeTapSnapshotPayload(respPayload)
}
func decodeTapSnapshotPayload(payload []byte) (*pb.TapSnapshotResponse, 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_TAP_SNAPSHOT {
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
}
r := msg.GetTapSnapshotResponse()
if r == nil {
return nil, fmt.Errorf("missing tap_snapshot_response")
}
return r, nil
}
func (m *managedSerial) accelStreamVia(
portFn func(func(*serialPort) error) error,
req *pb.AccelStreamRequest,
@ -325,6 +383,52 @@ func (s *serialPort) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamRes
return r, nil
}
func (s *serialPort) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_NOTIFY,
Payload: &pb.UartMessage_TapNotifyRequest{
TapNotifyRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
respPayload, err := s.exchangePayload(payload, "TAP_NOTIFY")
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.GetTapNotifyResponse()
if r == nil {
return nil, fmt.Errorf("missing tap_notify_response")
}
return r, nil
}
func (s *serialPort) readTapSnapshot(clientID uint32) (*pb.TapSnapshotResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_SNAPSHOT,
Payload: &pb.UartMessage_TapSnapshotRequest{
TapSnapshotRequest: &pb.TapSnapshotRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_SNAPSHOT)}, body...)
respPayload, err := s.exchangePayload(payload, "TAP_SNAPSHOT")
if err != nil {
return nil, err
}
return decodeTapSnapshotPayload(respPayload)
}
func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_DEADZONE,

View File

@ -18,9 +18,17 @@ func runClients(sp *serialPort) error {
fmt.Printf("clients (%d):\n", len(clients))
for i, c := range clients {
mac := hex.EncodeToString(c.GetMac())
fmt.Printf(" [%d] id=%d mac=%s ver=%d available=%v used=%v last_ping=%d last_success_ping=%d\n",
fmt.Printf(" [%d] id=%d mac=%s ver=%d available=%v used=%v last_ping=%d last_success_ping=%d tap=%s/%s/%s\n",
i, c.GetId(), mac, c.GetVersion(), c.GetAvailable(), c.GetUsed(),
c.GetLastPing(), c.GetLastSuccessPing())
c.GetLastPing(), c.GetLastSuccessPing(),
boolFlag(c.GetTapNotifySingle()), boolFlag(c.GetTapNotifyDouble()), boolFlag(c.GetTapNotifyTriple()))
}
return nil
}
func boolFlag(v bool) string {
if v {
return "on"
}
return "off"
}

View File

@ -38,20 +38,22 @@ func runServe(portName string, baud int, args []string) error {
hub := newWSHub()
streamCtl := newAccelStreamCtl()
tapCtl := newTapNotifyCtl()
stop := make(chan struct{})
defer close(stop)
go runPoller(link, portName, hub, streamCtl, *interval, stop)
go runPoller(link, portName, hub, streamCtl, tapCtl, *interval, stop)
go runBatteryPoller(link, hub, 5*time.Second, stop)
go runAccelDashboardPoller(link, hub, *accelInterval, stop)
go runTapDashboardPoller(link, hub, *accelInterval, stop)
var apiSrv *http.Server
if *apiAddr != "" {
apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, stop)
apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
defer shutdownAPIServer(apiSrv)
}
mux := http.NewServeMux()
mountServeAPI(mux, link, hub, streamCtl)
mountServeAPI(mux, link, hub, streamCtl, tapCtl)
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {

84
goTool/cmd_tap.go Normal file
View File

@ -0,0 +1,84 @@
package main
import (
"flag"
"fmt"
"powerpod/gotool/pb"
)
func runTapNotify(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("tap-notify", flag.ExitOnError)
write := fs.Bool("set", false, "write tap notify flags (default: read)")
clientID := fs.Uint("client", 0, "client id (>0 required for read/set one slave)")
all := fs.Bool("all", false, "apply to all registered slaves (with -set)")
single := fs.Bool("single", false, "notify on single tap")
doubleTap := fs.Bool("double", false, "notify on double tap")
triple := fs.Bool("triple", false, "notify on triple tap")
if err := fs.Parse(args); err != nil {
return err
}
if !*write && (*all || *clientID == 0) {
return fmt.Errorf("read requires -client <id>")
}
if *write && !*all && *clientID == 0 {
return fmt.Errorf("set requires -client <id> or -all")
}
r, err := sp.TapNotify(&pb.TapNotifyRequest{
Write: *write,
ClientId: uint32(*clientID),
AllClients: *all,
Single: *single,
DoubleTap: *doubleTap,
Triple: *triple,
})
if err != nil {
return err
}
fmt.Printf("client_id=%d success=%v slaves_updated=%d single=%v double=%v triple=%v\n",
r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated(),
r.GetSingle(), r.GetDoubleTap(), r.GetTriple())
return nil
}
func runTapSnapshot(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("tap", flag.ExitOnError)
clientID := fs.Uint("client", 0, "client id (0 = all slaves with tap notify)")
if err := fs.Parse(args); err != nil {
return err
}
return runTapSnapshotForClient(sp, uint32(*clientID))
}
func runTapSnapshotForClient(sp *serialPort, clientID uint32) error {
r, err := sp.readTapSnapshot(clientID)
if err != nil {
return err
}
events := r.GetEvents()
if len(events) == 0 {
fmt.Println("no tap events (none pending or older than 16 ms)")
return nil
}
for _, e := range events {
fmt.Printf("client %d: %s (age %d ms)\n",
e.GetClientId(), tapKindLabel(e.GetKind()), e.GetAgeMs())
}
return nil
}
func tapKindLabel(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 "none"
}
}

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
}

View File

@ -16,6 +16,8 @@ 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, " tap-notify get/set which tap kinds notify via ESP-NOW\n")
fmt.Fprintf(os.Stderr, " tap read cached tap events from master\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")
@ -51,7 +53,7 @@ func main() {
os.Exit(2)
}
runErr = runServe(*portName, *baud, flag.Args()[1:])
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "accel", "accel-read", "accel_read", "unicast-test", "unicast_test", "led-ring", "led_ring", "find-me", "find_me", "restart", "ota", "ota-progress", "ota_progress":
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "tap-notify", "tap_notify", "tap", "accel", "accel-read", "accel_read", "unicast-test", "unicast_test", "led-ring", "led_ring", "find-me", "find_me", "restart", "ota", "ota-progress", "ota_progress":
if *portName == "" {
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
usage()
@ -69,6 +71,10 @@ func main() {
runErr = runClients(sp)
case "deadzone", "accel-deadzone":
runErr = runDeadzone(sp, flag.Args()[1:])
case "tap-notify", "tap_notify":
runErr = runTapNotify(sp, flag.Args()[1:])
case "tap":
runErr = runTapSnapshot(sp, flag.Args()[1:])
case "accel", "accel-read", "accel_read":
runErr = runAccel(sp)
case "unicast-test", "unicast_test":

File diff suppressed because it is too large Load Diff

50
goTool/tap_notify_ctl.go Normal file
View File

@ -0,0 +1,50 @@
package main
import "sync"
// tapNotifyCtl tracks which slaves have tap notify enabled (mirrors firmware / dashboard).
type tapNotifyCtl struct {
mu sync.Mutex
flags map[uint32]tapNotifyFlags
}
type tapNotifyFlags struct {
single bool
doubleTap bool
triple bool
}
func newTapNotifyCtl() *tapNotifyCtl {
return &tapNotifyCtl{flags: make(map[uint32]tapNotifyFlags)}
}
func (c *tapNotifyCtl) Set(clientID uint32, single, doubleTap, triple bool) {
c.mu.Lock()
defer c.mu.Unlock()
if !single && !doubleTap && !triple {
delete(c.flags, clientID)
return
}
c.flags[clientID] = tapNotifyFlags{single: single, doubleTap: doubleTap, triple: triple}
}
func (c *tapNotifyCtl) Any() bool {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.flags) > 0
}
func (c *tapNotifyCtl) SyncFromClients(clients []ClientView) {
c.mu.Lock()
defer c.mu.Unlock()
c.flags = make(map[uint32]tapNotifyFlags)
for _, cl := range clients {
if cl.TapNotifySingle || cl.TapNotifyDouble || cl.TapNotifyTriple {
c.flags[cl.ID] = tapNotifyFlags{
single: cl.TapNotifySingle,
doubleTap: cl.TapNotifyDouble,
triple: cl.TapNotifyTriple,
}
}
}
}

View File

@ -62,6 +62,25 @@
}
.accel-stale { color: var(--pp-text-muted); }
.tap-toggle {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.72rem;
color: var(--pp-text-secondary);
white-space: nowrap;
}
.tap-toggle input { margin: 0; }
.tap-hit {
color: #ffd166;
font-weight: 600;
animation: tap-flash 2s ease-out;
}
@keyframes tap-flash {
from { color: #fff; transform: scale(1.08); }
to { color: #ffd166; transform: scale(1); }
}
.pp-table {
--bs-table-color: var(--pp-text);
--bs-table-bg: transparent;
@ -271,9 +290,21 @@
</div>
</div>
<p class="text-muted small px-3 pt-2 mb-0">
Accel-Stream pro Slave per „Stream an“ aktivieren (~16&nbsp;ms ESP-NOW). Ohne Aktivierung keine Werte.
Accel-Stream pro Slave per „Stream an“ aktivieren (~16&nbsp;ms ESP-NOW). Tap-Notify (S/D/T)
konfiguriert den Slave; „Empfang an“ startet das Abfragen von Tap-Events (~16&nbsp;ms).
</p>
<div class="card-body p-0 pt-2">
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
<span class="text-muted small">Tap alle Slaves:</span>
<label class="tap-toggle"><input type="checkbox" x-model="allTapSingle" :disabled="busy"> S</label>
<label class="tap-toggle"><input type="checkbox" x-model="allTapDouble" :disabled="busy"> D</label>
<label class="tap-toggle"><input type="checkbox" x-model="allTapTriple" :disabled="busy"> T</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setTapNotifyAll(allTapSingle, allTapDouble, allTapTriple)"
:disabled="busy || !state.uart_connected">
Tap setzen
</button>
</div>
<div class="card-body p-0 pt-1">
<div class="table-responsive">
<table class="table pp-table table-hover">
<thead>
@ -286,12 +317,14 @@
<th>Accel (LSB)</th>
<th>Akku</th>
<th>Stream</th>
<th>Tap-Notify</th>
<th>Tap</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<template x-if="!(state.clients || []).length">
<tr><td colspan="9" class="text-muted text-center py-4">No clients</td></tr>
<tr><td colspan="11" 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>
@ -319,6 +352,40 @@
: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">
<label class="tap-toggle" title="Single tap">
<input type="checkbox"
:checked="c.tap_notify_single"
@change="setTapNotify(c.id, $event.target.checked, c.tap_notify_double, c.tap_notify_triple)"
:disabled="busy || !state.uart_connected || !c.available"> S
</label>
<label class="tap-toggle" title="Double tap">
<input type="checkbox"
:checked="c.tap_notify_double"
@change="setTapNotify(c.id, c.tap_notify_single, $event.target.checked, c.tap_notify_triple)"
:disabled="busy || !state.uart_connected || !c.available"> D
</label>
<label class="tap-toggle" title="Triple tap">
<input type="checkbox"
:checked="c.tap_notify_triple"
@change="setTapNotify(c.id, c.tap_notify_single, c.tap_notify_double, $event.target.checked)"
:disabled="busy || !state.uart_connected || !c.available"> T
</label>
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1 align-items-center">
<button type="button"
class="btn btn-sm"
:class="c.tap_receive ? 'btn-warning' : 'btn-outline-success'"
@click="setTapReceive(c.id, !c.tap_receive)"
:disabled="busy || !state.uart_connected || !c.available || !tapNotifyAny(c)"
x-text="c.tap_receive ? 'Aus' : 'An'"
title="Tap-Events vom Master abfragen"></button>
<span :class="tapCellClass(c)" x-text="formatLastTap(c)" :title="tapTitle(c)"></span>
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1 align-items-center">
<input type="number" class="form-control form-control-sm dz-input"
@ -556,7 +623,13 @@
wsConnected: false,
masterDz: 100,
allDz: 100,
allTapSingle: false,
allTapDouble: false,
allTapTriple: false,
slaveDz: {},
TAP_DISPLAY_MS: 2000,
tapDisplay: {},
_tapClock: 0,
otaFile: null,
ota: {
active: false, phase: '', step: '', percent: 0,
@ -584,6 +657,8 @@
const url = proto + '//' + location.host + '/ws';
if (this._batteryTimer) clearInterval(this._batteryTimer);
this._batteryTimer = setInterval(() => this.refreshBattery(), 5000);
if (this._tapTimer) clearInterval(this._tapTimer);
this._tapTimer = setInterval(() => { this._tapClock++; }, 250);
const connect = () => {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
@ -608,6 +683,7 @@
const prev = this.state;
this.state = msg;
this.preserveBatteryInState(prev, this.state);
this.syncTapDisplay(msg.clients || []);
if (msg.master?.deadzone != null) {
this.masterDz = msg.master.deadzone;
}
@ -718,6 +794,56 @@
if (c.accel_age_ms != null && c.accel_age_ms > 200) return 'accel-stale';
return '';
},
tapNotifyAny(c) {
return !!(c?.tap_notify_single || c?.tap_notify_double || c?.tap_notify_triple);
},
syncTapDisplay(clients) {
const now = Date.now();
for (const c of clients) {
if (!c?.last_tap || !c?.last_tap_at) continue;
const prev = this.tapDisplay[c.id];
if (!prev || c.last_tap_at >= prev.shownAt) {
this.tapDisplay[c.id] = { kind: c.last_tap, shownAt: c.last_tap_at };
}
}
for (const id of Object.keys(this.tapDisplay)) {
if (now - this.tapDisplay[id].shownAt > this.TAP_DISPLAY_MS + 500) {
delete this.tapDisplay[id];
}
}
},
activeTapDisplay(c) {
void this._tapClock;
const d = this.tapDisplay[c?.id];
if (!d) return null;
if (Date.now() - d.shownAt >= this.TAP_DISPLAY_MS) return null;
return d;
},
formatLastTap(c) {
if (!c?.tap_receive) return '—';
if (!this.tapNotifyAny(c)) return '—';
const labels = { single: 'Single', double: 'Double', triple: 'Triple' };
const d = this.activeTapDisplay(c);
if (d) return labels[d.kind] || d.kind;
if (!c?.last_tap) return '…';
return labels[c.last_tap] || c.last_tap;
},
tapTitle(c) {
if (!c?.tap_receive) return 'Tap-Empfang aus — „An“ klicken';
if (!this.tapNotifyAny(c)) return 'Tap-Notify nicht konfiguriert (S/D/T)';
const d = this.activeTapDisplay(c);
if (!d && !c?.last_tap) return 'Warte auf Tap-Event…';
const kind = d?.kind || c?.last_tap;
const at = d?.shownAt || c?.last_tap_at;
const age = at ? (Date.now() - at) + ' ms her' : '';
return `Letzter Tap: ${kind}${age ? ' · ' + age : ''}`;
},
tapCellClass(c) {
if (this.activeTapDisplay(c)) return 'tap-hit';
if (!c?.last_tap || !c?.last_tap_at) return 'text-muted';
if (Date.now() - c.last_tap_at < this.TAP_DISPLAY_MS) return 'tap-hit';
return '';
},
formatSize(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
@ -992,6 +1118,104 @@
this.busy = false;
}
},
patchClientTapNotify(clientId, single, doubleTap, triple) {
const clients = (this.state.clients || []).map((c) => {
if (c.id !== clientId) return c;
const next = {
...c,
tap_notify_single: single,
tap_notify_double: doubleTap,
tap_notify_triple: triple
};
if (!single && !doubleTap && !triple) {
next.last_tap = '';
next.last_tap_at = 0;
delete this.tapDisplay[c.id];
}
return next;
});
this.state = { ...this.state, clients };
},
async setTapNotify(clientId, single, doubleTap, triple) {
this.busy = true;
try {
const r = await fetch(`/api/clients/${clientId}/tap-notify`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ single, double_tap: doubleTap, triple })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `Tap-Notify Slave ${clientId} fehlgeschlagen`, false);
return;
}
this.patchClientTapNotify(clientId, !!data.single, !!data.double_tap, !!data.triple);
const on = [data.single && 'S', data.double_tap && 'D', data.triple && 'T'].filter(Boolean).join('/') || 'aus';
this.flash(`Slave ${clientId}: Tap-Notify ${on}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setTapNotifyAll(single, doubleTap, triple) {
this.busy = true;
try {
const r = await fetch('/api/tap-notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all_clients: true, single, double_tap: doubleTap, triple })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Tap-Notify für alle Slaves fehlgeschlagen', false);
return;
}
for (const c of (this.state.clients || [])) {
this.patchClientTapNotify(c.id, !!single, !!doubleTap, !!triple);
}
const on = [single && 'S', doubleTap && 'D', triple && 'T'].filter(Boolean).join('/') || 'aus';
this.flash(`Alle Slaves: Tap-Notify ${on} (${data.slaves_updated} aktualisiert)`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
patchClientTapReceive(clientId, enabled) {
const clients = (this.state.clients || []).map((c) => {
if (c.id !== clientId) return c;
const next = { ...c, tap_receive: enabled };
if (!enabled) {
next.last_tap = '';
next.last_tap_at = 0;
delete this.tapDisplay[clientId];
}
return next;
});
this.state = { ...this.state, clients };
},
async setTapReceive(clientId, enable) {
this.busy = true;
try {
const r = await fetch(`/api/clients/${clientId}/tap-receive`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `Tap-Empfang Slave ${clientId} fehlgeschlagen`, false);
return;
}
this.patchClientTapReceive(clientId, !!data.enabled);
this.flash(`Slave ${clientId}: Tap-Empfang ${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);

View File

@ -20,6 +20,8 @@ idf_component_register(
"cmd/cmd_accel_deadzone.c"
"cmd/cmd_accel_snapshot.c"
"cmd/cmd_accel_stream.c"
"cmd/cmd_tap_notify.c"
"cmd/cmd_tap_snapshot.c"
"cmd/cmd_espnow_unicast_test.c"
"cmd/cmd_espnow_find_me.c"
"cmd/cmd_restart.c"

View File

@ -116,6 +116,8 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES
| `ESPNOW_FIND_ME` | Master → slave | `EspNowFindMe` (`client_id` filter) — LED locate sequence |
| `ESPNOW_RESTART` | Master → slave | `EspNowRestart` (`client_id` filter) — reboot slave |
| `ESPNOW_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 16 ms |
| `ESPNOW_SET_TAP_NOTIFY` | Master → slave | `EspNowTapNotify` (`client_id`, `single`, `double_tap`, `triple`) — which tap kinds to forward |
| `ESPNOW_TAP_EVENT` | Slave → master | `EspNowTapEvent` (`client_id`, `kind`) — on BMA456 tap interrupt if notify enabled |
| `ESPNOW_BATTERY_REPORT` | Slave → master | `EspNowBatteryReport` (`client_id`, `lipo1/2` mV) — ~every 30 s; cached in `client_registry` |
| `ESPNOW_OTA_START` | Master → slave (unicast) | `EspNowOtaStart` (`total_size`) |
| `ESPNOW_OTA_PAYLOAD` | Master → slave | `EspNowOtaPayload` (`seq`, up to 200 B `data`) |
@ -221,6 +223,8 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 =
| 22 | `FIND_ME` | Implemented (`cmd/cmd_espnow_find_me.c`) — `client_id=0` local ring, `>0` ESP-NOW to slave |
| 23 | `RESTART` | Implemented (`cmd/cmd_restart.c`) — `client_id=0` reboot master, `>0` ESP-NOW reboot slave |
| 24 | `ACCEL_SNAPSHOT` | Implemented (`cmd/cmd_accel_snapshot.c`) — cached slave accel from ESP-NOW stream |
| 27 | `TAP_NOTIFY` | Implemented (`cmd/cmd_tap_notify.c`) — get/set which tap kinds notify via ESP-NOW |
| 28 | `TAP_SNAPSHOT` | Implemented (`cmd/cmd_tap_snapshot.c`) — consume cached tap events from master registry |
Regenerate C code:
@ -337,6 +341,52 @@ go run . -port /dev/ttyUSB0 accel
External API (`serve -api-addr :8081`) polls this command every 16 ms and streams JSON over WebSocket.
### TAP_NOTIFY command
Configure which BMA456 tap kinds a **slave** forwards to the master over ESP-NOW. The slave only sends `ESPNOW_TAP_EVENT` when the matching notify flag is enabled (set locally on the slave via ESP-NOW).
**Request:** framed `1b` (`0x1b`) + `tap_notify_request`:
| Field | Meaning |
|-------|---------|
| `write` | `false` = read, `true` = write |
| `single`, `double_tap`, `triple` | Which tap kinds to notify (write) |
| `client_id` | Slave id (read/write one slave) |
| `all_clients` | Master: ESP-NOW unicast to every registered slave |
**Response:** `tap_notify_response` (`client_id`, `success`, `slaves_updated`, `single`, `double_tap`, `triple`).
Notify flags are mirrored in `ClientInfo` (`tap_notify_single/double/triple`) for the dashboard.
```bash
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
go run . -port /dev/ttyUSB0 tap-notify -client 16
```
### TAP_SNAPSHOT command
Read **cached** tap events on the **master** (one pending event per slave). Slaves send `ESPNOW_TAP_EVENT` on tap; the master stores the latest value per client in `client_registry.c` for up to **16 ms** (`CLIENT_REGISTRY_TAP_MAX_AGE_MS`). Each snapshot **consumes** fresh events (cleared after read).
Only slaves with at least one tap-notify flag enabled are included.
**Request:** framed `1c` (`0x1c`) + optional `tap_snapshot_request` (`client_id`: `0` = all, `>0` = one id).
**Response:** `tap_snapshot_response.events[]`:
| Field | Meaning |
|-------|---------|
| `client_id` | Slave id (registry) |
| `valid` | Fresh tap available (≤16 ms) |
| `kind` | `TAP_SINGLE`, `TAP_DOUBLE`, or `TAP_TRIPLE` |
| `age_ms` | Ms since tap was received on master |
Host tools poll this only when **receive** is enabled (dashboard tap column, WebSocket `set_tap_stream`). They keep events visible for **2 s** in the UI/API after first sight.
```bash
go run . -port /dev/ttyUSB0 tap
go run . -port /dev/ttyUSB0 tap -client 16
```
### ESPNOW_UNICAST_TEST command
Minimal master→slave ESP-NOW unicast check (no BMA456). Use this before debugging `ACCEL_DEADZONE` unicast.
@ -424,7 +474,7 @@ go run . -port /dev/ttyUSB0 led-ring -mode digit -digit 5 -all
**Response:** payload `04` + nanopb `UartMessage` with `client_info_response.clients` — one `ClientInfo` per registered slave (from ESP-NOW `SLAVE_INFO`).
Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `last_success_ping`**milliseconds since** the last packet / last successful heartbeat (computed when `CLIENT_INFO` is answered; typically 01000 while the slave is heartbeating every 1 s).
Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `last_success_ping`, `tap_notify_single`, `tap_notify_double`, `tap_notify_triple`**milliseconds since** the last packet / last successful heartbeat (computed when `CLIENT_INFO` is answered; typically 01000 while the slave is heartbeating every 1 s).
## Client registry
@ -435,6 +485,7 @@ Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `la
| `client_registry_heartbeat(mac, id, version, …)` | Same as upsert for heartbeats; reactivates inactive clients |
| `client_registry_check_timeouts(timeout_ms)` | Mark stale clients inactive (master monitor task) |
| `client_registry_count()` / `client_registry_at(i)` | Iterate for UART encoding |
| `client_registry_set_tap_notify()` / `client_registry_take_tap()` | Tap notify flags + short-lived tap cache (16 ms) |
Slaves register when the master receives `SLAVE_INFO` on the matching network; `HEARTBEAT` keeps them marked available. The registry **MAC is always the ESP-NOW source address** (`recv_info.src_addr`), not the optional `mac` bytes in the protobuf (used only on the wire for debugging).
@ -502,6 +553,8 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
| `client_registry.c/h` | Registered slave table |
| `bosch456.c/h` | BMA456H I2C driver, accel poll, on-demand read, tap INT, deadzone filter |
| `cmd/cmd_accel_snapshot.c` | UART `ACCEL_SNAPSHOT` — cached slave accel |
| `cmd/cmd_tap_notify.c` | UART `TAP_NOTIFY` — ESP-NOW tap notify config |
| `cmd/cmd_tap_snapshot.c` | UART `TAP_SNAPSHOT` — consume cached tap events |
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO12 |
| `pod_settings.c/h` | NVS persistence (accel deadzone, …) |
| `led_ring.c/h` | LED ring (digit display, progress bar) |

View File

@ -36,6 +36,8 @@ static bool s_have_last_sample;
static volatile bool s_int_pending;
static SemaphoreHandle_t s_accel_mutex;
static bma456_tap_handler_t s_tap_handler;
static void *s_tap_handler_ctx;
static esp_err_t check_bma4(const char *api_name, int8_t rslt);
@ -123,6 +125,11 @@ void bma456_set_accel_deadzone(uint32_t deadzone_lsb) {
uint32_t bma456_get_accel_deadzone(void) { return s_accel_deadzone; }
void bma456_set_tap_handler(bma456_tap_handler_t handler, void *ctx) {
s_tap_handler = handler;
s_tap_handler_ctx = ctx;
}
esp_err_t bma456_read_accel(int16_t *x, int16_t *y, int16_t *z) {
if (!s_bma456_ready || x == NULL || y == NULL || z == NULL) {
return ESP_ERR_INVALID_STATE;
@ -183,10 +190,19 @@ static void handle_tap_interrupt(void) {
if (tap_out.single_tap) {
ESP_LOGI(TAG, "tap: single");
if (s_tap_handler != NULL) {
s_tap_handler(BMA456_TAP_SINGLE, s_tap_handler_ctx);
}
} else if (tap_out.double_tap) {
ESP_LOGI(TAG, "tap: double");
if (s_tap_handler != NULL) {
s_tap_handler(BMA456_TAP_DOUBLE, s_tap_handler_ctx);
}
} else if (tap_out.triple_tap) {
ESP_LOGI(TAG, "tap: triple");
if (s_tap_handler != NULL) {
s_tap_handler(BMA456_TAP_TRIPLE, s_tap_handler_ctx);
}
}
}

View File

@ -35,6 +35,18 @@ uint32_t bma456_get_accel_deadzone(void);
/** Log accel when any axis moved more than deadzone since last reported sample. */
void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z);
/** Tap kinds from BMA456H multitap output. */
typedef enum {
BMA456_TAP_SINGLE = 1,
BMA456_TAP_DOUBLE = 2,
BMA456_TAP_TRIPLE = 3,
} bma456_tap_kind_t;
typedef void (*bma456_tap_handler_t)(bma456_tap_kind_t kind, void *ctx);
/** Optional callback invoked from sensor task on tap interrupt (may be NULL). */
void bma456_set_tap_handler(bma456_tap_handler_t handler, void *ctx);
/** On-demand read of current accel XYZ (raw LSB). Returns ESP_ERR_INVALID_STATE if sensor not ready. */
esp_err_t bma456_read_accel(int16_t *x, int16_t *y, int16_t *z);

View File

@ -257,6 +257,31 @@ static void clear_client_accel(client_slot_t *slot) {
slot->info.accel_updated_at = 0;
}
static void clear_client_tap(client_slot_t *slot) {
if (slot == NULL) {
return;
}
slot->info.tap_valid = false;
slot->info.tap_kind = 0;
slot->info.tap_updated_at = 0;
}
static bool tap_kind_enabled(const client_info_t *info, uint32_t kind) {
if (info == NULL) {
return false;
}
switch (kind) {
case 1:
return info->tap_notify_single;
case 2:
return info->tap_notify_double;
case 3:
return info->tap_notify_triple;
default:
return false;
}
}
esp_err_t client_registry_set_accel_stream(uint32_t client_id, bool enabled) {
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (!s_clients[i].active || s_clients[i].info.id != client_id) {
@ -325,6 +350,124 @@ esp_err_t client_registry_update_accel(const uint8_t mac[CLIENT_MAC_LEN],
return ESP_OK;
}
esp_err_t client_registry_set_tap_notify(uint32_t client_id, bool single,
bool double_tap, bool triple) {
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (!s_clients[i].active || s_clients[i].info.id != client_id) {
continue;
}
s_clients[i].info.tap_notify_single = single;
s_clients[i].info.tap_notify_double = double_tap;
s_clients[i].info.tap_notify_triple = triple;
if (!single && !double_tap && !triple) {
clear_client_tap(&s_clients[i]);
}
return ESP_OK;
}
return ESP_ERR_NOT_FOUND;
}
esp_err_t client_registry_get_tap_notify(uint32_t client_id, bool *single_out,
bool *double_tap_out,
bool *triple_out) {
if (single_out == NULL || double_tap_out == NULL || triple_out == NULL) {
return ESP_ERR_INVALID_ARG;
}
const client_info_t *info = client_registry_find_by_id(client_id);
if (info == NULL) {
return ESP_ERR_NOT_FOUND;
}
*single_out = info->tap_notify_single;
*double_tap_out = info->tap_notify_double;
*triple_out = info->tap_notify_triple;
return ESP_OK;
}
size_t client_registry_set_tap_notify_all(bool single, bool double_tap,
bool triple) {
size_t n = 0;
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (!s_clients[i].active) {
continue;
}
s_clients[i].info.tap_notify_single = single;
s_clients[i].info.tap_notify_double = double_tap;
s_clients[i].info.tap_notify_triple = triple;
if (!single && !double_tap && !triple) {
clear_client_tap(&s_clients[i]);
}
n++;
}
return n;
}
esp_err_t client_registry_update_tap(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t slave_id, uint32_t kind) {
if (mac == NULL || kind < 1 || kind > 3) {
return ESP_ERR_INVALID_ARG;
}
client_slot_t *slot = find_slot(mac);
if (slot == NULL) {
return ESP_ERR_NOT_FOUND;
}
if (slot->info.id != slave_id) {
return ESP_ERR_INVALID_ARG;
}
if (!tap_kind_enabled(&slot->info, kind)) {
return ESP_ERR_INVALID_STATE;
}
slot->info.tap_kind = kind;
slot->info.tap_valid = true;
slot->info.tap_updated_at = now_ms();
return ESP_OK;
}
void client_registry_expire_tap(client_info_t *info) {
if (info == NULL || !info->tap_valid) {
return;
}
if (client_registry_ms_since(info->tap_updated_at) >
CLIENT_REGISTRY_TAP_MAX_AGE_MS) {
info->tap_valid = false;
info->tap_kind = 0;
info->tap_updated_at = 0;
}
}
void client_registry_clear_tap(client_info_t *info) {
if (info == NULL) {
return;
}
info->tap_valid = false;
info->tap_kind = 0;
info->tap_updated_at = 0;
}
bool client_registry_take_tap(uint32_t client_id, uint32_t *kind_out,
uint32_t *age_ms_out) {
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (!s_clients[i].active || s_clients[i].info.id != client_id) {
continue;
}
client_info_t *info = &s_clients[i].info;
client_registry_expire_tap(info);
if (!info->tap_valid) {
return false;
}
if (kind_out != NULL) {
*kind_out = info->tap_kind;
}
if (age_ms_out != NULL) {
*age_ms_out = client_registry_ms_since(info->tap_updated_at);
}
client_registry_clear_tap(info);
return true;
}
return false;
}
void client_registry_set_master_battery(const board_lipo_reading_t *reading) {
if (reading == NULL) {
return;

View File

@ -30,6 +30,14 @@ typedef struct {
uint32_t accel_updated_at;
/** Host-enabled ESP-NOW accel stream to master. */
bool accel_stream_enabled;
/** Host-enabled ESP-NOW tap notify flags. */
bool tap_notify_single;
bool tap_notify_double;
bool tap_notify_triple;
/** Latest tap from slave ESP-NOW (master only, short-lived cache). */
bool tap_valid;
uint32_t tap_kind;
uint32_t tap_updated_at;
/** Latest LiPo ADC from slave ESP-NOW battery report (~30 s). */
bool lipo1_valid;
bool lipo2_valid;
@ -39,6 +47,8 @@ typedef struct {
} client_info_t;
#define CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE 100u
/** Tap events older than this are discarded (matches accel stream interval). */
#define CLIENT_REGISTRY_TAP_MAX_AGE_MS 16u
void client_registry_init(void);
@ -87,6 +97,31 @@ esp_err_t client_registry_set_accel_stream(uint32_t client_id, bool enabled);
esp_err_t client_registry_get_accel_stream(uint32_t client_id, bool *enabled_out);
size_t client_registry_set_accel_stream_all(bool enabled);
esp_err_t client_registry_set_tap_notify(uint32_t client_id, bool single,
bool double_tap, bool triple);
esp_err_t client_registry_get_tap_notify(uint32_t client_id, bool *single_out,
bool *double_tap_out,
bool *triple_out);
size_t client_registry_set_tap_notify_all(bool single, bool double_tap,
bool triple);
/** Store tap event from slave (matched by sender MAC). kind: 1=single, 2=double, 3=triple. */
esp_err_t client_registry_update_tap(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t slave_id, uint32_t kind);
/** Drop cached tap if older than CLIENT_REGISTRY_TAP_MAX_AGE_MS. */
void client_registry_expire_tap(client_info_t *info);
/** Clear cached tap after UART snapshot or expiry. */
void client_registry_clear_tap(client_info_t *info);
/**
* If client has a fresh tap (age <= CLIENT_REGISTRY_TAP_MAX_AGE_MS), copy it out
* and clear the cache. Returns true when an event was returned.
*/
bool client_registry_take_tap(uint32_t client_id, uint32_t *kind_out,
uint32_t *age_ms_out);
/** Master local LiPo (client_id 0 in UART battery responses). */
void client_registry_set_master_battery(const board_lipo_reading_t *reading);
bool client_registry_get_master_battery(board_lipo_reading_t *reading_out,

View File

@ -28,6 +28,9 @@ static bool encode_clients_list(pb_ostream_t *stream, const pb_field_t *field,
client_registry_ms_since(client->last_success_ping_at);
proto.version = client->version;
proto.accel_stream_enabled = client->accel_stream_enabled;
proto.tap_notify_single = client->tap_notify_single;
proto.tap_notify_double = client->tap_notify_double;
proto.tap_notify_triple = client->tap_notify_triple;
proto.mac.funcs.encode = uart_cmd_encode_bytes;
proto.mac.arg = &mac;

View File

@ -54,6 +54,10 @@ static const char *message_type_name(uint16_t id) {
return "ACCEL_STREAM";
case alox_MessageType_BATTERY_STATUS:
return "BATTERY_STATUS";
case alox_MessageType_TAP_NOTIFY:
return "TAP_NOTIFY";
case alox_MessageType_TAP_SNAPSHOT:
return "TAP_SNAPSHOT";
default:
return "UNKNOWN";
}

110
main/cmd/cmd_tap_notify.c Normal file
View File

@ -0,0 +1,110 @@
#include "client_registry.h"
#include "cmd_tap_notify.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "uart_cmd.h"
static const char *TAG = "[TAP_NOTIFY]";
static void reply(uint32_t client_id, bool success, uint32_t slaves_updated,
bool single, bool double_tap, bool triple) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_TAP_NOTIFY,
alox_UartMessage_tap_notify_response_tag);
response.payload.tap_notify_response.client_id = client_id;
response.payload.tap_notify_response.success = success;
response.payload.tap_notify_response.slaves_updated = slaves_updated;
response.payload.tap_notify_response.single = single;
response.payload.tap_notify_response.double_tap = double_tap;
response.payload.tap_notify_response.triple = triple;
uart_cmd_send(&response, TAG);
}
static esp_err_t push_tap_notify_to_slave(const client_info_t *client,
bool single, bool double_tap,
bool triple) {
if (client == NULL) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err =
client_registry_set_tap_notify(client->id, single, double_tap, triple);
if (err != ESP_OK) {
return err;
}
return esp_now_comm_send_tap_notify(client->mac, client->id, single,
double_tap, triple);
}
static void handle_tap_notify(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
alox_TapNotifyRequest req = alox_TapNotifyRequest_init_zero;
if (uart_cmd_decode(data, len, &uart_msg) == ESP_OK) {
const alox_TapNotifyRequest *req_ptr = UART_CMD_REQ(
&uart_msg, alox_UartMessage_tap_notify_request_tag, tap_notify_request);
if (req_ptr != NULL) {
req = *req_ptr;
}
}
if (req.write) {
if (req.all_clients) {
size_t n = client_registry_set_tap_notify_all(req.single, req.double_tap,
req.triple);
uint32_t sent = 0;
for (size_t i = 0; i < client_registry_count(); i++) {
const client_info_t *client = client_registry_at(i);
if (client == NULL) {
continue;
}
if (esp_now_comm_send_tap_notify(client->mac, client->id, req.single,
req.double_tap,
req.triple) == ESP_OK) {
sent++;
}
}
ESP_LOGI(TAG, "tap notify single=%d double=%d triple=%d for %u/%u slaves",
req.single, req.double_tap, req.triple, (unsigned)sent,
(unsigned)n);
reply(0, sent > 0, sent, req.single, req.double_tap, req.triple);
return;
}
if (req.client_id == 0) {
ESP_LOGW(TAG, "client_id required (or all_clients)");
reply(0, false, 0, req.single, req.double_tap, req.triple);
return;
}
const client_info_t *client = client_registry_find_by_id(req.client_id);
if (client == NULL) {
ESP_LOGW(TAG, "client id %lu not found", (unsigned long)req.client_id);
reply(req.client_id, false, 0, req.single, req.double_tap, req.triple);
return;
}
esp_err_t err =
push_tap_notify_to_slave(client, req.single, req.double_tap, req.triple);
reply(req.client_id, err == ESP_OK, err == ESP_OK ? 1u : 0u, req.single,
req.double_tap, req.triple);
return;
}
if (req.all_clients || req.client_id == 0) {
reply(0, false, 0, false, false, false);
return;
}
bool single = false;
bool double_tap = false;
bool triple = false;
esp_err_t err = client_registry_get_tap_notify(req.client_id, &single,
&double_tap, &triple);
reply(req.client_id, err == ESP_OK, 0, single, double_tap, triple);
}
void cmd_tap_notify_register(void) {
uart_cmd_register(alox_MessageType_TAP_NOTIFY, handle_tap_notify);
}

View File

@ -0,0 +1,6 @@
#ifndef CMD_TAP_NOTIFY_H
#define CMD_TAP_NOTIFY_H
void cmd_tap_notify_register(void);
#endif

View File

@ -0,0 +1,88 @@
#include "client_registry.h"
#include "cmd_tap_snapshot.h"
#include "uart_cmd.h"
static const char *TAG = "[TAP_SNAP]";
static alox_TapKind tap_kind_from_registry(uint32_t kind) {
switch (kind) {
case 1:
return alox_TapKind_TAP_SINGLE;
case 2:
return alox_TapKind_TAP_DOUBLE;
case 3:
return alox_TapKind_TAP_TRIPLE;
default:
return alox_TapKind_TAP_NONE;
}
}
static bool tap_notify_any(const client_info_t *client) {
return client != NULL &&
(client->tap_notify_single || client->tap_notify_double ||
client->tap_notify_triple);
}
static void fill_tap_snapshot(alox_TapSnapshotResponse *out,
uint32_t filter_client_id) {
if (out == NULL) {
return;
}
out->events_count = 0;
size_t count = client_registry_count();
for (size_t i = 0; i < count; i++) {
const client_info_t *client = client_registry_at(i);
if (client == NULL) {
continue;
}
if (filter_client_id != 0 && client->id != filter_client_id) {
continue;
}
if (!tap_notify_any(client)) {
continue;
}
if (out->events_count >= sizeof(out->events) / sizeof(out->events[0])) {
break;
}
uint32_t kind = 0;
uint32_t age_ms = 0;
if (!client_registry_take_tap(client->id, &kind, &age_ms)) {
continue;
}
alox_TapEvent *event = &out->events[out->events_count++];
event->client_id = client->id;
event->valid = true;
event->kind = tap_kind_from_registry(kind);
event->age_ms = age_ms;
}
}
static void handle_tap_snapshot(const uint8_t *data, size_t len) {
uint32_t filter_client_id = 0;
if (len > 0) {
alox_UartMessage req;
if (uart_cmd_decode(data, len, &req) == ESP_OK) {
alox_TapSnapshotRequest *snap_req = UART_CMD_REQ(
&req, alox_UartMessage_tap_snapshot_request_tag, tap_snapshot_request);
if (snap_req != NULL) {
filter_client_id = snap_req->client_id;
}
}
}
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_TAP_SNAPSHOT,
alox_UartMessage_tap_snapshot_response_tag);
fill_tap_snapshot(&response.payload.tap_snapshot_response, filter_client_id);
uart_cmd_send(&response, TAG);
}
void cmd_tap_snapshot_register(void) {
uart_cmd_register(alox_MessageType_TAP_SNAPSHOT, handle_tap_snapshot);
}

View File

@ -0,0 +1,6 @@
#ifndef CMD_TAP_SNAPSHOT_H
#define CMD_TAP_SNAPSHOT_H
void cmd_tap_snapshot_register(void);
#endif

View File

@ -43,6 +43,9 @@ static uint8_t s_wifi_channel;
static uint8_t s_own_mac[ESP_NOW_ETH_ALEN];
static bool s_slave_joined;
static bool s_accel_stream_enabled;
static bool s_tap_notify_single;
static bool s_tap_notify_double;
static bool s_tap_notify_triple;
static uint8_t s_master_mac[ESP_NOW_ETH_ALEN];
static uint32_t s_last_discover_ms;
@ -138,6 +141,28 @@ static esp_err_t send_accel_sample(const uint8_t *dest_mac, uint32_t slave_id,
return send_message(dest_mac, &msg);
}
static esp_err_t send_tap_event(const uint8_t *dest_mac, uint32_t slave_id,
uint32_t kind) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_TAP_EVENT;
msg.which_payload = alox_EspNowMessage_tap_event_tag;
msg.payload.tap_event.slave_id = slave_id;
msg.payload.tap_event.kind = kind;
return send_message(dest_mac, &msg);
}
static esp_err_t send_tap_notify(const uint8_t *dest_mac, uint32_t client_id,
bool single, bool double_tap, bool triple) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_SET_TAP_NOTIFY;
msg.which_payload = alox_EspNowMessage_tap_notify_tag;
msg.payload.tap_notify.client_id = client_id;
msg.payload.tap_notify.single = single;
msg.payload.tap_notify.double_tap = double_tap;
msg.payload.tap_notify.triple = triple;
return send_message(dest_mac, &msg);
}
static esp_err_t send_message_ex(const uint8_t *dest_mac,
const alox_EspNowMessage *msg, bool wait_done) {
uint8_t buf[ESPNOW_PB_MAX_SIZE];
@ -455,6 +480,29 @@ esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
return err;
}
esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool single,
bool double_tap, bool triple) {
if (mac == NULL || !s_config.master) {
return ESP_ERR_INVALID_STATE;
}
char mac_str[18];
mac_to_str(mac, mac_str, sizeof(mac_str));
esp_err_t err =
send_tap_notify(mac, client_id, single, double_tap, triple);
if (err == ESP_OK) {
ESP_LOGI(TAG,
"unicast SET_TAP_NOTIFY to %s: single=%d double=%d triple=%d "
"client_id=%lu",
mac_str, single, double_tap, triple, (unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast SET_TAP_NOTIFY to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, uint32_t deadzone) {
if (mac == NULL || !s_config.master) {
@ -705,6 +753,30 @@ static void handle_slave_accel_stream(const uint8_t *master_mac,
cfg->enable ? "on" : "off", mac_str, (unsigned long)my_id);
}
static void handle_slave_tap_notify(const uint8_t *master_mac,
const alox_EspNowTapNotify *cfg) {
uint32_t my_id = s_own_mac[5];
if (cfg->client_id != 0 && cfg->client_id != my_id) {
return;
}
if (s_slave_joined && !mac_equal(master_mac, s_master_mac)) {
return;
}
s_tap_notify_single = cfg->single;
s_tap_notify_double = cfg->double_tap;
s_tap_notify_triple = cfg->triple;
char mac_str[18];
mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG,
"tap notify single=%d double=%d triple=%d from master %s (id=%lu)",
cfg->single, cfg->double_tap, cfg->triple, mac_str,
(unsigned long)my_id);
}
static void handle_slave_accel_deadzone(const uint8_t *master_mac,
const alox_EspNowAccelDeadzone *cfg) {
uint32_t my_id = s_own_mac[5];
@ -749,6 +821,24 @@ static void handle_master_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
}
}
static void handle_master_tap_event(const uint8_t mac[CLIENT_MAC_LEN],
const alox_EspNowTapEvent *event) {
if (event == NULL) {
return;
}
esp_err_t err =
client_registry_update_tap(mac, event->slave_id, event->kind);
if (err == ESP_ERR_NOT_FOUND) {
return;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "tap event id=%lu kind=%lu rejected from %02x:…:%02x",
(unsigned long)event->slave_id, (unsigned long)event->kind, mac[0],
mac[5]);
}
}
static void handle_client_presence(const alox_EspNowSlavePresence *presence,
const uint8_t mac[CLIENT_MAC_LEN]) {
if (presence->network != s_config.network) {
@ -855,6 +945,34 @@ static void slave_accel_stream_task(void *param) {
}
}
static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
(void)ctx;
if (!s_slave_joined) {
return;
}
bool enabled = false;
switch (kind) {
case BMA456_TAP_SINGLE:
enabled = s_tap_notify_single;
break;
case BMA456_TAP_DOUBLE:
enabled = s_tap_notify_double;
break;
case BMA456_TAP_TRIPLE:
enabled = s_tap_notify_triple;
break;
default:
return;
}
if (!enabled) {
return;
}
(void)send_tap_event(s_master_mac, s_own_mac[5], (uint32_t)kind);
}
static void slave_heartbeat_task(void *param) {
(void)param;
uint32_t last_battery_ms = 0;
@ -942,6 +1060,12 @@ static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data,
}
handle_slave_accel_stream(info->src_addr, &msg.payload.accel_stream);
break;
case alox_EspNowMessage_tap_notify_tag:
if (!s_slave_joined || !mac_equal(info->src_addr, s_master_mac)) {
break;
}
handle_slave_tap_notify(info->src_addr, &msg.payload.tap_notify);
break;
case alox_EspNowMessage_battery_query_tag:
if (!s_slave_joined || !mac_equal(info->src_addr, s_master_mac)) {
break;
@ -1007,6 +1131,12 @@ static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data,
return;
}
if (msg.which_payload == alox_EspNowMessage_tap_event_tag) {
ensure_peer(info->src_addr);
handle_master_tap_event(info->src_addr, &msg.payload.tap_event);
return;
}
if (msg.which_payload == alox_EspNowMessage_battery_report_tag) {
ensure_peer(info->src_addr);
handle_master_battery_report(info->src_addr, &msg.payload.battery_report);
@ -1133,6 +1263,7 @@ esp_err_t esp_now_comm_init(const app_config_t *config) {
ESP_LOGE(TAG, "failed to create accel stream task");
return ESP_FAIL;
}
bma456_set_tap_handler(on_bma456_tap, NULL);
}
return ESP_OK;

View File

@ -13,6 +13,11 @@ esp_err_t esp_now_comm_init(const app_config_t *config);
esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool enable);
/** Master: configure tap notify flags on one slave. */
esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool single,
bool double_tap, bool triple);
/** Master: unicast accel deadzone to one slave (client_id is echoed for filtering). */
esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, uint32_t deadzone);

View File

@ -3,6 +3,8 @@
#include "cmd_accel_deadzone.h"
#include "cmd_accel_snapshot.h"
#include "cmd_accel_stream.h"
#include "cmd_tap_notify.h"
#include "cmd_tap_snapshot.h"
#include "cmd_espnow_unicast_test.h"
#include "cmd_espnow_find_me.h"
#include "cmd_restart.h"
@ -182,6 +184,8 @@ void app_main(void) {
cmd_accel_deadzone_register();
cmd_accel_snapshot_register();
cmd_accel_stream_register();
cmd_tap_notify_register();
cmd_tap_snapshot_register();
cmd_espnow_unicast_test_register();
cmd_espnow_find_me_register();
cmd_restart_register();

View File

@ -33,6 +33,12 @@ PB_BIND(alox_EspNowAccelSample, alox_EspNowAccelSample, AUTO)
PB_BIND(alox_EspNowBatteryQuery, alox_EspNowBatteryQuery, AUTO)
PB_BIND(alox_EspNowTapNotify, alox_EspNowTapNotify, AUTO)
PB_BIND(alox_EspNowTapEvent, alox_EspNowTapEvent, AUTO)
PB_BIND(alox_EspNowBatteryReport, alox_EspNowBatteryReport, AUTO)

View File

@ -27,7 +27,9 @@ typedef enum _alox_EspNowMessageType {
alox_EspNowMessageType_ESPNOW_SET_ACCEL_STREAM = 13,
alox_EspNowMessageType_ESPNOW_LED_RING = 14,
alox_EspNowMessageType_ESPNOW_BATTERY_QUERY = 15,
alox_EspNowMessageType_ESPNOW_BATTERY_REPORT = 16
alox_EspNowMessageType_ESPNOW_BATTERY_REPORT = 16,
alox_EspNowMessageType_ESPNOW_SET_TAP_NOTIFY = 17,
alox_EspNowMessageType_ESPNOW_TAP_EVENT = 18
} alox_EspNowMessageType;
/* Struct definitions */
@ -83,6 +85,21 @@ typedef struct _alox_EspNowBatteryQuery {
uint32_t client_id;
} alox_EspNowBatteryQuery;
/* * Master → slave: which tap kinds should be reported via ESP-NOW. */
typedef struct _alox_EspNowTapNotify {
uint32_t client_id;
bool single;
bool double_tap;
bool triple;
} alox_EspNowTapNotify;
/* * Slave → master: tap detected on BMA456 (event, not periodic). */
typedef struct _alox_EspNowTapEvent {
uint32_t slave_id;
/* * 1=single, 2=double, 3=triple */
uint32_t kind;
} alox_EspNowTapEvent;
/* * Slave → master: LiPo voltages (periodic ~30 s and on query). */
typedef struct _alox_EspNowBatteryReport {
uint32_t client_id;
@ -150,6 +167,8 @@ typedef struct _alox_EspNowMessage {
alox_EspNowLedRing led_ring;
alox_EspNowBatteryQuery battery_query;
alox_EspNowBatteryReport battery_report;
alox_EspNowTapNotify tap_notify;
alox_EspNowTapEvent tap_event;
} payload;
} alox_EspNowMessage;
@ -160,8 +179,10 @@ extern "C" {
/* Helper constants for enums */
#define _alox_EspNowMessageType_MIN alox_EspNowMessageType_ESPNOW_UNKNOWN
#define _alox_EspNowMessageType_MAX alox_EspNowMessageType_ESPNOW_BATTERY_REPORT
#define _alox_EspNowMessageType_ARRAYSIZE ((alox_EspNowMessageType)(alox_EspNowMessageType_ESPNOW_BATTERY_REPORT+1))
#define _alox_EspNowMessageType_MAX alox_EspNowMessageType_ESPNOW_TAP_EVENT
#define _alox_EspNowMessageType_ARRAYSIZE ((alox_EspNowMessageType)(alox_EspNowMessageType_ESPNOW_TAP_EVENT+1))
@ -191,6 +212,8 @@ extern "C" {
#define alox_EspNowAccelStream_init_default {0, 0}
#define alox_EspNowAccelSample_init_default {0, 0, 0, 0}
#define alox_EspNowBatteryQuery_init_default {0}
#define alox_EspNowTapNotify_init_default {0, 0, 0, 0}
#define alox_EspNowTapEvent_init_default {0, 0}
#define alox_EspNowBatteryReport_init_default {0, 0, 0, 0, 0}
#define alox_EspNowLedRing_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_EspNowOtaStart_init_default {0}
@ -207,6 +230,8 @@ extern "C" {
#define alox_EspNowAccelStream_init_zero {0, 0}
#define alox_EspNowAccelSample_init_zero {0, 0, 0, 0}
#define alox_EspNowBatteryQuery_init_zero {0}
#define alox_EspNowTapNotify_init_zero {0, 0, 0, 0}
#define alox_EspNowTapEvent_init_zero {0, 0}
#define alox_EspNowBatteryReport_init_zero {0, 0, 0, 0, 0}
#define alox_EspNowLedRing_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_EspNowOtaStart_init_zero {0}
@ -235,6 +260,12 @@ extern "C" {
#define alox_EspNowAccelSample_y_tag 3
#define alox_EspNowAccelSample_z_tag 4
#define alox_EspNowBatteryQuery_client_id_tag 1
#define alox_EspNowTapNotify_client_id_tag 1
#define alox_EspNowTapNotify_single_tag 2
#define alox_EspNowTapNotify_double_tap_tag 3
#define alox_EspNowTapNotify_triple_tag 4
#define alox_EspNowTapEvent_slave_id_tag 1
#define alox_EspNowTapEvent_kind_tag 2
#define alox_EspNowBatteryReport_client_id_tag 1
#define alox_EspNowBatteryReport_lipo1_valid_tag 2
#define alox_EspNowBatteryReport_lipo2_valid_tag 3
@ -273,6 +304,8 @@ extern "C" {
#define alox_EspNowMessage_led_ring_tag 15
#define alox_EspNowMessage_battery_query_tag 16
#define alox_EspNowMessage_battery_report_tag 17
#define alox_EspNowMessage_tap_notify_tag 18
#define alox_EspNowMessage_tap_event_tag 19
/* Struct field encoding specification for nanopb */
#define alox_EspNowUnicastTest_FIELDLIST(X, a) \
@ -330,6 +363,20 @@ X(a, STATIC, SINGULAR, UINT32, client_id, 1)
#define alox_EspNowBatteryQuery_CALLBACK NULL
#define alox_EspNowBatteryQuery_DEFAULT NULL
#define alox_EspNowTapNotify_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, single, 2) \
X(a, STATIC, SINGULAR, BOOL, double_tap, 3) \
X(a, STATIC, SINGULAR, BOOL, triple, 4)
#define alox_EspNowTapNotify_CALLBACK NULL
#define alox_EspNowTapNotify_DEFAULT NULL
#define alox_EspNowTapEvent_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, slave_id, 1) \
X(a, STATIC, SINGULAR, UINT32, kind, 2)
#define alox_EspNowTapEvent_CALLBACK NULL
#define alox_EspNowTapEvent_DEFAULT NULL
#define alox_EspNowBatteryReport_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, lipo1_valid, 2) \
@ -393,7 +440,9 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,accel_sample,payload.accel_sample),
X(a, STATIC, ONEOF, MESSAGE, (payload,accel_stream,payload.accel_stream), 14) \
X(a, STATIC, ONEOF, MESSAGE, (payload,led_ring,payload.led_ring), 15) \
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_query,payload.battery_query), 16) \
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_report,payload.battery_report), 17)
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_report,payload.battery_report), 17) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_notify,payload.tap_notify), 18) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_event,payload.tap_event), 19)
#define alox_EspNowMessage_CALLBACK NULL
#define alox_EspNowMessage_DEFAULT NULL
#define alox_EspNowMessage_payload_discover_MSGTYPE alox_EspNowDiscover
@ -412,6 +461,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,battery_report,payload.battery_repor
#define alox_EspNowMessage_payload_led_ring_MSGTYPE alox_EspNowLedRing
#define alox_EspNowMessage_payload_battery_query_MSGTYPE alox_EspNowBatteryQuery
#define alox_EspNowMessage_payload_battery_report_MSGTYPE alox_EspNowBatteryReport
#define alox_EspNowMessage_payload_tap_notify_MSGTYPE alox_EspNowTapNotify
#define alox_EspNowMessage_payload_tap_event_MSGTYPE alox_EspNowTapEvent
extern const pb_msgdesc_t alox_EspNowUnicastTest_msg;
extern const pb_msgdesc_t alox_EspNowFindMe_msg;
@ -422,6 +473,8 @@ extern const pb_msgdesc_t alox_EspNowAccelDeadzone_msg;
extern const pb_msgdesc_t alox_EspNowAccelStream_msg;
extern const pb_msgdesc_t alox_EspNowAccelSample_msg;
extern const pb_msgdesc_t alox_EspNowBatteryQuery_msg;
extern const pb_msgdesc_t alox_EspNowTapNotify_msg;
extern const pb_msgdesc_t alox_EspNowTapEvent_msg;
extern const pb_msgdesc_t alox_EspNowBatteryReport_msg;
extern const pb_msgdesc_t alox_EspNowLedRing_msg;
extern const pb_msgdesc_t alox_EspNowOtaStart_msg;
@ -440,6 +493,8 @@ extern const pb_msgdesc_t alox_EspNowMessage_msg;
#define alox_EspNowAccelStream_fields &alox_EspNowAccelStream_msg
#define alox_EspNowAccelSample_fields &alox_EspNowAccelSample_msg
#define alox_EspNowBatteryQuery_fields &alox_EspNowBatteryQuery_msg
#define alox_EspNowTapNotify_fields &alox_EspNowTapNotify_msg
#define alox_EspNowTapEvent_fields &alox_EspNowTapEvent_msg
#define alox_EspNowBatteryReport_fields &alox_EspNowBatteryReport_msg
#define alox_EspNowLedRing_fields &alox_EspNowLedRing_msg
#define alox_EspNowOtaStart_fields &alox_EspNowOtaStart_msg
@ -465,6 +520,8 @@ extern const pb_msgdesc_t alox_EspNowMessage_msg;
#define alox_EspNowOtaStart_size 6
#define alox_EspNowOtaStatus_size 18
#define alox_EspNowRestart_size 6
#define alox_EspNowTapEvent_size 12
#define alox_EspNowTapNotify_size 12
#define alox_EspNowUnicastTest_size 6
#ifdef __cplusplus

View File

@ -22,6 +22,8 @@ enum EspNowMessageType {
ESPNOW_LED_RING = 14;
ESPNOW_BATTERY_QUERY = 15;
ESPNOW_BATTERY_REPORT = 16;
ESPNOW_SET_TAP_NOTIFY = 17;
ESPNOW_TAP_EVENT = 18;
}
message EspNowUnicastTest {
@ -76,6 +78,21 @@ message EspNowBatteryQuery {
uint32 client_id = 1;
}
/** Master → slave: which tap kinds should be reported via ESP-NOW. */
message EspNowTapNotify {
uint32 client_id = 1;
bool single = 2;
bool double_tap = 3;
bool triple = 4;
}
/** Slave → master: tap detected on BMA456 (event, not periodic). */
message EspNowTapEvent {
uint32 slave_id = 1;
/** 1=single, 2=double, 3=triple */
uint32 kind = 2;
}
/** Slave → master: LiPo voltages (periodic ~30 s and on query). */
message EspNowBatteryReport {
uint32 client_id = 1;
@ -139,5 +156,7 @@ message EspNowMessage {
EspNowLedRing led_ring = 15;
EspNowBatteryQuery battery_query = 16;
EspNowBatteryReport battery_report = 17;
EspNowTapNotify tap_notify = 18;
EspNowTapEvent tap_event = 19;
}
}

View File

@ -63,6 +63,21 @@ PB_BIND(alox_AccelSample, alox_AccelSample, AUTO)
PB_BIND(alox_AccelSnapshotResponse, alox_AccelSnapshotResponse, 2)
PB_BIND(alox_TapNotifyRequest, alox_TapNotifyRequest, AUTO)
PB_BIND(alox_TapNotifyResponse, alox_TapNotifyResponse, AUTO)
PB_BIND(alox_TapSnapshotRequest, alox_TapSnapshotRequest, AUTO)
PB_BIND(alox_TapEvent, alox_TapEvent, AUTO)
PB_BIND(alox_TapSnapshotResponse, alox_TapSnapshotResponse, 2)
PB_BIND(alox_EspNowUnicastTestRequest, alox_EspNowUnicastTestRequest, AUTO)
@ -111,3 +126,5 @@ PB_BIND(alox_OtaSlaveProgressResponse, alox_OtaSlaveProgressResponse, 2)

View File

@ -30,9 +30,18 @@ typedef enum _alox_MessageType {
alox_MessageType_RESTART = 23,
alox_MessageType_ACCEL_SNAPSHOT = 24,
alox_MessageType_ACCEL_STREAM = 25,
alox_MessageType_BATTERY_STATUS = 26
alox_MessageType_BATTERY_STATUS = 26,
alox_MessageType_TAP_NOTIFY = 27,
alox_MessageType_TAP_SNAPSHOT = 28
} alox_MessageType;
typedef enum _alox_TapKind {
alox_TapKind_TAP_NONE = 0,
alox_TapKind_TAP_SINGLE = 1,
alox_TapKind_TAP_DOUBLE = 2,
alox_TapKind_TAP_TRIPLE = 3
} alox_TapKind;
/* Struct definitions */
typedef struct _alox_Ack {
char dummy_field;
@ -59,6 +68,10 @@ typedef struct _alox_ClientInfo {
uint32_t version;
/* * Master: ESP-NOW accel stream enabled for this slave. */
bool accel_stream_enabled;
/* * Master: ESP-NOW tap notify flags for this slave. */
bool tap_notify_single;
bool tap_notify_double;
bool tap_notify_triple;
} alox_ClientInfo;
typedef struct _alox_ClientInfoResponse {
@ -160,6 +173,42 @@ typedef struct _alox_AccelSnapshotResponse {
alox_AccelSample samples[16];
} alox_AccelSnapshotResponse;
/* * Host → master: enable/disable tap ESP-NOW notify per slave (single/double/triple). */
typedef struct _alox_TapNotifyRequest {
bool write;
uint32_t client_id;
bool all_clients;
bool single;
bool double_tap;
bool triple;
} alox_TapNotifyRequest;
typedef struct _alox_TapNotifyResponse {
uint32_t client_id;
bool success;
uint32_t slaves_updated;
bool single;
bool double_tap;
bool triple;
} alox_TapNotifyResponse;
/* * Host → master: read cached tap events (discarded after reply or when age > 16 ms). */
typedef struct _alox_TapSnapshotRequest {
uint32_t client_id;
} alox_TapSnapshotRequest;
typedef struct _alox_TapEvent {
uint32_t client_id;
bool valid;
alox_TapKind kind;
uint32_t age_ms;
} alox_TapEvent;
typedef struct _alox_TapSnapshotResponse {
pb_size_t events_count;
alox_TapEvent events[16];
} alox_TapSnapshotResponse;
typedef struct _alox_EspNowUnicastTestRequest {
uint32_t client_id;
uint32_t seq;
@ -304,6 +353,10 @@ typedef struct _alox_UartMessage {
alox_AccelStreamResponse accel_stream_response;
alox_BatteryStatusRequest battery_status_request;
alox_BatteryStatusResponse battery_status_response;
alox_TapNotifyRequest tap_notify_request;
alox_TapNotifyResponse tap_notify_response;
alox_TapSnapshotRequest tap_snapshot_request;
alox_TapSnapshotResponse tap_snapshot_response;
} payload;
} alox_UartMessage;
@ -314,8 +367,12 @@ extern "C" {
/* Helper constants for enums */
#define _alox_MessageType_MIN alox_MessageType_UNKNOWN
#define _alox_MessageType_MAX alox_MessageType_BATTERY_STATUS
#define _alox_MessageType_ARRAYSIZE ((alox_MessageType)(alox_MessageType_BATTERY_STATUS+1))
#define _alox_MessageType_MAX alox_MessageType_TAP_SNAPSHOT
#define _alox_MessageType_ARRAYSIZE ((alox_MessageType)(alox_MessageType_TAP_SNAPSHOT+1))
#define _alox_TapKind_MIN alox_TapKind_TAP_NONE
#define _alox_TapKind_MAX alox_TapKind_TAP_TRIPLE
#define _alox_TapKind_ARRAYSIZE ((alox_TapKind)(alox_TapKind_TAP_TRIPLE+1))
#define alox_UartMessage_type_ENUMTYPE alox_MessageType
@ -338,6 +395,12 @@ extern "C" {
#define alox_TapEvent_kind_ENUMTYPE alox_TapKind
@ -358,7 +421,7 @@ extern "C" {
#define alox_Ack_init_default {0}
#define alox_EchoPayload_init_default {{{NULL}, NULL}}
#define alox_VersionResponse_init_default {0, {{NULL}, NULL}, {{NULL}, NULL}}
#define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0}
#define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0, 0, 0, 0}
#define alox_ClientInfoResponse_init_default {{{NULL}, NULL}}
#define alox_ClientInput_init_default {0, 0, 0, 0}
#define alox_ClientInputResponse_init_default {{{NULL}, NULL}}
@ -373,6 +436,11 @@ extern "C" {
#define alox_AccelSnapshotRequest_init_default {0}
#define alox_AccelSample_init_default {0, 0, 0, 0, 0, 0}
#define alox_AccelSnapshotResponse_init_default {0, {alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default, alox_AccelSample_init_default}}
#define alox_TapNotifyRequest_init_default {0, 0, 0, 0, 0, 0}
#define alox_TapNotifyResponse_init_default {0, 0, 0, 0, 0, 0}
#define alox_TapSnapshotRequest_init_default {0}
#define alox_TapEvent_init_default {0, 0, _alox_TapKind_MIN, 0}
#define alox_TapSnapshotResponse_init_default {0, {alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default, alox_TapEvent_init_default}}
#define alox_EspNowUnicastTestRequest_init_default {0, 0}
#define alox_EspNowUnicastTestResponse_init_default {0, 0}
#define alox_LedRingProgressRequest_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
@ -392,7 +460,7 @@ extern "C" {
#define alox_Ack_init_zero {0}
#define alox_EchoPayload_init_zero {{{NULL}, NULL}}
#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}, {{NULL}, NULL}}
#define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0}
#define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0, 0, 0, 0}
#define alox_ClientInfoResponse_init_zero {{{NULL}, NULL}}
#define alox_ClientInput_init_zero {0, 0, 0, 0}
#define alox_ClientInputResponse_init_zero {{{NULL}, NULL}}
@ -407,6 +475,11 @@ extern "C" {
#define alox_AccelSnapshotRequest_init_zero {0}
#define alox_AccelSample_init_zero {0, 0, 0, 0, 0, 0}
#define alox_AccelSnapshotResponse_init_zero {0, {alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero, alox_AccelSample_init_zero}}
#define alox_TapNotifyRequest_init_zero {0, 0, 0, 0, 0, 0}
#define alox_TapNotifyResponse_init_zero {0, 0, 0, 0, 0, 0}
#define alox_TapSnapshotRequest_init_zero {0}
#define alox_TapEvent_init_zero {0, 0, _alox_TapKind_MIN, 0}
#define alox_TapSnapshotResponse_init_zero {0, {alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero, alox_TapEvent_init_zero}}
#define alox_EspNowUnicastTestRequest_init_zero {0, 0}
#define alox_EspNowUnicastTestResponse_init_zero {0, 0}
#define alox_LedRingProgressRequest_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
@ -436,6 +509,9 @@ extern "C" {
#define alox_ClientInfo_last_success_ping_tag 6
#define alox_ClientInfo_version_tag 7
#define alox_ClientInfo_accel_stream_enabled_tag 8
#define alox_ClientInfo_tap_notify_single_tag 9
#define alox_ClientInfo_tap_notify_double_tag 10
#define alox_ClientInfo_tap_notify_triple_tag 11
#define alox_ClientInfoResponse_clients_tag 1
#define alox_ClientInput_id_tag 1
#define alox_ClientInput_lage_x_tag 2
@ -476,6 +552,24 @@ extern "C" {
#define alox_AccelSample_z_tag 5
#define alox_AccelSample_age_ms_tag 6
#define alox_AccelSnapshotResponse_samples_tag 1
#define alox_TapNotifyRequest_write_tag 1
#define alox_TapNotifyRequest_client_id_tag 2
#define alox_TapNotifyRequest_all_clients_tag 3
#define alox_TapNotifyRequest_single_tag 4
#define alox_TapNotifyRequest_double_tap_tag 5
#define alox_TapNotifyRequest_triple_tag 6
#define alox_TapNotifyResponse_client_id_tag 1
#define alox_TapNotifyResponse_success_tag 2
#define alox_TapNotifyResponse_slaves_updated_tag 3
#define alox_TapNotifyResponse_single_tag 4
#define alox_TapNotifyResponse_double_tap_tag 5
#define alox_TapNotifyResponse_triple_tag 6
#define alox_TapSnapshotRequest_client_id_tag 1
#define alox_TapEvent_client_id_tag 1
#define alox_TapEvent_valid_tag 2
#define alox_TapEvent_kind_tag 3
#define alox_TapEvent_age_ms_tag 4
#define alox_TapSnapshotResponse_events_tag 1
#define alox_EspNowUnicastTestRequest_client_id_tag 1
#define alox_EspNowUnicastTestRequest_seq_tag 2
#define alox_EspNowUnicastTestResponse_success_tag 1
@ -550,6 +644,10 @@ extern "C" {
#define alox_UartMessage_accel_stream_response_tag 26
#define alox_UartMessage_battery_status_request_tag 27
#define alox_UartMessage_battery_status_response_tag 28
#define alox_UartMessage_tap_notify_request_tag 29
#define alox_UartMessage_tap_notify_response_tag 30
#define alox_UartMessage_tap_snapshot_request_tag 31
#define alox_UartMessage_tap_snapshot_response_tag 32
/* Struct field encoding specification for nanopb */
#define alox_UartMessage_FIELDLIST(X, a) \
@ -580,7 +678,11 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,accel_snapshot_response,payload.acce
X(a, STATIC, ONEOF, MESSAGE, (payload,accel_stream_request,payload.accel_stream_request), 25) \
X(a, STATIC, ONEOF, MESSAGE, (payload,accel_stream_response,payload.accel_stream_response), 26) \
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_status_request,payload.battery_status_request), 27) \
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_status_response,payload.battery_status_response), 28)
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_status_response,payload.battery_status_response), 28) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_notify_request,payload.tap_notify_request), 29) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_notify_response,payload.tap_notify_response), 30) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_snapshot_request,payload.tap_snapshot_request), 31) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_snapshot_response,payload.tap_snapshot_response), 32)
#define alox_UartMessage_CALLBACK NULL
#define alox_UartMessage_DEFAULT NULL
#define alox_UartMessage_payload_ack_payload_MSGTYPE alox_Ack
@ -610,6 +712,10 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,battery_status_response,payload.batt
#define alox_UartMessage_payload_accel_stream_response_MSGTYPE alox_AccelStreamResponse
#define alox_UartMessage_payload_battery_status_request_MSGTYPE alox_BatteryStatusRequest
#define alox_UartMessage_payload_battery_status_response_MSGTYPE alox_BatteryStatusResponse
#define alox_UartMessage_payload_tap_notify_request_MSGTYPE alox_TapNotifyRequest
#define alox_UartMessage_payload_tap_notify_response_MSGTYPE alox_TapNotifyResponse
#define alox_UartMessage_payload_tap_snapshot_request_MSGTYPE alox_TapSnapshotRequest
#define alox_UartMessage_payload_tap_snapshot_response_MSGTYPE alox_TapSnapshotResponse
#define alox_Ack_FIELDLIST(X, a) \
@ -636,7 +742,10 @@ X(a, CALLBACK, SINGULAR, BYTES, mac, 4) \
X(a, STATIC, SINGULAR, UINT32, last_ping, 5) \
X(a, STATIC, SINGULAR, UINT32, last_success_ping, 6) \
X(a, STATIC, SINGULAR, UINT32, version, 7) \
X(a, STATIC, SINGULAR, BOOL, accel_stream_enabled, 8)
X(a, STATIC, SINGULAR, BOOL, accel_stream_enabled, 8) \
X(a, STATIC, SINGULAR, BOOL, tap_notify_single, 9) \
X(a, STATIC, SINGULAR, BOOL, tap_notify_double, 10) \
X(a, STATIC, SINGULAR, BOOL, tap_notify_triple, 11)
#define alox_ClientInfo_CALLBACK pb_default_field_callback
#define alox_ClientInfo_DEFAULT NULL
@ -742,6 +851,45 @@ X(a, STATIC, REPEATED, MESSAGE, samples, 1)
#define alox_AccelSnapshotResponse_DEFAULT NULL
#define alox_AccelSnapshotResponse_samples_MSGTYPE alox_AccelSample
#define alox_TapNotifyRequest_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, BOOL, write, 1) \
X(a, STATIC, SINGULAR, UINT32, client_id, 2) \
X(a, STATIC, SINGULAR, BOOL, all_clients, 3) \
X(a, STATIC, SINGULAR, BOOL, single, 4) \
X(a, STATIC, SINGULAR, BOOL, double_tap, 5) \
X(a, STATIC, SINGULAR, BOOL, triple, 6)
#define alox_TapNotifyRequest_CALLBACK NULL
#define alox_TapNotifyRequest_DEFAULT NULL
#define alox_TapNotifyResponse_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, success, 2) \
X(a, STATIC, SINGULAR, UINT32, slaves_updated, 3) \
X(a, STATIC, SINGULAR, BOOL, single, 4) \
X(a, STATIC, SINGULAR, BOOL, double_tap, 5) \
X(a, STATIC, SINGULAR, BOOL, triple, 6)
#define alox_TapNotifyResponse_CALLBACK NULL
#define alox_TapNotifyResponse_DEFAULT NULL
#define alox_TapSnapshotRequest_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1)
#define alox_TapSnapshotRequest_CALLBACK NULL
#define alox_TapSnapshotRequest_DEFAULT NULL
#define alox_TapEvent_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, valid, 2) \
X(a, STATIC, SINGULAR, UENUM, kind, 3) \
X(a, STATIC, SINGULAR, UINT32, age_ms, 4)
#define alox_TapEvent_CALLBACK NULL
#define alox_TapEvent_DEFAULT NULL
#define alox_TapSnapshotResponse_FIELDLIST(X, a) \
X(a, STATIC, REPEATED, MESSAGE, events, 1)
#define alox_TapSnapshotResponse_CALLBACK NULL
#define alox_TapSnapshotResponse_DEFAULT NULL
#define alox_TapSnapshotResponse_events_MSGTYPE alox_TapEvent
#define alox_EspNowUnicastTestRequest_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, UINT32, seq, 2)
@ -869,6 +1017,11 @@ extern const pb_msgdesc_t alox_BatteryStatusResponse_msg;
extern const pb_msgdesc_t alox_AccelSnapshotRequest_msg;
extern const pb_msgdesc_t alox_AccelSample_msg;
extern const pb_msgdesc_t alox_AccelSnapshotResponse_msg;
extern const pb_msgdesc_t alox_TapNotifyRequest_msg;
extern const pb_msgdesc_t alox_TapNotifyResponse_msg;
extern const pb_msgdesc_t alox_TapSnapshotRequest_msg;
extern const pb_msgdesc_t alox_TapEvent_msg;
extern const pb_msgdesc_t alox_TapSnapshotResponse_msg;
extern const pb_msgdesc_t alox_EspNowUnicastTestRequest_msg;
extern const pb_msgdesc_t alox_EspNowUnicastTestResponse_msg;
extern const pb_msgdesc_t alox_LedRingProgressRequest_msg;
@ -905,6 +1058,11 @@ extern const pb_msgdesc_t alox_OtaSlaveProgressResponse_msg;
#define alox_AccelSnapshotRequest_fields &alox_AccelSnapshotRequest_msg
#define alox_AccelSample_fields &alox_AccelSample_msg
#define alox_AccelSnapshotResponse_fields &alox_AccelSnapshotResponse_msg
#define alox_TapNotifyRequest_fields &alox_TapNotifyRequest_msg
#define alox_TapNotifyResponse_fields &alox_TapNotifyResponse_msg
#define alox_TapSnapshotRequest_fields &alox_TapSnapshotRequest_msg
#define alox_TapEvent_fields &alox_TapEvent_msg
#define alox_TapSnapshotResponse_fields &alox_TapSnapshotResponse_msg
#define alox_EspNowUnicastTestRequest_fields &alox_EspNowUnicastTestRequest_msg
#define alox_EspNowUnicastTestResponse_fields &alox_EspNowUnicastTestResponse_msg
#define alox_LedRingProgressRequest_fields &alox_LedRingProgressRequest_msg
@ -957,6 +1115,11 @@ extern const pb_msgdesc_t alox_OtaSlaveProgressResponse_msg;
#define alox_OtaStatusPayload_size 24
#define alox_RestartRequest_size 6
#define alox_RestartResponse_size 8
#define alox_TapEvent_size 16
#define alox_TapNotifyRequest_size 16
#define alox_TapNotifyResponse_size 20
#define alox_TapSnapshotRequest_size 6
#define alox_TapSnapshotResponse_size 288
#ifdef __cplusplus
} /* extern "C" */

View File

@ -25,6 +25,8 @@ enum MessageType {
ACCEL_SNAPSHOT = 24;
ACCEL_STREAM = 25;
BATTERY_STATUS = 26;
TAP_NOTIFY = 27;
TAP_SNAPSHOT = 28;
}
message UartMessage {
@ -57,6 +59,10 @@ message UartMessage {
AccelStreamResponse accel_stream_response = 26;
BatteryStatusRequest battery_status_request = 27;
BatteryStatusResponse battery_status_response = 28;
TapNotifyRequest tap_notify_request = 29;
TapNotifyResponse tap_notify_response = 30;
TapSnapshotRequest tap_snapshot_request = 31;
TapSnapshotResponse tap_snapshot_response = 32;
}
}
@ -83,6 +89,10 @@ message ClientInfo {
uint32 version = 7;
/** Master: ESP-NOW accel stream enabled for this slave. */
bool accel_stream_enabled = 8;
/** Master: ESP-NOW tap notify flags for this slave. */
bool tap_notify_single = 9;
bool tap_notify_double = 10;
bool tap_notify_triple = 11;
}
message ClientInfoResponse {
@ -180,6 +190,48 @@ message AccelSnapshotResponse {
repeated AccelSample samples = 1 [(nanopb).max_count = 16];
}
/** Host → master: enable/disable tap ESP-NOW notify per slave (single/double/triple). */
message TapNotifyRequest {
bool write = 1;
uint32 client_id = 2;
bool all_clients = 3;
bool single = 4;
bool double_tap = 5;
bool triple = 6;
}
message TapNotifyResponse {
uint32 client_id = 1;
bool success = 2;
uint32 slaves_updated = 3;
bool single = 4;
bool double_tap = 5;
bool triple = 6;
}
enum TapKind {
TAP_NONE = 0;
TAP_SINGLE = 1;
TAP_DOUBLE = 2;
TAP_TRIPLE = 3;
}
/** Host → master: read cached tap events (discarded after reply or when age > 16 ms). */
message TapSnapshotRequest {
uint32 client_id = 1;
}
message TapEvent {
uint32 client_id = 1;
bool valid = 2;
TapKind kind = 3;
uint32 age_ms = 4;
}
message TapSnapshotResponse {
repeated TapEvent events = 1 [(nanopb).max_count = 16];
}
message EspNowUnicastTestRequest {
uint32 client_id = 1;
uint32 seq = 2;