Add web dashboard configuration for master and slaves.
Expose deadzone and unicast-test via HTTP API and UI, reusing the same UART commands as the CLI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
caf1b8d0d8
commit
85aeab85c0
@ -58,6 +58,16 @@ Open [http://localhost:8080](http://localhost:8080) — shows master firmware in
|
|||||||
|
|
||||||
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.
|
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:
|
||||||
|
|
||||||
|
| UI action | CLI equivalent |
|
||||||
|
|-----------|------------------|
|
||||||
|
| Master / slave deadzone | `deadzone -set -value N -client ID` |
|
||||||
|
| Alle Slaves | `deadzone -set -value N -all` |
|
||||||
|
| Unicast test | `unicast-test -client ID` |
|
||||||
|
|
||||||
|
HTTP API (used by the web UI): `GET/POST /api/deadzone`, `POST /api/unicast-test`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
||||||
```
|
```
|
||||||
|
|||||||
148
goTool/api_serve.go
Normal file
148
goTool/api_serve.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deadzoneAPIResponse struct {
|
||||||
|
Deadzone uint32 `json:"deadzone"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deadzoneAPIRequest struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Deadzone uint32 `json:"deadzone"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unicastAPIRequest struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Seq uint32 `json:"seq"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unicastAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Seq uint32 `json:"seq"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountServeAPI(mux *http.ServeMux, link *managedSerial) {
|
||||||
|
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveDeadzoneGet(w, r, link)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveDeadzonePost(w, r, link)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/unicast-test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveUnicastTest(w, r, link)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveDeadzoneGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, deadzoneAPIResponse{
|
||||||
|
Deadzone: resp.GetDeadzone(),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveDeadzonePost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body deadzoneAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
|
||||||
|
Write: true,
|
||||||
|
Deadzone: body.Deadzone,
|
||||||
|
ClientId: body.ClientID,
|
||||||
|
AllClients: body.AllClients,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
|
||||||
|
ClientID: body.ClientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, deadzoneAPIResponse{
|
||||||
|
Deadzone: resp.GetDeadzone(),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUnicastTest(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body unicastAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, unicastAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ClientID == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, unicastAPIResponse{Error: "client_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Seq == 0 {
|
||||||
|
body.Seq = 1
|
||||||
|
}
|
||||||
|
resp, err := link.EspnowUnicastTest(body.ClientID, body.Seq)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, unicastAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, unicastAPIResponse{
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Seq: resp.GetSeq(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUintQuery(r *http.Request, key string, def uint32) (uint32, error) {
|
||||||
|
s := r.URL.Query().Get(key)
|
||||||
|
if s == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint32(v), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@ -24,6 +24,26 @@ func (m *managedSerial) listClients() ([]*pb.ClientInfo, error) {
|
|||||||
return decodeClientsPayload(payload)
|
return decodeClientsPayload(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
|
var resp *pb.AccelDeadzoneResponse
|
||||||
|
err := m.withPort(func(sp *serialPort) error {
|
||||||
|
var e error
|
||||||
|
resp, e = sp.AccelDeadzone(req)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||||
|
var resp *pb.EspNowUnicastTestResponse
|
||||||
|
err := m.withPort(func(sp *serialPort) error {
|
||||||
|
var e error
|
||||||
|
resp, e = sp.EspnowUnicastTest(clientID, seq)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
|
func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
|
||||||
var msg pb.UartMessage
|
var msg pb.UartMessage
|
||||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
|||||||
@ -40,6 +40,7 @@ func runServe(portName string, baud int, args []string) error {
|
|||||||
go runPoller(link, portName, hub, *interval, stop)
|
go runPoller(link, portName, hub, *interval, stop)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mountServeAPI(mux, link)
|
||||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -3,24 +3,29 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MasterView struct {
|
type MasterView struct {
|
||||||
Version uint32 `json:"version"`
|
Version uint32 `json:"version"`
|
||||||
GitHash string `json:"git_hash"`
|
GitHash string `json:"git_hash"`
|
||||||
OK bool `json:"ok"`
|
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientView struct {
|
type ClientView struct {
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
MAC string `json:"mac"`
|
MAC string `json:"mac"`
|
||||||
Version uint32 `json:"version"`
|
Version uint32 `json:"version"`
|
||||||
|
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Used bool `json:"used"`
|
Used bool `json:"used"`
|
||||||
LastPing uint32 `json:"last_ping"`
|
LastPing uint32 `json:"last_ping"`
|
||||||
@ -100,6 +105,9 @@ func pollDashboard(link *managedSerial, portName string) DashboardState {
|
|||||||
GitHash: ver.GetGitHash(),
|
GitHash: ver.GetGitHash(),
|
||||||
OK: true,
|
OK: true,
|
||||||
}
|
}
|
||||||
|
if dz, err := readDeadzone(link, 0); err == nil {
|
||||||
|
st.Master.Deadzone = dz
|
||||||
|
}
|
||||||
|
|
||||||
clients, err := link.listClients()
|
clients, err := link.listClients()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -110,7 +118,7 @@ func pollDashboard(link *managedSerial, portName string) DashboardState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
st.Clients = append(st.Clients, ClientView{
|
cv := ClientView{
|
||||||
ID: c.GetId(),
|
ID: c.GetId(),
|
||||||
MAC: formatMAC(c.GetMac()),
|
MAC: formatMAC(c.GetMac()),
|
||||||
Version: c.GetVersion(),
|
Version: c.GetVersion(),
|
||||||
@ -118,11 +126,29 @@ func pollDashboard(link *managedSerial, portName string) DashboardState {
|
|||||||
Used: c.GetUsed(),
|
Used: c.GetUsed(),
|
||||||
LastPing: c.GetLastPing(),
|
LastPing: c.GetLastPing(),
|
||||||
LastSuccessPing: c.GetLastSuccessPing(),
|
LastSuccessPing: c.GetLastSuccessPing(),
|
||||||
})
|
}
|
||||||
|
if dz, err := readDeadzone(link, c.GetId()); err == nil {
|
||||||
|
cv.Deadzone = dz
|
||||||
|
}
|
||||||
|
st.Clients = append(st.Clients, cv)
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readDeadzone(link *managedSerial, clientID uint32) (uint32, error) {
|
||||||
|
r, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !r.GetSuccess() {
|
||||||
|
return 0, fmt.Errorf("deadzone read failed for client %d", clientID)
|
||||||
|
}
|
||||||
|
return r.GetDeadzone(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func formatMAC(mac []byte) string {
|
func formatMAC(mac []byte) string {
|
||||||
if len(mac) == 0 {
|
if len(mac) == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -92,6 +92,32 @@
|
|||||||
border-color: #d4a012;
|
border-color: #d4a012;
|
||||||
color: #ffe08a;
|
color: #ffe08a;
|
||||||
}
|
}
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(0, 168, 107, 0.15);
|
||||||
|
border-color: #00a86b;
|
||||||
|
color: #b8f0d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control, .form-control:focus, .btn-outline-secondary {
|
||||||
|
background: var(--pp-surface-raised);
|
||||||
|
border-color: var(--pp-border);
|
||||||
|
color: var(--pp-text);
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(142, 200, 255, 0.2);
|
||||||
|
border-color: var(--pp-accent);
|
||||||
|
}
|
||||||
|
.form-control::placeholder { color: var(--pp-text-muted); }
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
background: var(--pp-border);
|
||||||
|
color: var(--pp-heading);
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #2d6cdf;
|
||||||
|
border-color: #2d6cdf;
|
||||||
|
}
|
||||||
|
.btn-sm { font-size: 0.8rem; }
|
||||||
|
.dz-input { width: 5.5rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="dashboard()" x-init="connect()">
|
<body x-data="dashboard()" x-init="connect()">
|
||||||
@ -110,6 +136,12 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="container pb-5">
|
<main class="container pb-5">
|
||||||
|
<template x-if="configMsg">
|
||||||
|
<div class="alert py-2 mb-3"
|
||||||
|
:class="configMsgOk ? 'alert-success' : 'alert-danger'"
|
||||||
|
x-text="configMsg"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<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">
|
||||||
@ -124,12 +156,25 @@
|
|||||||
<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-0">
|
<dl class="row mb-3">
|
||||||
<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>
|
||||||
<dd class="col-7 text-break" x-text="state.master.git_hash"></dd>
|
<dd class="col-7 text-break" x-text="state.master.git_hash"></dd>
|
||||||
|
<dt class="col-5 text-muted">Deadzone</dt>
|
||||||
|
<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" x-text="state.master.error"></div>
|
||||||
@ -140,9 +185,20 @@
|
|||||||
|
|
||||||
<section class="col-lg-8">
|
<section class="col-lg-8">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<span>ESP-NOW Clients</span>
|
<span>ESP-NOW Slaves</span>
|
||||||
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<input type="number" class="form-control form-control-sm dz-input"
|
||||||
|
min="0" max="4095" placeholder="LSB"
|
||||||
|
x-model.number="allDz"
|
||||||
|
:disabled="busy">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
@click="setDeadzoneAll(allDz)"
|
||||||
|
:disabled="busy || !state.uart_connected">
|
||||||
|
Alle Slaves
|
||||||
|
</button>
|
||||||
|
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -153,14 +209,13 @@
|
|||||||
<th>MAC</th>
|
<th>MAC</th>
|
||||||
<th>Ver</th>
|
<th>Ver</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Last ping</th>
|
<th>Deadzone</th>
|
||||||
<th>Last OK</th>
|
<th>Aktion</th>
|
||||||
<th>Used</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template x-if="!(state.clients || []).length">
|
<template x-if="!(state.clients || []).length">
|
||||||
<tr><td colspan="7" class="text-muted text-center py-4">No clients</td></tr>
|
<tr><td colspan="6" class="text-muted text-center py-4">No clients</td></tr>
|
||||||
</template>
|
</template>
|
||||||
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
|
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
|
||||||
<tr>
|
<tr>
|
||||||
@ -172,9 +227,27 @@
|
|||||||
:class="c.available ? 'badge-online' : 'badge-offline'"
|
:class="c.available ? 'badge-online' : 'badge-offline'"
|
||||||
x-text="c.available ? 'available' : 'inactive'"></span>
|
x-text="c.available ? 'available' : 'inactive'"></span>
|
||||||
</td>
|
</td>
|
||||||
<td x-text="c.last_ping + ' ms'"></td>
|
<td x-text="c.deadzone != null ? c.deadzone : '—'"></td>
|
||||||
<td x-text="c.last_success_ping + ' ms'"></td>
|
<td>
|
||||||
<td x-text="c.used ? 'yes' : 'no'"></td>
|
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||||
|
<input type="number" class="form-control form-control-sm dz-input"
|
||||||
|
min="0" max="4095"
|
||||||
|
:placeholder="String(c.deadzone || 100)"
|
||||||
|
x-model.number="slaveDz[c.id]"
|
||||||
|
:disabled="busy">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
@click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)"
|
||||||
|
:disabled="busy || !state.uart_connected || !c.available">
|
||||||
|
Setzen
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
@click="unicastTest(c.id)"
|
||||||
|
:disabled="busy || !state.uart_connected || !c.available"
|
||||||
|
title="ESP-NOW Unicast-Test">
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -192,6 +265,12 @@
|
|||||||
state: { master: {}, clients: [] },
|
state: { master: {}, clients: [] },
|
||||||
ws: null,
|
ws: null,
|
||||||
wsConnected: false,
|
wsConnected: false,
|
||||||
|
masterDz: 100,
|
||||||
|
allDz: 100,
|
||||||
|
slaveDz: {},
|
||||||
|
busy: false,
|
||||||
|
configMsg: '',
|
||||||
|
configMsgOk: false,
|
||||||
connect() {
|
connect() {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const url = proto + '//' + location.host + '/ws';
|
const url = proto + '//' + location.host + '/ws';
|
||||||
@ -203,7 +282,18 @@
|
|||||||
setTimeout(connect, 2000);
|
setTimeout(connect, 2000);
|
||||||
};
|
};
|
||||||
this.ws.onmessage = (e) => {
|
this.ws.onmessage = (e) => {
|
||||||
try { this.state = JSON.parse(e.data); } catch (_) {}
|
try {
|
||||||
|
const st = JSON.parse(e.data);
|
||||||
|
this.state = st;
|
||||||
|
if (st.master?.deadzone != null) {
|
||||||
|
this.masterDz = st.master.deadzone;
|
||||||
|
}
|
||||||
|
for (const c of (st.clients || [])) {
|
||||||
|
if (c.deadzone != null && this.slaveDz[c.id] == null) {
|
||||||
|
this.slaveDz[c.id] = c.deadzone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
connect();
|
connect();
|
||||||
@ -211,6 +301,90 @@
|
|||||||
formatMac(hex) {
|
formatMac(hex) {
|
||||||
if (!hex || hex.length !== 12) return hex || '';
|
if (!hex || hex.length !== 12) return hex || '';
|
||||||
return hex.match(/.{2}/g).join(':');
|
return hex.match(/.{2}/g).join(':');
|
||||||
|
},
|
||||||
|
flash(msg, ok) {
|
||||||
|
this.configMsg = msg;
|
||||||
|
this.configMsgOk = ok;
|
||||||
|
setTimeout(() => { this.configMsg = ''; }, 5000);
|
||||||
|
},
|
||||||
|
async setDeadzone(clientId, deadzone) {
|
||||||
|
if (deadzone == null || deadzone < 0) {
|
||||||
|
this.flash('Ungültiger Deadzone-Wert', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/deadzone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
write: true,
|
||||||
|
deadzone: deadzone,
|
||||||
|
client_id: clientId,
|
||||||
|
all_clients: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok || !data.success) {
|
||||||
|
this.flash(data.error || `Deadzone für Client ${clientId} fehlgeschlagen`, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const who = clientId === 0 ? 'Master' : `Slave ${clientId}`;
|
||||||
|
this.flash(`${who}: Deadzone ${data.deadzone} LSB gesetzt`, 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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/deadzone', {
|
||||||
|
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();
|
||||||
|
if (!r.ok || !data.success) {
|
||||||
|
this.flash(data.error || 'Deadzone für alle Slaves fehlgeschlagen', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.flash(`Alle Slaves: Deadzone ${data.deadzone} LSB (${data.slaves_updated} per ESP-NOW)`, true);
|
||||||
|
} catch (e) {
|
||||||
|
this.flash(String(e), false);
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async unicastTest(clientId) {
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/unicast-test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ client_id: clientId, seq: Date.now() % 100000 })
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok || !data.success) {
|
||||||
|
this.flash(data.error || `Unicast-Test Slave ${clientId} fehlgeschlagen`, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.flash(`Unicast-Test Slave ${clientId} OK (seq ${data.seq})`, true);
|
||||||
|
} catch (e) {
|
||||||
|
this.flash(String(e), false);
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user