Add selectable ESP-NOW send path for accel/tap input.

Expose best_effort/fast/wait/reliable via UART, dashboard, and CLI so
loss vs latency can be compared under real multi-pod load.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 22:34:03 +01:00
co-authored by Cursor
parent 178ab46cec
commit 1adaddd6c8
25 changed files with 1178 additions and 255 deletions
+1
View File
@@ -38,6 +38,7 @@ go run . -port /dev/ttyUSB0 clients
| `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) |
| `restart` | 23 | Reboot master or slave (`-client 0` / `>0`) |
| `log-level` | `0x1f` | Get/set master ESP-IDF log level for tag `"*"` (`-set`, `-level` 0–5); output on UART0 debug, not host UART |
| `input-send-mode` | `0x23` | Get/set slave accel/tap ESP-NOW TX path (`-set -mode 0..3`, `-all` or `-client N`): best_effort/fast/wait/reliable |
`clients` requires slaves to have responded to master discover broadcasts first.
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"net/http"
pb "powerpod/gotool/pb"
)
type inputSendModeAPIResponse struct {
Success bool `json:"success"`
Mode uint32 `json:"mode"`
ModeName string `json:"mode_name"`
ClientID uint32 `json:"client_id,omitempty"`
SlavesUpdated uint32 `json:"slaves_updated,omitempty"`
Error string `json:"error,omitempty"`
}
func inputSendModeName(mode pb.InputSendMode) string {
switch mode {
case pb.InputSendMode_INPUT_SEND_BEST_EFFORT:
return "best_effort"
case pb.InputSendMode_INPUT_SEND_FAST:
return "fast"
case pb.InputSendMode_INPUT_SEND_WAIT:
return "wait"
case pb.InputSendMode_INPUT_SEND_RELIABLE:
return "reliable"
default:
return "unknown"
}
}
func mountInputSendModeAPI(mux *http.ServeMux, link *managedSerial) {
mux.HandleFunc("/api/input-send-mode", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
serveInputSendModeGet(w, link)
case http.MethodPut, http.MethodPost:
serveInputSendModePut(w, r, link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
}
func serveInputSendModeGet(w http.ResponseWriter, link *managedSerial) {
resp, err := link.InputSendMode(&pb.InputSendModeRequest{Write: false})
if err != nil {
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, inputSendModeAPIResponse{
Success: resp.GetSuccess(),
Mode: uint32(resp.GetMode()),
ModeName: inputSendModeName(resp.GetMode()),
})
}
func serveInputSendModePut(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body struct {
Mode uint32 `json:"mode"`
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "invalid JSON"})
return
}
if body.Mode > uint32(pb.InputSendMode_INPUT_SEND_RELIABLE) {
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "mode must be 0..3"})
return
}
if !body.AllClients && body.ClientID == 0 {
// Dashboard default: apply to every registered slave.
body.AllClients = true
}
resp, err := link.InputSendMode(&pb.InputSendModeRequest{
Write: true,
Mode: pb.InputSendMode(body.Mode),
ClientId: body.ClientID,
AllClients: body.AllClients,
})
if err != nil {
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
return
}
status := http.StatusOK
if !resp.GetSuccess() {
status = http.StatusBadRequest
}
writeJSON(w, status, inputSendModeAPIResponse{
Success: resp.GetSuccess(),
Mode: uint32(resp.GetMode()),
ModeName: inputSendModeName(resp.GetMode()),
ClientID: resp.GetClientId(),
SlavesUpdated: resp.GetSlavesUpdated(),
})
}
+1
View File
@@ -102,6 +102,7 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
mountBleTestAPI(mux, link)
mountLiveStreamAPI(mux, hub)
mountAccelStreamAPI(mux, link, hub, streamCtl)
mountInputSendModeAPI(mux, link)
mountTapAPI(mux, link, hub, tapCtl)
mountTapConfigAPI(mux, link)
mountLedRingAPI(mux, link)
+8 -4
View File
@@ -156,7 +156,7 @@ func (m *managedSerial) TapNotifyPoll(req *pb.TapNotifyRequest) (*pb.TapNotifyRe
func (m *managedSerial) tapNotifyExchange(poll bool, req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_NOTIFY,
Type: pb.MessageType_TAP_NOTIFY,
Payload: &pb.UartMessage_TapNotifyRequest{TapNotifyRequest: req},
}
body, err := proto.Marshal(msg)
@@ -216,7 +216,7 @@ func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
func (m *managedSerial) accelStreamExchange(poll bool, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_STREAM,
Type: pb.MessageType_ACCEL_STREAM,
Payload: &pb.UartMessage_AccelStreamRequest{AccelStreamRequest: req},
}
body, err := proto.Marshal(msg)
@@ -262,7 +262,7 @@ func (m *managedSerial) TapConfigPoll(req *pb.TapConfigRequest) (*pb.TapConfigRe
func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_CONFIG,
Type: pb.MessageType_TAP_CONFIG,
Payload: &pb.UartMessage_TapConfigRequest{TapConfigRequest: req},
}
body, err := proto.Marshal(msg)
@@ -292,7 +292,7 @@ func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (
func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_DEADZONE,
Type: pb.MessageType_ACCEL_DEADZONE,
Payload: &pb.UartMessage_AccelDeadzoneRequest{AccelDeadzoneRequest: req},
}
body, err := proto.Marshal(msg)
@@ -606,6 +606,10 @@ func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelRe
return hostSetLogLevel(m, write, level)
}
func (m *managedSerial) InputSendMode(req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
return hostInputSendMode(m, req)
}
func (m *managedSerial) Restart(clientID uint32) error {
if err := hostRestart(m, clientID); err != nil {
return err
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"flag"
"fmt"
pb "powerpod/gotool/pb"
)
func runInputSendModeCmd(h hostConn, args []string) error {
fs := flag.NewFlagSet("input-send-mode", flag.ExitOnError)
write := fs.Bool("set", false, "write mode to slave(s)")
mode := fs.Uint("mode", 0, "0=best_effort 1=fast 2=wait 3=reliable")
clientID := fs.Uint("client", 0, "slave client_id (required unless -all)")
all := fs.Bool("all", false, "apply to every registered slave")
_ = fs.Parse(args)
req := &pb.InputSendModeRequest{
Write: *write,
Mode: pb.InputSendMode(*mode),
ClientId: uint32(*clientID),
AllClients: *all,
}
if *write && !*all && *clientID == 0 {
return fmt.Errorf("need -client N or -all when using -set")
}
if *write && *mode > uint(pb.InputSendMode_INPUT_SEND_RELIABLE) {
return fmt.Errorf("mode must be 0..3")
}
r, err := hostInputSendMode(h, req)
if err != nil {
return err
}
fmt.Printf("input-send-mode: success=%v mode=%d (%s) client_id=%d slaves_updated=%d\n",
r.GetSuccess(), r.GetMode(), inputSendModeName(r.GetMode()), r.GetClientId(),
r.GetSlavesUpdated())
if *write && !r.GetSuccess() {
return fmt.Errorf("input-send-mode rejected")
}
return nil
}
+32
View File
@@ -400,6 +400,37 @@ go run . -port /dev/ttyUSB0 log-level -set -level 3
Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**.
### Input send mode (slave accel/tap ESP-NOW TX path)
Debug A/B for how slaves transmit `ACCEL_SAMPLE` / `TAP_EVENT`. Default on slaves is `best_effort` (0). Master remembers the last mode for GET; write pushes via ESP-NOW.
```http
GET /api/input-send-mode
PUT /api/input-send-mode
Content-Type: application/json
{"mode": 2, "all_clients": true}
```
| `mode` | Path |
|--------|------|
| 0 | `best_effort` (fire-and-forget, quiet) |
| 1 | `fast` (fire-and-forget, logs failures) |
| 2 | `wait` (send-done + retries) |
| 3 | `reliable` (OTA-style retries; can stall high-rate accel) |
```json
{"success": true, "mode": 2, "mode_name": "wait", "slaves_updated": 2}
```
**CLI:**
```bash
go run . -port /dev/ttyUSB0 input-send-mode
go run . -port /dev/ttyUSB0 input-send-mode -set -mode 2 -all
```
Dashboard: ESP-NOW Slaves toolbar → **Input-Send** dropdown + **Setzen** / **Lesen**.
### OTA (master host upload, UART or BLE)
```http
@@ -455,5 +486,6 @@ Same as external API:
| Unicast test | `POST /api/unicast-test` |
| Echo ping | `POST /api/echo-ping` (per-slave latency; UI shows Host ms + ESP ms) |
| Master log level | `GET` / `POST /api/log-level` or CLI `log-level` |
| Input-Send mode | `GET` / `PUT /api/input-send-mode` or CLI `input-send-mode` |
| Tap notify S/D/T | `PUT /api/clients/{id}/tap-notify` |
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |
+28 -1
View File
@@ -4,8 +4,8 @@ import (
"fmt"
"time"
pb "powerpod/gotool/pb"
"google.golang.org/protobuf/proto"
pb "powerpod/gotool/pb"
)
// hostConn is the UART or BLE host transport used by CLI commands.
@@ -160,6 +160,33 @@ func hostSetLogLevel(h hostConn, write bool, level uint32) (*pb.SetLogLevelRespo
return r, nil
}
func hostInputSendMode(h hostConn, req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_INPUT_SEND_MODE,
Payload: &pb.UartMessage_InputSendModeRequest{
InputSendModeRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_INPUT_SEND_MODE)}, body...)
respPayload, err := h.exchangePayload(payload, "INPUT_SEND_MODE")
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.GetInputSendModeResponse()
if r == nil {
return nil, fmt.Errorf("missing input_send_mode_response")
}
return r, nil
}
func hostLedRing(h hostConn, req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_LED_RING,
+5 -2
View File
@@ -34,7 +34,8 @@ func usage() {
fmt.Fprintf(os.Stderr, " led-ring set LED ring progress bar (0–100%%, rgb, intensity)\n")
fmt.Fprintf(os.Stderr, " find-me blink LED ring red/green/blue (3× each, full brightness)\n")
fmt.Fprintf(os.Stderr, " restart reboot master or slave (ESP-NOW)\n")
fmt.Fprintf(os.Stderr, " log-level get/set master ESP-IDF log level (global)\n\n")
fmt.Fprintf(os.Stderr, " log-level get/set master ESP-IDF log level (global)\n")
fmt.Fprintf(os.Stderr, " input-send-mode get/set slave accel/tap ESP-NOW send path\n\n")
flag.PrintDefaults()
}
@@ -73,7 +74,7 @@ func main() {
bleName: *bleName,
settle: time.Duration(*bleSettle * float64(time.Second)),
}, flag.Args()[1:])
case "version", "clients", "client-info", "clients-reset-ids", "clients_reset_ids", "deadzone", "accel-deadzone", "tap-config", "tap_config", "tap-notify", "tap_notify", "cache-status", "cache_status", "accel-stream", "accel_stream", "unicast-test", "unicast_test", "echo-ping", "echo_ping", "ble-rtt", "ble_rtt", "ble-stream", "ble_stream", "led-ring", "led_ring", "find-me", "find_me", "restart", "log-level", "log_level", "ota", "ota-progress", "ota_progress":
case "version", "clients", "client-info", "clients-reset-ids", "clients_reset_ids", "deadzone", "accel-deadzone", "tap-config", "tap_config", "tap-notify", "tap_notify", "cache-status", "cache_status", "accel-stream", "accel_stream", "unicast-test", "unicast_test", "echo-ping", "echo_ping", "ble-rtt", "ble_rtt", "ble-stream", "ble_stream", "led-ring", "led_ring", "find-me", "find_me", "restart", "log-level", "log_level", "input-send-mode", "input_send_mode", "ota", "ota-progress", "ota_progress":
if *useBLE && *portName != "" {
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
os.Exit(2)
@@ -179,6 +180,8 @@ func main() {
break
}
runErr = runLogLevel(sp, flag.Args()[1:])
case "input-send-mode", "input_send_mode":
runErr = runInputSendModeCmd(h, flag.Args()[1:])
case "ota":
t, ok := h.(otaTransport)
if !ok {
File diff suppressed because it is too large Load Diff
+71
View File
@@ -553,6 +553,8 @@
<strong>Live-Stream</strong>: UART ≈ <code>CACHE_STATUS</code>-Poll (~16&nbsp;ms); BLE ≈
<code>BLE_STREAM_TICK</code>-Push. Pro Slave <strong>Accel</strong> aktiviert den ESP-NOW-Stream;
Tap-Notify (S/D/T) steuert die Tap-Arten auf dem Slave.
<strong>Input-Send</strong> wählt den ESP-NOW-TX-Pfad für Accel/Tap auf den Slaves
(<code>best_effort</code> Default; <code>wait</code>/<code>reliable</code> blockieren länger).
</p>
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
<button type="button"
@@ -564,6 +566,28 @@
<span class="text-muted small font-monospace" x-show="isBLEHost() && state.live_stream"
x-text="bleTickLabel()"
title="Median Δt zwischen BLE_STREAM_TICK (esp_timestamp_us)"></span>
<label class="text-muted small mb-0" for="input-send-mode">Input-Send:</label>
<select id="input-send-mode" class="form-select form-select-sm"
style="width: auto; min-width: 9rem;"
x-model.number="inputSendMode"
:disabled="busy || !hostUp() || !(state.clients || []).length">
<option :value="0">best_effort</option>
<option :value="1">fast</option>
<option :value="2">wait</option>
<option :value="3">reliable</option>
</select>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setInputSendMode(inputSendMode)"
:disabled="busy || !hostUp() || !(state.clients || []).length"
title="Modus an alle registrierten Slaves pushen">
Setzen
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readInputSendMode()"
:disabled="busy || !hostUp()"
title="Zuletzt gesetzten Modus vom Master lesen">
Lesen
</button>
<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>
@@ -913,6 +937,7 @@
masterDz: 100,
masterDzSeeded: false,
masterLogLevel: 0,
inputSendMode: 0,
masterTapCfg: {
tap_sens_thres: 5,
max_gest_dur: 100,
@@ -1796,6 +1821,52 @@
this.busy = false;
}
},
formatInputSendMode(mode) {
const labels = ['best_effort', 'fast', 'wait', 'reliable'];
if (mode == null || mode < 0 || mode > 3) return '—';
return labels[mode];
},
async readInputSendMode() {
this.busy = true;
try {
const r = await fetch('/api/input-send-mode');
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Input-Send lesen fehlgeschlagen', false);
return;
}
this.inputSendMode = data.mode ?? 0;
this.flash(`Input-Send: ${data.mode_name || this.formatInputSendMode(data.mode)}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setInputSendMode(mode) {
this.busy = true;
try {
const r = await fetch('/api/input-send-mode', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, all_clients: true })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Input-Send setzen fehlgeschlagen', false);
return;
}
this.inputSendMode = data.mode ?? mode;
this.flash(
`Input-Send ${data.mode_name || this.formatInputSendMode(data.mode)} → ${data.slaves_updated || 0} Slaves`,
true
);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
patchClientAccelStream(clientId, enabled) {
const clients = (this.state.clients || []).map((c) => {
if (c.id !== clientId) return c;