Add web dashboard OTA upload with live progress.

Share UART OTA logic between CLI and serve via POST /api/ota, WebSocket progress events, and a dashboard upload UI showing the running partition.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
simon 2026-05-19 00:45:54 +02:00
parent 59ca269407
commit 4bf43d8a5e
6 changed files with 463 additions and 198 deletions

View File

@ -3,12 +3,15 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"powerpod/gotool/pb" "powerpod/gotool/pb"
) )
const otaMaxFirmwareSize = 2 * 1024 * 1024
type deadzoneAPIResponse struct { type deadzoneAPIResponse struct {
Deadzone uint32 `json:"deadzone"` Deadzone uint32 `json:"deadzone"`
ClientID uint32 `json:"client_id"` ClientID uint32 `json:"client_id"`
@ -37,7 +40,14 @@ type unicastAPIResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
func mountServeAPI(mux *http.ServeMux, link *managedSerial) { type otaAPIResponse struct {
Success bool `json:"success"`
BytesWritten uint32 `json:"bytes_written,omitempty"`
TargetSlot uint32 `json:"target_slot,omitempty"`
Error string `json:"error,omitempty"`
}
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub) {
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@ -55,6 +65,56 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial) {
} }
serveUnicastTest(w, r, link) serveUnicastTest(w, r, link)
}) })
mux.HandleFunc("/api/ota", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveOTAUpload(w, r, link, hub)
})
}
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
return
}
file, _, err := r.FormFile("firmware")
if err != nil {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "firmware file required"})
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, otaMaxFirmwareSize))
if err != nil {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: err.Error()})
return
}
if len(data) == 0 {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "empty firmware"})
return
}
var last OTAProgress
err = runOTAUpload(link, data, func(p OTAProgress) {
last = p
if hub != nil {
hub.broadcastRaw(p)
}
})
if err != nil {
if hub != nil {
hub.broadcastRaw(OTAProgress{Type: "ota_progress", Phase: "error", Message: err.Error()})
}
writeJSON(w, http.StatusServiceUnavailable, otaAPIResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, otaAPIResponse{
Success: true,
BytesWritten: last.Bytes,
TargetSlot: last.Slot,
})
} }
func serveDeadzoneGet(w http.ResponseWriter, r *http.Request, link *managedSerial) { func serveDeadzoneGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {

View File

@ -2,29 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"time"
"google.golang.org/protobuf/proto"
uartframe "powerpod/gotool/uart"
"powerpod/gotool/pb"
)
const (
otaHostChunkSize = 200
otaFlashBlockSize = 4096
otaPrepareTimeout = 120 * time.Second
otaDefaultTimeout = 15 * time.Second
)
const (
otaStPreparing = 1
otaStReady = 2
otaStBlockAck = 3
otaStSuccess = 4
otaStFailed = 5
) )
func runOTA(sp *serialPort, args []string) error { func runOTA(sp *serialPort, args []string) error {
@ -35,170 +13,20 @@ func runOTA(sp *serialPort, args []string) error {
if err != nil { if err != nil {
return err return err
} }
if len(data) == 0 {
return fmt.Errorf("empty firmware file")
}
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil {
return err
}
defer sp.port.SetReadTimeout(readTimeout)
sp.mu.Lock() sp.mu.Lock()
defer sp.mu.Unlock() defer sp.mu.Unlock()
m := &managedSerial{quiet: false, sp: sp}
fmt.Printf("OTA start: %d bytes firmware\n", len(data)) return runOTAOnPortUnlocked(m, data, func(p OTAProgress) {
if err := writeUartMessageLocked(sp, &pb.UartMessage{ switch p.Phase {
Type: pb.MessageType_OTA_START, case "preparing", "ready":
Payload: &pb.UartMessage_OtaStart{ fmt.Println(p.Message)
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(len(data))}, case "uploading":
}, if p.Percent%10 == 0 {
}, "OTA_START"); err != nil { fmt.Printf(" %s (%d%%)\n", p.Message, p.Percent)
return err
}
if _, err := waitOtaStatusLocked(sp, otaStReady, otaPrepareTimeout); err != nil {
return err
}
if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil {
return err
}
var seq uint32
blockNum := 0
for offset := 0; offset < len(data); {
bytesInBlock := 0
for bytesInBlock < otaFlashBlockSize && offset < len(data) {
n := otaHostChunkSize
room := otaFlashBlockSize - bytesInBlock
if n > room {
n = room
} }
if offset+n > len(data) { case "done", "error":
n = len(data) - offset fmt.Println(p.Message)
}
chunk := data[offset : offset+n]
if err := writeUartMessageLocked(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_PAYLOAD,
Payload: &pb.UartMessage_OtaPayload{
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
},
}, "OTA_PAYLOAD"); err != nil {
return err
}
seq++
offset += n
bytesInBlock += n
} }
})
if bytesInBlock == otaFlashBlockSize {
blockNum++
st, err := waitOtaStatusLocked(sp, otaStBlockAck, otaDefaultTimeout)
if err != nil {
return err
}
fmt.Printf(" block %d ack (%d bytes in flash, %d%%)\n",
blockNum, st.GetBytesWritten(), offset*100/len(data))
}
}
if err := writeUartMessageLocked(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_END,
Payload: &pb.UartMessage_OtaEnd{
OtaEnd: &pb.OtaEndPayload{},
},
}, "OTA_END"); err != nil {
return err
}
st, err := readOtaStatusLocked(sp)
if err != nil {
return err
}
if st.GetStatus() != otaStSuccess {
return fmt.Errorf("OTA failed: status=%d error=%d written=%d",
st.GetStatus(), st.GetError(), st.GetBytesWritten())
}
fmt.Printf("OTA success: %d bytes written (slot %d) — reboot to boot new image\n",
st.GetBytesWritten(), st.GetTargetSlot())
return nil
}
func writeUartMessageLocked(sp *serialPort, msg *pb.UartMessage, cmdName string) error {
frame, err := encodeUartMessage(msg)
if err != nil {
return err
}
if !sp.quiet {
log.Printf("sending %s (%d frame bytes)", cmdName, len(frame))
}
_, err = sp.port.Write(frame)
return err
}
func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
body, err := proto.Marshal(msg)
if err != nil {
return nil, err
}
payload := append([]byte{byte(msg.Type)}, body...)
return uartframe.EncodeFrame(payload)
}
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty response")
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, err
}
msg.Type = pb.MessageType(payload[0])
return &msg, nil
}
func waitOtaStatusLocked(sp *serialPort, want uint32, timeout time.Duration) (*pb.OtaStatusPayload, error) {
deadline := time.Now().Add(timeout)
for {
if time.Now().After(deadline) {
return nil, fmt.Errorf("timeout waiting for OTA status %d", want)
}
if err := sp.port.SetReadTimeout(time.Until(deadline)); err != nil {
return nil, err
}
st, err := readOtaStatusLocked(sp)
if err != nil {
return nil, err
}
switch st.GetStatus() {
case want:
if want == otaStReady {
fmt.Printf("OTA ready: inactive slot %d\n", st.GetTargetSlot())
}
return st, nil
case otaStPreparing:
fmt.Printf("OTA preparing partition (erase may take ~30s)…\n")
case otaStFailed:
return nil, fmt.Errorf("OTA failed (error=%d)", st.GetError())
}
}
}
func readOtaStatusLocked(sp *serialPort) (*pb.OtaStatusPayload, error) {
payload, err := uartframe.ReadFrame(sp.port, nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
msg, err := decodeUartPayload(payload)
if err != nil {
return nil, err
}
if msg.GetType() != pb.MessageType_OTA_STATUS {
return nil, fmt.Errorf("unexpected response type %v", msg.GetType())
}
st := msg.GetOtaStatus()
if st == nil {
return nil, fmt.Errorf("missing ota_status")
}
return st, nil
} }

