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
17 changes: 15 additions & 2 deletions pkg/espflasher/flasher.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ type FlasherOptions struct {
// Logger receives informational messages during flashing.
// If nil, messages are discarded silently.
Logger Logger

// SerialOpener, if non-nil, is used instead of serial.Open for all
// port opens (initial and reopen-after-USB-reenumeration). Useful for
// callers that multiplex port access across a monitor and the flasher.
SerialOpener func(name string, mode *serial.Mode) (serial.Port, error)
}

// Logger is the interface for receiving progress and status messages.
Expand All @@ -86,6 +91,14 @@ func DefaultOptions() *FlasherOptions {
}
}

// openPort opens a serial port, using SerialOpener if available.
func (opts *FlasherOptions) openPort(name string, mode *serial.Mode) (serial.Port, error) {
if opts != nil && opts.SerialOpener != nil {
return opts.SerialOpener(name, mode)
}
return serial.Open(name, mode)
}

// connection defines the low-level protocol operations for communicating
// with an ESP bootloader over a serial connection.
type connection interface {
Expand Down Expand Up @@ -153,7 +166,7 @@ func New(portName string, opts *FlasherOptions) (*Flasher, error) {
StopBits: serial.OneStopBit,
}

port, err := serial.Open(portName, mode)
port, err := opts.openPort(portName, mode)
if err != nil {
return nil, fmt.Errorf("open serial port %s: %w", portName, err)
}
Expand Down Expand Up @@ -194,7 +207,7 @@ func (f *Flasher) reopenPort() error {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
port, err := serial.Open(f.portStr, &serial.Mode{
port, err := f.opts.openPort(f.portStr, &serial.Mode{
BaudRate: f.opts.BaudRate,
Parity: serial.NoParity,
DataBits: 8,
Expand Down
86 changes: 86 additions & 0 deletions pkg/espflasher/opener_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package espflasher

import (
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
"go.bug.st/serial"
)

func TestNewUsesSerialOpener(t *testing.T) {
var openCount atomic.Int32
mock := &mockPort{
readFunc: func(p []byte) (int, error) {
// Return sync response to allow connect() to proceed
if len(p) >= 8 {
// SLIP-encoded sync response (simplified)
return 0, nil
}
return 0, nil
},
}

opts := &FlasherOptions{
BaudRate: 115200,
FlashBaudRate: 460800,
ChipType: ChipAuto,
ResetMode: ResetNoReset,
ConnectAttempts: 1,
Compress: true,
SerialOpener: func(name string, mode *serial.Mode) (serial.Port, error) {
openCount.Add(1)
return mock, nil
},
}

f, _ := New("fake", opts)
// New may fail at connect step, but that's fine—we just verify the opener was called.
if f != nil {
defer f.Close() //nolint:errcheck
}

// Verify opener was called at least once (during New).
if openCount.Load() == 0 {
t.Errorf("SerialOpener was not called during New")
}
}

func TestReopenPortUsesSerialOpener(t *testing.T) {
var reopenCount atomic.Int32

// Create initial mock
initialMock := &mockPort{}

// Create replacement mock for reopen
reopenMock := &mockPort{}

opts := &FlasherOptions{
BaudRate: 115200,
FlashBaudRate: 460800,
ChipType: ChipAuto,
ResetMode: ResetDefault,
SerialOpener: func(name string, mode *serial.Mode) (serial.Port, error) {
reopenCount.Add(1)
return reopenMock, nil
},
}

// Construct flasher directly (bypass New to avoid connect)
f := &Flasher{
port: initialMock,
conn: newConn(initialMock),
opts: opts,
portStr: "fake",
}

// Call reopenPort
err := f.reopenPort()
assert.NoError(t, err)

// Verify opener was called during reopenPort
assert.Equal(t, int32(1), reopenCount.Load())

// Verify port was updated
assert.Equal(t, reopenMock, f.port)
}
Loading