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 (
|
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) {
|
||||||
|
|||||||
@ -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 {
|
case "done", "error":
|
||||||
return err
|
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)
|
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 {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ 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"`
|
||||||
|
RunningPartition string `json:"running_partition,omitempty"`
|
||||||
Deadzone uint32 `json:"deadzone,omitempty"`
|
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@ -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),
|
||||||
@ -103,6 +121,7 @@ func pollDashboard(link *managedSerial, portName string) DashboardState {
|
|||||||
st.Master = MasterView{
|
st.Master = MasterView{
|
||||||
Version: ver.GetVersion(),
|
Version: ver.GetVersion(),
|
||||||
GitHash: ver.GetGitHash(),
|
GitHash: ver.GetGitHash(),
|
||||||
|
RunningPartition: ver.GetRunningPartition(),
|
||||||
OK: true,
|
OK: true,
|
||||||
}
|
}
|
||||||
if dz, err := readDeadzone(link, 0); err == nil {
|
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;
|
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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user