diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 432fe91..15c4ebf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -109,7 +109,7 @@ sequenceDiagram └─────────────────────────────────────────────────────────────┘ ``` -**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX. `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA nur UART**. Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (8–16 ms) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll). +**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX. `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA** über UART oder BLE (ATT MTU ≥ ~219). Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (8–16 ms; pausiert während OTA) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll). --- diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index b3d1b32..3ace42a 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -55,7 +55,7 @@ Zielbild für den Host: Befehle an den Master senden; der Master steuert Slaves | LiPo ADC 1 | 1 | `board_input.c` | | LiPo ADC 2 | 11 | `board_input.c` | -**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool CLI: `-port …` oder `-ble` / `-ble-name Powerpod`. **`gotool serve`** startet ohne Pflicht-Gerät; Dashboard/REST/WS verbinden UART oder BLE dynamisch (`-port`/`-ble` nur optionale Vorbelegung). **OTA nur UART** (DIP8=0). Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 8–16) speisen Live-Accel im Dashboard/API. +**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool CLI: `-port …` oder `-ble` / `-ble-name Powerpod`. **`gotool serve`** startet ohne Pflicht-Gerät; Dashboard/REST/WS verbinden UART oder BLE dynamisch (`-port`/`-ble` nur optionale Vorbelegung). **OTA** über UART oder BLE (DIP8); BLE braucht ATT MTU ≥ ~219 für 200-B-Chunks. Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 8–16; während OTA pausiert). **ESP-IDF-Log (Debug):** `esp_log_*` geht auf **UART0** (Standard-Konsole, typisch USB am Dev-Board, **115200** Baud, `CONFIG_ESP_CONSOLE_UART_NUM=0`). Das ist **getrennt** vom Host-UART1 — goTool liest keine `esp_log`-Ausgabe. Ohne angeschlossenes Debug-Kabel werden aktivierte Logs trotzdem formatiert und an UART0 gesendet (CPU-Overhead bleibt); nur `ESP_LOG_NONE` / Level-Filter vermeiden die Arbeit. @@ -226,7 +226,7 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar | 6 | ACCEL_DEADZONE | `cmd_accel_deadzone.c` | Get/Set Deadzone lokal + ESP-NOW | | 7 | ESPNOW_UNICAST_TEST | `cmd_espnow_unicast_test.c` | Link-Test zu Slave | | 8 | LED_RING | `cmd_led_ring.c` | Ring lokal / ESP-NOW | -| 16 | OTA_START | `cmd_ota.c` | UART-OTA beginnen | +| 16 | OTA_START | `cmd_ota.c` | Host-OTA beginnen (UART oder BLE) | | 17 | OTA_PAYLOAD | `cmd_ota.c` | Chunk ≤200 B | | 18 | OTA_END | `cmd_ota.c` | Abschluss + ESP-NOW-Verteilung | | 19 | OTA_STATUS | `cmd_ota.c` | Gerät → Host Status | @@ -358,9 +358,9 @@ go run . -port /dev/ttyUSB0 log-level -set -level 3 ## 8. OTA -### 8.1 UART-OTA (nur Master) +### 8.1 Host-OTA (nur Master, UART oder BLE) -Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`. +Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`. Gleiches Protokoll über DIP8-Host (UART oder BLE NUS). | Phase | Host → Master | Master → Host | |-------|---------------|---------------| @@ -370,10 +370,11 @@ Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`. - Inaktive Partition: `esp_ota_get_next_update_partition()`. - LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung). +- BLE: ATT MTU ≥ ~219 (preferred 247); `BLE_STREAM_TICK` pausiert während `ota_session_busy()`. ### 8.2 ESP-NOW-OTA (Master → Slaves) -`ota_espnow.c` — gleiche Status-Codes wie UART. +`ota_espnow.c` — gleiche Status-Codes wie Host-OTA. 1. `ESPNOW_OTA_START` + `total_size` → Slave `ota_espnow_slave_on_start` 2. `ESPNOW_OTA_PAYLOAD` bis 200 B → 4 KiB Buffer auf Slave diff --git a/goTool/README.md b/goTool/README.md index 1d919c9..eee3727 100644 --- a/goTool/README.md +++ b/goTool/README.md @@ -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. diff --git a/goTool/api_serve.go b/goTool/api_serve.go index 1483ef9..0089d05 100644 --- a/goTool/api_serve.go +++ b/goTool/api_serve.go @@ -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 { diff --git a/goTool/ble_link.go b/goTool/ble_link.go index 87a1229..cebbf82 100644 --- a/goTool/ble_link.go +++ b/goTool/ble_link.go @@ -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 } diff --git a/goTool/cmd_ota.go b/goTool/cmd_ota.go index 8e61100..a7dac3d 100644 --- a/goTool/cmd_ota.go +++ b/goTool/cmd_ota.go @@ -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 ") } @@ -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) diff --git a/goTool/cmd_ota_progress.go b/goTool/cmd_ota_progress.go index 708a3d1..30d6c26 100644 --- a/goTool/cmd_ota_progress.go +++ b/goTool/cmd_ota_progress.go @@ -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 } diff --git a/goTool/docs/API_REST.md b/goTool/docs/API_REST.md index 5b2991a..5933d64 100644 --- a/goTool/docs/API_REST.md +++ b/goTool/docs/API_REST.md @@ -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 diff --git a/goTool/host_managed.go b/goTool/host_managed.go index f545ddb..633901d 100644 --- a/goTool/host_managed.go +++ b/goTool/host_managed.go @@ -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) } } diff --git a/goTool/main.go b/goTool/main.go index 59a07bc..bd57556 100644 --- a/goTool/main.go +++ b/goTool/main.go @@ -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) diff --git a/goTool/ota_upload.go b/goTool/ota_upload.go index a130990..306bf59 100644 --- a/goTool/ota_upload.go +++ b/goTool/ota_upload.go @@ -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 } diff --git a/goTool/webui/index.html b/goTool/webui/index.html index 1215b62..d6853b9 100644 --- a/goTool/webui/index.html +++ b/goTool/webui/index.html @@ -788,23 +788,23 @@ -
+
Firmware OTA (A/B)

