Improve dashboard master config and separate slave deadzone updates.

Always show master deadzone input with read/set controls; apply bulk slave
changes via slaves_only without changing the master's BMA456.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
simon 2026-05-19 00:15:35 +02:00
parent 85aeab85c0
commit 80fb9cf55e
3 changed files with 121 additions and 39 deletions

View File

@ -62,8 +62,9 @@ The dashboard can configure nodes using the same UART commands as the CLI:
| UI action | CLI equivalent | | UI action | CLI equivalent |
|-----------|------------------| |-----------|------------------|
| Master / slave deadzone | `deadzone -set -value N -client ID` | | Nur Master | `deadzone -set -value N -client 0` |
| Alle Slaves | `deadzone -set -value N -all` | | Einzelner Slave | `deadzone -set -value N -client ID` |
| Alle Slaves | per-slave ESP-NOW (Master bleibt unverändert; CLI `-all` setzt auch den Master) |
| Unicast test | `unicast-test -client ID` | | Unicast test | `unicast-test -client ID` |
HTTP API (used by the web UI): `GET/POST /api/deadzone`, `POST /api/unicast-test`. HTTP API (used by the web UI): `GET/POST /api/deadzone`, `POST /api/unicast-test`.

View File

@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -21,6 +22,8 @@ type deadzoneAPIRequest struct {
Deadzone uint32 `json:"deadzone"` Deadzone uint32 `json:"deadzone"`
ClientID uint32 `json:"client_id"` ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"` AllClients bool `json:"all_clients"`
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master BMA456 unchanged).
SlavesOnly bool `json:"slaves_only"`
} }
type unicastAPIRequest struct { type unicastAPIRequest struct {
@ -84,12 +87,30 @@ func serveDeadzonePost(w http.ResponseWriter, r *http.Request, link *managedSeri
writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: "invalid JSON"}) writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: "invalid JSON"})
return return
} }
resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{ if body.AllClients && body.SlavesOnly {
updated, err := applyDeadzoneToSlaves(link, body.Deadzone)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, deadzoneAPIResponse{
Deadzone: body.Deadzone,
Success: updated > 0,
SlavesUpdated: updated,
})
return
}
req := &pb.AccelDeadzoneRequest{
Write: true, Write: true,
Deadzone: body.Deadzone, Deadzone: body.Deadzone,
ClientId: body.ClientID, ClientId: body.ClientID,
AllClients: body.AllClients, AllClients: body.AllClients,
}) }
// client_id 0 without all_clients: master BMA456 only (same as CLI -client 0).
resp, err := link.AccelDeadzone(req)
if err != nil { if err != nil {
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{ writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
ClientID: body.ClientID, ClientID: body.ClientID,
@ -105,6 +126,36 @@ func serveDeadzonePost(w http.ResponseWriter, r *http.Request, link *managedSeri
}) })
} }
// applyDeadzoneToSlaves sets deadzone on each registered slave via per-client UART/ESP-NOW.
// Does not change the master's local BMA456 (use client_id 0 for that).
func applyDeadzoneToSlaves(link *managedSerial, deadzone uint32) (uint32, error) {
clients, err := link.listClients()
if err != nil {
return 0, err
}
var updated uint32
for _, c := range clients {
resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
Write: true,
Deadzone: deadzone,
ClientId: c.GetId(),
})
if err != nil {
continue
}
if resp.GetSuccess() {
updated++
}
}
if len(clients) == 0 {
return 0, fmt.Errorf("no slaves registered")
}
if updated == 0 {
return 0, fmt.Errorf("deadzone not applied to any slave")
}
return updated, nil
}
func serveUnicastTest(w http.ResponseWriter, r *http.Request, link *managedSerial) { func serveUnicastTest(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body unicastAPIRequest var body unicastAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {

View File

@ -118,6 +118,12 @@
} }
.btn-sm { font-size: 0.8rem; } .btn-sm { font-size: 0.8rem; }
.dz-input { width: 5.5rem; } .dz-input { width: 5.5rem; }
.config-input { max-width: 8rem; }
.config-block {
border-top: 1px solid var(--pp-border);
margin-top: 1rem;
padding-top: 1rem;
}
</style> </style>
</head> </head>
<body x-data="dashboard()" x-init="connect()"> <body x-data="dashboard()" x-init="connect()">
@ -145,9 +151,10 @@
<div class="row g-4"> <div class="row g-4">
<section class="col-lg-4"> <section class="col-lg-4">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">Master</div> <div class="card-header">Master (BMA456 lokal)</div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small mb-2" x-text="'UART ' + (state.serial_port || '—')"></p> <p class="text-muted small mb-2" x-text="'UART ' + (state.serial_port || '—')"></p>
<p class="text-muted small mb-3">Deadzone nur am Master — Slaves bleiben unverändert.</p>
<template x-if="!state.uart_connected"> <template x-if="!state.uart_connected">
<div class="alert alert-warning py-2 mb-2" <div class="alert alert-warning py-2 mb-2"
x-text="state.serial_error || 'UART disconnected — reconnecting…'"></div> x-text="state.serial_error || 'UART disconnected — reconnecting…'"></div>
@ -156,7 +163,7 @@
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div> <div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div>
</template> </template>
<template x-if="state.master?.ok"> <template x-if="state.master?.ok">
<dl class="row mb-3"> <dl class="row mb-0">
<dt class="col-5 text-muted">Version</dt> <dt class="col-5 text-muted">Version</dt>
<dd class="col-7" x-text="state.master.version"></dd> <dd class="col-7" x-text="state.master.version"></dd>
<dt class="col-5 text-muted">Git</dt> <dt class="col-5 text-muted">Git</dt>
@ -164,21 +171,38 @@
<dt class="col-5 text-muted">Deadzone</dt> <dt class="col-5 text-muted">Deadzone</dt>
<dd class="col-7" x-text="state.master.deadzone != null ? state.master.deadzone + ' LSB' : '—'"></dd> <dd class="col-7" x-text="state.master.deadzone != null ? state.master.deadzone + ' LSB' : '—'"></dd>
</dl> </dl>
<div class="d-flex flex-wrap gap-2 align-items-center" x-show="state.uart_connected">
<input type="number" class="form-control form-control-sm dz-input"
min="0" max="4095" placeholder="LSB"
x-model.number="masterDz"
:disabled="busy">
<button type="button" class="btn btn-primary btn-sm"
@click="setDeadzone(0, masterDz)"
:disabled="busy || !state.uart_connected">
Master setzen
</button>
</div>
</template> </template>
<template x-if="state.master && !state.master.ok"> <template x-if="state.master && !state.master.ok">
<div class="alert alert-warning py-2" x-text="state.master.error"></div> <div class="alert alert-warning py-2 mb-0" x-text="state.master.error"></div>
</template> </template>
<div class="config-block">
<h6 class="text-secondary mb-2">Konfiguration Master</h6>
<label class="form-label text-muted small mb-1" for="master-dz-input">
Accel Deadzone (LSB)
</label>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input id="master-dz-input" type="number"
class="form-control form-control-sm config-input"
min="0" max="4095" step="1"
placeholder="z. B. 100"
x-model.number="masterDz"
:disabled="busy || !state.uart_connected">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Setzen
</button>
</div>
<p class="text-muted small mt-2 mb-0" x-show="!state.uart_connected">
UART nicht verbunden — Eingabe gesperrt.
</p>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -200,7 +224,8 @@
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span> <span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
</div> </div>
</div> </div>
<div class="card-body p-0"> <p class="text-muted small px-3 pt-2 mb-0">Slaves per ESP-NOW — Master-Deadzone bleibt separat.</p>
<div class="card-body p-0 pt-2">
<div class="table-responsive"> <div class="table-responsive">
<table class="table pp-table table-hover"> <table class="table pp-table table-hover">
<thead> <thead>
@ -307,7 +332,7 @@
this.configMsgOk = ok; this.configMsgOk = ok;
setTimeout(() => { this.configMsg = ''; }, 5000); setTimeout(() => { this.configMsg = ''; }, 5000);
}, },
async setDeadzone(clientId, deadzone) { async setDeadzone(clientId, deadzone, opts = {}) {
if (deadzone == null || deadzone < 0) { if (deadzone == null || deadzone < 0) {
this.flash('Ungültiger Deadzone-Wert', false); this.flash('Ungültiger Deadzone-Wert', false);
return; return;
@ -321,12 +346,19 @@
write: true, write: true,
deadzone: deadzone, deadzone: deadzone,
client_id: clientId, client_id: clientId,
all_clients: false all_clients: !!opts.allClients,
slaves_only: !!opts.slavesOnly
}) })
}); });
const data = await r.json(); const data = await r.json();
if (!r.ok || !data.success) { if (!r.ok || !data.success) {
this.flash(data.error || `Deadzone für Client ${clientId} fehlgeschlagen`, false); const err = data.error ||
(opts.slavesOnly ? 'Deadzone für Slaves fehlgeschlagen' : `Deadzone für Client ${clientId} fehlgeschlagen`);
this.flash(err, false);
return;
}
if (opts.slavesOnly) {
this.flash(`Slaves: Deadzone ${data.deadzone} LSB (${data.slaves_updated} per ESP-NOW, Master unverändert)`, true);
return; return;
} }
const who = clientId === 0 ? 'Master' : `Slave ${clientId}`; const who = clientId === 0 ? 'Master' : `Slave ${clientId}`;
@ -337,35 +369,33 @@
this.busy = false; this.busy = false;
} }
}, },
async setDeadzoneAll(deadzone) { async readMasterDeadzone() {
if (deadzone == null || deadzone < 0) {
this.flash('Ungültiger Deadzone-Wert', false);
return;
}
this.busy = true; this.busy = true;
try { try {
const r = await fetch('/api/deadzone', { const r = await fetch('/api/deadzone?client_id=0');
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
write: true,
deadzone: deadzone,
client_id: 0,
all_clients: true
})
});
const data = await r.json(); const data = await r.json();
if (!r.ok || !data.success) { if (!r.ok || !data.success) {
this.flash(data.error || 'Deadzone für alle Slaves fehlgeschlagen', false); this.flash(data.error || 'Master-Deadzone lesen fehlgeschlagen', false);
return; return;
} }
this.flash(`Alle Slaves: Deadzone ${data.deadzone} LSB (${data.slaves_updated} per ESP-NOW)`, true); this.masterDz = data.deadzone;
this.flash(`Master: Deadzone ${data.deadzone} LSB`, true);
} catch (e) { } catch (e) {
this.flash(String(e), false); this.flash(String(e), false);
} finally { } finally {
this.busy = false; this.busy = false;
} }
}, },
async setMasterDeadzone() {
await this.setDeadzone(0, this.masterDz);
},
async setDeadzoneAll(deadzone) {
if (deadzone == null || deadzone < 0) {
this.flash('Ungültiger Deadzone-Wert', false);
return;
}
await this.setDeadzone(0, deadzone, { allClients: true, slavesOnly: true });
},
async unicastTest(clientId) { async unicastTest(clientId) {
this.busy = true; this.busy = true;
try { try {