diff --git a/pkg/espflasher/flasher.go b/pkg/espflasher/flasher.go index a633f0e..f82fea9 100644 --- a/pkg/espflasher/flasher.go +++ b/pkg/espflasher/flasher.go @@ -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. @@ -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 { @@ -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) } @@ -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, diff --git a/pkg/espflasher/opener_test.go b/pkg/espflasher/opener_test.go new file mode 100644 index 0000000..17522fd --- /dev/null +++ b/pkg/espflasher/opener_test.go @@ -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) +}