diff --git a/Makefile b/Makefile index 22668e1..b229cab 100644 --- a/Makefile +++ b/Makefile @@ -18,10 +18,6 @@ default: @echo "Targets: proto_generate gotool-build gotool-clients gotool-version …" @echo "Set PORT=$(PORT) (current) for goTool targets." -proto_generate_uart: - cd main/proto && python ../../libs/nanopb/generator/nanopb_generator.py \ - -I ../../libs/nanopb/generator/proto uart_messages.proto - proto_generate_espnow: cd main/proto && python ../../libs/nanopb/generator/nanopb_generator.py \ -I ../../libs/nanopb/generator/proto esp_now_messages.proto @@ -31,7 +27,15 @@ proto_generate: proto_generate_uart proto_generate_espnow gotool-proto: cd $(GOTOOL_DIR) && protoc --go_out=./pb --go_opt=paths=source_relative \ --go_opt=Muart_messages.proto=powerpod/gotool/pb \ - -I ../main/proto ../main/proto/uart_messages.proto + --go_opt=Mnanopb.proto=powerpod/gotool/pb/nanopb \ + -I ../main/proto \ + -I ../libs/nanopb/generator/proto \ + ../main/proto/uart_messages.proto + @sed -i '/powerpod\/gotool\/pb\/nanopb/d' $(GOTOOL_DIR)/pb/uart_messages.pb.go + +proto_generate_uart: + cd main/proto && python3 ../../libs/nanopb/generator/nanopb_generator.py \ + -I . -I ../../libs/nanopb/generator/proto uart_messages.proto gotool-tidy: cd $(GOTOOL_DIR) && go mod tidy diff --git a/goTool/README.md b/goTool/README.md index 591e978..c01668b 100644 --- a/goTool/README.md +++ b/goTool/README.md @@ -28,6 +28,7 @@ go run . -port /dev/ttyUSB0 clients | `test` | — | Run an automated scenario (JSON configs under `testdata/`) | | `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) | | `ota` | 16–19 | UART firmware upload to master; firmware then pushes to slaves via ESP-NOW | +| `ota-progress` | 21 | Query per-slave ESP-NOW OTA progress on the master (`-client N`, default all) | `clients` requires slaves to have responded to master discover broadcasts first. @@ -72,7 +73,7 @@ HTTP API (used by the web UI): `GET/POST /api/deadzone`, `POST /api/unicast-test | UI / API | Behaviour | |----------|-----------| -| Firmware OTA card | Same as `ota` CLI; progress via WebSocket `ota_progress` events | +| Firmware OTA card | Same as `ota` CLI; WebSocket `ota_progress` with `step` `master` (UART) then `slaves` (ESP-NOW) | | `POST /api/ota` | Upload `.bin` to master only — slaves are updated by firmware over ESP-NOW after `OTA_END` | ```bash @@ -96,8 +97,9 @@ clients (2): ## Regenerate protobuf +From repo root (needs `protoc`, `protoc-gen-go`, and for C also `pip install protobuf`): + ```bash -protoc --go_out=./pb --go_opt=paths=source_relative \ - --go_opt=Muart_messages.proto=powerpod/gotool/pb \ - -I ../main/proto ../main/proto/uart_messages.proto +make gotool-proto # Go: goTool/pb/uart_messages.pb.go +make proto_generate # C: main/proto/*.pb.h, *.pb.c ``` diff --git a/goTool/client_api.go b/goTool/client_api.go index e9cc4b6..39fdd02 100644 --- a/goTool/client_api.go +++ b/goTool/client_api.go @@ -16,6 +16,14 @@ func (m *managedSerial) getVersion() (*pb.VersionResponse, error) { return decodeVersionPayload(payload) } +func (m *managedSerial) getVersionPoll() (*pb.VersionResponse, error) { + payload, err := m.exchangePoll(byte(pb.MessageType_VERSION), "VERSION") + if err != nil { + return nil, err + } + return decodeVersionPayload(payload) +} + func (m *managedSerial) listClients() ([]*pb.ClientInfo, error) { payload, err := m.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO") if err != nil { @@ -24,9 +32,28 @@ func (m *managedSerial) listClients() ([]*pb.ClientInfo, error) { return decodeClientsPayload(payload) } +func (m *managedSerial) listClientsPoll() ([]*pb.ClientInfo, error) { + payload, err := m.exchangePoll(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO") + if err != nil { + return nil, err + } + return decodeClientsPayload(payload) +} + func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) { + return m.accelDeadzoneVia(m.withPort, req) +} + +func (m *managedSerial) AccelDeadzonePoll(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) { + return m.accelDeadzoneVia(m.withPortPoll, req) +} + +func (m *managedSerial) accelDeadzoneVia( + portFn func(func(*serialPort) error) error, + req *pb.AccelDeadzoneRequest, +) (*pb.AccelDeadzoneResponse, error) { var resp *pb.AccelDeadzoneResponse - err := m.withPort(func(sp *serialPort) error { + err := portFn(func(sp *serialPort) error { var e error resp, e = sp.AccelDeadzone(req) return e diff --git a/goTool/cmd_ota_progress.go b/goTool/cmd_ota_progress.go new file mode 100644 index 0000000..708a3d1 --- /dev/null +++ b/goTool/cmd_ota_progress.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "fmt" +) + +func runOtaProgress(sp *serialPort, args []string) error { + fs := flag.NewFlagSet("ota-progress", flag.ExitOnError) + clientID := fs.Uint("client", 0, "slave client id (0 = all in session)") + if err := fs.Parse(args); err != nil { + return err + } + + r, err := QueryOtaSlaveProgress(sp, uint32(*clientID)) + if err != nil { + return err + } + + fmt.Printf("active=%v total=%d aggregate=%d slaves=%d\n", + r.GetActive(), r.GetTotalBytes(), r.GetAggregateBytes(), r.GetSlaveCount()) + for _, s := range r.GetSlaves() { + fmt.Printf(" slave %d: %d / %d bytes status=%d error=%d\n", + s.GetClientId(), s.GetBytesWritten(), s.GetTotalBytes(), + s.GetStatus(), s.GetError()) + } + return nil +} diff --git a/goTool/dashboard.go b/goTool/dashboard.go index 1fb8bd3..2477286 100644 --- a/goTool/dashboard.go +++ b/goTool/dashboard.go @@ -3,6 +3,7 @@ package main import ( "encoding/hex" "encoding/json" + "errors" "fmt" "log" "sync" @@ -105,14 +106,17 @@ func (h *wsHub) broadcastRaw(v any) { } } -func pollDashboard(link *managedSerial, portName string) DashboardState { +func pollDashboard(link *managedSerial, portName string, last *DashboardState) DashboardState { st := DashboardState{ UpdatedAt: time.Now().Format(time.RFC3339), SerialPort: portName, Clients: []ClientView{}, } - ver, err := link.getVersion() + ver, err := link.getVersionPoll() + if errors.Is(err, errUARTBusy) { + return pausedPollState(portName, last) + } if err != nil { return disconnectedState(portName, err) } @@ -124,12 +128,15 @@ func pollDashboard(link *managedSerial, portName string) DashboardState { RunningPartition: ver.GetRunningPartition(), OK: true, } - if dz, err := readDeadzone(link, 0); err == nil { + if dz, err := readDeadzonePoll(link, 0); err == nil { st.Master.Deadzone = dz } - clients, err := link.listClients() + clients, err := link.listClientsPoll() if err != nil { + if errors.Is(err, errUARTBusy) { + return pausedPollState(portName, last) + } st.SerialOK = false st.SerialError = err.Error() st.UARTConnected = link.IsConnected() @@ -146,7 +153,7 @@ func pollDashboard(link *managedSerial, portName string) DashboardState { LastPing: c.GetLastPing(), LastSuccessPing: c.GetLastSuccessPing(), } - if dz, err := readDeadzone(link, c.GetId()); err == nil { + if dz, err := readDeadzonePoll(link, c.GetId()); err == nil { cv.Deadzone = dz } st.Clients = append(st.Clients, cv) @@ -154,6 +161,18 @@ func pollDashboard(link *managedSerial, portName string) DashboardState { return st } +func pausedPollState(portName string, last *DashboardState) DashboardState { + if last != nil && last.UARTConnected { + st := *last + st.UpdatedAt = time.Now().Format(time.RFC3339) + st.SerialPort = portName + st.SerialOK = true + st.SerialError = "Live-Polling pausiert (OTA läuft)" + return st + } + return disconnectedState(portName, errUARTBusy) +} + func readDeadzone(link *managedSerial, clientID uint32) (uint32, error) { r, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{ Write: false, @@ -168,6 +187,20 @@ func readDeadzone(link *managedSerial, clientID uint32) (uint32, error) { return r.GetDeadzone(), nil } +func readDeadzonePoll(link *managedSerial, clientID uint32) (uint32, error) { + r, err := link.AccelDeadzonePoll(&pb.AccelDeadzoneRequest{ + Write: false, + ClientId: clientID, + }) + if err != nil { + return 0, err + } + if !r.GetSuccess() { + return 0, fmt.Errorf("deadzone read failed for client %d", clientID) + } + return r.GetDeadzone(), nil +} + func formatMAC(mac []byte) string { if len(mac) == 0 { return "" @@ -180,8 +213,12 @@ func runPoller(link *managedSerial, portName string, hub *wsHub, interval time.D defer ticker.Stop() uartUp := false + var lastGood DashboardState publish := func() { - st := pollDashboard(link, portName) + st := pollDashboard(link, portName, &lastGood) + if st.UARTConnected && st.SerialOK { + lastGood = st + } if st.UARTConnected && !uartUp { log.Printf("UART %s connected", portName) } diff --git a/goTool/main.go b/goTool/main.go index fee006d..b10b7a1 100644 --- a/goTool/main.go +++ b/goTool/main.go @@ -19,7 +19,8 @@ func usage() { fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n") fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n") fmt.Fprintf(os.Stderr, " serve web dashboard (Bootstrap + WebSocket)\n") - fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions)\n\n") + fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions)\n") + fmt.Fprintf(os.Stderr, " ota-progress query per-slave ESP-NOW OTA progress on master\n\n") flag.PrintDefaults() } @@ -46,7 +47,7 @@ func main() { os.Exit(2) } runErr = runServe(*portName, *baud, flag.Args()[1:]) - case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test", "ota": + case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test", "ota", "ota-progress", "ota_progress": if *portName == "" { fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd) usage() @@ -68,6 +69,8 @@ func main() { runErr = runUnicastTest(sp, flag.Args()[1:]) case "ota": runErr = runOTA(sp, flag.Args()[1:]) + case "ota-progress", "ota_progress": + runErr = runOtaProgress(sp, flag.Args()[1:]) } default: fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd) diff --git a/goTool/ota_upload.go b/goTool/ota_upload.go index b0e5707..d3089e4 100644 --- a/goTool/ota_upload.go +++ b/goTool/ota_upload.go @@ -12,32 +12,62 @@ import ( ) const ( - otaHostChunkSize = 200 - otaFlashBlockSize = 4096 - otaPrepareTimeout = 120 * time.Second - otaDefaultTimeout = 15 * time.Second + otaHostChunkSize = 200 + otaFlashBlockSize = 4096 + otaPrepareTimeout = 120 * time.Second + otaDefaultTimeout = 15 * time.Second + otaStatusPollTimeout = 3 * time.Second + otaDistReadTimeout = 400 * time.Millisecond + otaDistQueryInterval = 500 * time.Millisecond + otaDistQueryTimeout = 2 * time.Second + otaDistEmitMinInterval = 150 * time.Millisecond ) const ( - otaStPreparing = 1 - otaStReady = 2 - otaStBlockAck = 3 - otaStSuccess = 4 - otaStFailed = 5 + otaStPreparing = 1 + otaStReady = 2 + otaStBlockAck = 3 + otaStSuccess = 4 + otaStFailed = 5 + otaStDistributing = 6 + otaDistAggregate = 0 + otaDistPerSlave = 1 + otaDistTimeout = 45 * time.Minute ) +// OtaSlaveDetail is per-slave ESP-NOW OTA state from OTA_SLAVE_PROGRESS. +type OtaSlaveDetail struct { + BytesWritten uint32 `json:"bytes_written"` + TotalBytes uint32 `json:"total_bytes"` + Status uint32 `json:"status"` + Error uint32 `json:"error"` +} + // 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 string `json:"type"` // always "ota_progress" + Phase string `json:"phase"` + Step string `json:"step,omitempty"` // master, slaves + Percent int `json:"percent"` + MasterPercent int `json:"master_percent,omitempty"` + MasterDone bool `json:"master_done,omitempty"` + Message string `json:"message"` + MasterMessage string `json:"master_message,omitempty"` + Bytes uint32 `json:"bytes_written,omitempty"` + Slot uint32 `json:"target_slot,omitempty"` + Slaves uint32 `json:"slaves,omitempty"` + ImageSize uint32 `json:"image_size,omitempty"` + SlaveProgress map[uint32]uint32 `json:"slave_progress,omitempty"` // client_id -> bytes + SlaveDetails map[uint32]OtaSlaveDetail `json:"slave_details,omitempty"` } type otaProgressFn func(OTAProgress) +const ( + otaStepMaster = "master" + otaStepSlaves = "slaves" +) + func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) error { m.mu.Lock() defer m.mu.Unlock() @@ -52,69 +82,95 @@ func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgr if len(firmware) == 0 { return fmt.Errorf("empty firmware") } - notify := func(phase string, percent int, msg string, extra ...OTAProgress) { + imageSize := len(firmware) + masterPct := 0 + masterMsg := "" + notify := func(phase, step string, percent int, msg string, extra ...OTAProgress) { if onProgress == nil { return } - p := OTAProgress{Type: "ota_progress", Phase: phase, Percent: percent, Message: msg} + p := OTAProgress{ + Type: "ota_progress", Phase: phase, Step: step, + Percent: percent, Message: msg, + ImageSize: uint32(imageSize), + } + if step == otaStepMaster || phase == "preparing" || phase == "ready" || phase == "uploading" { + masterPct = percent + masterMsg = msg + } + p.MasterPercent = masterPct + p.MasterMessage = masterMsg + if step == otaStepSlaves || phase == "distributing" || phase == "done" { + p.MasterDone = true + } if len(extra) > 0 { - p.Bytes = extra[0].Bytes - p.Slot = extra[0].Slot + e := extra[0] + p.Bytes = e.Bytes + p.Slot = e.Slot + p.Slaves = e.Slaves + p.SlaveProgress = e.SlaveProgress + p.SlaveDetails = e.SlaveDetails + if e.MasterPercent > 0 { + p.MasterPercent = e.MasterPercent + } + if e.MasterMessage != "" { + p.MasterMessage = e.MasterMessage + } } onProgress(p) } if m.sp == nil { if err := m.openLocked(); err != nil { - notify("error", 0, err.Error()) + notify("error", "", 0, err.Error()) return err } } sp := m.sp if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil { - notify("error", 0, err.Error()) + notify("error", "", 0, err.Error()) return err } defer sp.port.SetReadTimeout(readTimeout) - notify("preparing", 0, fmt.Sprintf("OTA start (%d bytes)…", len(firmware))) + notify("preparing", otaStepMaster, 0, fmt.Sprintf("Master: OTA start (%d bytes)…", imageSize)) if err := writeUartMessage(sp, &pb.UartMessage{ Type: pb.MessageType_OTA_START, Payload: &pb.UartMessage_OtaStart{ - OtaStart: &pb.OtaStartPayload{TotalSize: uint32(len(firmware))}, + OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)}, }, }, false); err != nil { - notify("error", 0, err.Error()) + notify("error", "", 0, err.Error()) return err } ready, err := waitOtaStatus(sp, otaStReady, otaPrepareTimeout, func(msg string) { - notify("preparing", 2, msg) + notify("preparing", otaStepMaster, 2, msg) }) if err != nil { - notify("error", 0, err.Error()) + notify("error", "", 0, err.Error()) return err } - notify("ready", 5, fmt.Sprintf("Ziel-Slot %d bereit", ready.GetTargetSlot())) + notify("ready", otaStepMaster, 5, fmt.Sprintf("Master: Slot %d bereit", ready.GetTargetSlot())) if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil { - notify("error", 0, err.Error()) + notify("error", "", 0, err.Error()) return err } var seq uint32 - for offset := 0; offset < len(firmware); { + for offset := 0; offset < imageSize; { bytesInBlock := 0 - for bytesInBlock < otaFlashBlockSize && offset < len(firmware) { + for bytesInBlock < otaFlashBlockSize && offset < imageSize { n := otaHostChunkSize room := otaFlashBlockSize - bytesInBlock if n > room { n = room } - if offset+n > len(firmware) { - n = len(firmware) - offset + if offset+n > imageSize { + n = imageSize - offset } chunk := firmware[offset : offset+n] @@ -124,55 +180,313 @@ func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgr OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk}, }, }, false); err != nil { - notify("error", 0, err.Error()) + 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))) + pct := offset * 100 / imageSize + if pct > 99 { + pct = 99 + } + notify("uploading", otaStepMaster, pct, fmt.Sprintf("Master: %d / %d bytes", offset, imageSize)) } if bytesInBlock == otaFlashBlockSize { st, err := waitOtaStatus(sp, otaStBlockAck, otaDefaultTimeout, nil) if err != nil { - notify("error", 0, err.Error()) + 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()), + pct := offset * 100 / imageSize + if pct > 99 { + pct = 99 + } + notify("uploading", otaStepMaster, pct, + fmt.Sprintf("Master: Block geschrieben (%d bytes)", st.GetBytesWritten()), OTAProgress{Bytes: st.GetBytesWritten()}) } } + masterPct = 100 + masterMsg = "Master: UART-Upload abgeschlossen" + notify("uploading", otaStepMaster, 100, masterMsg) + 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()) + notify("error", "", 0, err.Error()) return err } - st, err := readOtaStatus(sp) + slaveBytes := make(map[uint32]uint32) + slaveDetails := make(map[uint32]OtaSlaveDetail) + + emitSlaveOTA := func(msg string, aggBytes uint32, slaveCount uint32) { + if slaveCount == 0 && len(slaveDetails) > 0 { + slaveCount = uint32(len(slaveDetails)) + } + notify("distributing", otaStepSlaves, 0, msg, + OTAProgress{ + Bytes: aggBytes, Slaves: slaveCount, + MasterPercent: 100, MasterMessage: masterMsg, + SlaveProgress: copySlaveMap(slaveBytes), + SlaveDetails: copySlaveDetails(slaveDetails), + }) + } + + onDistStatus := func(st *pb.OtaStatusPayload) { + applyDistributingOtaStatus(st, imageSize, slaveBytes, slaveDetails) + } + + var lastEmit, lastQuery time.Time + slaveDistMessage := func() (msg string, aggBytes, slaveCount uint32) { + slaveCount = uint32(len(slaveDetails)) + for _, d := range slaveDetails { + if d.BytesWritten > aggBytes { + aggBytes = d.BytesWritten + } + } + if slaveCount == 0 { + return "Keine verfügbaren Slaves — Verteilung übersprungen", 0, 0 + } + return fmt.Sprintf("ESP-NOW: %d / %d bytes (%d Slaves)", + aggBytes, imageSize, slaveCount), aggBytes, slaveCount + } + + emitSlaveThrottled := func(force bool) { + if !force && time.Since(lastEmit) < otaDistEmitMinInterval { + return + } + lastEmit = time.Now() + msg, agg, n := slaveDistMessage() + emitSlaveOTA(msg, agg, n) + } + + querySlaveProgress := func() { + if time.Since(lastQuery) < otaDistQueryInterval { + return + } + lastQuery = time.Now() + prog, err := queryOtaSlaveProgressLocked(sp, 0, onDistStatus, otaDistQueryTimeout) + if err != nil { + if len(slaveDetails) > 0 { + emitSlaveThrottled(true) + } + return + } + mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails) + emitSlaveThrottled(true) + } + + pushSlaveDist := func(st *pb.OtaStatusPayload) { + onDistStatus(st) + emitSlaveThrottled(false) + } + + onWaitTick := func() { + querySlaveProgress() + } + + lastQuery = time.Time{} // first query immediately when distribution starts + querySlaveProgress() + st, err := waitOtaComplete(sp, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout) 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()) + 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()}) + if prog, err := queryOtaSlaveProgressLocked(sp, 0, nil, otaDistQueryTimeout); err == nil { + mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails) + } + notify("done", "", 100, + fmt.Sprintf("Fertig — %d bytes, Boot-Slot %d. Master und Slaves neu starten.", + st.GetBytesWritten(), st.GetTargetSlot()), + OTAProgress{ + Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot(), + MasterPercent: 100, MasterMessage: "Master: OK", + SlaveProgress: copySlaveMap(slaveBytes), + SlaveDetails: copySlaveDetails(slaveDetails), + }) return nil } +// QueryOtaSlaveProgress queries the master for per-slave ESP-NOW OTA progress. +func QueryOtaSlaveProgress(sp *serialPort, clientID uint32) (*pb.OtaSlaveProgressResponse, error) { + sp.mu.Lock() + defer sp.mu.Unlock() + return queryOtaSlaveProgressLocked(sp, clientID, nil, otaDefaultTimeout) +} + +func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32, + onStatus func(*pb.OtaStatusPayload), queryTimeout time.Duration) (*pb.OtaSlaveProgressResponse, error) { + req := &pb.UartMessage{ + Type: pb.MessageType_OTA_SLAVE_PROGRESS, + Payload: &pb.UartMessage_OtaSlaveProgressRequest{ + OtaSlaveProgressRequest: &pb.OtaSlaveProgressRequest{ + ClientId: clientID, + }, + }, + } + if err := writeUartMessage(sp, req, false); err != nil { + return nil, err + } + if queryTimeout <= 0 { + queryTimeout = otaDefaultTimeout + } + deadline := time.Now().Add(queryTimeout) + msg, err := readUartMessageUntil(sp, deadline, pb.MessageType_OTA_SLAVE_PROGRESS, onStatus, otaDistReadTimeout) + if err != nil { + return nil, err + } + r := msg.GetOtaSlaveProgressResponse() + if r == nil { + return nil, fmt.Errorf("missing ota_slave_progress_response") + } + return r, nil +} + +func applyDistributingOtaStatus(st *pb.OtaStatusPayload, imageSize int, + slaveBytes map[uint32]uint32, details map[uint32]OtaSlaveDetail) { + if st == nil || st.GetStatus() != otaStDistributing { + return + } + if st.GetError() != otaDistPerSlave { + return + } + id := st.GetTargetSlot() + bw := st.GetBytesWritten() + slaveBytes[id] = bw + d := details[id] + d.BytesWritten = bw + if d.TotalBytes == 0 { + d.TotalBytes = uint32(imageSize) + } + if d.Status == 0 || d.Status == 1 || d.Status == 2 { + d.Status = 3 + } + details[id] = d +} + +func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageType, + onStatus func(*pb.OtaStatusPayload), readChunk time.Duration) (*pb.UartMessage, error) { + if readChunk <= 0 { + readChunk = otaStatusPollTimeout + } + for { + if time.Now().After(deadline) { + return nil, fmt.Errorf("timeout waiting for %v", want) + } + wait := time.Until(deadline) + if wait > readChunk { + wait = readChunk + } + if err := sp.port.SetReadTimeout(wait); err != nil { + return nil, err + } + payload, err := uartframe.ReadFrame(sp.port, nil) + if err != nil { + return nil, err + } + msg, err := decodeUartPayload(payload) + if err != nil { + continue + } + if msg.GetType() == pb.MessageType_OTA_STATUS { + if onStatus != nil { + if st := msg.GetOtaStatus(); st != nil { + onStatus(st) + } + } + continue + } + if msg.GetType() == want { + return msg, nil + } + } +} + +func mergeSlaveProgressResponse(r *pb.OtaSlaveProgressResponse, + bytesOut map[uint32]uint32, detailsOut map[uint32]OtaSlaveDetail) { + if r == nil { + return + } + for _, s := range r.GetSlaves() { + id := s.GetClientId() + bytesOut[id] = s.GetBytesWritten() + detailsOut[id] = OtaSlaveDetail{ + BytesWritten: s.GetBytesWritten(), + TotalBytes: s.GetTotalBytes(), + Status: s.GetStatus(), + Error: s.GetError(), + } + } +} + +func copySlaveDetails(m map[uint32]OtaSlaveDetail) map[uint32]OtaSlaveDetail { + out := make(map[uint32]OtaSlaveDetail, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func copySlaveMap(m map[uint32]uint32) map[uint32]uint32 { + out := make(map[uint32]uint32, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func waitOtaComplete(sp *serialPort, timeout time.Duration, + onDistributing func(*pb.OtaStatusPayload), onInterval func(), + readTimeout time.Duration) (*pb.OtaStatusPayload, error) { + if readTimeout <= 0 { + readTimeout = otaStatusPollTimeout + } + deadline := time.Now().Add(timeout) + for { + if time.Now().After(deadline) { + return nil, fmt.Errorf("timeout waiting for OTA success (slave distribution?)") + } + readWait := time.Until(deadline) + if readWait > readTimeout { + readWait = readTimeout + } + if err := sp.port.SetReadTimeout(readWait); err != nil { + return nil, err + } + st, err := readOtaStatus(sp) + if err != nil { + if onInterval != nil { + onInterval() + } + continue + } + switch st.GetStatus() { + case otaStSuccess: + return st, nil + case otaStFailed: + return nil, fmt.Errorf("OTA failed (error=%d)", st.GetError()) + case otaStDistributing: + if onDistributing != nil { + onDistributing(st) + } + if onInterval != nil { + onInterval() + } + default: + // ignore other interim statuses + } + } +} + func writeUartMessage(sp *serialPort, msg *pb.UartMessage, logFrame bool) error { frame, err := encodeUartMessage(msg) if err != nil { diff --git a/goTool/pb/uart_messages.pb.go b/goTool/pb/uart_messages.pb.go index a34769f..f738ee7 100644 --- a/goTool/pb/uart_messages.pb.go +++ b/goTool/pb/uart_messages.pb.go @@ -37,6 +37,7 @@ const ( MessageType_OTA_END MessageType = 18 MessageType_OTA_STATUS MessageType = 19 MessageType_OTA_START_ESPNOW MessageType = 20 + MessageType_OTA_SLAVE_PROGRESS MessageType = 21 ) // Enum value maps for MessageType. @@ -55,6 +56,7 @@ var ( 18: "OTA_END", 19: "OTA_STATUS", 20: "OTA_START_ESPNOW", + 21: "OTA_SLAVE_PROGRESS", } MessageType_value = map[string]int32{ "UNKNOWN": 0, @@ -70,6 +72,7 @@ var ( "OTA_END": 18, "OTA_STATUS": 19, "OTA_START_ESPNOW": 20, + "OTA_SLAVE_PROGRESS": 21, } ) @@ -118,6 +121,8 @@ type UartMessage struct { // *UartMessage_AccelDeadzoneResponse // *UartMessage_EspnowUnicastTestRequest // *UartMessage_EspnowUnicastTestResponse + // *UartMessage_OtaSlaveProgressRequest + // *UartMessage_OtaSlaveProgressResponse Payload isUartMessage_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -284,6 +289,24 @@ func (x *UartMessage) GetEspnowUnicastTestResponse() *EspNowUnicastTestResponse return nil } +func (x *UartMessage) GetOtaSlaveProgressRequest() *OtaSlaveProgressRequest { + if x != nil { + if x, ok := x.Payload.(*UartMessage_OtaSlaveProgressRequest); ok { + return x.OtaSlaveProgressRequest + } + } + return nil +} + +func (x *UartMessage) GetOtaSlaveProgressResponse() *OtaSlaveProgressResponse { + if x != nil { + if x, ok := x.Payload.(*UartMessage_OtaSlaveProgressResponse); ok { + return x.OtaSlaveProgressResponse + } + } + return nil +} + type isUartMessage_Payload interface { isUartMessage_Payload() } @@ -340,6 +363,14 @@ type UartMessage_EspnowUnicastTestResponse struct { EspnowUnicastTestResponse *EspNowUnicastTestResponse `protobuf:"bytes,14,opt,name=espnow_unicast_test_response,json=espnowUnicastTestResponse,proto3,oneof"` } +type UartMessage_OtaSlaveProgressRequest struct { + OtaSlaveProgressRequest *OtaSlaveProgressRequest `protobuf:"bytes,15,opt,name=ota_slave_progress_request,json=otaSlaveProgressRequest,proto3,oneof"` +} + +type UartMessage_OtaSlaveProgressResponse struct { + OtaSlaveProgressResponse *OtaSlaveProgressResponse `protobuf:"bytes,16,opt,name=ota_slave_progress_response,json=otaSlaveProgressResponse,proto3,oneof"` +} + func (*UartMessage_AckPayload) isUartMessage_Payload() {} func (*UartMessage_EchoPayload) isUartMessage_Payload() {} @@ -366,6 +397,10 @@ func (*UartMessage_EspnowUnicastTestRequest) isUartMessage_Payload() {} func (*UartMessage_EspnowUnicastTestResponse) isUartMessage_Payload() {} +func (*UartMessage_OtaSlaveProgressRequest) isUartMessage_Payload() {} + +func (*UartMessage_OtaSlaveProgressResponse) isUartMessage_Payload() {} + type Ack struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1134,7 +1169,7 @@ func (*OtaEndPayload) Descriptor() ([]byte, []int) { } // Device → host status (also used as ACK after each 4 KiB written). -// status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed +// status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed, 6=distributing type OtaStatusPayload struct { state protoimpl.MessageState `protogen:"open.v1"` Status uint32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` @@ -1203,11 +1238,209 @@ func (x *OtaStatusPayload) GetError() uint32 { return 0 } +// Host → master: query ESP-NOW slave OTA progress (client_id 0 = all slaves in session). +type OtaSlaveProgressRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId uint32 `protobuf:"varint,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OtaSlaveProgressRequest) Reset() { + *x = OtaSlaveProgressRequest{} + mi := &file_uart_messages_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OtaSlaveProgressRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OtaSlaveProgressRequest) ProtoMessage() {} + +func (x *OtaSlaveProgressRequest) ProtoReflect() protoreflect.Message { + mi := &file_uart_messages_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OtaSlaveProgressRequest.ProtoReflect.Descriptor instead. +func (*OtaSlaveProgressRequest) Descriptor() ([]byte, []int) { + return file_uart_messages_proto_rawDescGZIP(), []int{16} +} + +func (x *OtaSlaveProgressRequest) GetClientId() uint32 { + if x != nil { + return x.ClientId + } + return 0 +} + +type OtaSlaveProgressEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId uint32 `protobuf:"varint,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + BytesWritten uint32 `protobuf:"varint,2,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` + TotalBytes uint32 `protobuf:"varint,3,opt,name=total_bytes,json=totalBytes,proto3" json:"total_bytes,omitempty"` + // * 0=idle, 1=preparing, 2=ready, 3=distributing, 4=success, 5=failed + Status uint32 `protobuf:"varint,4,opt,name=status,proto3" json:"status,omitempty"` + Error uint32 `protobuf:"varint,5,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OtaSlaveProgressEntry) Reset() { + *x = OtaSlaveProgressEntry{} + mi := &file_uart_messages_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OtaSlaveProgressEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OtaSlaveProgressEntry) ProtoMessage() {} + +func (x *OtaSlaveProgressEntry) ProtoReflect() protoreflect.Message { + mi := &file_uart_messages_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OtaSlaveProgressEntry.ProtoReflect.Descriptor instead. +func (*OtaSlaveProgressEntry) Descriptor() ([]byte, []int) { + return file_uart_messages_proto_rawDescGZIP(), []int{17} +} + +func (x *OtaSlaveProgressEntry) GetClientId() uint32 { + if x != nil { + return x.ClientId + } + return 0 +} + +func (x *OtaSlaveProgressEntry) GetBytesWritten() uint32 { + if x != nil { + return x.BytesWritten + } + return 0 +} + +func (x *OtaSlaveProgressEntry) GetTotalBytes() uint32 { + if x != nil { + return x.TotalBytes + } + return 0 +} + +func (x *OtaSlaveProgressEntry) GetStatus() uint32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *OtaSlaveProgressEntry) GetError() uint32 { + if x != nil { + return x.Error + } + return 0 +} + +type OtaSlaveProgressResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Active bool `protobuf:"varint,1,opt,name=active,proto3" json:"active,omitempty"` + TotalBytes uint32 `protobuf:"varint,2,opt,name=total_bytes,json=totalBytes,proto3" json:"total_bytes,omitempty"` + AggregateBytes uint32 `protobuf:"varint,3,opt,name=aggregate_bytes,json=aggregateBytes,proto3" json:"aggregate_bytes,omitempty"` + SlaveCount uint32 `protobuf:"varint,4,opt,name=slave_count,json=slaveCount,proto3" json:"slave_count,omitempty"` + Slaves []*OtaSlaveProgressEntry `protobuf:"bytes,5,rep,name=slaves,proto3" json:"slaves,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OtaSlaveProgressResponse) Reset() { + *x = OtaSlaveProgressResponse{} + mi := &file_uart_messages_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OtaSlaveProgressResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OtaSlaveProgressResponse) ProtoMessage() {} + +func (x *OtaSlaveProgressResponse) ProtoReflect() protoreflect.Message { + mi := &file_uart_messages_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OtaSlaveProgressResponse.ProtoReflect.Descriptor instead. +func (*OtaSlaveProgressResponse) Descriptor() ([]byte, []int) { + return file_uart_messages_proto_rawDescGZIP(), []int{18} +} + +func (x *OtaSlaveProgressResponse) GetActive() bool { + if x != nil { + return x.Active + } + return false +} + +func (x *OtaSlaveProgressResponse) GetTotalBytes() uint32 { + if x != nil { + return x.TotalBytes + } + return 0 +} + +func (x *OtaSlaveProgressResponse) GetAggregateBytes() uint32 { + if x != nil { + return x.AggregateBytes + } + return 0 +} + +func (x *OtaSlaveProgressResponse) GetSlaveCount() uint32 { + if x != nil { + return x.SlaveCount + } + return 0 +} + +func (x *OtaSlaveProgressResponse) GetSlaves() []*OtaSlaveProgressEntry { + if x != nil { + return x.Slaves + } + return nil +} + var File_uart_messages_proto protoreflect.FileDescriptor const file_uart_messages_proto_rawDesc = "" + "\n" + - "\x13uart_messages.proto\x12\x04alox\"\xcc\a\n" + + "\x13uart_messages.proto\x12\x04alox\x1a\fnanopb.proto\"\x8b\t\n" + "\vUartMessage\x12%\n" + "\x04type\x18\x01 \x01(\x0e2\x11.alox.MessageTypeR\x04type\x12,\n" + "\vack_payload\x18\x02 \x01(\v2\t.alox.AckH\x00R\n" + @@ -1226,7 +1459,9 @@ const file_uart_messages_proto_rawDesc = "" + "\x16accel_deadzone_request\x18\v \x01(\v2\x1a.alox.AccelDeadzoneRequestH\x00R\x14accelDeadzoneRequest\x12U\n" + "\x17accel_deadzone_response\x18\f \x01(\v2\x1b.alox.AccelDeadzoneResponseH\x00R\x15accelDeadzoneResponse\x12_\n" + "\x1bespnow_unicast_test_request\x18\r \x01(\v2\x1e.alox.EspNowUnicastTestRequestH\x00R\x18espnowUnicastTestRequest\x12b\n" + - "\x1cespnow_unicast_test_response\x18\x0e \x01(\v2\x1f.alox.EspNowUnicastTestResponseH\x00R\x19espnowUnicastTestResponseB\t\n" + + "\x1cespnow_unicast_test_response\x18\x0e \x01(\v2\x1f.alox.EspNowUnicastTestResponseH\x00R\x19espnowUnicastTestResponse\x12\\\n" + + "\x1aota_slave_progress_request\x18\x0f \x01(\v2\x1d.alox.OtaSlaveProgressRequestH\x00R\x17otaSlaveProgressRequest\x12_\n" + + "\x1bota_slave_progress_response\x18\x10 \x01(\v2\x1e.alox.OtaSlaveProgressResponseH\x00R\x18otaSlaveProgressResponseB\t\n" + "\apayload\"\x05\n" + "\x03Ack\"!\n" + "\vEchoPayload\x12\x12\n" + @@ -1272,18 +1507,35 @@ const file_uart_messages_proto_rawDesc = "" + "\x03seq\x18\x02 \x01(\rR\x03seq\"0\n" + "\x0fOtaStartPayload\x12\x1d\n" + "\n" + - "total_size\x18\x01 \x01(\rR\ttotalSize\"2\n" + + "total_size\x18\x01 \x01(\rR\ttotalSize\":\n" + "\n" + "OtaPayload\x12\x10\n" + - "\x03seq\x18\x01 \x01(\rR\x03seq\x12\x12\n" + - "\x04data\x18\x02 \x01(\fR\x04data\"\x0f\n" + + "\x03seq\x18\x01 \x01(\rR\x03seq\x12\x1a\n" + + "\x04data\x18\x02 \x01(\fB\x06\x92?\x03\b\xc8\x01R\x04data\"\x0f\n" + "\rOtaEndPayload\"\x86\x01\n" + "\x10OtaStatusPayload\x12\x16\n" + "\x06status\x18\x01 \x01(\rR\x06status\x12#\n" + "\rbytes_written\x18\x02 \x01(\rR\fbytesWritten\x12\x1f\n" + "\vtarget_slot\x18\x03 \x01(\rR\n" + "targetSlot\x12\x14\n" + - "\x05error\x18\x04 \x01(\rR\x05error*\xdd\x01\n" + + "\x05error\x18\x04 \x01(\rR\x05error\"6\n" + + "\x17OtaSlaveProgressRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\rR\bclientId\"\xa8\x01\n" + + "\x15OtaSlaveProgressEntry\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\rR\bclientId\x12#\n" + + "\rbytes_written\x18\x02 \x01(\rR\fbytesWritten\x12\x1f\n" + + "\vtotal_bytes\x18\x03 \x01(\rR\n" + + "totalBytes\x12\x16\n" + + "\x06status\x18\x04 \x01(\rR\x06status\x12\x14\n" + + "\x05error\x18\x05 \x01(\rR\x05error\"\xd9\x01\n" + + "\x18OtaSlaveProgressResponse\x12\x16\n" + + "\x06active\x18\x01 \x01(\bR\x06active\x12\x1f\n" + + "\vtotal_bytes\x18\x02 \x01(\rR\n" + + "totalBytes\x12'\n" + + "\x0faggregate_bytes\x18\x03 \x01(\rR\x0eaggregateBytes\x12\x1f\n" + + "\vslave_count\x18\x04 \x01(\rR\n" + + "slaveCount\x12:\n" + + "\x06slaves\x18\x05 \x03(\v2\x1b.alox.OtaSlaveProgressEntryB\x05\x92?\x02\x10\x10R\x06slaves*\xf5\x01\n" + "\vMessageType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\a\n" + "\x03ACK\x10\x01\x12\b\n" + @@ -1298,7 +1550,8 @@ const file_uart_messages_proto_rawDesc = "" + "\aOTA_END\x10\x12\x12\x0e\n" + "\n" + "OTA_STATUS\x10\x13\x12\x14\n" + - "\x10OTA_START_ESPNOW\x10\x14b\x06proto3" + "\x10OTA_START_ESPNOW\x10\x14\x12\x16\n" + + "\x12OTA_SLAVE_PROGRESS\x10\x15b\x06proto3" var ( file_uart_messages_proto_rawDescOnce sync.Once @@ -1313,7 +1566,7 @@ func file_uart_messages_proto_rawDescGZIP() []byte { } var file_uart_messages_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_uart_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_uart_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_uart_messages_proto_goTypes = []any{ (MessageType)(0), // 0: alox.MessageType (*UartMessage)(nil), // 1: alox.UartMessage @@ -1332,6 +1585,9 @@ var file_uart_messages_proto_goTypes = []any{ (*OtaPayload)(nil), // 14: alox.OtaPayload (*OtaEndPayload)(nil), // 15: alox.OtaEndPayload (*OtaStatusPayload)(nil), // 16: alox.OtaStatusPayload + (*OtaSlaveProgressRequest)(nil), // 17: alox.OtaSlaveProgressRequest + (*OtaSlaveProgressEntry)(nil), // 18: alox.OtaSlaveProgressEntry + (*OtaSlaveProgressResponse)(nil), // 19: alox.OtaSlaveProgressResponse } var file_uart_messages_proto_depIdxs = []int32{ 0, // 0: alox.UartMessage.type:type_name -> alox.MessageType @@ -1348,13 +1604,16 @@ var file_uart_messages_proto_depIdxs = []int32{ 10, // 11: alox.UartMessage.accel_deadzone_response:type_name -> alox.AccelDeadzoneResponse 11, // 12: alox.UartMessage.espnow_unicast_test_request:type_name -> alox.EspNowUnicastTestRequest 12, // 13: alox.UartMessage.espnow_unicast_test_response:type_name -> alox.EspNowUnicastTestResponse - 5, // 14: alox.ClientInfoResponse.clients:type_name -> alox.ClientInfo - 7, // 15: alox.ClientInputResponse.clients:type_name -> alox.ClientInput - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 17, // 14: alox.UartMessage.ota_slave_progress_request:type_name -> alox.OtaSlaveProgressRequest + 19, // 15: alox.UartMessage.ota_slave_progress_response:type_name -> alox.OtaSlaveProgressResponse + 5, // 16: alox.ClientInfoResponse.clients:type_name -> alox.ClientInfo + 7, // 17: alox.ClientInputResponse.clients:type_name -> alox.ClientInput + 18, // 18: alox.OtaSlaveProgressResponse.slaves:type_name -> alox.OtaSlaveProgressEntry + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_uart_messages_proto_init() } @@ -1376,6 +1635,8 @@ func file_uart_messages_proto_init() { (*UartMessage_AccelDeadzoneResponse)(nil), (*UartMessage_EspnowUnicastTestRequest)(nil), (*UartMessage_EspnowUnicastTestResponse)(nil), + (*UartMessage_OtaSlaveProgressRequest)(nil), + (*UartMessage_OtaSlaveProgressResponse)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -1383,7 +1644,7 @@ func file_uart_messages_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_uart_messages_proto_rawDesc), len(file_uart_messages_proto_rawDesc)), NumEnums: 1, - NumMessages: 16, + NumMessages: 19, NumExtensions: 0, NumServices: 0, }, diff --git a/goTool/serial_link.go b/goTool/serial_link.go index 2e6ec50..2179367 100644 --- a/goTool/serial_link.go +++ b/goTool/serial_link.go @@ -1,12 +1,16 @@ package main import ( + "errors" "fmt" "log" "sync" "time" ) +// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug). +var errUARTBusy = errors.New("uart busy (OTA in progress)") + // managedSerial keeps the UART open and reconnects after I/O failures or unplug. type managedSerial struct { portName string @@ -69,7 +73,22 @@ func (m *managedSerial) invalidateLocked(reason error) { } func (m *managedSerial) withPort(fn func(*serialPort) error) error { - m.mu.Lock() + return m.withPortLocked(false, fn) +} + +// withPortPoll is like withPort but returns errUARTBusy instead of blocking during OTA. +func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error { + return m.withPortLocked(true, fn) +} + +func (m *managedSerial) withPortLocked(try bool, fn func(*serialPort) error) error { + if try { + if !m.mu.TryLock() { + return errUARTBusy + } + } else { + m.mu.Lock() + } defer m.mu.Unlock() if m.sp == nil { @@ -86,8 +105,19 @@ func (m *managedSerial) withPort(fn func(*serialPort) error) error { } func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) { + return m.exchangePayloadVia(m.withPort, payload, cmdName) +} + +func (m *managedSerial) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) { + return m.exchangePayloadVia(m.withPortPoll, payload, cmdName) +} + +func (m *managedSerial) exchangePayloadVia( + portFn func(func(*serialPort) error) error, + payload []byte, cmdName string, +) ([]byte, error) { var resp []byte - err := m.withPort(func(sp *serialPort) error { + err := portFn(func(sp *serialPort) error { var e error resp, e = sp.exchangePayloadLocked(payload, cmdName) return e @@ -96,8 +126,19 @@ func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, } func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) { + return m.exchangeVia(m.withPort, cmdID, cmdName) +} + +func (m *managedSerial) exchangePoll(cmdID byte, cmdName string) ([]byte, error) { + return m.exchangeVia(m.withPortPoll, cmdID, cmdName) +} + +func (m *managedSerial) exchangeVia( + portFn func(func(*serialPort) error) error, + cmdID byte, cmdName string, +) ([]byte, error) { var resp []byte - err := m.withPort(func(sp *serialPort) error { + err := portFn(func(sp *serialPort) error { var e error resp, e = sp.exchangeLocked(cmdID, cmdName) return e diff --git a/goTool/webui/index.html b/goTool/webui/index.html index 9481c6d..a18f3bf 100644 --- a/goTool/webui/index.html +++ b/goTool/webui/index.html @@ -131,6 +131,19 @@ .progress-bar { background: #2d6cdf; } + .progress-bar.bg-success { background: #00a86b !important; } + .progress-bar.bg-danger { background: #e35d6a !important; } + .progress-bar.bg-info { background: #2d6cdf !important; } + .progress-bar.bg-secondary { background: #5c6570 !important; } + + .ota-progress-table .ota-progress-col { + width: 100%; + } + .ota-progress-table .ota-restart-col { + width: 6.5rem; + white-space: nowrap; + vertical-align: middle !important; + } .form-control[type="file"]::file-selector-button { background: var(--pp-border); border: none; @@ -180,7 +193,7 @@
- Lädt eine .bin auf die inaktive OTA-Partition (wie gotool ota).
+ Lädt eine .bin auf den Master (UART), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
Während des Uploads pausiert das Live-Polling.
+
Master (UART)
+|
+
+ Master
+
+
+
+
+
+
+ |
+ + + | +
+ Slaves (ESP-NOW) + +
+ - +|
+
+
+ Slave
+
+
+
+
+
+
+
+
+
+ |
+ + + | +
| + Warte auf Slave-Fortschritt… + | +|