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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
+491
-231
File diff suppressed because it is too large
Load Diff
@@ -553,6 +553,8 @@
|
||||
<strong>Live-Stream</strong>: UART ≈ <code>CACHE_STATUS</code>-Poll (~16 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;
|
||||
|
||||
Reference in New Issue
Block a user