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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 <firmware.bin>")
|
||||
}
|
||||
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
|
||||
}
|
||||
+5
-2
@@ -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)
|
||||
|
||||
@@ -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" +
|
||||
|
||||
Reference in New Issue
Block a user