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:
parent
59ca269407
commit
4bf43d8a5e
@ -3,12 +3,15 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
const otaMaxFirmwareSize = 2 * 1024 * 1024
|
||||
|
||||
type deadzoneAPIResponse struct {
|
||||
Deadzone uint32 `json:"deadzone"`
|
||||
ClientID uint32 `json:"client_id"`
|
||||
@ -37,7 +40,14 @@ type unicastAPIResponse struct {
|
||||
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) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@ -55,6 +65,56 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial) {
|
||||
}
|
||||
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) {
|
||||
|
||||
@ -2,29 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"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 {
|
||||
@ -35,170 +13,20 @@ func runOTA(sp *serialPort, args []string) error {
|
||||
if err != nil {
|
||||
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()
|
||||
defer sp.mu.Unlock()
|
||||
|
||||
fmt.Printf("OTA start: %d bytes firmware\n", len(data))
|
||||
if err := writeUartMessageLocked(sp, &pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_START,
|
||||
Payload: &pb.UartMessage_OtaStart{
|
||||
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(len(data))},
|
||||
},
|
||||
}, "OTA_START"); err != nil {
|
||||
return err
|
||||
m := &managedSerial{quiet: false, sp: sp}
|
||||
return runOTAOnPortUnlocked(m, data, func(p OTAProgress) {
|
||||
switch p.Phase {
|
||||
case "preparing", "ready":
|
||||
fmt.Println(p.Message)
|
||||
case "uploading":
|
||||
if p.Percent%10 == 0 {
|
||||
fmt.Printf(" %s (%d%%)\n", p.Message, p.Percent)
|
||||
}
|
||||
if _, err := waitOtaStatusLocked(sp, otaStReady, otaPrepareTimeout); err != nil {
|
||||
return err
|
||||
case "done", "error":
|
||||
fmt.Println(p.Message)
|
||||
}
|
||||
|
||||
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) {
|
||||
n = len(data) - offset
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ func runServe(portName string, baud int, args []string) error {
|
||||
go runPoller(link, portName, hub, *interval, stop)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mountServeAPI(mux, link)
|
||||
mountServeAPI(mux, link, hub)
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
type MasterView struct {
|
||||
Version uint32 `json:"version"`
|
||||
GitHash string `json:"git_hash"`
|
||||
RunningPartition string `json:"running_partition,omitempty"`
|
||||
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@ -87,6 +88,23 @@ func (h *wsHub) unregister(c *websocket.Conn) {
|
||||
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 {
|
||||
st := DashboardState{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
@ -103,6 +121,7 @@ func pollDashboard(link *managedSerial, portName string) DashboardState {
|
||||
st.Master = MasterView{
|
||||
Version: ver.GetVersion(),
|
||||
GitHash: ver.GetGitHash(),
|
||||
RunningPartition: ver.GetRunningPartition(),
|
||||
OK: true,
|
||||
}
|
||||
if dz, err := readDeadzone(link, 0); err == nil {
|
||||
|
||||
252
goTool/ota_upload.go
Normal file
252
goTool/ota_upload.go
Normal 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
|
||||
}
|
||||
@ -124,6 +124,20 @@
|
||||
margin-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>
|
||||
</head>
|
||||
<body x-data="dashboard()" x-init="connect()">
|
||||
@ -168,6 +182,8 @@
|
||||
<dd class="col-7" x-text="state.master.version"></dd>
|
||||
<dt class="col-5 text-muted">Git</dt>
|
||||
<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>
|
||||
<dd class="col-7" x-text="state.master.deadzone != null ? state.master.deadzone + ' LSB' : '—'"></dd>
|
||||
</dl>
|
||||
@ -281,6 +297,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</main>
|
||||
|
||||
@ -293,6 +346,8 @@
|
||||
masterDz: 100,
|
||||
allDz: 100,
|
||||
slaveDz: {},
|
||||
otaFile: null,
|
||||
ota: { active: false, phase: '', percent: 0, message: '' },
|
||||
busy: false,
|
||||
configMsg: '',
|
||||
configMsgOk: false,
|
||||
@ -308,12 +363,16 @@
|
||||
};
|
||||
this.ws.onmessage = (e) => {
|
||||
try {
|
||||
const st = JSON.parse(e.data);
|
||||
this.state = st;
|
||||
if (st.master?.deadzone != null) {
|
||||
this.masterDz = st.master.deadzone;
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'ota_progress') {
|
||||
this.applyOTAProgress(msg);
|
||||
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) {
|
||||
this.slaveDz[c.id] = c.deadzone;
|
||||
}
|
||||
@ -327,6 +386,53 @@
|
||||
if (!hex || hex.length !== 12) return hex || '';
|
||||
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) {
|
||||
this.configMsg = msg;
|
||||
this.configMsgOk = ok;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user