Add LED ring control per client and broadcast over REST and WebSocket.

Solid color mode fills all ring LEDs; master routes UART commands to slaves
via ESPNOW_LED_RING. goTool exposes POST /api/led-ring, WebSocket set_led_ring,
and a dashboard LED panel with master/slave/all targets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 19:24:55 +02:00
co-authored by Cursor
parent 47c75110c9
commit eb67a46158
21 changed files with 759 additions and 133 deletions
+22 -3
View File
@@ -31,7 +31,7 @@ go run . -port /dev/ttyUSB0 clients
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
| `ota` | 1619 | UART firmware upload to master; firmware then pushes to slaves via ESP-NOW |
| `ota-progress` | 21 | Query per-slave ESP-NOW OTA progress on the master (`-client N`, default all) |
| `led-ring` | 8 | LED ring: `-mode clear\|progress\|digit\|blink\|find-me`, |
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me`, `-client`, `-all` |
| `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) |
| `restart` | 23 | Reboot master or slave (`-client 0` / `>0`) |
@@ -87,7 +87,7 @@ Polling runs only when at least one connection has `receive_accel: true` **and**
**Hello** (on connect; accel is off until `set_stream`):
```json
{"type":"hello","serial_port":"/dev/ttyUSB0","interval_ms":16,"commands":["set_stream","get_stream","set_accel_stream","get_accel_stream"]}
{"type":"hello","serial_port":"/dev/ttyUSB0","interval_ms":16,"commands":["set_stream","get_stream","set_accel_stream","get_accel_stream","set_led_ring"]}
```
**Receive accel on this connection** (optional `interval_ms`, default from `-accel-interval`):
@@ -116,6 +116,15 @@ Reply:
{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true}
```
**LED ring** (same JSON fields as `POST /api/led-ring`):
```json
{"type":"set_led_ring","mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":200}
{"type":"set_led_ring","mode":"digit","all_clients":true,"slaves_only":true,"digit":3,"g":255}
```
Reply: `{"type":"led_ring_status","success":true,"slaves_updated":2,...}`
**Accel** (only to connections with `receive_accel: true`, and only while slaves stream):
```json
@@ -158,7 +167,17 @@ 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`), `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`), `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`):
```json
{"mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":128}
{"mode":"digit","client_id":0,"digit":3,"r":0,"g":255,"b":0}
{"mode":"find-me","all_clients":true,"slaves_only":true}
```
Modes: `clear`, `color` (full ring), `progress` (0100), `digit` (010 symbols), `blink`, `find-me`. Use `client_id` (0 = master), or `all_clients` (+ optional `slaves_only`) for broadcast.
**Accel stream per slave** (must be enabled before values appear; goTool polls only while at least one slave has stream on):
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"encoding/json"
"net/http"
)
func mountLedRingAPI(mux *http.ServeMux, link *managedSerial) {
mux.HandleFunc("/api/led-ring", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveLedRingPost(w, r, link)
})
}
func serveLedRingPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body ledRingAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, ledRingAPIResponse{Error: "invalid JSON"})
return
}
if body.Mode == "" {
writeJSON(w, http.StatusBadRequest, ledRingAPIResponse{Error: "mode required"})
return
}
out := applyLedRing(link, body)
status := http.StatusOK
if out.Error != "" {
status = http.StatusServiceUnavailable
} else if !out.Success {
status = http.StatusServiceUnavailable
}
writeJSON(w, status, out)
}
+1
View File
@@ -69,6 +69,7 @@ type otaAPIResponse struct {
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl) {
mountAccelStreamAPI(mux, link, hub, streamCtl)
mountLedRingAPI(mux, link)
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
+28 -4
View File
@@ -132,7 +132,9 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
Type: "hello",
Serial: portName,
IntervalMs: int(h.defaultInterval / time.Millisecond),
Commands: []string{"set_stream", "get_stream", "set_accel_stream", "get_accel_stream"},
Commands: []string{
"set_stream", "get_stream", "set_accel_stream", "get_accel_stream", "set_led_ring",
},
}
if data, err := json.Marshal(hello); err == nil {
_ = conn.WriteMessage(websocket.TextMessage, data)
@@ -317,6 +319,15 @@ func writeStreamStatus(conn *websocket.Conn, msg StreamStatusMessage) {
_ = conn.WriteMessage(websocket.TextMessage, data)
}
func writeLedRingStatus(conn *websocket.Conn, out ledRingAPIResponse) {
out.Type = "led_ring_status"
data, err := json.Marshal(out)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
}
func writeAccelStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
msg := AccelStreamStatusMessage{
Type: "accel_stream_status",
@@ -393,10 +404,22 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
Success: resp.GetSuccess(),
})
case "set_led_ring":
var body ledRingAPIRequest
if err := json.Unmarshal(data, &body); err != nil {
writeLedRingStatus(conn, ledRingAPIResponse{Error: "invalid JSON"})
return
}
if body.Mode == "" {
writeLedRingStatus(conn, ledRingAPIResponse{Error: "mode required"})
return
}
writeLedRingStatus(conn, applyLedRing(link, body))
default:
writeStreamStatus(conn, StreamStatusMessage{
Type: "stream_status",
Error: "unknown type (set_stream, get_stream, set_accel_stream, get_accel_stream)",
Type: "stream_status",
Error: "unknown type (set_stream, get_stream, set_accel_stream, get_accel_stream, set_led_ring)",
})
}
}
@@ -434,7 +457,7 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
DefaultIntervalMs: defMs,
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
Description: "WebSocket: per-connection accel receive + interval; slave stream via set_accel_stream",
Description: "WebSocket: accel stream + set_led_ring (modes: clear, color, progress, digit, blink, find-me)",
})
})
@@ -454,6 +477,7 @@ func runAPIServer(portName string, link *managedSerial, addr string, defaultInte
mux := http.NewServeMux()
mountExternalAPI(mux, portName, defaultInterval, hub, link, dash, ctl)
mountLedRingAPI(mux, link)
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
+2
View File
@@ -347,6 +347,8 @@ func ledRingModeValue(mode string) (uint32, error) {
return 3, nil
case "find_me", "findme":
return 4, nil
case "color", "solid", "fill":
return 5, nil
default:
return 0, fmt.Errorf("unknown led_ring mode %q", mode)
}
+10
View File
@@ -377,6 +377,16 @@ func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgres
return s.ledRingProgress(req)
}
func (m *managedSerial) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
var resp *pb.LedRingProgressResponse
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.LedRing(req)
return e
})
return resp, err
}
func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
return s.espnowFindMe(clientID)
}
+13 -25
View File
@@ -7,17 +7,12 @@ import (
"powerpod/gotool/pb"
)
const (
ledRingModeClear = 0
ledRingModeProgress = 1
ledRingModeDigit = 2
ledRingModeBlink = 3
ledRingModeFindMe = 4
)
func runLedRing(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("led-ring", flag.ExitOnError)
mode := fs.String("mode", "progress", "clear, progress, digit, blink, or find-me")
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, or find-me")
clientID := fs.Uint("client", 0, "0=master ring, >0=slave via ESP-NOW")
allClients := fs.Bool("all", false, "broadcast to all slaves")
slavesOnly := fs.Bool("slaves-only", false, "with -all: do not change master ring")
progress := fs.Uint("progress", 0, "fill level 0100 (mode=progress)")
digit := fs.Uint("digit", 0, "digit 010 (mode=digit)")
r := fs.Uint("r", 0, "red 0255")
@@ -30,20 +25,9 @@ func runLedRing(sp *serialPort, args []string) error {
return err
}
var modeVal uint32
switch *mode {
case "clear":
modeVal = ledRingModeClear
case "progress":
modeVal = ledRingModeProgress
case "digit":
modeVal = ledRingModeDigit
case "blink":
modeVal = ledRingModeBlink
case "find-me", "find_me", "findme":
modeVal = ledRingModeFindMe
default:
return fmt.Errorf("unknown -mode %q (clear, progress, digit, blink, find-me)", *mode)
modeVal, err := ledRingModeFromString(*mode)
if err != nil {
return err
}
resp, err := sp.ledRingProgress(&pb.LedRingProgressRequest{
@@ -56,11 +40,15 @@ func runLedRing(sp *serialPort, args []string) error {
Intensity: uint32(*intensity),
BlinkMs: uint32(*blinkMs),
BlinkCount: uint32(*blinkCount),
ClientId: uint32(*clientID),
AllClients: *allClients,
SlavesOnly: *slavesOnly,
})
if err != nil {
return err
}
fmt.Printf("success=%v mode=%d progress=%d digit=%d\n",
resp.GetSuccess(), resp.GetMode(), resp.GetProgress(), resp.GetDigit())
fmt.Printf("success=%v mode=%d progress=%d digit=%d client_id=%d slaves_updated=%d\n",
resp.GetSuccess(), resp.GetMode(), resp.GetProgress(), resp.GetDigit(),
resp.GetClientId(), resp.GetSlavesUpdated())
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"fmt"
"strings"
"powerpod/gotool/pb"
)
const (
ledRingModeClear = 0
ledRingModeProgress = 1
ledRingModeDigit = 2
ledRingModeBlink = 3
ledRingModeFindMe = 4
ledRingModeColor = 5
)
type ledRingAPIRequest struct {
Mode string `json:"mode"`
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
SlavesOnly bool `json:"slaves_only"`
Progress uint32 `json:"progress"`
Digit uint32 `json:"digit"`
R uint32 `json:"r"`
G uint32 `json:"g"`
B uint32 `json:"b"`
Intensity uint32 `json:"intensity"`
BlinkMs uint32 `json:"blink_ms"`
BlinkCount uint32 `json:"blink_count"`
}
type ledRingAPIResponse struct {
Type string `json:"type,omitempty"` // led_ring_status (WebSocket)
Success bool `json:"success"`
Mode uint32 `json:"mode,omitempty"`
Progress uint32 `json:"progress,omitempty"`
Digit uint32 `json:"digit,omitempty"`
ClientID uint32 `json:"client_id,omitempty"`
SlavesUpdated uint32 `json:"slaves_updated,omitempty"`
Error string `json:"error,omitempty"`
}
func ledRingModeFromString(s string) (uint32, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "clear", "":
return ledRingModeClear, nil
case "color", "solid", "fill":
return ledRingModeColor, nil
case "progress":
return ledRingModeProgress, nil
case "digit":
return ledRingModeDigit, nil
case "blink":
return ledRingModeBlink, nil
case "find-me", "find_me", "findme":
return ledRingModeFindMe, nil
default:
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me)", s)
}
}
func ledRingPBFromAPI(in ledRingAPIRequest) (*pb.LedRingProgressRequest, error) {
mode, err := ledRingModeFromString(in.Mode)
if err != nil {
return nil, err
}
return &pb.LedRingProgressRequest{
Mode: mode,
Progress: in.Progress,
Digit: in.Digit,
R: in.R,
G: in.G,
B: in.B,
Intensity: in.Intensity,
BlinkMs: in.BlinkMs,
BlinkCount: in.BlinkCount,
ClientId: in.ClientID,
AllClients: in.AllClients,
SlavesOnly: in.SlavesOnly,
}, nil
}
func applyLedRing(link *managedSerial, in ledRingAPIRequest) ledRingAPIResponse {
req, err := ledRingPBFromAPI(in)
if err != nil {
return ledRingAPIResponse{Error: err.Error()}
}
resp, err := link.LedRing(req)
if err != nil {
return ledRingAPIResponse{Error: err.Error()}
}
out := ledRingAPIResponse{
Success: resp.GetSuccess(),
Mode: resp.GetMode(),
Progress: resp.GetProgress(),
Digit: resp.GetDigit(),
ClientID: resp.GetClientId(),
SlavesUpdated: resp.GetSlavesUpdated(),
}
if !out.Success && out.Error == "" {
out.Error = "led ring command rejected"
}
return out
}
+57 -6
View File
@@ -1530,8 +1530,8 @@ func (x *EspNowUnicastTestResponse) GetSeq() uint32 {
return 0
}
// Host → device: LED ring display (progress bar, digit, clear, blink, or find-me).
// mode: 0=clear, 1=progress (0100 %), 2=digit (010), 3=blink full ring, 4=find-me (R/G/B ×3 @ full brightness).
// Host → master: LED ring on master (client_id=0) and/or slaves via ESP-NOW.
// mode: 0=clear, 1=progress (0100 %), 2=digit (010), 3=blink, 4=find-me, 5=all LEDs solid color.
type LedRingProgressRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Mode uint32 `protobuf:"varint,1,opt,name=mode,proto3" json:"mode,omitempty"`
@@ -1547,7 +1547,13 @@ type LedRingProgressRequest struct {
// * Pulse length in ms (mode=blink, default 350)
BlinkMs uint32 `protobuf:"varint,8,opt,name=blink_ms,json=blinkMs,proto3" json:"blink_ms,omitempty"`
// * Number of pulses (mode=blink, default 1)
BlinkCount uint32 `protobuf:"varint,9,opt,name=blink_count,json=blinkCount,proto3" json:"blink_count,omitempty"`
BlinkCount uint32 `protobuf:"varint,9,opt,name=blink_count,json=blinkCount,proto3" json:"blink_count,omitempty"`
// * 0 = master ring only; >0 = one slave; ignored when all_clients
ClientId uint32 `protobuf:"varint,10,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
// * Broadcast to all registered slaves (and optionally master unless slaves_only)
AllClients bool `protobuf:"varint,11,opt,name=all_clients,json=allClients,proto3" json:"all_clients,omitempty"`
// * With all_clients: do not change master ring
SlavesOnly bool `protobuf:"varint,12,opt,name=slaves_only,json=slavesOnly,proto3" json:"slaves_only,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1645,12 +1651,35 @@ func (x *LedRingProgressRequest) GetBlinkCount() uint32 {
return 0
}
func (x *LedRingProgressRequest) GetClientId() uint32 {
if x != nil {
return x.ClientId
}
return 0
}
func (x *LedRingProgressRequest) GetAllClients() bool {
if x != nil {
return x.AllClients
}
return false
}
func (x *LedRingProgressRequest) GetSlavesOnly() bool {
if x != nil {
return x.SlavesOnly
}
return false
}
type LedRingProgressResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
Mode uint32 `protobuf:"varint,2,opt,name=mode,proto3" json:"mode,omitempty"`
Progress uint32 `protobuf:"varint,3,opt,name=progress,proto3" json:"progress,omitempty"`
Digit uint32 `protobuf:"varint,4,opt,name=digit,proto3" json:"digit,omitempty"`
ClientId uint32 `protobuf:"varint,5,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
SlavesUpdated uint32 `protobuf:"varint,6,opt,name=slaves_updated,json=slavesUpdated,proto3" json:"slaves_updated,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1713,6 +1742,20 @@ func (x *LedRingProgressResponse) GetDigit() uint32 {
return 0
}
func (x *LedRingProgressResponse) GetClientId() uint32 {
if x != nil {
return x.ClientId
}
return 0
}
func (x *LedRingProgressResponse) GetSlavesUpdated() uint32 {
if x != nil {
return x.SlavesUpdated
}
return 0
}
// * Host → master: find-me on local ring (client_id=0) or ESP-NOW unicast to one slave.
type EspNowFindMeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2411,7 +2454,7 @@ const file_uart_messages_proto_rawDesc = "" +
"\x03seq\x18\x02 \x01(\rR\x03seq\"G\n" +
"\x19EspNowUnicastTestResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x10\n" +
"\x03seq\x18\x02 \x01(\rR\x03seq\"\xe2\x01\n" +
"\x03seq\x18\x02 \x01(\rR\x03seq\"\xc1\x02\n" +
"\x16LedRingProgressRequest\x12\x12\n" +
"\x04mode\x18\x01 \x01(\rR\x04mode\x12\x1a\n" +
"\bprogress\x18\x02 \x01(\rR\bprogress\x12\x14\n" +
@@ -2422,12 +2465,20 @@ const file_uart_messages_proto_rawDesc = "" +
"\tintensity\x18\a \x01(\rR\tintensity\x12\x19\n" +
"\bblink_ms\x18\b \x01(\rR\ablinkMs\x12\x1f\n" +
"\vblink_count\x18\t \x01(\rR\n" +
"blinkCount\"y\n" +
"blinkCount\x12\x1b\n" +
"\tclient_id\x18\n" +
" \x01(\rR\bclientId\x12\x1f\n" +
"\vall_clients\x18\v \x01(\bR\n" +
"allClients\x12\x1f\n" +
"\vslaves_only\x18\f \x01(\bR\n" +
"slavesOnly\"\xbd\x01\n" +
"\x17LedRingProgressResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x12\n" +
"\x04mode\x18\x02 \x01(\rR\x04mode\x12\x1a\n" +
"\bprogress\x18\x03 \x01(\rR\bprogress\x12\x14\n" +
"\x05digit\x18\x04 \x01(\rR\x05digit\"2\n" +
"\x05digit\x18\x04 \x01(\rR\x05digit\x12\x1b\n" +
"\tclient_id\x18\x05 \x01(\rR\bclientId\x12%\n" +
"\x0eslaves_updated\x18\x06 \x01(\rR\rslavesUpdated\"2\n" +
"\x13EspNowFindMeRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\rR\bclientId\"M\n" +
"\x14EspNowFindMeResponse\x12\x18\n" +
+140 -4
View File
@@ -331,11 +331,17 @@
title="ESP-NOW Unicast-Test">
Test
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="findMe(c.id)"
<button type="button" class="btn btn-outline-info btn-sm"
@click="ledRing({ clientId: c.id })"
:disabled="busy || !state.uart_connected || !c.available"
title="LED-Ring Find me (ESP-NOW)">
Find me
title="LED-Ring (aktueller Modus)">
LED
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="ledRing({ clientId: c.id, mode: 'find-me' })"
:disabled="busy || !state.uart_connected || !c.available"
title="Find me">
Find
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(c.id)"
@@ -354,6 +360,82 @@
</div>
</section>
<section class="col-12">
<div class="card">
<div class="card-header">LED-Ring</div>
<div class="card-body">
<p class="text-muted small mb-3">
Modi: <code>clear</code>, <code>color</code> (ganzer Ring), <code>progress</code> (0100&nbsp;%),
<code>digit</code> (010), <code>blink</code>, <code>find-me</code>.
Ziel: Master (<code>client_id=0</code>), ein Slave oder alle Slaves (Broadcast).
</p>
<div class="row g-3 align-items-end">
<div class="col-md-2">
<label class="form-label small text-muted">Modus</label>
<select class="form-select form-select-sm" x-model="led.mode" :disabled="busy">
<option value="color">Farbe (alle LEDs)</option>
<option value="clear">Aus (clear)</option>
<option value="progress">Progress</option>
<option value="digit">Ziffer/Symbol</option>
<option value="blink">Blink</option>
<option value="find-me">Find me</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">RGB / Intensität</label>
<div class="d-flex flex-wrap gap-2">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="R" x-model.number="led.r" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="G" x-model.number="led.g" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="B" x-model.number="led.b" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:5rem" min="0" max="255"
title="0 = Geräte-Default (~5 %)"
placeholder="Int." x-model.number="led.intensity" :disabled="busy">
</div>
</div>
<div class="col-md-2" x-show="led.mode === 'progress'">
<label class="form-label small text-muted">Progress %</label>
<input type="number" class="form-control form-control-sm" min="0" max="100"
x-model.number="led.progress" :disabled="busy">
</div>
<div class="col-md-2" x-show="led.mode === 'digit'">
<label class="form-label small text-muted">Ziffer 010</label>
<input type="number" class="form-control form-control-sm" min="0" max="10"
x-model.number="led.digit" :disabled="busy">
</div>
<div class="col-md-2" x-show="led.mode === 'blink'">
<label class="form-label small text-muted">Blink ms × Anzahl</label>
<div class="d-flex gap-1">
<input type="number" class="form-control form-control-sm" min="1"
x-model.number="led.blinkMs" :disabled="busy">
<input type="number" class="form-control form-control-sm" min="1"
x-model.number="led.blinkCount" :disabled="busy">
</div>
</div>
<div class="col-md-4 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm"
@click="ledRing({ clientId: 0 })"
:disabled="busy || !state.uart_connected">
Master
</button>
<button type="button" class="btn btn-outline-primary btn-sm"
@click="ledRing({ allClients: true, slavesOnly: true })"
:disabled="busy || !state.uart_connected">
Alle Slaves
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="ledRing({ allClients: true })"
:disabled="busy || !state.uart_connected">
Alle + Master
</button>
</div>
</div>
</div>
</div>
</section>
<section class="col-12">
<div class="card">
<div class="card-header">Firmware OTA (A/B)</div>
@@ -480,6 +562,17 @@
busy: false,
configMsg: '',
configMsgOk: false,
led: {
mode: 'color',
r: 0,
g: 120,
b: 255,
intensity: 0,
progress: 50,
digit: 0,
blinkMs: 350,
blinkCount: 1
},
connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/ws';
@@ -853,6 +946,49 @@
this.busy = false;
}
},
async ledRing(opts = {}) {
const clientId = opts.clientId ?? 0;
const mode = opts.mode ?? this.led.mode;
this.busy = true;
try {
const r = await fetch('/api/led-ring', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode,
client_id: clientId,
all_clients: !!opts.allClients,
slaves_only: !!opts.slavesOnly,
r: this.led.r,
g: this.led.g,
b: this.led.b,
intensity: this.led.intensity,
progress: this.led.progress,
digit: this.led.digit,
blink_ms: this.led.blinkMs,
blink_count: this.led.blinkCount
})
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'LED-Ring fehlgeschlagen', false);
return;
}
let label = 'Master';
if (opts.allClients) {
label = opts.slavesOnly
? `Alle Slaves (${data.slaves_updated})`
: `Alle + Master (${data.slaves_updated} Slaves)`;
} else if (clientId > 0) {
label = `Slave ${clientId}`;
}
this.flash(`LED ${mode}${label}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async findMe(clientId = 0) {
this.busy = true;
try {