- Lädt eine .bin 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 .bin 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.

+ :disabled="ota.active || busy || !hostUp()">
-

Master (UART)

+

Master (Host)

@@ -845,7 +845,8 @@ Slaves (ESP-NOW)

-

@@ -877,9 +878,8 @@ - +
- Warte auf Slave-Fortschritt… -
@@ -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) { diff --git a/main/ble_stream.c b/main/ble_stream.c index fc66f37..1a8bc1f 100644 --- a/main/ble_stream.c +++ b/main/ble_stream.c @@ -6,6 +6,7 @@ #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "ota_session.h" #include "pb_encode.h" #include "uart.h" #include "uart_cmd.h" @@ -146,7 +147,9 @@ static void ble_stream_task(void *param) { period = BLE_STREAM_PERIOD_MAX_MS; } - if (ble_host_ready() && any_accel_stream()) { + /* Pause ticks during host OTA / ESP-NOW distribute so ATT bandwidth + * stays available for OTA_STATUS notifies. */ + if (ble_host_ready() && any_accel_stream() && !ota_session_busy()) { alox_UartMessage msg = alox_UartMessage_init_zero; uart_cmd_init_response(&msg, alox_MessageType_BLE_STREAM_TICK, alox_UartMessage_ble_stream_tick_tag); diff --git a/main/cmd/cmd_ota.c b/main/cmd/cmd_ota.c index ce1209c..b18b3f1 100644 --- a/main/cmd/cmd_ota.c +++ b/main/cmd/cmd_ota.c @@ -1,5 +1,4 @@ #include "cmd_ota.h" -#include "app_config.h" #include "led_ring.h" #include "ota_espnow.h" #include "ota_uart.h" @@ -14,6 +13,8 @@ static const char *TAG = "[OTA_CMD]"; #define OTA_PREPARE_STACK 8192 #define OTA_PREPARE_PRIO 5 +/* Block buffer is static .bss; stack is for nested EspNowMessage + encode buf + + * esp_now_send path. 4 KiB overflowed; 8 KiB matches the pre-crash budget. */ #define OTA_DIST_STACK 8192 #define OTA_DIST_PRIO 5 @@ -104,24 +105,10 @@ static void ota_prepare_task(void *param) { vTaskDelete(NULL); } -static bool ota_host_is_uart(void) { - const app_config_t *cfg = app_config_get(); - if (cfg != NULL && cfg->host_ble) { - ESP_LOGW(TAG, "OTA requires UART host (DIP8=0); BLE host active"); - return false; - } - return true; -} - static void handle_ota_start(const uint8_t *data, size_t len) { alox_UartMessage uart_msg; alox_OtaStartPayload req = alox_OtaStartPayload_init_zero; - if (!ota_host_is_uart()) { - send_ota_failed(20); - return; - } - if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) { send_ota_failed( 2); return; @@ -349,10 +336,6 @@ static void ota_start_espnow_task(void *param) { } static void handle_ota_start_espnow(const uint8_t *data, size_t len) { - if (!ota_host_is_uart()) { - send_ota_failed(20); - return; - } (void)data; (void)len; diff --git a/main/ota_espnow.c b/main/ota_espnow.c index ffc7d20..a0bafd3 100644 --- a/main/ota_espnow.c +++ b/main/ota_espnow.c @@ -28,6 +28,12 @@ static const char *TAG = "[OTA_ESPNOW]"; #define OTA_ST_SUCCESS 4u #define OTA_ST_FAILED 5u +/** + * Event-group fail bit. FreeRTOS reserves the top 8 bits (assert if used); + * targets use bits 0..CLIENT_REGISTRY_MAX-1 (max 16). + */ +#define OTA_EG_FAIL_BIT (1u << 16) + /** ESP-NOW OTA receive on slave (blue progress bar). */ #define OTA_LED_ESPNOW_RX_R 0 #define OTA_LED_ESPNOW_RX_G 0 @@ -58,6 +64,7 @@ typedef struct { static EventGroupHandle_t s_eg; static QueueHandle_t s_slave_work_queue; static bool s_distribution_active; +static TaskHandle_t s_slave_prep_task; typedef struct { uint8_t count; @@ -70,6 +77,9 @@ typedef struct { static ota_dist_t s_dist; +/** Always-reserved flash block for ESP-NOW distribute (not on task stack). */ +static uint8_t s_ota_block_buf[OTA_UART_FLASH_BLOCK_SIZE]; + typedef struct { uint32_t client_id; uint32_t bytes_written; @@ -164,14 +174,46 @@ static uint32_t all_target_bits(void) { return (1u << s_dist.count) - 1u; } +static bool any_target_failed(void) { + for (uint8_t i = 0; i < s_prog.count; i++) { + if (s_prog.entries[i].status == OTA_ST_FAILED) { + return true; + } + } + return false; +} + +/** + * Wait until all want_bits are set, or OTA_EG_FAIL_BIT (slave FAILED). + * Returns true only when every target bit is set; false on fail or timeout. + */ static bool wait_target_bits(uint32_t want_bits, uint32_t timeout_ms) { if (s_eg == NULL || want_bits == 0) { return false; } - EventBits_t got = - xEventGroupWaitBits(s_eg, want_bits, pdTRUE, pdTRUE, - pdMS_TO_TICKS(timeout_ms)); - return (got & want_bits) == want_bits; + + const TickType_t start = xTaskGetTickCount(); + const TickType_t timeout_ticks = pdMS_TO_TICKS(timeout_ms); + const EventBits_t wait_mask = want_bits | OTA_EG_FAIL_BIT; + + while (true) { + TickType_t elapsed = xTaskGetTickCount() - start; + if (elapsed >= timeout_ticks) { + return false; + } + TickType_t remain = timeout_ticks - elapsed; + + EventBits_t got = + xEventGroupWaitBits(s_eg, wait_mask, pdFALSE, pdFALSE, remain); + if (got & OTA_EG_FAIL_BIT) { + xEventGroupClearBits(s_eg, OTA_EG_FAIL_BIT); + return false; + } + if ((got & want_bits) == want_bits) { + xEventGroupClearBits(s_eg, want_bits); + return true; + } + } } static uint32_t block_ack_timeout_ms(void) { @@ -372,11 +414,21 @@ void ota_espnow_slave_init(void) { } } +static void drain_slave_work_queue(void) { + if (s_slave_work_queue == NULL) { + return; + } + ota_slave_work_t junk; + while (xQueueReceive(s_slave_work_queue, &junk, 0) == pdTRUE) { + } +} + static void ota_slave_prepare_task(void *param) { uint32_t total_size = (uint32_t)(uintptr_t)param; uint8_t master_mac[6]; if (!esp_now_comm_get_master_mac(master_mac)) { + s_slave_prep_task = NULL; vTaskDelete(NULL); return; } @@ -386,6 +438,7 @@ static void ota_slave_prepare_task(void *param) { int slot = ota_uart_prepare(total_size); if (slot < 0) { send_slave_status(master_mac, OTA_ST_FAILED, 0, 1); + s_slave_prep_task = NULL; vTaskDelete(NULL); return; } @@ -393,6 +446,7 @@ static void ota_slave_prepare_task(void *param) { send_slave_status(master_mac, OTA_ST_READY, 0, 0); led_ring_show_ota_progress(0, total_size, OTA_LED_ESPNOW_RX_R, OTA_LED_ESPNOW_RX_G, OTA_LED_ESPNOW_RX_B); + s_slave_prep_task = NULL; vTaskDelete(NULL); } @@ -404,14 +458,31 @@ void ota_espnow_slave_on_start(const uint8_t master_mac[6], ESP_LOGI(TAG, "ESP-NOW OTA_START (%lu bytes)", (unsigned long)start->total_size); - if (ota_uart_is_active()) { - queue_slave_status(master_mac, OTA_ST_FAILED, 0, 4); + /* Duplicate START while prepare task still running — ignore. */ + if (s_slave_prep_task != NULL) { + ESP_LOGW(TAG, "OTA prepare already in flight — ignore START"); return; } + if (ota_uart_is_active()) { + /* Same image, nothing written yet: sticky READY after master timeout / + * duplicate START — re-ACK instead of failing with err=4. */ + if (ota_uart_total_size() == start->total_size && + ota_uart_bytes_received() == 0) { + ESP_LOGI(TAG, "OTA already prepared for %lu bytes — re-send READY", + (unsigned long)start->total_size); + queue_slave_status(master_mac, OTA_ST_READY, 0, 0); + return; + } + ESP_LOGW(TAG, "aborting sticky OTA session for new START"); + ota_uart_abort(); + drain_slave_work_queue(); + } + if (xTaskCreate(ota_slave_prepare_task, "ota_esp_prep", OTA_ESPNOW_PREPARE_STACK, (void *)(uintptr_t)start->total_size, OTA_ESPNOW_PREPARE_PRIO, - NULL) != pdPASS) { + &s_slave_prep_task) != pdPASS) { + s_slave_prep_task = NULL; queue_slave_status(master_mac, OTA_ST_FAILED, 0, 5); } } @@ -479,6 +550,8 @@ void ota_espnow_master_on_status(const uint8_t slave_mac[6], status->error); ESP_LOGW(TAG, "slave %lu OTA failed (err=%lu)", (unsigned long)s_dist.id[idx], (unsigned long)status->error); + /* Wake wait_target_bits so distribute aborts instead of waiting for READY. */ + xEventGroupSetBits(s_eg, OTA_EG_FAIL_BIT); break; default: break; @@ -527,7 +600,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition, uint32_t target_mask = all_target_bits(); esp_err_t err; - xEventGroupClearBits(s_eg, target_mask); + xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT); for (uint8_t i = 0; i < s_dist.count; i++) { err = esp_now_comm_send_ota_start(s_dist.mac[i], size); if (err != ESP_OK) { @@ -540,6 +613,12 @@ static esp_err_t distribute_image(const esp_partition_t *partition, } if (!wait_target_bits(target_mask, OTA_PREPARE_TIMEOUT_MS)) { + if (any_target_failed()) { + ESP_LOGE(TAG, "slave OTA failed during prepare"); + prog_end(); + s_distribution_active = false; + return ESP_FAIL; + } ESP_LOGE(TAG, "timeout waiting for slave OTA ready"); prog_end(); s_distribution_active = false; @@ -551,7 +630,6 @@ static esp_err_t distribute_image(const esp_partition_t *partition, s_dist.progress.aggregate(0, size, s_dist.count); } - uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE]; uint32_t offset = 0; uint32_t seq = 0; @@ -561,7 +639,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition, block_len = OTA_UART_FLASH_BLOCK_SIZE; } - err = esp_partition_read(partition, offset, block_buf, block_len); + err = esp_partition_read(partition, offset, s_ota_block_buf, block_len); if (err != ESP_OK) { ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset, esp_err_to_name(err)); @@ -591,7 +669,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition, (unsigned long)send_attempt, (unsigned long)OTA_BLOCK_MAX_RETRIES); } - err = send_block_payloads(block_buf, block_len, &seq); + err = send_block_payloads(s_ota_block_buf, block_len, &seq); if (err == ESP_OK) { block_sent = true; break; @@ -614,6 +692,13 @@ static esp_err_t distribute_image(const esp_partition_t *partition, acked = true; break; } + if (any_target_failed()) { + ESP_LOGE(TAG, "slave OTA failed during block @%lu", + (unsigned long)s_dist.expected_bytes); + prog_end(); + s_distribution_active = false; + return ESP_FAIL; + } log_missing_block_acks(s_dist.expected_bytes); if (attempt >= OTA_BLOCK_MAX_RETRIES) { break; @@ -622,9 +707,9 @@ static esp_err_t distribute_image(const esp_partition_t *partition, (unsigned long)s_dist.expected_bytes, (unsigned long)(attempt + 1), (unsigned long)OTA_BLOCK_MAX_RETRIES); - xEventGroupClearBits(s_eg, target_mask); + xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT); seq = block_start_seq; - err = send_block_payloads(block_buf, block_len, &seq); + err = send_block_payloads(s_ota_block_buf, block_len, &seq); if (err != ESP_OK) { prog_end(); s_distribution_active = false; @@ -653,7 +738,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition, } } - xEventGroupClearBits(s_eg, target_mask); + xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT); for (uint8_t i = 0; i < s_dist.count; i++) { err = esp_now_comm_send_ota_end(s_dist.mac[i]); if (err != ESP_OK) { @@ -664,6 +749,12 @@ static esp_err_t distribute_image(const esp_partition_t *partition, } if (!wait_target_bits(target_mask, OTA_END_TIMEOUT_MS)) { + if (any_target_failed()) { + ESP_LOGE(TAG, "slave OTA failed during end"); + prog_end(); + s_distribution_active = false; + return ESP_FAIL; + } ESP_LOGE(TAG, "timeout waiting for slave OTA success"); prog_end(); s_distribution_active = false; diff --git a/main/proto/uart_messages.proto b/main/proto/uart_messages.proto index 4d80abd..a5102f6 100644 --- a/main/proto/uart_messages.proto +++ b/main/proto/uart_messages.proto @@ -407,7 +407,7 @@ message TapConfigResponse { bool enable_triple = 12; } -// Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). +// Host → device: begin host OTA over UART or BLE (erase inactive OTA slot; device replies OTA_STATUS). message OtaStartPayload { uint32 total_size = 1; }