Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
module tinygo.org/x/espflasher

go 1.22
go 1.25.0

require (
github.com/stretchr/testify v1.7.0
go.bug.st/serial v1.6.2
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261
go.bug.st/serial v1.8.0
golang.org/x/sys v0.43.0
)

require (
github.com/creack/goselect v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
Expand Down
10 changes: 4 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
go.bug.st/serial v1.8.0 h1:ZtnmN8aYXtPlTghwSvDWPHKBHL9TM6oFDa+KpSn4SQE=
go.bug.st/serial v1.8.0/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
Expand Down
71 changes: 55 additions & 16 deletions pkg/espflasher/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,74 @@ import "fmt"

// Error types for ESP flash operations.

// CommandError is returned when the ROM bootloader returns a non-zero status.
// CommandError is returned when the device returns a non-zero status.
type CommandError struct {
OpCode byte
Status byte
ErrCode byte
}

func (e *CommandError) Error() string {
// The v1.1.0+ esp-flasher-stub uses 16-bit big-endian response codes
// where the high byte (Status) encodes the error category and the low
// byte (ErrCode) is always 0x00. Earlier ROM bootloaders use Status=1
// with the low byte as the detailed error code.
desc := "unknown error"
switch e.ErrCode {
case 0x05:
desc = "received message is invalid"
case 0x06:
desc = "failed to act on received message"
case 0x07:
desc = "invalid CRC in message"
case 0x08:
desc = "flash write error"
case 0x09:
desc = "flash read error"
case 0x0A:
desc = "flash read length error"
case 0x0B:
desc = "deflate error"
switch {
// ROM bootloader error codes (Status == 0x01, error in ErrCode)
case e.Status == 0x01:
switch e.ErrCode {
case 0x05:
desc = "received message is invalid"
case 0x06:
desc = "failed to act on received message"
case 0x07:
desc = "invalid CRC in message"
case 0x08:
desc = "flash write error"
case 0x09:
desc = "flash read error"
case 0x0A:
desc = "flash read length error"
case 0x0B:
desc = "deflate error"
}
// Stub response codes (16-bit big-endian, ErrCode == 0x00)
case e.Status == 0xC0 && e.ErrCode == 0x00:
desc = "bad data length"
case e.Status == 0xC1 && e.ErrCode == 0x00:
desc = "bad data checksum"
case e.Status == 0xC2 && e.ErrCode == 0x00:
desc = "bad block size"
case e.Status == 0xC3 && e.ErrCode == 0x00:
desc = "invalid command"
case e.Status == 0xC4 && e.ErrCode == 0x00:
desc = "SPI operation failed"
case e.Status == 0xC5 && e.ErrCode == 0x00:
desc = "SPI unlock failed"
case e.Status == 0xC6 && e.ErrCode == 0x00:
desc = "not in flash mode"
case e.Status == 0xC7 && e.ErrCode == 0x00:
desc = "inflate error"
case e.Status == 0xC8 && e.ErrCode == 0x00:
desc = "not enough data"
case e.Status == 0xC9 && e.ErrCode == 0x00:
desc = "too much data"
case e.Status == 0xFF && e.ErrCode == 0x00:
desc = "command not implemented"
}
return fmt.Sprintf("command 0x%02X failed: status=0x%02X error=0x%02X (%s)",
e.OpCode, e.Status, e.ErrCode, desc)
}

// IsRetryable returns true if the error indicates a transient serial data
// loss (bad length or checksum) rather than a persistent device-side failure.
// These errors mean the SLIP frame was corrupted during UART transmission
// but the stub is in a clean state and ready for a resend.
func (e *CommandError) IsRetryable() bool {
return (e.Status == 0xC0 || e.Status == 0xC1) && e.ErrCode == 0x00
}

// TimeoutError is returned when a response is not received within the timeout.
type TimeoutError struct {
Op string
Expand Down
56 changes: 47 additions & 9 deletions pkg/espflasher/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,39 @@ import (
func TestCommandError(t *testing.T) {
tests := []struct {
name string
status byte
errCode byte
contains string
}{
{"invalid message", 0x05, "received message is invalid"},
{"failed to act", 0x06, "failed to act on received message"},
{"invalid CRC", 0x07, "invalid CRC in message"},
{"flash write", 0x08, "flash write error"},
{"flash read", 0x09, "flash read error"},
{"flash read length", 0x0A, "flash read length error"},
{"deflate error", 0x0B, "deflate error"},
{"unknown error", 0xFF, "unknown error"},
// ROM bootloader error codes
{"invalid message", 0x01, 0x05, "received message is invalid"},
{"failed to act", 0x01, 0x06, "failed to act on received message"},
{"invalid CRC", 0x01, 0x07, "invalid CRC in message"},
{"flash write", 0x01, 0x08, "flash write error"},
{"flash read", 0x01, 0x09, "flash read error"},
{"flash read length", 0x01, 0x0A, "flash read length error"},
{"deflate error", 0x01, 0x0B, "deflate error"},
{"unknown ROM error", 0x01, 0xFF, "unknown error"},
// Stub response codes (v1.1.0+)
{"bad data length", 0xC0, 0x00, "bad data length"},
{"bad data checksum", 0xC1, 0x00, "bad data checksum"},
{"bad block size", 0xC2, 0x00, "bad block size"},
{"invalid command", 0xC3, 0x00, "invalid command"},
{"SPI op failed", 0xC4, 0x00, "SPI operation failed"},
{"SPI unlock failed", 0xC5, 0x00, "SPI unlock failed"},
{"not in flash mode", 0xC6, 0x00, "not in flash mode"},
{"inflate error", 0xC7, 0x00, "inflate error"},
{"not enough data", 0xC8, 0x00, "not enough data"},
{"too much data", 0xC9, 0x00, "too much data"},
{"cmd not implemented", 0xFF, 0x00, "command not implemented"},
{"unknown stub error", 0xCF, 0x00, "unknown error"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &CommandError{
OpCode: 0x02,
Status: 0x01,
Status: tt.status,
ErrCode: tt.errCode,
}
msg := err.Error()
Expand Down Expand Up @@ -55,6 +70,29 @@ func TestCommandErrorFormat(t *testing.T) {
}
}

func TestCommandErrorIsRetryable(t *testing.T) {
tests := []struct {
name string
status byte
errCode byte
retryable bool
}{
{"bad data length", 0xC0, 0x00, true},
{"bad data checksum", 0xC1, 0x00, true},
{"SPI op failed", 0xC4, 0x00, false},
{"inflate error", 0xC7, 0x00, false},
{"ROM error", 0x01, 0x05, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &CommandError{Status: tt.status, ErrCode: tt.errCode}
if err.IsRetryable() != tt.retryable {
t.Errorf("IsRetryable() = %v, want %v", err.IsRetryable(), tt.retryable)
}
})
}
}

func TestTimeoutError(t *testing.T) {
err := &TimeoutError{Op: "sync"}
msg := err.Error()
Expand Down
87 changes: 58 additions & 29 deletions pkg/espflasher/flasher.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"compress/zlib"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
Expand Down Expand Up @@ -741,7 +742,7 @@ func (f *Flasher) flashCompressed(data []byte, offset uint32, progress ProgressF
if err := f.retryFlashBlock(seq, numBlocks, func() error {
return f.conn.flashDeflData(block, seq)
}); err != nil {
return err
return fmt.Errorf("flash block %d of %d: %w", seq, numBlocks, err)
}

sent += blockLen
Expand All @@ -759,9 +760,10 @@ func (f *Flasher) flashCompressed(data []byte, offset uint32, progress ProgressF
// bootloader upon receiving it, which can interfere with flash operations.
// esptool also skips this for ROM: "skip sending flash_finish to ROM loader,
// as it causes the loader to exit and run user code."
// For the stub, the end command acts as a write barrier: the stub ACKs each
// block on receive but writes to flash asynchronously, so the end command
// ensures the last block is actually written before we proceed.
// For the stub, the end command acts as a write barrier: the stub uses
// double-buffering (ACKs each block immediately, writes to flash as a
// post-process), so the end command ensures the last block's flash write
// has completed before we proceed.
if f.conn.isStub() {
if err := f.conn.flashDeflEnd(false); err != nil {
return err
Expand Down Expand Up @@ -816,7 +818,7 @@ func (f *Flasher) flashUncompressed(data []byte, offset uint32, progress Progres
if err := f.retryFlashBlock(seq, numBlocks, func() error {
return f.conn.flashData(block, seq)
}); err != nil {
return err
return fmt.Errorf("flash block %d of %d: %w", seq, numBlocks, err)
}

sent += blockLen
Expand Down Expand Up @@ -882,7 +884,7 @@ func (f *Flasher) EraseFlash(progress ProgressFunc) error {
return &UnsupportedCommandError{Command: "erase flash (requires stub)"}
}

f.logf("Erasing entire flash...")
f.logf("Erasing flash...")
if progress == nil {
if err := f.conn.eraseFlash(); err != nil {
return err
Expand Down Expand Up @@ -928,17 +930,19 @@ func (f *Flasher) EraseRegion(offset, size uint32, progress ProgressFunc) error
})
}

// flashBlockRetries is the number of attempts for each flash data block write.
// Transient serial errors (SLIP timeouts, framing glitches, USB CDC buffer
// drops) are common during flashing and typically resolve on retry. The stub
// handles duplicate sequence numbers gracefully, so resending an
// already-processed block is safe.
const flashBlockRetries = 3

// eraseProgressInterval is the tick interval used by tickErase when reporting
// synthetic erase progress via EraseFlash and EraseRegion.
const eraseProgressInterval = 500 * time.Millisecond

// flashBlockRetries is the number of attempts for each flash data block write.
// At high baud rates (460800+), USB-UART bridges occasionally lose bytes during
// transmission, causing the stub to receive a truncated or corrupted SLIP frame.
// The stub detects this (bad data length or bad checksum) and responds with a
// clean error, leaving it ready for a resend. We retry only for these
// serial-integrity errors; device-side failures (SPI errors, inflate errors)
// are not retried.
const flashBlockRetries = 3

// tickErase runs work (a blocking erase call) while emitting synthetic ETA
// progress updates against est every interval, until work returns. progress
// is called with (elapsedMs, estMs), capped so elapsed never reaches est
Expand Down Expand Up @@ -1281,38 +1285,63 @@ func compressData(data []byte) ([]byte, error) {
return buf.Bytes(), nil
}

// retryFlashBlock attempts a flash block write up to flashBlockRetries times.
// On failure, it flushes stale serial data and waits briefly before retrying.
// This handles transient SLIP timeouts and framing errors that are common
// during ESP32 flash operations, matching esptool.py's WRITE_BLOCK_ATTEMPTS
// retry behavior.
// logf logs a message if a logger is configured.
func (f *Flasher) logf(format string, args ...interface{}) {
if f.opts.Logger != nil {
f.opts.Logger.Logf(format, args...)
}
}

// retryFlashBlock calls writeFn up to flashBlockRetries times, retrying on
// errors that indicate transient serial data loss. Two cases are retried:
//
// 1. CommandError with IsRetryable() (bad data length / bad checksum): the
// stub received a corrupted frame and responded with a clean error. It is
// ready for a resend.
//
// 2. TimeoutError: the stub never responded, likely because bytes were lost
// during UART transmission, leaving the stub waiting for the rest of an
// incomplete SLIP frame. On resend, the leading 0xC0 terminates the stub's
// partial frame; the stub may then emit a stale error response for the
// truncated frame before processing our retry, which is handled by the
// next retry iteration.
//
// Between retries the serial RX buffer and SLIP reader are flushed so stale
// responses from partial-frame cleanup don't corrupt subsequent reads.
// Device-side failures (SPI errors, inflate errors) are NOT retried.
func (f *Flasher) retryFlashBlock(seq, numBlocks uint32, writeFn func() error) error {
var err error
for attempt := range flashBlockRetries {
err = writeFn()
if err == nil {
return nil
}
if !isRetryableFlashError(err) {
return err
}
if attempt < flashBlockRetries-1 {
f.logf("Warning: block %d/%d write failed (attempt %d/%d): %v — retrying",
seq, numBlocks, attempt+1, flashBlockRetries, err)
// Wait before flushing so a delayed response from the timed-out
// attempt has time to arrive and gets cleared by the flush.
// Without this ordering, the stale response arrives after the
// flush and corrupts the retry's response parsing (e.g. SLIP
// END 0xC0 read as a status byte).
time.Sleep(200 * time.Millisecond)
// Flush stale data so the retry starts with a clean serial state.
// After a timeout the stub may still be holding a partial frame;
// our next send's leading 0xC0 will terminate it, producing a
// stale error response that the flush on the FOLLOWING iteration
// (if needed) will clear.
f.conn.flushInput()
}
}
return fmt.Errorf("flash block %d of %d: %w", seq, numBlocks, err)
return err
}

// logf logs a message if a logger is configured.
func (f *Flasher) logf(format string, args ...interface{}) {
if f.opts.Logger != nil {
f.opts.Logger.Logf(format, args...)
// isRetryableFlashError returns true if the error is a transient serial-link
// issue (data corruption or loss) that can be recovered by resending.
func isRetryableFlashError(err error) bool {
var cmdErr *CommandError
if errors.As(err, &cmdErr) {
return cmdErr.IsRetryable()
}
var timeoutErr *TimeoutError
return errors.As(err, &timeoutErr)
}

// connectStatus emits a connect-phase status update if a ConnectStatus
Expand Down
Loading
Loading