powerpods/goTool/ota_upload.go
simon 4bf43d8a5e 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>
2026-05-19 00:45:54 +02:00

253 lines
6.3 KiB
Go

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
}