View File

@ -40,7 +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) mountServeAPI(mux, link, hub)
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 {

View File

@ -14,11 +14,12 @@ import (
) )
type MasterView struct { type MasterView struct {
Version uint32 `json:"version"` Version uint32 `json:"version"`
GitHash string `json:"git_hash"` GitHash string `json:"git_hash"`
Deadzone uint32 `json:"deadzone,omitempty"` RunningPartition string `json:"running_partition,omitempty"`
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 {
@ -87,6 +88,23 @@ func (h *wsHub) unregister(c *websocket.Conn) {
h.mu.Unlock() h.mu.Unlock()
} }
func (h *wsHub) broadcastRaw(v any) {
h.mu.RLock()
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
h.mu.RUnlock()
data, err := json.Marshal(v)
if err != nil {
return
}
for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data)
}
}
func pollDashboard(link *managedSerial, portName string) DashboardState { func pollDashboard(link *managedSerial, portName string) DashboardState {
st := DashboardState{ st := DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339), UpdatedAt: time.Now().Format(time.RFC3339),
@ -101,9 +119,10 @@ func pollDashboard(link *managedSerial, portName string) DashboardState {
st.UARTConnected = true st.UARTConnected = true
st.SerialOK = true st.SerialOK = true
st.Master = MasterView{ st.Master = MasterView{
Version: ver.GetVersion(), Version: ver.GetVersion(),
GitHash: ver.GetGitHash(), GitHash: ver.GetGitHash(),
OK: true, RunningPartition: ver.GetRunningPartition(),
OK: true,
} }
if dz, err := readDeadzone(link, 0); err == nil { if dz, err := readDeadzone(link, 0); err == nil {
st.Master.Deadzone = dz st.Master.Deadzone = dz

252
goTool/ota_upload.go Normal file
View File

@ -0,0 +1,252 @@
package main
import (
"fmt"
"log"
"time"
"google.golang.org/protobuf/proto"
uartframe "powerpod/gotool/uart"
"powerpod/gotool/pb"
)
const (
otaHostChunkSize = 200
otaFlashBlockSize = 4096
otaPrepareTimeout = 120 * time.Second
otaDefaultTimeout = 15 * time.Second
)
const (
otaStPreparing = 1
otaStReady = 2
otaStBlockAck = 3
otaStSuccess = 4
otaStFailed = 5
)
// OTAProgress is pushed to the dashboard during web uploads.
type OTAProgress struct {
Type string `json:"type"` // always "ota_progress"
Phase string `json:"phase"` // preparing, ready, uploading, done, error
Percent int `json:"percent"`
Message string `json:"message"`
Bytes uint32 `json:"bytes_written,omitempty"`
Slot uint32 `json:"target_slot,omitempty"`
}
type otaProgressFn func(OTAProgress)
func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) error {
m.mu.Lock()
defer m.mu.Unlock()
err := runOTAOnPortUnlocked(m, firmware, onProgress)
if err != nil {
m.invalidateLocked(err)
}
return err
}
func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgressFn) error {
if len(firmware) == 0 {
return fmt.Errorf("empty firmware")
}
notify := func(phase string, percent int, msg string, extra ...OTAProgress) {
if onProgress == nil {
return
}
p := OTAProgress{Type: "ota_progress", Phase: phase, Percent: percent, Message: msg}
if len(extra) > 0 {
p.Bytes = extra[0].Bytes
p.Slot = extra[0].Slot
}
onProgress(p)
}
if m.sp == nil {
if err := m.openLocked(); err != nil {
notify("error", 0, err.Error())
return err
}
}
sp := m.sp
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil {
notify("error", 0, err.Error())
return err
}
defer sp.port.SetReadTimeout(readTimeout)
notify("preparing", 0, fmt.Sprintf("OTA start (%d bytes)…", len(firmware)))
if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_START,
Payload: &pb.UartMessage_OtaStart{
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(len(firmware))},
},
}, false); err != nil {
notify("error", 0, err.Error())
return err
}
ready, err := waitOtaStatus(sp, otaStReady, otaPrepareTimeout, func(msg string) {
notify("preparing", 2, msg)
})
if err != nil {
notify("error", 0, err.Error())
return err
}
notify("ready", 5, fmt.Sprintf("Ziel-Slot %d bereit", ready.GetTargetSlot()))
if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil {
notify("error", 0, err.Error())
return err
}
var seq uint32
for offset := 0; offset < len(firmware); {
bytesInBlock := 0
for bytesInBlock < otaFlashBlockSize && offset < len(firmware) {
n := otaHostChunkSize
room := otaFlashBlockSize - bytesInBlock
if n > room {
n = room
}
if offset+n > len(firmware) {
n = len(firmware) - offset
}
chunk := firmware[offset : offset+n]
if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_PAYLOAD,
Payload: &pb.UartMessage_OtaPayload{
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
},
}, false); err != nil {
notify("error", 0, err.Error())
return err
}
seq++
offset += n
bytesInBlock += n
pct := 5 + (offset * 90 / len(firmware))
notify("uploading", pct, fmt.Sprintf("%d / %d bytes", offset, len(firmware)))
}
if bytesInBlock == otaFlashBlockSize {
st, err := waitOtaStatus(sp, otaStBlockAck, otaDefaultTimeout, nil)
if err != nil {
notify("error", 0, err.Error())
return err
}
pct := 5 + (offset * 90 / len(firmware))
notify("uploading", pct, fmt.Sprintf("Block geschrieben (%d bytes in flash)", st.GetBytesWritten()),
OTAProgress{Bytes: st.GetBytesWritten()})
}
}
if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_END,
Payload: &pb.UartMessage_OtaEnd{
OtaEnd: &pb.OtaEndPayload{},
},
}, false); err != nil {
notify("error", 0, err.Error())
return err
}
st, err := readOtaStatus(sp)
if err != nil {
notify("error", 0, err.Error())
return err
}
if st.GetStatus() != otaStSuccess {
err := fmt.Errorf("OTA failed: status=%d error=%d", st.GetStatus(), st.GetError())
notify("error", 0, err.Error())
return err
}
notify("done", 100, fmt.Sprintf("Erfolg — %d bytes auf Slot %d (Neustart)", st.GetBytesWritten(), st.GetTargetSlot()),
OTAProgress{Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot()})
return nil
}
func writeUartMessage(sp *serialPort, msg *pb.UartMessage, logFrame bool) error {
frame, err := encodeUartMessage(msg)
if err != nil {
return err
}
if logFrame {
log.Printf("sending %s (%d frame bytes)", msg.Type, len(frame))
}
_, err = sp.port.Write(frame)
return err
}
func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
deadline := time.Now().Add(timeout)
for {
if time.Now().After(deadline) {
return nil, fmt.Errorf("timeout waiting for OTA status %d", want)
}
if err := sp.port.SetReadTimeout(time.Until(deadline)); err != nil {
return nil, err
}
st, err := readOtaStatus(sp)
if err != nil {
return nil, err
}
switch st.GetStatus() {
case want:
return st, nil
case otaStPreparing:
if onPreparing != nil {
onPreparing("Partition wird vorbereitet (~30s)…")
}
case otaStFailed:
return nil, fmt.Errorf("OTA failed (error=%d)", st.GetError())
}
}
}
func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) {
payload, err := uartframe.ReadFrame(sp.port, nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
msg, err := decodeUartPayload(payload)
if err != nil {
return nil, err
}
if msg.GetType() != pb.MessageType_OTA_STATUS {
return nil, fmt.Errorf("unexpected response type %v", msg.GetType())
}
st := msg.GetOtaStatus()
if st == nil {
return nil, fmt.Errorf("missing ota_status")
}
return st, nil
}
func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
body, err := proto.Marshal(msg)
if err != nil {
return nil, err
}
payload := append([]byte{byte(msg.Type)}, body...)
return uartframe.EncodeFrame(payload)
}
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty response")
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, err
}
msg.Type = pb.MessageType(payload[0])
return &msg, nil
}

