From 59ca26940774286046a8325271b38221282a29b0 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 19 May 2026 00:39:59 +0200 Subject: [PATCH] Add UART OTA upload with A/B partition support. Firmware buffers 200-byte chunks into 4 KiB blocks for esp_ota_write; goTool uploads with per-block ACK flow control and larger UART buffers to avoid stalls. Co-authored-by: Cursor --- Makefile | 3 +- goTool/README.md | 7 ++ goTool/cmd_ota.go | 204 +++++++++++++++++++++++++++++++ goTool/main.go | 7 +- goTool/pb/uart_messages.pb.go | 107 +++++++++------- main/CMakeLists.txt | 2 + main/README.md | 29 ++++- main/app_config.h | 2 + main/cmd_handler.c | 2 +- main/cmd_ota.c | 163 ++++++++++++++++++++++++ main/cmd_ota.h | 6 + main/cmd_version.c | 8 ++ main/ota_uart.c | 184 ++++++++++++++++++++++++++++ main/ota_uart.h | 45 +++++++ main/powerpod.c | 25 +++- main/proto/uart_messages.options | 1 + main/proto/uart_messages.pb.h | 74 ++++++----- main/proto/uart_messages.proto | 26 ++-- main/uart.c | 8 +- main/uart.h | 4 +- 20 files changed, 806 insertions(+), 101 deletions(-) create mode 100644 goTool/cmd_ota.go create mode 100644 main/cmd_ota.c create mode 100644 main/cmd_ota.h create mode 100644 main/ota_uart.c create mode 100644 main/ota_uart.h create mode 100644 main/proto/uart_messages.options diff --git a/Makefile b/Makefile index a60c391..b93eab8 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,8 @@ default: @echo "Set PORT=$(PORT) (current) for goTool targets." proto_generate_uart: - python libs/nanopb/generator/nanopb_generator.py main/proto/uart_messages.proto + cd main/proto && python ../../libs/nanopb/generator/nanopb_generator.py \ + -I ../../libs/nanopb/generator/proto uart_messages.proto proto_generate_espnow: python libs/nanopb/generator/nanopb_generator.py main/proto/esp_now_messages.proto diff --git a/goTool/README.md b/goTool/README.md index a219caf..dad1f68 100644 --- a/goTool/README.md +++ b/goTool/README.md @@ -27,6 +27,7 @@ go run . -port /dev/ttyUSB0 clients | `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) | | `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 inactive OTA slot (200 B chunks, 4 KiB flash blocks) | `clients` requires slaves to have responded to master discover broadcasts first. @@ -69,6 +70,12 @@ The dashboard can configure nodes using the same UART commands as the CLI: HTTP API (used by the web UI): `GET/POST /api/deadzone`, `POST /api/unicast-test`. +```bash +go run . -port /dev/ttyUSB0 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` and **success**. + ```bash go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42 ``` diff --git a/goTool/cmd_ota.go b/goTool/cmd_ota.go new file mode 100644 index 0000000..5b6fb90 --- /dev/null +++ b/goTool/cmd_ota.go @@ -0,0 +1,204 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "google.golang.org/protobuf/proto" + + uartframe "powerpod/gotool/uart" + "powerpod/gotool/pb" +) + +const ( + otaHostChunkSize = 200 + otaFlashBlockSize = 4096 + otaPrepareTimeout = 120 * time.Second + otaDefaultTimeout = 15 * time.Second +) + +const ( + otaStPreparing = 1 + otaStReady = 2 + otaStBlockAck = 3 + otaStSuccess = 4 + otaStFailed = 5 +) + +func runOTA(sp *serialPort, args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: ota ") + } + data, err := os.ReadFile(args[0]) + if err != nil { + return err + } + if len(data) == 0 { + return fmt.Errorf("empty firmware file") + } + + if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil { + return err + } + defer sp.port.SetReadTimeout(readTimeout) + + sp.mu.Lock() + defer sp.mu.Unlock() + + fmt.Printf("OTA start: %d bytes firmware\n", len(data)) + if err := writeUartMessageLocked(sp, &pb.UartMessage{ + Type: pb.MessageType_OTA_START, + Payload: &pb.UartMessage_OtaStart{ + OtaStart: &pb.OtaStartPayload{TotalSize: uint32(len(data))}, + }, + }, "OTA_START"); err != nil { + return err + } + if _, err := waitOtaStatusLocked(sp, otaStReady, otaPrepareTimeout); err != nil { + return err + } + + if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil { + return err + } + + var seq uint32 + blockNum := 0 + for offset := 0; offset < len(data); { + bytesInBlock := 0 + for bytesInBlock < otaFlashBlockSize && offset < len(data) { + n := otaHostChunkSize + room := otaFlashBlockSize - bytesInBlock + if n > room { + n = room + } + if offset+n > len(data) { + n = len(data) - offset + } + chunk := data[offset : offset+n] + + if err := writeUartMessageLocked(sp, &pb.UartMessage{ + Type: pb.MessageType_OTA_PAYLOAD, + Payload: &pb.UartMessage_OtaPayload{ + OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk}, + }, + }, "OTA_PAYLOAD"); err != nil { + return err + } + seq++ + offset += n + bytesInBlock += n + } + + if bytesInBlock == otaFlashBlockSize { + blockNum++ + st, err := waitOtaStatusLocked(sp, otaStBlockAck, otaDefaultTimeout) + if err != nil { + return err + } + fmt.Printf(" block %d ack (%d bytes in flash, %d%%)\n", + blockNum, st.GetBytesWritten(), offset*100/len(data)) + } + } + + if err := writeUartMessageLocked(sp, &pb.UartMessage{ + Type: pb.MessageType_OTA_END, + Payload: &pb.UartMessage_OtaEnd{ + OtaEnd: &pb.OtaEndPayload{}, + }, + }, "OTA_END"); err != nil { + return err + } + st, err := readOtaStatusLocked(sp) + if err != nil { + return err + } + if st.GetStatus() != otaStSuccess { + return fmt.Errorf("OTA failed: status=%d error=%d written=%d", + st.GetStatus(), st.GetError(), st.GetBytesWritten()) + } + fmt.Printf("OTA success: %d bytes written (slot %d) — reboot to boot new image\n", + st.GetBytesWritten(), st.GetTargetSlot()) + return nil +} + +func writeUartMessageLocked(sp *serialPort, msg *pb.UartMessage, cmdName string) error { + frame, err := encodeUartMessage(msg) + if err != nil { + return err + } + if !sp.quiet { + log.Printf("sending %s (%d frame bytes)", cmdName, len(frame)) + } + _, err = sp.port.Write(frame) + return err +} + +func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) { + body, err := proto.Marshal(msg) + if err != nil { + return nil, err + } + payload := append([]byte{byte(msg.Type)}, body...) + return uartframe.EncodeFrame(payload) +} + +func decodeUartPayload(payload []byte) (*pb.UartMessage, error) { + if len(payload) == 0 { + return nil, fmt.Errorf("empty response") + } + var msg pb.UartMessage + if err := proto.Unmarshal(payload[1:], &msg); err != nil { + return nil, err + } + msg.Type = pb.MessageType(payload[0]) + return &msg, nil +} + +func waitOtaStatusLocked(sp *serialPort, want uint32, timeout time.Duration) (*pb.OtaStatusPayload, error) { + deadline := time.Now().Add(timeout) + for { + if time.Now().After(deadline) { + return nil, fmt.Errorf("timeout waiting for OTA status %d", want) + } + if err := sp.port.SetReadTimeout(time.Until(deadline)); err != nil { + return nil, err + } + st, err := readOtaStatusLocked(sp) + if err != nil { + return nil, err + } + switch st.GetStatus() { + case want: + if want == otaStReady { + fmt.Printf("OTA ready: inactive slot %d\n", st.GetTargetSlot()) + } + return st, nil + case otaStPreparing: + fmt.Printf("OTA preparing partition (erase may take ~30s)…\n") + case otaStFailed: + return nil, fmt.Errorf("OTA failed (error=%d)", st.GetError()) + } + } +} + +func readOtaStatusLocked(sp *serialPort) (*pb.OtaStatusPayload, error) { + payload, err := uartframe.ReadFrame(sp.port, nil) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + msg, err := decodeUartPayload(payload) + if err != nil { + return nil, err + } + if msg.GetType() != pb.MessageType_OTA_STATUS { + return nil, fmt.Errorf("unexpected response type %v", msg.GetType()) + } + st := msg.GetOtaStatus() + if st == nil { + return nil, fmt.Errorf("missing ota_status") + } + return st, nil +} diff --git a/goTool/main.go b/goTool/main.go index 677f3e4..fee006d 100644 --- a/goTool/main.go +++ b/goTool/main.go @@ -18,7 +18,8 @@ func usage() { fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n") 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\n") + fmt.Fprintf(os.Stderr, " serve web dashboard (Bootstrap + WebSocket)\n") + fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions)\n\n") flag.PrintDefaults() } @@ -45,7 +46,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": + case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test", "ota": if *portName == "" { fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd) usage() @@ -65,6 +66,8 @@ func main() { runErr = runDeadzone(sp, flag.Args()[1:]) case "unicast-test", "unicast_test": runErr = runUnicastTest(sp, flag.Args()[1:]) + case "ota": + runErr = runOTA(sp, flag.Args()[1:]) } default: fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd) diff --git a/goTool/pb/uart_messages.pb.go b/goTool/pb/uart_messages.pb.go index b93d1c5..a34769f 100644 --- a/goTool/pb/uart_messages.pb.go +++ b/goTool/pb/uart_messages.pb.go @@ -447,11 +447,13 @@ func (x *EchoPayload) GetData() []byte { } type VersionResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` - GitHash string `protobuf:"bytes,2,opt,name=git_hash,json=gitHash,proto3" json:"git_hash,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + GitHash string `protobuf:"bytes,2,opt,name=git_hash,json=gitHash,proto3" json:"git_hash,omitempty"` + // * Active OTA app partition label, e.g. "ota_0" or "ota_1". + RunningPartition string `protobuf:"bytes,3,opt,name=running_partition,json=runningPartition,proto3" json:"running_partition,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VersionResponse) Reset() { @@ -498,6 +500,13 @@ func (x *VersionResponse) GetGitHash() string { return "" } +func (x *VersionResponse) GetRunningPartition() string { + if x != nil { + return x.RunningPartition + } + return "" +} + type ClientInfo struct { state protoimpl.MessageState `protogen:"open.v1"` Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` @@ -989,10 +998,10 @@ func (x *EspNowUnicastTestResponse) GetSeq() uint32 { return 0 } +// Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). type OtaStartPayload struct { state protoimpl.MessageState `protogen:"open.v1"` TotalSize uint32 `protobuf:"varint,1,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"` - BlockSize uint32 `protobuf:"varint,2,opt,name=block_size,json=blockSize,proto3" json:"block_size,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1034,18 +1043,11 @@ func (x *OtaStartPayload) GetTotalSize() uint32 { return 0 } -func (x *OtaStartPayload) GetBlockSize() uint32 { - if x != nil { - return x.BlockSize - } - return 0 -} - +// Host → device: firmware chunk (up to 200 bytes); device buffers 4 KiB before flash write. type OtaPayload struct { state protoimpl.MessageState `protogen:"open.v1"` - BlockId uint32 `protobuf:"varint,1,opt,name=block_id,json=blockId,proto3" json:"block_id,omitempty"` - ChunkId uint32 `protobuf:"varint,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` - Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + Seq uint32 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1080,16 +1082,9 @@ func (*OtaPayload) Descriptor() ([]byte, []int) { return file_uart_messages_proto_rawDescGZIP(), []int{13} } -func (x *OtaPayload) GetBlockId() uint32 { +func (x *OtaPayload) GetSeq() uint32 { if x != nil { - return x.BlockId - } - return 0 -} - -func (x *OtaPayload) GetChunkId() uint32 { - if x != nil { - return x.ChunkId + return x.Seq } return 0 } @@ -1101,9 +1096,9 @@ func (x *OtaPayload) GetData() []byte { return nil } +// Host → device: no more payload; device flushes buffer and finalizes OTA. type OtaEndPayload struct { state protoimpl.MessageState `protogen:"open.v1"` - Status uint32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1138,16 +1133,14 @@ func (*OtaEndPayload) Descriptor() ([]byte, []int) { return file_uart_messages_proto_rawDescGZIP(), []int{14} } -func (x *OtaEndPayload) GetStatus() uint32 { - if x != nil { - return x.Status - } - return 0 -} - +// Device → host status (also used as ACK after each 4 KiB written). +// status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed type OtaStatusPayload struct { state protoimpl.MessageState `protogen:"open.v1"` Status uint32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + BytesWritten uint32 `protobuf:"varint,2,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` + TargetSlot uint32 `protobuf:"varint,3,opt,name=target_slot,json=targetSlot,proto3" json:"target_slot,omitempty"` + Error uint32 `protobuf:"varint,4,opt,name=error,proto3" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1189,6 +1182,27 @@ func (x *OtaStatusPayload) GetStatus() uint32 { return 0 } +func (x *OtaStatusPayload) GetBytesWritten() uint32 { + if x != nil { + return x.BytesWritten + } + return 0 +} + +func (x *OtaStatusPayload) GetTargetSlot() uint32 { + if x != nil { + return x.TargetSlot + } + return 0 +} + +func (x *OtaStatusPayload) GetError() uint32 { + if x != nil { + return x.Error + } + return 0 +} + var File_uart_messages_proto protoreflect.FileDescriptor const file_uart_messages_proto_rawDesc = "" + @@ -1216,10 +1230,11 @@ const file_uart_messages_proto_rawDesc = "" + "\apayload\"\x05\n" + "\x03Ack\"!\n" + "\vEchoPayload\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04data\"F\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"s\n" + "\x0fVersionResponse\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12\x19\n" + - "\bgit_hash\x18\x02 \x01(\tR\agitHash\"\xc3\x01\n" + + "\bgit_hash\x18\x02 \x01(\tR\agitHash\x12+\n" + + "\x11running_partition\x18\x03 \x01(\tR\x10runningPartition\"\xc3\x01\n" + "\n" + "ClientInfo\x12\x0e\n" + "\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" + @@ -1254,21 +1269,21 @@ const file_uart_messages_proto_rawDesc = "" + "\x03seq\x18\x02 \x01(\rR\x03seq\"G\n" + "\x19EspNowUnicastTestResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x10\n" + - "\x03seq\x18\x02 \x01(\rR\x03seq\"O\n" + + "\x03seq\x18\x02 \x01(\rR\x03seq\"0\n" + "\x0fOtaStartPayload\x12\x1d\n" + "\n" + - "total_size\x18\x01 \x01(\rR\ttotalSize\x12\x1d\n" + + "total_size\x18\x01 \x01(\rR\ttotalSize\"2\n" + "\n" + - "block_size\x18\x02 \x01(\rR\tblockSize\"V\n" + - "\n" + - "OtaPayload\x12\x19\n" + - "\bblock_id\x18\x01 \x01(\rR\ablockId\x12\x19\n" + - "\bchunk_id\x18\x02 \x01(\rR\achunkId\x12\x12\n" + - "\x04data\x18\x03 \x01(\fR\x04data\"'\n" + - "\rOtaEndPayload\x12\x16\n" + - "\x06status\x18\x01 \x01(\rR\x06status\"*\n" + + "OtaPayload\x12\x10\n" + + "\x03seq\x18\x01 \x01(\rR\x03seq\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\"\x0f\n" + + "\rOtaEndPayload\"\x86\x01\n" + "\x10OtaStatusPayload\x12\x16\n" + - "\x06status\x18\x01 \x01(\rR\x06status*\xdd\x01\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" + "\vMessageType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\a\n" + "\x03ACK\x10\x01\x12\b\n" + diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index a31a3ae..96c8c72 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -19,6 +19,8 @@ idf_component_register( "cmd_client_info.c" "cmd_accel_deadzone.c" "cmd_espnow_unicast_test.c" + "cmd_ota.c" + "ota_uart.c" "client_registry.c" "esp_now_comm.c" "esp_now_proto.c" diff --git a/main/README.md b/main/README.md index 2a00d56..406e3dc 100644 --- a/main/README.md +++ b/main/README.md @@ -184,7 +184,11 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 = | 4 | `CLIENT_INFO` | Implemented (`cmd_client_info.c`) — slave list from registry | | 5 | `CLIENT_INPUT` | Planned | | 6 | `ACCEL_DEADZONE` | Implemented (`cmd_accel_deadzone.c`) — get/set accel filter LSB | -| 16–20 | OTA / ESP-NOW OTA | Planned | +| 16 | `OTA_START` | Implemented (`cmd_ota.c`) — begin UART OTA on inactive slot | +| 17 | `OTA_PAYLOAD` | Implemented — up to 200 B per frame; device buffers 4 KiB | +| 18 | `OTA_END` | Implemented — flush, `esp_ota_end`, set boot partition | +| 19 | `OTA_STATUS` | Device → host (prepare/ready/block ACK/success/failed) | +| 20 | `OTA_START_ESPNOW` | Planned | Regenerate C code: @@ -208,9 +212,32 @@ Build embeds `POWERPOD_GIT_HASH` via `git rev-parse` in `main/CMakeLists.txt`. - `type = VERSION` - `version_response.version` — `POWERPOD_FW_VERSION` - `version_response.git_hash` — build git hash string +- `version_response.running_partition` — active OTA label (`ota_0` / `ota_1`) Encoding: `uart_send_uart_message()` in `uart_proto.c`. +At boot, firmware logs the running partition and OTA slot index (A/B). + +### UART OTA (A/B) + +Master only. Inactive app partition is selected with `esp_ota_get_next_update_partition()`; `esp_ota_begin` erases it (can take ~30 s — host should wait). + +| Step | Host → device | Device → host | +|------|----------------|---------------| +| 1 | `OTA_START` + `total_size` | `OTA_STATUS` preparing, then **ready** (+ `target_slot` 0/1) | +| 2 | `OTA_PAYLOAD` chunks (**≤200 B**, `seq` optional) | `OTA_STATUS` **block_ack** only after each **4096 B** written to flash | +| 3 | `OTA_END` | `OTA_STATUS` **success** or **failed** (+ `bytes_written`) | + +Implementation: `ota_uart.c` (4 KiB buffer, `esp_ota_write`), `cmd_ota.c`. + +Host upload: + +```bash +go run . -port /dev/ttyUSB0 ota build/powerpod.bin +``` + +`OtaStatusPayload.status`: `1` preparing, `2` ready, `3` block_ack, `4` success, `5` failed. + ### ACCEL_DEADZONE command Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA456 accelerometer](#bma456-accelerometer-bosch456c)). Default **100** LSB. diff --git a/main/app_config.h b/main/app_config.h index 5d2ca23..351364b 100644 --- a/main/app_config.h +++ b/main/app_config.h @@ -12,4 +12,6 @@ typedef struct { char running_partition[APP_RUNNING_PARTITION_LABEL_MAX]; } app_config_t; +const app_config_t *app_config_get(void); + #endif diff --git a/main/cmd_handler.c b/main/cmd_handler.c index b444b00..26e4bf8 100644 --- a/main/cmd_handler.c +++ b/main/cmd_handler.c @@ -47,7 +47,7 @@ static const char *message_type_name(uint16_t id) { void init_cmdHandler(QueueHandle_t queue) { cmd_queue = queue; - if (xTaskCreate(vCmdDispatcherTask, "cmd_dispatch", 4096, NULL, 5, NULL) != + if (xTaskCreate(vCmdDispatcherTask, "cmd_dispatch", 8192, NULL, 5, NULL) != pdPASS) { ESP_LOGE(TAG, "failed to create cmd_dispatch task"); } diff --git a/main/cmd_ota.c b/main/cmd_ota.c new file mode 100644 index 0000000..7d50400 --- /dev/null +++ b/main/cmd_ota.c @@ -0,0 +1,163 @@ +#include "cmd_ota.h" +#include "ota_uart.h" +#include "uart_cmd.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/idf_additions.h" +#include + +static const char *TAG = "[OTA_CMD]"; + +#define OTA_PREPARE_STACK 8192 +#define OTA_PREPARE_PRIO 5 + +static void send_ota_status(ota_uart_status_t status, uint32_t err_code) { + alox_UartMessage response; + uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS, + alox_UartMessage_ota_status_tag); + response.payload.ota_status.status = (uint32_t)status; + response.payload.ota_status.bytes_written = ota_uart_bytes_written(); + int slot = ota_uart_target_slot(); + response.payload.ota_status.target_slot = + slot >= 0 ? (uint32_t)slot : 0; + response.payload.ota_status.error = err_code; + uart_cmd_send(&response, TAG); +} + +static void ota_prepare_task(void *param) { + uint32_t total_size = (uint32_t)(uintptr_t)param; + + send_ota_status(OTA_UART_ST_PREPARING, 0); + + int slot = ota_uart_prepare(total_size); + if (slot < 0) { + send_ota_status(OTA_UART_ST_FAILED, 1); + vTaskDelete(NULL); + return; + } + + alox_UartMessage response; + uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS, + alox_UartMessage_ota_status_tag); + response.payload.ota_status.status = (uint32_t)OTA_UART_ST_READY; + response.payload.ota_status.bytes_written = 0; + response.payload.ota_status.target_slot = (uint32_t)slot; + response.payload.ota_status.error = 0; + uart_cmd_send(&response, TAG); + + vTaskDelete(NULL); +} + +static void handle_ota_start(const uint8_t *data, size_t len) { + alox_UartMessage uart_msg; + alox_OtaStartPayload req = alox_OtaStartPayload_init_zero; + + if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) { + send_ota_status(OTA_UART_ST_FAILED, 2); + return; + } + + const alox_OtaStartPayload *req_ptr = + UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_start_tag, ota_start); + if (req_ptr != NULL) { + req = *req_ptr; + } + + if (req.total_size == 0) { + ESP_LOGW(TAG, "OTA_START: total_size required"); + send_ota_status(OTA_UART_ST_FAILED, 3); + return; + } + + if (ota_uart_is_active()) { + ESP_LOGW(TAG, "OTA_START while session active"); + send_ota_status(OTA_UART_ST_FAILED, 4); + return; + } + + if (xTaskCreate(ota_prepare_task, "ota_prepare", OTA_PREPARE_STACK, + (void *)(uintptr_t)req.total_size, OTA_PREPARE_PRIO, + NULL) != pdPASS) { + ESP_LOGE(TAG, "failed to create ota_prepare task"); + send_ota_status(OTA_UART_ST_FAILED, 5); + } +} + +static void handle_ota_payload(const uint8_t *data, size_t len) { + alox_UartMessage uart_msg; + + if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) { + ESP_LOGW(TAG, "OTA_PAYLOAD decode failed"); + send_ota_status(OTA_UART_ST_FAILED, 10); + return; + } + + const alox_OtaPayload *req_ptr = + UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_payload_tag, ota_payload); + if (req_ptr == NULL) { + ESP_LOGW(TAG, "OTA_PAYLOAD: missing ota_payload (which=%u)", + (unsigned)uart_msg.which_payload); + send_ota_status(OTA_UART_ST_FAILED, 11); + return; + } + + if (req_ptr->data.size == 0) { + ESP_LOGW(TAG, "OTA_PAYLOAD: empty data (seq=%lu)", + (unsigned long)req_ptr->seq); + send_ota_status(OTA_UART_ST_FAILED, 11); + return; + } + + if (!ota_uart_is_active()) { + ESP_LOGW(TAG, "OTA_PAYLOAD without active session (seq=%lu)", + (unsigned long)req_ptr->seq); + send_ota_status(OTA_UART_ST_FAILED, 12); + return; + } + + ota_feed_result_t r = + ota_uart_feed(req_ptr->data.bytes, req_ptr->data.size); + if (r == OTA_FEED_ERROR) { + send_ota_status(OTA_UART_ST_FAILED, 13); + return; + } + if (r == OTA_FEED_BLOCK_WRITTEN) { + ESP_LOGI(TAG, "OTA block ack (%lu bytes in flash)", + (unsigned long)ota_uart_bytes_written()); + send_ota_status(OTA_UART_ST_BLOCK_ACK, 0); + } +} + +static void handle_ota_end(const uint8_t *data, size_t len) { + (void)data; + (void)len; + + if (!ota_uart_is_active()) { + send_ota_status(OTA_UART_ST_FAILED, 20); + return; + } + + uint32_t written = ota_uart_bytes_written(); + int slot = ota_uart_target_slot(); + bool success = false; + esp_err_t err = ota_uart_finish(&success); + if (err != ESP_OK || !success) { + send_ota_status(OTA_UART_ST_FAILED, (uint32_t)err); + return; + } + + alox_UartMessage response; + uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS, + alox_UartMessage_ota_status_tag); + response.payload.ota_status.status = (uint32_t)OTA_UART_ST_SUCCESS; + response.payload.ota_status.bytes_written = written; + response.payload.ota_status.target_slot = slot >= 0 ? (uint32_t)slot : 0; + response.payload.ota_status.error = 0; + uart_cmd_send(&response, TAG); +} + +void cmd_ota_register(void) { + uart_cmd_register(alox_MessageType_OTA_START, handle_ota_start); + uart_cmd_register(alox_MessageType_OTA_PAYLOAD, handle_ota_payload); + uart_cmd_register(alox_MessageType_OTA_END, handle_ota_end); +} diff --git a/main/cmd_ota.h b/main/cmd_ota.h new file mode 100644 index 0000000..d08a489 --- /dev/null +++ b/main/cmd_ota.h @@ -0,0 +1,6 @@ +#ifndef CMD_OTA_H +#define CMD_OTA_H + +void cmd_ota_register(void); + +#endif diff --git a/main/cmd_version.c b/main/cmd_version.c index ebd2687..46b794b 100644 --- a/main/cmd_version.c +++ b/main/cmd_version.c @@ -1,4 +1,5 @@ #include "cmd_version.h" +#include "app_config.h" #include "uart_cmd.h" #ifndef POWERPOD_FW_VERSION @@ -21,6 +22,13 @@ static void handle_version(const uint8_t *data, size_t len) { response.payload.version_response.version = POWERPOD_FW_VERSION; response.payload.version_response.git_hash.funcs.encode = uart_cmd_encode_string; response.payload.version_response.git_hash.arg = (void *)POWERPOD_GIT_HASH; + const app_config_t *cfg = app_config_get(); + if (cfg != NULL && cfg->running_partition[0] != '\0') { + response.payload.version_response.running_partition.funcs.encode = + uart_cmd_encode_string; + response.payload.version_response.running_partition.arg = + (void *)cfg->running_partition; + } uart_cmd_send(&response, TAG); } diff --git a/main/ota_uart.c b/main/ota_uart.c new file mode 100644 index 0000000..a6c2635 --- /dev/null +++ b/main/ota_uart.c @@ -0,0 +1,184 @@ +#include "ota_uart.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include + +static const char *TAG = "[OTA_UART]"; + +typedef struct { + bool active; + esp_ota_handle_t handle; + const esp_partition_t *update_partition; + uint32_t total_size; + uint32_t received; + uint32_t written; + int target_slot; + uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE]; + size_t block_len; +} ota_uart_state_t; + +static ota_uart_state_t s_ota; + +static int partition_slot(const esp_partition_t *part) { + if (part == NULL) { + return -1; + } + if (part->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_0) { + return 0; + } + if (part->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_1) { + return 1; + } + return -1; +} + +bool ota_uart_is_active(void) { return s_ota.active; } + +int ota_uart_target_slot(void) { + return s_ota.active ? s_ota.target_slot : -1; +} + +void ota_uart_abort(void) { + if (!s_ota.active) { + return; + } + esp_ota_abort(s_ota.handle); + memset(&s_ota, 0, sizeof(s_ota)); +} + +static esp_err_t flush_block(void) { + if (s_ota.block_len == 0) { + return ESP_OK; + } + esp_err_t err = + esp_ota_write(s_ota.handle, s_ota.block_buf, s_ota.block_len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_write %u bytes failed: %s", (unsigned)s_ota.block_len, + esp_err_to_name(err)); + return err; + } + s_ota.written += (uint32_t)s_ota.block_len; + s_ota.block_len = 0; + return ESP_OK; +} + +int ota_uart_prepare(uint32_t total_size) { + if (s_ota.active) { + ESP_LOGW(TAG, "OTA already active"); + return -1; + } + + const esp_partition_t *running = esp_ota_get_running_partition(); + const esp_partition_t *update_partition = + esp_ota_get_next_update_partition(NULL); + if (update_partition == NULL) { + ESP_LOGE(TAG, "no OTA update partition"); + return -1; + } + + ESP_LOGI(TAG, "running=%s, update=%s, image_size=%lu", + running != NULL ? running->label : "?", + update_partition->label, (unsigned long)total_size); + + if (total_size > 0 && total_size > update_partition->size) { + ESP_LOGE(TAG, "image too large (%lu > %lu)", (unsigned long)total_size, + (unsigned long)update_partition->size); + return -1; + } + + esp_ota_handle_t handle; + esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err)); + return -1; + } + + memset(&s_ota, 0, sizeof(s_ota)); + s_ota.active = true; + s_ota.handle = handle; + s_ota.update_partition = update_partition; + s_ota.total_size = total_size; + s_ota.target_slot = partition_slot(update_partition); + + ESP_LOGI(TAG, "OTA prepared, target slot %d (%s) — send 4 KiB chunks", + s_ota.target_slot, update_partition->label); + return s_ota.target_slot; +} + +ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len) { + if (!s_ota.active || data == NULL || len == 0) { + return OTA_FEED_ERROR; + } + if (len > OTA_UART_HOST_CHUNK_SIZE) { + ESP_LOGW(TAG, "chunk %u > %u, truncating", (unsigned)len, + OTA_UART_HOST_CHUNK_SIZE); + len = OTA_UART_HOST_CHUNK_SIZE; + } + + bool block_written = false; + size_t offset = 0; + while (offset < len) { + size_t space = OTA_UART_FLASH_BLOCK_SIZE - s_ota.block_len; + size_t n = len - offset; + if (n > space) { + n = space; + } + memcpy(s_ota.block_buf + s_ota.block_len, data + offset, n); + s_ota.block_len += n; + s_ota.received += (uint32_t)n; + offset += n; + + if (s_ota.block_len < OTA_UART_FLASH_BLOCK_SIZE) { + continue; + } + + if (flush_block() != ESP_OK) { + ota_uart_abort(); + return OTA_FEED_ERROR; + } + block_written = true; + } + + return block_written ? OTA_FEED_BLOCK_WRITTEN : OTA_FEED_OK; +} + +uint32_t ota_uart_bytes_written(void) { return s_ota.written; } + +esp_err_t ota_uart_finish(bool *success_out) { + if (success_out != NULL) { + *success_out = false; + } + if (!s_ota.active) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = flush_block(); + if (err != ESP_OK) { + ota_uart_abort(); + return err; + } + + err = esp_ota_end(s_ota.handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err)); + ota_uart_abort(); + return err; + } + + err = esp_ota_set_boot_partition(s_ota.update_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err)); + memset(&s_ota, 0, sizeof(s_ota)); + return err; + } + + ESP_LOGI(TAG, "OTA complete: %lu bytes written to %s (slot %d), reboot to run", + (unsigned long)s_ota.written, s_ota.update_partition->label, + s_ota.target_slot); + + if (success_out != NULL) { + *success_out = true; + } + memset(&s_ota, 0, sizeof(s_ota)); + return ESP_OK; +} diff --git a/main/ota_uart.h b/main/ota_uart.h new file mode 100644 index 0000000..df55d91 --- /dev/null +++ b/main/ota_uart.h @@ -0,0 +1,45 @@ +#ifndef OTA_UART_H +#define OTA_UART_H + +#include "esp_err.h" +#include +#include +#include + +#define OTA_UART_HOST_CHUNK_SIZE 200u +#define OTA_UART_FLASH_BLOCK_SIZE 4096u + +/** OtaStatusPayload.status values (device → host). */ +typedef enum { + OTA_UART_ST_PREPARING = 1, + OTA_UART_ST_READY = 2, + OTA_UART_ST_BLOCK_ACK = 3, + OTA_UART_ST_SUCCESS = 4, + OTA_UART_ST_FAILED = 5, +} ota_uart_status_t; + +typedef enum { + OTA_FEED_OK = 0, + OTA_FEED_BLOCK_WRITTEN, + OTA_FEED_ERROR, +} ota_feed_result_t; + +bool ota_uart_is_active(void); + +/** 0/1 while session active, else -1. */ +int ota_uart_target_slot(void); + +/** Begin OTA on the inactive app partition (esp_ota_begin). Returns target slot 0/1. */ +int ota_uart_prepare(uint32_t total_size); + +void ota_uart_abort(void); + +/** Append up to 200 bytes; flushes 4 KiB blocks to flash when full. */ +ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len); + +uint32_t ota_uart_bytes_written(void); + +/** Flush remainder, esp_ota_end, set boot partition on success. */ +esp_err_t ota_uart_finish(bool *success_out); + +#endif diff --git a/main/powerpod.c b/main/powerpod.c index e8ac655..40bc42e 100644 --- a/main/powerpod.c +++ b/main/powerpod.c @@ -4,6 +4,7 @@ #include "cmd_espnow_unicast_test.h" #include "cmd_client_info.h" #include "cmd_version.h" +#include "cmd_ota.h" #include "esp_now_comm.h" #include "powerpod.h" #include "driver/gpio.h" @@ -50,6 +51,8 @@ static i2c_master_dev_handle_t io_expander; static app_config_t app_config; +const app_config_t *app_config_get(void) { return &app_config; } + static QueueHandle_t cmd_queue; uint8_t reverse_high_nibble_lut(uint8_t n) { @@ -123,16 +126,29 @@ void app_main(void) { } const esp_partition_t *running = esp_ota_get_running_partition(); + int ota_slot = -1; + if (running != NULL) { + if (running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_0) { + ota_slot = 0; + } else if (running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_1) { + ota_slot = 1; + } + } app_config.master = (master != 0); app_config.network = network; - memcpy(app_config.running_partition, running->label, - sizeof(app_config.running_partition)); + if (running != NULL) { + memcpy(app_config.running_partition, running->label, + sizeof(app_config.running_partition)); + } else { + app_config.running_partition[0] = '\0'; + } ESP_LOGI(TAG, "RUNNING CONFIG:"); ESP_LOGI(TAG, "Master: %d", app_config.master); ESP_LOGI(TAG, "Network: %d", app_config.network); - ESP_LOGI(TAG, "Running Partition: %s", app_config.running_partition); + ESP_LOGI(TAG, "Running Partition: %s (OTA slot %d)", + app_config.running_partition, ota_slot); err = esp_now_comm_init(&app_config); if (err != ESP_OK) { @@ -144,13 +160,14 @@ void app_main(void) { board_input_init(); if (app_config.master) { - cmd_queue = xQueueCreate(10, sizeof(generic_msg_t)); + cmd_queue = xQueueCreate(64, sizeof(generic_msg_t)); init_cmdHandler(cmd_queue); init_uart(cmd_queue); cmd_version_register(); cmd_client_info_register(); cmd_accel_deadzone_register(); cmd_espnow_unicast_test_register(); + cmd_ota_register(); } uint8_t current_digit = 10; diff --git a/main/proto/uart_messages.options b/main/proto/uart_messages.options new file mode 100644 index 0000000..a9d1fe6 --- /dev/null +++ b/main/proto/uart_messages.options @@ -0,0 +1 @@ +OtaPayload.data max_size:200 diff --git a/main/proto/uart_messages.pb.h b/main/proto/uart_messages.pb.h index 306a355..f821b79 100644 --- a/main/proto/uart_messages.pb.h +++ b/main/proto/uart_messages.pb.h @@ -38,6 +38,8 @@ typedef struct _alox_EchoPayload { typedef struct _alox_VersionResponse { uint32_t version; pb_callback_t git_hash; + /* * Active OTA app partition label, e.g. "ota_0" or "ota_1". */ + pb_callback_t running_partition; } alox_VersionResponse; typedef struct _alox_ClientInfo { @@ -92,23 +94,30 @@ typedef struct _alox_EspNowUnicastTestResponse { uint32_t seq; } alox_EspNowUnicastTestResponse; +/* Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). */ typedef struct _alox_OtaStartPayload { uint32_t total_size; - uint32_t block_size; } alox_OtaStartPayload; +/* Host → device: firmware chunk (up to 200 bytes); device buffers 4 KiB before flash write. */ +typedef PB_BYTES_ARRAY_T(200) alox_OtaPayload_data_t; typedef struct _alox_OtaPayload { - uint32_t block_id; - uint32_t chunk_id; - pb_callback_t data; + uint32_t seq; + alox_OtaPayload_data_t data; } alox_OtaPayload; +/* Host → device: no more payload; device flushes buffer and finalizes OTA. */ typedef struct _alox_OtaEndPayload { - uint32_t status; + char dummy_field; } alox_OtaEndPayload; +/* Device → host status (also used as ACK after each 4 KiB written). + status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed */ typedef struct _alox_OtaStatusPayload { uint32_t status; + uint32_t bytes_written; + uint32_t target_slot; + uint32_t error; } alox_OtaStatusPayload; typedef struct _alox_UartMessage { @@ -163,7 +172,7 @@ extern "C" { #define alox_UartMessage_init_default {_alox_MessageType_MIN, 0, {alox_Ack_init_default}} #define alox_Ack_init_default {0} #define alox_EchoPayload_init_default {{{NULL}, NULL}} -#define alox_VersionResponse_init_default {0, {{NULL}, NULL}} +#define alox_VersionResponse_init_default {0, {{NULL}, NULL}, {{NULL}, NULL}} #define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0} #define alox_ClientInfoResponse_init_default {{{NULL}, NULL}} #define alox_ClientInput_init_default {0, 0, 0, 0} @@ -172,14 +181,14 @@ extern "C" { #define alox_AccelDeadzoneResponse_init_default {0, 0, 0, 0} #define alox_EspNowUnicastTestRequest_init_default {0, 0} #define alox_EspNowUnicastTestResponse_init_default {0, 0} -#define alox_OtaStartPayload_init_default {0, 0} -#define alox_OtaPayload_init_default {0, 0, {{NULL}, NULL}} +#define alox_OtaStartPayload_init_default {0} +#define alox_OtaPayload_init_default {0, {0, {0}}} #define alox_OtaEndPayload_init_default {0} -#define alox_OtaStatusPayload_init_default {0} +#define alox_OtaStatusPayload_init_default {0, 0, 0, 0} #define alox_UartMessage_init_zero {_alox_MessageType_MIN, 0, {alox_Ack_init_zero}} #define alox_Ack_init_zero {0} #define alox_EchoPayload_init_zero {{{NULL}, NULL}} -#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}} +#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}, {{NULL}, NULL}} #define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0} #define alox_ClientInfoResponse_init_zero {{{NULL}, NULL}} #define alox_ClientInput_init_zero {0, 0, 0, 0} @@ -188,15 +197,16 @@ extern "C" { #define alox_AccelDeadzoneResponse_init_zero {0, 0, 0, 0} #define alox_EspNowUnicastTestRequest_init_zero {0, 0} #define alox_EspNowUnicastTestResponse_init_zero {0, 0} -#define alox_OtaStartPayload_init_zero {0, 0} -#define alox_OtaPayload_init_zero {0, 0, {{NULL}, NULL}} +#define alox_OtaStartPayload_init_zero {0} +#define alox_OtaPayload_init_zero {0, {0, {0}}} #define alox_OtaEndPayload_init_zero {0} -#define alox_OtaStatusPayload_init_zero {0} +#define alox_OtaStatusPayload_init_zero {0, 0, 0, 0} /* Field tags (for use in manual encoding/decoding) */ #define alox_EchoPayload_data_tag 1 #define alox_VersionResponse_version_tag 1 #define alox_VersionResponse_git_hash_tag 2 +#define alox_VersionResponse_running_partition_tag 3 #define alox_ClientInfo_id_tag 1 #define alox_ClientInfo_available_tag 2 #define alox_ClientInfo_used_tag 3 @@ -223,12 +233,12 @@ extern "C" { #define alox_EspNowUnicastTestResponse_success_tag 1 #define alox_EspNowUnicastTestResponse_seq_tag 2 #define alox_OtaStartPayload_total_size_tag 1 -#define alox_OtaStartPayload_block_size_tag 2 -#define alox_OtaPayload_block_id_tag 1 -#define alox_OtaPayload_chunk_id_tag 2 -#define alox_OtaPayload_data_tag 3 -#define alox_OtaEndPayload_status_tag 1 +#define alox_OtaPayload_seq_tag 1 +#define alox_OtaPayload_data_tag 2 #define alox_OtaStatusPayload_status_tag 1 +#define alox_OtaStatusPayload_bytes_written_tag 2 +#define alox_OtaStatusPayload_target_slot_tag 3 +#define alox_OtaStatusPayload_error_tag 4 #define alox_UartMessage_type_tag 1 #define alox_UartMessage_ack_payload_tag 2 #define alox_UartMessage_echo_payload_tag 3 @@ -288,7 +298,8 @@ X(a, CALLBACK, SINGULAR, BYTES, data, 1) #define alox_VersionResponse_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, version, 1) \ -X(a, CALLBACK, SINGULAR, STRING, git_hash, 2) +X(a, CALLBACK, SINGULAR, STRING, git_hash, 2) \ +X(a, CALLBACK, SINGULAR, STRING, running_partition, 3) #define alox_VersionResponse_CALLBACK pb_default_field_callback #define alox_VersionResponse_DEFAULT NULL @@ -352,25 +363,26 @@ X(a, STATIC, SINGULAR, UINT32, seq, 2) #define alox_EspNowUnicastTestResponse_DEFAULT NULL #define alox_OtaStartPayload_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, total_size, 1) \ -X(a, STATIC, SINGULAR, UINT32, block_size, 2) +X(a, STATIC, SINGULAR, UINT32, total_size, 1) #define alox_OtaStartPayload_CALLBACK NULL #define alox_OtaStartPayload_DEFAULT NULL #define alox_OtaPayload_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, block_id, 1) \ -X(a, STATIC, SINGULAR, UINT32, chunk_id, 2) \ -X(a, CALLBACK, SINGULAR, BYTES, data, 3) -#define alox_OtaPayload_CALLBACK pb_default_field_callback +X(a, STATIC, SINGULAR, UINT32, seq, 1) \ +X(a, STATIC, SINGULAR, BYTES, data, 2) +#define alox_OtaPayload_CALLBACK NULL #define alox_OtaPayload_DEFAULT NULL #define alox_OtaEndPayload_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, status, 1) + #define alox_OtaEndPayload_CALLBACK NULL #define alox_OtaEndPayload_DEFAULT NULL #define alox_OtaStatusPayload_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UINT32, status, 1) +X(a, STATIC, SINGULAR, UINT32, status, 1) \ +X(a, STATIC, SINGULAR, UINT32, bytes_written, 2) \ +X(a, STATIC, SINGULAR, UINT32, target_slot, 3) \ +X(a, STATIC, SINGULAR, UINT32, error, 4) #define alox_OtaStatusPayload_CALLBACK NULL #define alox_OtaStatusPayload_DEFAULT NULL @@ -417,16 +429,16 @@ extern const pb_msgdesc_t alox_OtaStatusPayload_msg; /* alox_ClientInfoResponse_size depends on runtime parameters */ /* alox_ClientInputResponse_size depends on runtime parameters */ /* alox_OtaPayload_size depends on runtime parameters */ -#define ALOX_MAIN_PROTO_UART_MESSAGES_PB_H_MAX_SIZE alox_ClientInput_size +#define ALOX_MAIN_PROTO_UART_MESSAGES_PB_H_MAX_SIZE alox_OtaStatusPayload_size #define alox_AccelDeadzoneRequest_size 16 #define alox_AccelDeadzoneResponse_size 20 #define alox_Ack_size 0 #define alox_ClientInput_size 22 #define alox_EspNowUnicastTestRequest_size 12 #define alox_EspNowUnicastTestResponse_size 8 -#define alox_OtaEndPayload_size 6 -#define alox_OtaStartPayload_size 12 -#define alox_OtaStatusPayload_size 6 +#define alox_OtaEndPayload_size 0 +#define alox_OtaStartPayload_size 6 +#define alox_OtaStatusPayload_size 24 #ifdef __cplusplus } /* extern "C" */ diff --git a/main/proto/uart_messages.proto b/main/proto/uart_messages.proto index f0b180e..7737828 100644 --- a/main/proto/uart_messages.proto +++ b/main/proto/uart_messages.proto @@ -1,5 +1,7 @@ syntax = "proto3"; +import "nanopb.proto"; + package alox; enum MessageType { @@ -46,6 +48,8 @@ message EchoPayload { message VersionResponse { uint32 version = 1; string git_hash = 2; + /** Active OTA app partition label, e.g. "ota_0" or "ota_1". */ + string running_partition = 3; } message ClientInfo { @@ -100,21 +104,25 @@ message EspNowUnicastTestResponse { uint32 seq = 2; } +// Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). message OtaStartPayload { uint32 total_size = 1; - uint32 block_size = 2; } +// Host → device: firmware chunk (up to 200 bytes); device buffers 4 KiB before flash write. message OtaPayload { - uint32 block_id = 1; - uint32 chunk_id = 2; - bytes data = 3; + uint32 seq = 1; + bytes data = 2 [(nanopb).max_size = 200]; } -message OtaEndPayload { - uint32 status = 1; -} +// Host → device: no more payload; device flushes buffer and finalizes OTA. +message OtaEndPayload {} -message OtaStatusPayload { - uint32 status = 1; +// Device → host status (also used as ACK after each 4 KiB written). +// status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed +message OtaStatusPayload { + uint32 status = 1; + uint32 bytes_written = 2; + uint32 target_slot = 3; + uint32 error = 4; } diff --git a/main/uart.c b/main/uart.c index c86cda1..9c49b5d 100644 --- a/main/uart.c +++ b/main/uart.c @@ -32,9 +32,9 @@ static bool uart_enqueue_packet(const uart_packet_t *packet) { memcpy(msg.payload, &packet->payload[1], msg.len); } - if (xQueueSend(uart_cmd_queue, &msg, 0) != pdPASS) { + if (xQueueSend(uart_cmd_queue, &msg, pdMS_TO_TICKS(500)) != pdPASS) { free(msg.payload); - ESP_LOGW(TAG, "command queue full"); + ESP_LOGW(TAG, "command queue full (cmd 0x%02x)", (unsigned)msg.msg_id); return false; } return true; @@ -52,7 +52,7 @@ void init_uart(QueueHandle_t cmd_queue) { .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, }; - err = uart_driver_install(UART_NUM, UART_BUF_SIZE * 2, 0, 0, NULL, 0); + err = uart_driver_install(UART_NUM, UART_BUF_SIZE * 2, UART_BUF_SIZE, 0, NULL, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); return; @@ -69,7 +69,7 @@ void init_uart(QueueHandle_t cmd_queue) { return; } - if (xTaskCreate(uart_read_task, "uart_rx", 4096, NULL, 1, NULL) != pdPASS) { + if (xTaskCreate(uart_read_task, "uart_rx", 4096, NULL, 5, NULL) != pdPASS) { ESP_LOGE(TAG, "failed to create uart_read_task"); } } diff --git a/main/uart.h b/main/uart.h index d5d0ba0..f652468 100644 --- a/main/uart.h +++ b/main/uart.h @@ -12,10 +12,10 @@ #define UART_TXD_PIN 3 #define UART_RXD_PIN 2 -#define UART_BUF_SIZE 256 +#define UART_BUF_SIZE 2048 #define START_MARKER 0xAA #define STOP_MARKER 0xCC -#define MAX_BUF_SIZE 256 +#define MAX_BUF_SIZE 252 #define MAX_PAYLOAD_SIZE \ MAX_BUF_SIZE - 4 // Buffer overhead, Start, Len, CRC, End