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: 7 additions & 0 deletions pkg/espflasher/chip.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ type chipDef struct {
// mechanism.
HardResetOTG func(f *Flasher) bool

// RTC_CNTL_OPTION1 register and its FORCE_DOWNLOAD_BOOT bit. The bit
// survives a reset (RTC domain), so Flasher.Reset clears it or the
// chip comes back up in download mode. 0 when not known for the chip.
// Reference: esptool/targets/esp32s3.py hard_reset().
ForceDownloadBootReg uint32
ForceDownloadBootMask uint32

// ReadMAC reads the factory-programmed base MAC address from eFuse.
// Nil for chips (ESP8266) that don't expose it via this scheme.
ReadMAC func(f *Flasher) (net.HardwareAddr, error)
Expand Down
38 changes: 33 additions & 5 deletions pkg/espflasher/flasher.go
Original file line number Diff line number Diff line change
Expand Up @@ -1169,14 +1169,22 @@ func (f *Flasher) ReadFlash(offset, size uint32, progress ProgressFunc) ([]byte,

// Reset performs a hard reset of the device, causing it to run user code.
func (f *Flasher) Reset() {
// Clear force-download-boot first, while the loader still answers
// commands: the bit survives the resets below, so leaving it set makes
// the chip boot into download mode instead of the application.
f.clearForceDownloadBoot()

if f.conn.isStub() {
// Tell the stub to cleanly exit flash mode and reboot.
// flashEnd(true) triggers a software reboot inside the stub.
// Exit flash mode but keep the stub running: reboot=true means
// "reboot into the ROM bootloader", which lands the chip in
// download mode and re-enumerates USB, leaving the reset below to
// toggle a stale fd. esptool also ends with reboot=False.
//
// For ROM bootloaders, skip flash_begin/flash_end — sending
// CMD_FLASH_BEGIN after a compressed download may interfere with
// the flash controller state at offset 0.
f.conn.flashBegin(0, 0, false) //nolint:errcheck
f.conn.flashEnd(true) //nolint:errcheck
f.conn.flashEnd(false) //nolint:errcheck
time.Sleep(50 * time.Millisecond)
}

Expand All @@ -1188,14 +1196,34 @@ func (f *Flasher) Reset() {
return
}

var err error
if f.usesUSB {
hardResetUSB(f.port)
err = hardResetUSB(f.port)
} else {
hardReset(f.port, false)
err = hardReset(f.port, false)
}
if err != nil {
// The DTR/RTS writes never reached the device, so don't claim a
// reset that most likely didn't happen.
f.logf("Warning: device may not have been reset: %v", err)
return
}
f.logf("Device reset.")
}

// clearForceDownloadBoot clears the force-download-boot bit for chips that
// expose it. Non-fatal: the register is unwritable in secure download mode.
// Mirrors esptool's ESP32S3ROM.hard_reset().
func (f *Flasher) clearForceDownloadBoot() {
if f.chip == nil || f.chip.ForceDownloadBootReg == 0 {
return
}
err := f.conn.writeReg(f.chip.ForceDownloadBootReg, 0, f.chip.ForceDownloadBootMask, 0)
if err != nil {
f.logf("Warning: could not clear force-download-boot: %v", err)
}
}

// attachFlash attaches the SPI flash and configures parameters.
// ESP8266 does not need (or support) SPI attach; its ROM handles flash directly.
func (f *Flasher) attachFlash() error {
Expand Down
25 changes: 19 additions & 6 deletions pkg/espflasher/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,27 +161,40 @@ func usbJTAGSerialReset(port serial.Port) {
}

// hardReset performs a hardware reset (chip restarts and runs user code).
func hardReset(port serial.Port, usesUSB bool) {
//
// It returns the first modem-control error but always runs the full
// sequence, so a mid-sequence failure can't leave EN asserted. An error
// usually means a stale fd (the USB device re-enumerated) and so a chip
// that was never reset.
func hardReset(port serial.Port, usesUSB bool) error {
var firstErr error
set := func(err error) {
if err != nil && firstErr == nil {
firstErr = err
}
}

if usesUSB {
// On USB-JTAG/Serial, the peripheral latches DTR (GPIO0 state)
// at reset time. Ensure DTR=false so GPIO0=HIGH → normal boot,
// not bootloader mode.
port.SetDTR(false) //nolint:errcheck
set(port.SetDTR(false))
}
port.SetRTS(true) //nolint:errcheck // EN=LOW (chip in reset)
set(port.SetRTS(true)) // EN=LOW (chip in reset)
if usesUSB {
time.Sleep(200 * time.Millisecond)
port.SetRTS(false) //nolint:errcheck
set(port.SetRTS(false))
time.Sleep(200 * time.Millisecond)
} else {
time.Sleep(100 * time.Millisecond)
// Release DTR before exiting reset. Otherwise a leftover DTR=true
// from a prior operation holds IO0 LOW at reset exit and the chip
// boots into the download-mode bootloader instead of the
// application. Matches esptool.py HardReset.
port.SetDTR(false) //nolint:errcheck
port.SetRTS(false) //nolint:errcheck
set(port.SetDTR(false))
set(port.SetRTS(false))
}
return firstErr
}

// String returns the string representation of the ResetMode.
Expand Down
4 changes: 2 additions & 2 deletions pkg/espflasher/reset_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ import "go.bug.st/serial"
// hardResetUSB performs a hardware reset for USB-JTAG/Serial devices.
// On non-Windows platforms, this delegates to the standard hardReset
// which uses SetRTS/SetDTR through the serial library.
func hardResetUSB(port serial.Port) {
hardReset(port, true)
func hardResetUSB(port serial.Port) error {
return hardReset(port, true)
}
200 changes: 200 additions & 0 deletions pkg/espflasher/reset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package espflasher

import (
"errors"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -290,3 +291,202 @@ func TestFlasherResetUSBJTAGChipUnchanged(t *testing.T) {

assert.NotEmpty(t, port.calls, "USB-Serial-JTAG chips must still use the DTR/RTS reset path")
}

// TestFlasherResetClearsForceDownloadBoot verifies Reset() clears the
// force-download-boot bit; left set, the chip comes back up in download
// mode instead of running the application.
func TestFlasherResetClearsForceDownloadBoot(t *testing.T) {
for _, tt := range []struct {
name string
chip *chipDef
reg uint32
mask uint32
}{
{"esp32c3", defESP32C3, esp32c3RTCCntlOption1Reg, esp32c3RTCCntlForceDownloadBoot},
{"esp32s3", defESP32S3, esp32s3RTCCntlOption1Reg, esp32s3RTCCntlForceDownloadBoot},
} {
t.Run(tt.name, func(t *testing.T) {
type regWrite struct {
addr, value, mask uint32
}
var writes []regWrite
mc := &mockConnection{
writeRegFunc: func(addr, value, mask, delayUS uint32) error {
writes = append(writes, regWrite{addr, value, mask})
return nil
},
}
f := &Flasher{
conn: mc,
port: &recordingPort{},
opts: &FlasherOptions{},
chip: tt.chip,
usesUSB: true,
}

f.Reset()

assert.Contains(t, writes, regWrite{tt.reg, 0, tt.mask},
"Reset must clear the force-download-boot bit")
})
}
}

// TestFlasherResetClearsForceDownloadBootBeforeStubReboot verifies the clear
// happens while the loader still answers commands.
func TestFlasherResetClearsForceDownloadBootBeforeStubReboot(t *testing.T) {
var order []string
mc := &mockConnection{
stubMode: true,
writeRegFunc: func(addr, value, mask, delayUS uint32) error {
if addr == esp32c3RTCCntlOption1Reg {
order = append(order, "clear")
}
return nil
},
flashEndFunc: func(reboot bool) error {
order = append(order, "flashEnd")
return nil
},
}
f := &Flasher{
conn: mc,
port: &recordingPort{},
opts: &FlasherOptions{},
chip: defESP32C3,
usesUSB: true,
}

f.Reset()

require.Equal(t, []string{"clear", "flashEnd"}, order)
}

// TestFlasherResetForceDownloadBootFailureIsNonFatal verifies a failed clear
// (e.g. secure download mode) doesn't stop the reset.
func TestFlasherResetForceDownloadBootFailureIsNonFatal(t *testing.T) {
port := &recordingPort{}
mc := &mockConnection{
writeRegFunc: func(addr, value, mask, delayUS uint32) error {
return errors.New("write failed")
},
}
f := &Flasher{
conn: mc,
port: port,
opts: &FlasherOptions{},
chip: defESP32C3,
usesUSB: true,
}

f.Reset()

assert.NotEmpty(t, port.calls, "reset must still run when the clear fails")
}

// TestFlasherResetNoForceDownloadBootReg verifies chips without the register
// skip the clear instead of writing to address 0.
func TestFlasherResetNoForceDownloadBootReg(t *testing.T) {
writes := 0
mc := &mockConnection{
writeRegFunc: func(addr, value, mask, delayUS uint32) error {
writes++
return nil
},
}
f := &Flasher{
conn: mc,
port: &recordingPort{},
opts: &FlasherOptions{},
chip: defESP8266,
}

f.Reset()

assert.Equal(t, 0, writes, "chips without the register must not be written to")
}

// TestFlasherResetKeepsStubRunning verifies Reset() ends the session with
// reboot=false. reboot=true means "reboot into the ROM bootloader", leaving
// the chip in download mode with a re-enumerated (stale) port.
func TestFlasherResetKeepsStubRunning(t *testing.T) {
var reboots []bool
port := &recordingPort{}
mc := &mockConnection{
stubMode: true,
flashEndFunc: func(reboot bool) error {
reboots = append(reboots, reboot)
return nil
},
}
f := &Flasher{
conn: mc,
port: port,
opts: &FlasherOptions{},
chip: defESP32C3,
usesUSB: true,
}

f.Reset()

require.Equal(t, []bool{false}, reboots, "must not reboot back into the ROM bootloader")
assert.NotEmpty(t, port.calls, "the hardware reset must still run")
}

// failingPort fails its modem-control writes, as a stale fd does after the
// USB device re-enumerated.
type failingPort struct {
recordingPort
err error
}

func (f *failingPort) SetDTR(dtr bool) error {
f.recordingPort.SetDTR(dtr) //nolint:errcheck
return f.err
}

func (f *failingPort) SetRTS(rts bool) error {
f.recordingPort.SetRTS(rts) //nolint:errcheck
return f.err
}

// TestHardResetReportsError verifies hardReset surfaces a failed write while
// still running the full sequence, so EN isn't left asserted.
func TestHardResetReportsError(t *testing.T) {
for _, usesUSB := range []bool{false, true} {
port := &failingPort{err: errors.New("input/output error")}

err := hardReset(port, usesUSB)

assert.Error(t, err, "usesUSB=%v", usesUSB)
assert.Equal(t, false, port.rtsCalls[len(port.rtsCalls)-1],
"the sequence must run to completion and release EN")
}
}

// TestFlasherResetReportsFailedReset verifies Reset() doesn't claim
// "Device reset." when the DTR/RTS writes never reached the device.
func TestFlasherResetReportsFailedReset(t *testing.T) {
var logged []string
f := &Flasher{
conn: &mockConnection{},
port: &failingPort{err: errors.New("input/output error")},
opts: &FlasherOptions{Logger: loggerFunc(func(format string, args ...interface{}) {
logged = append(logged, fmt.Sprintf(format, args...))
})},
chip: defESP32C3,
usesUSB: true,
}

f.Reset()

require.NotEmpty(t, logged)
last := logged[len(logged)-1]
assert.Contains(t, last, "may not have been reset")
assert.NotContains(t, last, "Device reset.")
}

// loggerFunc adapts a function to the Logger interface.
type loggerFunc func(format string, args ...interface{})

func (l loggerFunc) Logf(format string, args ...interface{}) { l(format, args...) }
9 changes: 6 additions & 3 deletions pkg/espflasher/reset_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import (
// On Windows, a single hardReset is not sufficient for USB CDC devices.
// Performing two resets (first non-USB, then USB timing) reliably triggers
// the chip to restart.
func hardResetUSB(port serial.Port) {
hardReset(port, false)
func hardResetUSB(port serial.Port) error {
err := hardReset(port, false)
time.Sleep(defaultResetDelay)
hardReset(port, true)
if err2 := hardReset(port, true); err == nil {
err = err2
}
return err
}
Loading
Loading