View File

@ -124,6 +124,20 @@
margin-top: 1rem; margin-top: 1rem;
padding-top: 1rem; padding-top: 1rem;
} }
.progress {
background: var(--pp-surface-raised);
border: 1px solid var(--pp-border);
}
.progress-bar {
background: #2d6cdf;
}
.form-control[type="file"]::file-selector-button {
background: var(--pp-border);
border: none;
color: var(--pp-text);
margin-right: 0.5rem;
padding: 0.25rem 0.5rem;
}
</style> </style>
</head> </head>
<body x-data="dashboard()" x-init="connect()"> <body x-data="dashboard()" x-init="connect()">
@ -168,6 +182,8 @@
<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">Partition</dt>
<dd class="col-7" x-text="state.master.running_partition || '—'"></dd>
<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>
@ -281,6 +297,43 @@
</div> </div>
</div> </div>
</section> </section>
<section class="col-12">
<div class="card">
<div class="card-header">Firmware OTA (A/B)</div>
<div class="card-body">
<p class="text-muted small mb-3">
Lädt eine <code>.bin</code> auf die inaktive OTA-Partition (wie <code>gotool ota</code>).
Während des Uploads pausiert das Live-Polling.
</p>
<div class="mb-3">
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
@change="otaFile = $event.target.files[0]"
:disabled="ota.active || busy || !state.uart_connected">
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="uploadOTA()"
:disabled="ota.active || busy || !state.uart_connected || !otaFile">
Firmware hochladen
</button>
<span class="text-muted small" x-show="otaFile"
x-text="otaFile ? otaFile.name + ' (' + formatSize(otaFile.size) + ')' : ''"></span>
</div>
<template x-if="ota.active || ota.phase === 'done' || ota.phase === 'error'">
<div class="progress mb-2" style="height: 1.25rem;">
<div class="progress-bar" role="progressbar"
:style="'width: ' + ota.percent + '%'"
:class="ota.phase === 'error' ? 'bg-danger' : (ota.phase === 'done' ? 'bg-success' : '')"
x-text="ota.percent + '%'"></div>
</div>
<p class="small mb-0"
:class="ota.phase === 'error' ? 'text-danger' : (ota.phase === 'done' ? 'text-success' : 'text-muted')"
x-text="ota.message"></p>
</template>
</div>
</div>
</section>
</div> </div>
</main> </main>
@ -293,6 +346,8 @@
masterDz: 100, masterDz: 100,
allDz: 100, allDz: 100,
slaveDz: {}, slaveDz: {},
otaFile: null,
ota: { active: false, phase: '', percent: 0, message: '' },
busy: false, busy: false,
configMsg: '', configMsg: '',
configMsgOk: false, configMsgOk: false,
@ -308,12 +363,16 @@
}; };
this.ws.onmessage = (e) => { this.ws.onmessage = (e) => {
try { try {
const st = JSON.parse(e.data); const msg = JSON.parse(e.data);
this.state = st; if (msg.type === 'ota_progress') {
if (st.master?.deadzone != null) { this.applyOTAProgress(msg);
this.masterDz = st.master.deadzone; return;
} }
for (const c of (st.clients || [])) { this.state = msg;
if (msg.master?.deadzone != null) {
this.masterDz = msg.master.deadzone;
}
for (const c of (msg.clients || [])) {
if (c.deadzone != null && this.slaveDz[c.id] == null) { if (c.deadzone != null && this.slaveDz[c.id] == null) {
this.slaveDz[c.id] = c.deadzone; this.slaveDz[c.id] = c.deadzone;
} }
@ -327,6 +386,53 @@
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(':');
}, },
formatSize(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
return (n / (1024 * 1024)).toFixed(2) + ' MiB';
},
applyOTAProgress(p) {
this.ota.phase = p.phase || '';
this.ota.percent = p.percent ?? 0;
this.ota.message = p.message || '';
if (p.phase === 'preparing' || p.phase === 'ready' || p.phase === 'uploading') {
this.ota.active = true;
}
if (p.phase === 'done' || p.phase === 'error') {
this.ota.active = false;
}
},
async uploadOTA() {
if (!this.otaFile) return;
this.ota = { active: true, phase: 'preparing', percent: 0, message: 'Upload startet…' };
this.busy = true;
const form = new FormData();
form.append('firmware', this.otaFile);
try {
const r = await fetch('/api/ota', { method: 'POST', body: form });
const data = await r.json();
if (!r.ok || !data.success) {
this.applyOTAProgress({
phase: 'error',
percent: 0,
message: data.error || 'OTA fehlgeschlagen'
});
return;
}
const slot = data.target_slot != null ? 'ota_' + data.target_slot : '?';
this.applyOTAProgress({
phase: 'done',
percent: 100,
message: `OK — ${data.bytes_written} Bytes nach ${slot} (Neustart zum Booten)`
});
} catch (e) {
this.applyOTAProgress({ phase: 'error', percent: 0, message: String(e) });
} finally {
this.busy = false;
this.ota.active = false;
}
},
flash(msg, ok) { flash(msg, ok) {
this.configMsg = msg; this.configMsg = msg;
this.configMsgOk = ok; this.configMsgOk = ok;