Enable host OTA over BLE and harden ESP-NOW slave distribution.
Allow the same staged firmware upload path on BLE as UART, fix distribute stack/event-group crashes, and recover sticky slave OTA sessions instead of failing with err=4. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -61,7 +61,7 @@ See [`testdata/README.md`](testdata/README.md) for the JSON schema.
|
||||
|
||||
### Web dashboard
|
||||
|
||||
Starts without a required host link. Connect UART or BLE from the UI (or pre-connect with `-port` / `-ble`). State is pushed to the browser via WebSocket (Alpine.js + Bootstrap 5). OTA remains UART-only.
|
||||
Starts without a required host link. Connect UART or BLE from the UI (or pre-connect with `-port` / `-ble`). State is pushed to the browser via WebSocket (Alpine.js + Bootstrap 5). OTA works over UART or BLE (BLE needs ATT MTU ≥ 219).
|
||||
|
||||
```bash
|
||||
go run . serve
|
||||
@@ -110,6 +110,7 @@ go run . -port /dev/ttyUSB0 cache-status
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
|
||||
go run . -ble ota build/powerpod.bin
|
||||
```
|
||||
|
||||
Waits for **ready** after start (~30 s erase), sends 200-byte `OTA_PAYLOAD` frames, reads **block_ack** every 4 KiB, then `OTA_END`. The master then distributes to all available slaves (no extra host traffic); **success** is reported only when that finishes. Allow several minutes for large images. Reboot master and slaves to boot the new firmware.
|
||||
|
||||
+2
-2
@@ -167,8 +167,8 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
|
||||
}
|
||||
|
||||
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
|
||||
if link.Transport() != hostTransportUART || !link.IsConnected() {
|
||||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "OTA requires UART host (DIP8=0)"})
|
||||
if !link.IsConnected() {
|
||||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "host not connected"})
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
|
||||
|
||||
+79
-4
@@ -26,6 +26,7 @@ type bleLink struct {
|
||||
respCh chan []byte
|
||||
streamCh chan []byte
|
||||
quiet bool
|
||||
attMTU int // negotiated ATT MTU (0 if unknown)
|
||||
}
|
||||
|
||||
func openBLE(name string, settle time.Duration) (*bleLink, error) {
|
||||
@@ -125,19 +126,23 @@ func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, e
|
||||
}
|
||||
char := chars[0]
|
||||
|
||||
attMTU := 0
|
||||
if mtu, err := char.GetMTU(); err == nil {
|
||||
log.Printf("BLE ATT MTU=%d", mtu)
|
||||
if mtu < 203 {
|
||||
log.Printf("warning: MTU %d < 203 — large frames/stream ticks may fail", mtu)
|
||||
attMTU = int(mtu)
|
||||
log.Printf("BLE ATT MTU=%d", attMTU)
|
||||
if attMTU < otaMinATTMTU {
|
||||
log.Printf("warning: MTU %d < %d — OTA (200 B chunks) and large stream ticks may fail",
|
||||
attMTU, otaMinATTMTU)
|
||||
}
|
||||
}
|
||||
|
||||
link := &bleLink{
|
||||
device: device,
|
||||
device: device,
|
||||
char: char,
|
||||
parser: uartframe.NewParser(),
|
||||
respCh: make(chan []byte, 8),
|
||||
streamCh: make(chan []byte, 64),
|
||||
attMTU: attMTU,
|
||||
}
|
||||
|
||||
if err := char.EnableNotifications(func(buf []byte) {
|
||||
@@ -277,3 +282,73 @@ func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
||||
return nil, fmt.Errorf("BLE stream tick timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureOTAMTU rejects OTA when the negotiated ATT MTU cannot carry 200 B chunks.
|
||||
func (b *bleLink) ensureOTAMTU() error {
|
||||
mtu := b.attMTU
|
||||
if mtu <= 0 {
|
||||
if v, err := b.char.GetMTU(); err == nil {
|
||||
mtu = int(v)
|
||||
b.attMTU = mtu
|
||||
}
|
||||
}
|
||||
if mtu < otaMinATTMTU {
|
||||
return fmt.Errorf("BLE ATT MTU %d < %d required for OTA (negotiate larger MTU)", mtu, otaMinATTMTU)
|
||||
}
|
||||
maxWrite := mtu - 3
|
||||
if maxWrite < 216 {
|
||||
return fmt.Errorf("BLE max write %d < 216 needed for 200 B OTA frames", maxWrite)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bleLink) writeMsg(msg *pb.UartMessage) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
frame, err := encodeUartMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.attMTU > 0 && len(frame) > b.attMTU-3 {
|
||||
return fmt.Errorf("OTA frame %d exceeds BLE max write %d (MTU=%d)",
|
||||
len(frame), b.attMTU-3, b.attMTU)
|
||||
}
|
||||
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
|
||||
return fmt.Errorf("BLE write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bleLink) readFrame(timeout time.Duration) ([]byte, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = readTimeout
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case p := <-b.respCh:
|
||||
return p, nil
|
||||
case <-timer.C:
|
||||
return nil, fmt.Errorf("BLE read timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bleLink) flushInput() {
|
||||
for {
|
||||
select {
|
||||
case <-b.respCh:
|
||||
default:
|
||||
goto streams
|
||||
}
|
||||
}
|
||||
streams:
|
||||
for {
|
||||
select {
|
||||
case <-b.streamCh:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bleLink) chunkPace() time.Duration { return otaHostChunkPaceBLE }
|
||||
|
||||
+12
-4
@@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func runOTA(sp *serialPort, args []string) error {
|
||||
func runOTA(t otaTransport, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("usage: ota <firmware.bin>")
|
||||
}
|
||||
@@ -14,9 +14,17 @@ func runOTA(sp *serialPort, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
sp.mu.Lock()
|
||||
defer sp.mu.Unlock()
|
||||
return runOTAOnPortUnlocked(sp, data, func(p OTAProgress) {
|
||||
switch x := t.(type) {
|
||||
case *serialPort:
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
case *bleLink:
|
||||
if err := x.ensureOTAMTU(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return runOTAUnlocked(t, data, func(p OTAProgress) {
|
||||
switch p.Phase {
|
||||
case "preparing", "ready":
|
||||
fmt.Println(p.Message)
|
||||
|
||||
@@ -5,14 +5,19 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runOtaProgress(sp *serialPort, args []string) error {
|
||||
func runOtaProgress(t otaTransport, 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 sp, ok := t.(*serialPort); ok {
|
||||
sp.mu.Lock()
|
||||
defer sp.mu.Unlock()
|
||||
}
|
||||
|
||||
r, err := QueryOtaSlaveProgress(t, uint32(*clientID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ POST /api/ble/scan
|
||||
|
||||
Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept).
|
||||
|
||||
**OTA** (`POST /api/ota`) returns **400** unless `transport==uart` and connected.
|
||||
**OTA** (`POST /api/ota`) returns **400** unless a host (UART or BLE) is connected.
|
||||
|
||||
### BLE latency tests (BLE host only)
|
||||
|
||||
@@ -400,14 +400,14 @@ go run . -port /dev/ttyUSB0 log-level -set -level 3
|
||||
|
||||
Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**.
|
||||
|
||||
### OTA (master UART upload)
|
||||
### OTA (master host upload, UART or BLE)
|
||||
|
||||
```http
|
||||
POST /api/ota
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Form field **`firmware`**: binary image, max **2 MiB**.
|
||||
Form field **`firmware`**: binary image, max **2 MiB**. Requires connected host (`transport` uart or ble). BLE needs ATT MTU ≥ 219 for 200 B chunks.
|
||||
|
||||
```json
|
||||
{"success": true, "bytes_written": 123456, "target_slot": 1}
|
||||
@@ -415,7 +415,12 @@ Form field **`firmware`**: binary image, max **2 MiB**.
|
||||
|
||||
Firmware distributes to slaves over ESP-NOW after `OTA_END`. Progress also appears on dashboard WebSocket as `ota_progress` messages.
|
||||
|
||||
CLI equivalent: `go run . -port /dev/ttyUSB0 ota build/powerpod.bin`
|
||||
CLI equivalent:
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
|
||||
go run . -ble ota build/powerpod.bin
|
||||
```
|
||||
|
||||
### LED ring and battery
|
||||
|
||||
|
||||
+11
-11
@@ -17,7 +17,7 @@ const (
|
||||
hostTransportBLE = "ble"
|
||||
)
|
||||
|
||||
// errHostBusy is returned when the link is held for OTA (UART only).
|
||||
// errHostBusy is returned when the link is held for OTA.
|
||||
var errHostBusy = errUARTBusy
|
||||
|
||||
// managedHost is a reconnecting UART or BLE host link for serve/CLI.
|
||||
@@ -26,15 +26,15 @@ type managedHost struct {
|
||||
baud int
|
||||
settle time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
transport string // none|uart|ble
|
||||
endpoint string // port path or BLE address string
|
||||
bleName string // last connected BLE local name (optional)
|
||||
sp *serialPort
|
||||
ble *bleLink
|
||||
otaActive bool
|
||||
pausePoll bool // true while BLE latency tests run (pollers yield)
|
||||
uartPort string // remembered UART port for reconnect
|
||||
mu sync.Mutex
|
||||
transport string // none|uart|ble
|
||||
endpoint string // port path or BLE address string
|
||||
bleName string // last connected BLE local name (optional)
|
||||
sp *serialPort
|
||||
ble *bleLink
|
||||
otaActive bool
|
||||
pausePoll bool // true while BLE latency tests run (pollers yield)
|
||||
uartPort string // remembered UART port for reconnect
|
||||
}
|
||||
|
||||
func newManagedHost(baud int) *managedHost {
|
||||
@@ -279,7 +279,7 @@ func (m *managedHost) recoverAfterMasterRestart() {
|
||||
log.Printf("UART reconnect after master restart: %v", err)
|
||||
return
|
||||
}
|
||||
flushSerialInput(m.sp)
|
||||
m.sp.flushInput()
|
||||
log.Printf("UART %s ready after master restart", m.uartPort)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -29,7 +29,7 @@ func usage() {
|
||||
fmt.Fprintf(os.Stderr, " ble-stream enable stream + measure BleStreamTick window\n")
|
||||
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
|
||||
fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions; UART host only)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota host OTA upload (A/B partitions; UART or BLE)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota-progress query per-slave ESP-NOW OTA progress on master\n")
|
||||
fmt.Fprintf(os.Stderr, " led-ring set LED ring progress bar (0–100%%, rgb, intensity)\n")
|
||||
fmt.Fprintf(os.Stderr, " find-me blink LED ring red/green/blue (3× each, full brightness)\n")
|
||||
@@ -83,13 +83,6 @@ func main() {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if cmd == "ota" || cmd == "ota-progress" || cmd == "ota_progress" {
|
||||
if *useBLE {
|
||||
fmt.Fprintf(os.Stderr, "OTA requires UART host (DIP8=0, -port)\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
var h hostConn
|
||||
if *useBLE {
|
||||
bl, err := openBLE(*bleName, time.Duration(*bleSettle*float64(time.Second)))
|
||||
@@ -187,11 +180,19 @@ func main() {
|
||||
}
|
||||
runErr = runLogLevel(sp, flag.Args()[1:])
|
||||
case "ota":
|
||||
sp := h.(*serialPort)
|
||||
runErr = runOTA(sp, flag.Args()[1:])
|
||||
t, ok := h.(otaTransport)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("ota: unsupported host transport")
|
||||
break
|
||||
}
|
||||
runErr = runOTA(t, flag.Args()[1:])
|
||||
case "ota-progress", "ota_progress":
|
||||
sp := h.(*serialPort)
|
||||
runErr = runOtaProgress(sp, flag.Args()[1:])
|
||||
t, ok := h.(otaTransport)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("ota-progress: unsupported host transport")
|
||||
break
|
||||
}
|
||||
runErr = runOtaProgress(t, flag.Args()[1:])
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
|
||||
|
||||
+124
-102
@@ -6,14 +6,14 @@ import (
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
uartframe "powerpod/gotool/uart"
|
||||
"powerpod/gotool/pb"
|
||||
uartframe "powerpod/gotool/uart"
|
||||
)
|
||||
|
||||
const (
|
||||
otaHostChunkSize = 200
|
||||
otaFlashBlockSize = 4096
|
||||
otaPrepareTimeout = 120 * time.Second
|
||||
otaHostChunkSize = 200
|
||||
otaFlashBlockSize = 4096
|
||||
otaPrepareTimeout = 120 * time.Second
|
||||
otaDefaultTimeout = 15 * time.Second
|
||||
otaStatusPollTimeout = 3 * time.Second
|
||||
otaDistReadTimeout = 400 * time.Millisecond
|
||||
@@ -21,8 +21,12 @@ const (
|
||||
otaDistQueryTimeout = 2 * time.Second
|
||||
otaDistEmitMinInterval = 150 * time.Millisecond
|
||||
// Pace host chunks so the master UART RX ring is not overrun (~20 frames/block).
|
||||
otaHostChunkPace = 3 * time.Millisecond
|
||||
otaBlockMaxRetries = 3
|
||||
otaHostChunkPaceUART = 3 * time.Millisecond
|
||||
// Slightly slower for BLE WriteWithoutResponse flow control.
|
||||
otaHostChunkPaceBLE = 6 * time.Millisecond
|
||||
otaBlockMaxRetries = 3
|
||||
// 200 B OTA_PAYLOAD → ~216 B framed; needs ATT MTU >= 219.
|
||||
otaMinATTMTU = 219
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,6 +41,14 @@ const (
|
||||
otaDistTimeout = 45 * time.Minute
|
||||
)
|
||||
|
||||
// otaTransport is the host IO used by the OTA protocol (UART or BLE).
|
||||
type otaTransport interface {
|
||||
writeMsg(msg *pb.UartMessage) error
|
||||
readFrame(timeout time.Duration) ([]byte, error)
|
||||
flushInput()
|
||||
chunkPace() time.Duration
|
||||
}
|
||||
|
||||
// OtaSlaveDetail is per-slave ESP-NOW OTA state from OTA_SLAVE_PROGRESS.
|
||||
type OtaSlaveDetail struct {
|
||||
BytesWritten uint32 `json:"bytes_written"`
|
||||
@@ -47,20 +59,20 @@ type OtaSlaveDetail struct {
|
||||
|
||||
// OTAProgress is pushed to the dashboard during web uploads.
|
||||
type OTAProgress struct {
|
||||
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 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)
|
||||
@@ -80,27 +92,52 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
|
||||
Percent: 0, Message: msg, MasterMessage: msg,
|
||||
})
|
||||
}
|
||||
push("preparing", "UART wird vorbereitet…")
|
||||
push("preparing", "Host wird für OTA vorbereitet…")
|
||||
|
||||
// Block until the UART is free, then hold m.mu for the entire upload so
|
||||
// dashboard/API polling cannot interleave on the serial port.
|
||||
// Hold m.mu for the entire upload so dashboard/API polling cannot interleave.
|
||||
m.mu.Lock()
|
||||
if m.otaActive {
|
||||
m.mu.Unlock()
|
||||
return errOTAInProgress
|
||||
}
|
||||
m.otaActive = true
|
||||
if m.sp == nil {
|
||||
if err := m.openLocked(); err != nil {
|
||||
|
||||
var t otaTransport
|
||||
switch m.transport {
|
||||
case hostTransportBLE:
|
||||
if m.ble == nil {
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
err := fmt.Errorf("BLE not connected")
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
if err := m.ble.ensureOTAMTU(); err != nil {
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
t = m.ble
|
||||
case hostTransportUART, hostTransportNone:
|
||||
if m.sp == nil {
|
||||
if err := m.openLocked(); err != nil {
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
t = m.sp
|
||||
default:
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
err := fmt.Errorf("not connected")
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
sp := m.sp
|
||||
|
||||
err := runOTAOnPortUnlocked(sp, firmware, onProgress)
|
||||
err := runOTAUnlocked(t, firmware, onProgress)
|
||||
if err != nil {
|
||||
m.invalidateLocked(err)
|
||||
}
|
||||
@@ -109,7 +146,7 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
|
||||
return err
|
||||
}
|
||||
|
||||
func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgressFn) error {
|
||||
func runOTAUnlocked(t otaTransport, firmware []byte, onProgress otaProgressFn) error {
|
||||
if len(firmware) == 0 {
|
||||
return fmt.Errorf("empty firmware")
|
||||
}
|
||||
@@ -151,16 +188,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
onProgress(p)
|
||||
}
|
||||
|
||||
if err := sp.port.SetReadTimeout(readTimeout); err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
notify("preparing", otaStepMaster, 0, fmt.Sprintf("Master: OTA start (%d bytes)…", imageSize))
|
||||
|
||||
flushSerialInput(sp)
|
||||
t.flushInput()
|
||||
|
||||
if err := writeUartMessage(sp, &pb.UartMessage{
|
||||
if err := t.writeMsg(&pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_START,
|
||||
Payload: &pb.UartMessage_OtaStart{
|
||||
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)},
|
||||
@@ -170,13 +202,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
return err
|
||||
}
|
||||
|
||||
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sp.port.SetReadTimeout(readTimeout) }()
|
||||
|
||||
ready, err := waitOtaStatus(sp, otaStReady, otaPrepareTimeout, func(msg string) {
|
||||
ready, err := waitOtaStatus(t, otaStReady, otaPrepareTimeout, func(msg string) {
|
||||
notify("preparing", otaStepMaster, 2, msg)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -185,11 +211,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
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())
|
||||
return err
|
||||
}
|
||||
|
||||
pace := t.chunkPace()
|
||||
var seq uint32
|
||||
for offset := 0; offset < imageSize; {
|
||||
blockStart := offset
|
||||
@@ -208,7 +230,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
chunk := firmware[offset : offset+n]
|
||||
|
||||
if err := writeUartMessage(sp, &pb.UartMessage{
|
||||
if err := t.writeMsg(&pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_PAYLOAD,
|
||||
Payload: &pb.UartMessage_OtaPayload{
|
||||
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
|
||||
@@ -216,7 +238,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
time.Sleep(otaHostChunkPace)
|
||||
time.Sleep(pace)
|
||||
seq++
|
||||
offset += n
|
||||
bytesInBlock += n
|
||||
@@ -252,7 +274,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
return err
|
||||
}
|
||||
}
|
||||
st, ackErr = waitOtaStatus(sp, otaStBlockAck, otaDefaultTimeout, nil)
|
||||
st, ackErr = waitOtaStatus(t, otaStBlockAck, otaDefaultTimeout, nil)
|
||||
if ackErr == nil {
|
||||
break
|
||||
}
|
||||
@@ -272,10 +294,10 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
|
||||
masterPct = 100
|
||||
masterMsg = "Master: UART-Upload abgeschlossen"
|
||||
masterMsg = "Master: Upload abgeschlossen"
|
||||
notify("uploading", otaStepMaster, 100, masterMsg)
|
||||
|
||||
if err := writeUartMessage(sp, &pb.UartMessage{
|
||||
if err := t.writeMsg(&pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_END,
|
||||
Payload: &pb.UartMessage_OtaEnd{
|
||||
OtaEnd: &pb.OtaEndPayload{},
|
||||
@@ -314,7 +336,8 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
}
|
||||
if slaveCount == 0 {
|
||||
return "Keine verfügbaren Slaves — Verteilung übersprungen", 0, 0
|
||||
// Do not claim "skipped" mid-flight — details often arrive after the first query.
|
||||
return "ESP-NOW: warte auf Slave-Fortschritt…", 0, 0
|
||||
}
|
||||
return fmt.Sprintf("ESP-NOW: %d / %d bytes (%d Slaves)",
|
||||
aggBytes, imageSize, slaveCount), aggBytes, slaveCount
|
||||
@@ -334,7 +357,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
return
|
||||
}
|
||||
lastQuery = time.Now()
|
||||
prog, err := queryOtaSlaveProgressLocked(sp, 0, onDistStatus, otaDistQueryTimeout)
|
||||
prog, err := queryOtaSlaveProgress(t, 0, onDistStatus, otaDistQueryTimeout)
|
||||
if err != nil {
|
||||
if len(slaveDetails) > 0 {
|
||||
emitSlaveThrottled(true)
|
||||
@@ -356,13 +379,13 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
|
||||
lastQuery = time.Time{} // first query immediately when distribution starts
|
||||
querySlaveProgress()
|
||||
st, err := waitOtaComplete(sp, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
|
||||
st, err := waitOtaComplete(t, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
|
||||
if err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if prog, err := queryOtaSlaveProgressLocked(sp, 0, nil, otaDistQueryTimeout); err == nil {
|
||||
if prog, err := queryOtaSlaveProgress(t, 0, nil, otaDistQueryTimeout); err == nil {
|
||||
mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails)
|
||||
}
|
||||
notify("done", "", 100,
|
||||
@@ -378,13 +401,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
|
||||
// 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 QueryOtaSlaveProgress(t otaTransport, clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
|
||||
return queryOtaSlaveProgress(t, clientID, nil, otaDefaultTimeout)
|
||||
}
|
||||
|
||||
func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32,
|
||||
func queryOtaSlaveProgress(t otaTransport, clientID uint32,
|
||||
onStatus func(*pb.OtaStatusPayload), queryTimeout time.Duration) (*pb.OtaSlaveProgressResponse, error) {
|
||||
req := &pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_SLAVE_PROGRESS,
|
||||
@@ -394,14 +415,14 @@ func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := writeUartMessage(sp, req); err != nil {
|
||||
if err := t.writeMsg(req); 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)
|
||||
msg, err := readUartMessageUntil(t, deadline, pb.MessageType_OTA_SLAVE_PROGRESS, onStatus, otaDistReadTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -434,7 +455,7 @@ func applyDistributingOtaStatus(st *pb.OtaStatusPayload, imageSize int,
|
||||
details[id] = d
|
||||
}
|
||||
|
||||
func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageType,
|
||||
func readUartMessageUntil(t otaTransport, deadline time.Time, want pb.MessageType,
|
||||
onStatus func(*pb.OtaStatusPayload), readChunk time.Duration) (*pb.UartMessage, error) {
|
||||
if readChunk <= 0 {
|
||||
readChunk = otaStatusPollTimeout
|
||||
@@ -447,10 +468,7 @@ func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageTyp
|
||||
if wait > readChunk {
|
||||
wait = readChunk
|
||||
}
|
||||
if err := sp.port.SetReadTimeout(wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := uartframe.ReadFrame(sp.port, nil, wait)
|
||||
payload, err := t.readFrame(wait)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -505,7 +523,7 @@ func copySlaveMap(m map[uint32]uint32) map[uint32]uint32 {
|
||||
return out
|
||||
}
|
||||
|
||||
func waitOtaComplete(sp *serialPort, timeout time.Duration,
|
||||
func waitOtaComplete(t otaTransport, timeout time.Duration,
|
||||
onDistributing func(*pb.OtaStatusPayload), onInterval func(),
|
||||
readTimeout time.Duration) (*pb.OtaStatusPayload, error) {
|
||||
if readTimeout <= 0 {
|
||||
@@ -520,10 +538,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
|
||||
if readWait > readTimeout {
|
||||
readWait = readTimeout
|
||||
}
|
||||
if err := sp.port.SetReadTimeout(readWait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := readOtaStatus(sp)
|
||||
st, err := readOtaStatus(t, readWait)
|
||||
if err != nil {
|
||||
if onInterval != nil {
|
||||
onInterval()
|
||||
@@ -548,16 +563,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
|
||||
}
|
||||
}
|
||||
|
||||
func writeUartMessage(sp *serialPort, msg *pb.UartMessage) error {
|
||||
frame, err := encodeUartMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = sp.port.Write(frame)
|
||||
return err
|
||||
}
|
||||
|
||||
func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
|
||||
func waitOtaStatus(t otaTransport, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
@@ -567,10 +573,7 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
|
||||
if readWait > otaStatusPollTimeout {
|
||||
readWait = otaStatusPollTimeout
|
||||
}
|
||||
if err := sp.port.SetReadTimeout(readWait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := uartframe.ReadFrame(sp.port, nil, readWait)
|
||||
payload, err := t.readFrame(readWait)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -595,8 +598,8 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
|
||||
}
|
||||
}
|
||||
|
||||
func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) {
|
||||
payload, err := uartframe.ReadFrame(sp.port, nil, readTimeout)
|
||||
func readOtaStatus(t otaTransport, timeout time.Duration) (*pb.OtaStatusPayload, error) {
|
||||
payload, err := t.readFrame(timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
@@ -623,8 +626,37 @@ func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
|
||||
return uartframe.EncodeFrame(payload)
|
||||
}
|
||||
|
||||
// flushSerialInput drops stale RX bytes (not full frames — avoids ReadFrame blocking).
|
||||
func flushSerialInput(sp *serialPort) {
|
||||
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
|
||||
}
|
||||
|
||||
// --- UART otaTransport ---
|
||||
|
||||
func (sp *serialPort) writeMsg(msg *pb.UartMessage) error {
|
||||
frame, err := encodeUartMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = sp.port.Write(frame)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sp *serialPort) readFrame(timeout time.Duration) ([]byte, error) {
|
||||
if err := sp.port.SetReadTimeout(timeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uartframe.ReadFrame(sp.port, nil, timeout)
|
||||
}
|
||||
|
||||
func (sp *serialPort) flushInput() {
|
||||
if sp == nil {
|
||||
return
|
||||
}
|
||||
@@ -639,14 +671,4 @@ func flushSerialInput(sp *serialPort) {
|
||||
}
|
||||
}
|
||||
|
||||
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 (sp *serialPort) chunkPace() time.Duration { return otaHostChunkPaceUART }
|
||||
|
||||
+22
-25
@@ -788,23 +788,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="col-12" x-show="isUARTHost() || !hostUp()">
|
||||
<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 den Master (nur UART-Host, DIP8=0), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
|
||||
Während des Uploads pausiert das Live-Polling. Bei BLE-Host ist OTA nicht verfügbar.
|
||||
Lädt eine <code>.bin</code> auf den Master (UART oder BLE), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
|
||||
Während des Uploads pausiert das Live-Polling. BLE braucht ATT MTU ≥ 219.
|
||||
</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 || !isUARTHost()">
|
||||
:disabled="ota.active || busy || !hostUp()">
|
||||
</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 || !isUARTHost() || !otaFile">
|
||||
:disabled="ota.active || busy || !hostUp() || !otaFile">
|
||||
Firmware hochladen
|
||||
</button>
|
||||
<span class="text-muted small" x-show="otaFile"
|
||||
@@ -812,7 +812,7 @@
|
||||
</div>
|
||||
<div class="ota-progress-panel mt-3"
|
||||
x-show="ota.active || ota.phase === 'distributing' || ota.phase === 'done' || ota.phase === 'error'">
|
||||
<p class="small text-muted mb-2">Master (UART)</p>
|
||||
<p class="small text-muted mb-2">Master (Host)</p>
|
||||
<table class="table table-sm pp-table ota-progress-table mb-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -845,7 +845,8 @@
|
||||
Slaves (ESP-NOW)
|
||||
<span x-text="'(' + otaSlaveRows().length + ')'"></span>
|
||||
</p>
|
||||
<p class="small text-muted mb-2" x-show="ota.message && ota.step === 'slaves'"
|
||||
<p class="small text-muted mb-2"
|
||||
x-show="ota.step === 'slaves' && otaSlaveRows().length > 0 && ota.message"
|
||||
x-text="ota.message"></p>
|
||||
<table class="table table-sm pp-table ota-progress-table mb-2">
|
||||
<tbody>
|
||||
@@ -877,9 +878,8 @@
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="otaSlaveRows().length === 0">
|
||||
<td colspan="2" class="text-muted small py-3">
|
||||
Warte auf Slave-Fortschritt…
|
||||
</td>
|
||||
<td colspan="2" class="text-muted small py-3"
|
||||
x-text="otaDistEmptyHint()"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1422,7 +1422,19 @@
|
||||
if (status === 1 || status === 2) return 'bg-secondary';
|
||||
return 'bg-info';
|
||||
},
|
||||
otaDistEmptyHint() {
|
||||
const msg = this.ota.message || '';
|
||||
if (/übersprungen/i.test(msg)) {
|
||||
return msg;
|
||||
}
|
||||
if (this.ota.phase === 'done') {
|
||||
return 'Keine Slave-Verteilung gemeldet.';
|
||||
}
|
||||
return 'Warte auf Slave-Fortschritt…';
|
||||
},
|
||||
otaSlaveRows() {
|
||||
// Only real OTA progress — do not invent placeholder rows from the
|
||||
// client list (that contradicted "keine Slaves / übersprungen").
|
||||
const details = this.mergeSlaveDetails(this.ota.slaveDetails);
|
||||
const ids = new Set(Object.keys(details).map(Number).filter(id => !Number.isNaN(id)));
|
||||
for (const [k, v] of Object.entries(this.ota.slaveProgress || {})) {
|
||||
@@ -1454,21 +1466,6 @@
|
||||
bytesLabel: total ? `${bytes} / ${total} bytes` : `${bytes} bytes`
|
||||
});
|
||||
}
|
||||
if (rows.length === 0 && (this.ota.phase === 'distributing' || this.ota.step === 'slaves')) {
|
||||
const targets = (this.state.clients || []).filter(c => c.available);
|
||||
const limit = this.ota.slaves > 0 ? this.ota.slaves : targets.length;
|
||||
for (const c of targets.slice(0, limit)) {
|
||||
rows.push({
|
||||
id: c.id,
|
||||
mac: c.mac ? this.formatMac(c.mac) : '—',
|
||||
percent: 0,
|
||||
status: 1,
|
||||
statusLabel: this.otaSlaveStatusLabel(1),
|
||||
barClass: this.otaSlaveBarClass(1),
|
||||
bytesLabel: '—'
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
applyOTAProgress(p) {
|
||||
|
||||
Reference in New Issue
Block a user