diff --git a/goTool/api_serve.go b/goTool/api_serve.go index 20228e7..d51ade7 100644 --- a/goTool/api_serve.go +++ b/goTool/api_serve.go @@ -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) { diff --git a/goTool/cmd_ota.go b/goTool/cmd_ota.go index 5b6fb90..f82196a 100644 --- a/goTool/cmd_ota.go +++ b/goTool/cmd_ota.go @@ -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 - } - 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 + 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 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 + case "done", "error": + fmt.Println(p.Message) } - - 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 + }) } diff --git a/goTool/cmd_serve.go b/goTool/cmd_serve.go index ed8e40d..71e430f 100644 --- a/goTool/cmd_serve.go +++ b/goTool/cmd_serve.go @@ -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 { diff --git a/goTool/dashboard.go b/goTool/dashboard.go index 216b4cd..1fb8bd3 100644 --- a/goTool/dashboard.go +++ b/goTool/dashboard.go @@ -14,11 +14,12 @@ import ( ) type MasterView struct { - Version uint32 `json:"version"` - GitHash string `json:"git_hash"` - Deadzone uint32 `json:"deadzone,omitempty"` - OK bool `json:"ok"` - Error string `json:"error,omitempty"` + 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"` } type ClientView struct { @@ -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), @@ -101,9 +119,10 @@ func pollDashboard(link *managedSerial, portName string) DashboardState { st.UARTConnected = true st.SerialOK = true st.Master = MasterView{ - Version: ver.GetVersion(), - GitHash: ver.GetGitHash(), - OK: true, + Version: ver.GetVersion(), + GitHash: ver.GetGitHash(), + RunningPartition: ver.GetRunningPartition(), + OK: true, } if dz, err := readDeadzone(link, 0); err == nil { st.Master.Deadzone = dz diff --git a/goTool/ota_upload.go b/goTool/ota_upload.go new file mode 100644 index 0000000..b0e5707 --- /dev/null +++ b/goTool/ota_upload.go @@ -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 +} diff --git a/goTool/webui/index.html b/goTool/webui/index.html index 35e9429..9481c6d 100644 --- a/goTool/webui/index.html +++ b/goTool/webui/index.html @@ -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; + } @@ -168,6 +182,8 @@
Git
+
Partition
+
Deadzone
@@ -281,6 +297,43 @@ + +
+
+
Firmware OTA (A/B)
+
+

+ Lädt eine .bin auf die inaktive OTA-Partition (wie gotool ota). + Während des Uploads pausiert das Live-Polling. +

+
+ +
+
+ + +
+ +
+
+
@@ -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;