From 9571905ab920609e92042f8ed530d8b0646a73af Mon Sep 17 00:00:00 2001 From: deadprogram Date: Sun, 23 Aug 2026 21:39:06 +0200 Subject: [PATCH] fix: keep ESP32-C3 from booting into download mode after flashing Three causes of the "wait usb download" after a successful flash (#60): - The ROM's RTC_CNTL_OPTION1 force-download-boot bit survives a reset, so clear it before resetting (C3, S3), as esptool does. - Reset() ended the stub session with reboot=true, rebooting into the ROM bootloader and re-enumerating USB, so the DTR/RTS reset that followed hit a stale fd. Use reboot=false and let the hard reset restart the chip. - C3 USB-Serial/JTAG detection picked the UARTDEV_BUF_NO address from a revision decoded out of MAC eFuse bytes, so it usually read the wrong address and missed the interface. Match on USB VID/PID instead, as esptool does, with the ROM variable as fallback. Also report failed DTR/RTS writes instead of logging "Device reset." Signed-off-by: deadprogram --- pkg/espflasher/chip.go | 7 + pkg/espflasher/flasher.go | 38 ++++- pkg/espflasher/reset.go | 25 +++- pkg/espflasher/reset_other.go | 4 +- pkg/espflasher/reset_test.go | 200 ++++++++++++++++++++++++++ pkg/espflasher/reset_windows.go | 9 +- pkg/espflasher/target_esp32c3.go | 71 ++++----- pkg/espflasher/target_esp32c3_test.go | 165 +++++---------------- pkg/espflasher/target_esp32c5.go | 18 ++- pkg/espflasher/target_esp32c6.go | 18 ++- pkg/espflasher/target_esp32h2.go | 18 ++- pkg/espflasher/target_esp32p4_rev1.go | 14 +- pkg/espflasher/target_esp32s2.go | 18 ++- pkg/espflasher/target_esp32s3.go | 30 +++- pkg/espflasher/usbjtag.go | 58 ++++++++ pkg/espflasher/usbjtag_enumerate.go | 33 +++++ pkg/espflasher/usbjtag_nocgo.go | 7 + pkg/espflasher/usbjtag_test.go | 97 +++++++++++++ 18 files changed, 609 insertions(+), 221 deletions(-) create mode 100644 pkg/espflasher/usbjtag.go create mode 100644 pkg/espflasher/usbjtag_enumerate.go create mode 100644 pkg/espflasher/usbjtag_nocgo.go create mode 100644 pkg/espflasher/usbjtag_test.go diff --git a/pkg/espflasher/chip.go b/pkg/espflasher/chip.go index df98608..77e3404 100644 --- a/pkg/espflasher/chip.go +++ b/pkg/espflasher/chip.go @@ -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) diff --git a/pkg/espflasher/flasher.go b/pkg/espflasher/flasher.go index 74bbcf3..199d72b 100644 --- a/pkg/espflasher/flasher.go +++ b/pkg/espflasher/flasher.go @@ -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) } @@ -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 { diff --git a/pkg/espflasher/reset.go b/pkg/espflasher/reset.go index c3bf11d..c46c094 100644 --- a/pkg/espflasher/reset.go +++ b/pkg/espflasher/reset.go @@ -161,17 +161,29 @@ 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) @@ -179,9 +191,10 @@ func hardReset(port serial.Port, usesUSB bool) { // 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. diff --git a/pkg/espflasher/reset_other.go b/pkg/espflasher/reset_other.go index 10f3095..7798bba 100644 --- a/pkg/espflasher/reset_other.go +++ b/pkg/espflasher/reset_other.go @@ -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) } diff --git a/pkg/espflasher/reset_test.go b/pkg/espflasher/reset_test.go index 6770715..6acad8a 100644 --- a/pkg/espflasher/reset_test.go +++ b/pkg/espflasher/reset_test.go @@ -2,6 +2,7 @@ package espflasher import ( "errors" + "fmt" "testing" "time" @@ -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...) } diff --git a/pkg/espflasher/reset_windows.go b/pkg/espflasher/reset_windows.go index efc0967..6f02eb4 100644 --- a/pkg/espflasher/reset_windows.go +++ b/pkg/espflasher/reset_windows.go @@ -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 } diff --git a/pkg/espflasher/target_esp32c3.go b/pkg/espflasher/target_esp32c3.go index 518b12c..dc4d556 100644 --- a/pkg/espflasher/target_esp32c3.go +++ b/pkg/espflasher/target_esp32c3.go @@ -8,17 +8,15 @@ import ( // ESP32-C3 register addresses for USB interface detection and watchdog control. // Reference: esptool/targets/esp32c3.py const ( - // UARTDEV_BUF_NO address depends on chip revision (read from efuse). - // Revision < 1 (ECO0-ECO3): base 0x3FCDF064 - // Revision >= 1 (ECO4+): base 0x3FCDF060 - // The actual register is 24 bytes (+0x18) from the base address. - esp32c3UARTDevBufNoRev0 uint32 = 0x3FCDF07C // 0x3FCDF064 + 24 - esp32c3UARTDevBufNoRev101 uint32 = 0x3FCDF078 // 0x3FCDF060 + 24 - esp32c3UARTDevBufNoUSBJTAGSerial uint32 = 3 // USB-JTAG/Serial active - - // Efuse register for chip version detection. - // Major chip version is in bits 24:22; minor in bits 21:20. - esp32c3EfuseRdMacSpiSys1 uint32 = 0x60008844 + // UARTDEV_BUF_NO: ROM .bss variable holding the console interface in + // use. Address matches esptool's ESP32C3ROM. + esp32c3UARTDevBufNo uint32 = 0x3FCDF07C + esp32c3UARTDevBufNoUSBJTAGSerial uint32 = 3 // USB-JTAG/Serial active + + // RTC_CNTL_OPTION1 force-download-boot bit, set by the ROM on entering + // download mode over USB-Serial/JTAG. Cleared before reset. + esp32c3RTCCntlOption1Reg uint32 = 0x600080F4 + esp32c3RTCCntlForceDownloadBoot uint32 = 0x1 // RTC_CNTL watchdog registers (different offsets from S3). esp32c3RTCCntlWDTConfig0 uint32 = 0x60008090 @@ -82,53 +80,34 @@ var defESP32C3 = &chipDef{ PostConnect: esp32c3PostConnect, + ForceDownloadBootReg: esp32c3RTCCntlOption1Reg, + ForceDownloadBootMask: esp32c3RTCCntlForceDownloadBoot, + ReadMAC: esp32c3ReadMAC, ReadChipRevision: esp32c3ReadChipRevision, ReadChipFeatures: esp32c3ReadChipFeatures, } -// esp32c3ChipRevision reads the chip revision from efuse. -// Returns major*100 + minor (e.g., 101 = major 1, minor 0, sub 1). -// Chip revision is encoded in EFUSE_RD_MAC_SPI_SYS_1_REG: -// - Major version: bits 24:22 -// - Minor version: bits 21:20 -func esp32c3ChipRevision(f *Flasher) (uint32, error) { - val, err := f.ReadRegister(esp32c3EfuseRdMacSpiSys1) - if err != nil { - return 0, err - } - major := (val >> 22) & 0x7 - minor := (val >> 20) & 0x3 - return major*100 + minor, nil -} - -// esp32c3UARTDevAddr returns the correct UARTDEV_BUF_NO address based on chip revision. -func esp32c3UARTDevAddr(f *Flasher) uint32 { - rev, err := esp32c3ChipRevision(f) - if err != nil { - // Default to older revision if efuse read fails - return esp32c3UARTDevBufNoRev0 - } - if rev >= 101 { - return esp32c3UARTDevBufNoRev101 - } - return esp32c3UARTDevBufNoRev0 -} - // esp32c3PostConnect detects the USB interface type and disables watchdogs // when connected via USB-JTAG/Serial. Without this, the RTC WDT fires // during flash and resets the chip mid-operation. // Reference: esptool/targets/esp32c3.py _post_connect() func esp32c3PostConnect(f *Flasher) error { - addr := esp32c3UARTDevAddr(f) - val, err := f.ReadRegister(addr) - if err != nil { - // In secure download mode, the register may be unreadable. - // Default to non-USB behavior (safe fallback). - return nil + // Prefer VID/PID (as esptool does): it doesn't depend on the ROM .bss + // layout. Fall back to the ROM variable when the host reports no + // VID/PID (macOS without cgo) or the port name doesn't match. + usbJTAG := f.usbInterfaceFromPort() == usbInterfaceSerialJTAG + if !usbJTAG { + val, err := f.ReadRegister(esp32c3UARTDevBufNo) + if err != nil { + // In secure download mode, the register may be unreadable. + // Default to non-USB behavior (safe fallback). + return nil + } + usbJTAG = val == esp32c3UARTDevBufNoUSBJTAGSerial } - if val == esp32c3UARTDevBufNoUSBJTAGSerial { + if usbJTAG { f.usesUSB = true f.logf("USB-JTAG/Serial interface detected, disabling watchdogs") if err := disableWatchdogsESP32C3(f); err != nil { diff --git a/pkg/espflasher/target_esp32c3_test.go b/pkg/espflasher/target_esp32c3_test.go index 45dea03..4ba05f7 100644 --- a/pkg/espflasher/target_esp32c3_test.go +++ b/pkg/espflasher/target_esp32c3_test.go @@ -8,95 +8,29 @@ import ( "github.com/stretchr/testify/require" ) -func TestESP32C3ChipRevisionRev0(t *testing.T) { - // Efuse value with major=0, minor=0 - mc := &mockConnection{ - readRegFunc: func(addr uint32) (uint32, error) { - if addr == esp32c3EfuseRdMacSpiSys1 { - return 0x00000000, nil - } - return 0, nil - }, - } - f := &Flasher{ - conn: mc, - opts: &FlasherOptions{}, - } - - rev, err := esp32c3ChipRevision(f) - require.NoError(t, err) - assert.Equal(t, uint32(0), rev, "revision should be 0 for major=0, minor=0") -} - -func TestESP32C3ChipRevisionRev101(t *testing.T) { - // Efuse value with major=1, minor=1 - // major=1: bits 24:22 = (1 << 22) = 0x00400000 - // minor=1: bits 21:20 = (1 << 20) = 0x00100000 - // Combined: 0x00500000 - mc := &mockConnection{ - readRegFunc: func(addr uint32) (uint32, error) { - if addr == esp32c3EfuseRdMacSpiSys1 { - // major=1: (1 << 22) = 0x00400000 - // minor=1: (1 << 20) = 0x00100000 - // combined: 0x00500000 - return 0x00500000, nil - } - return 0, nil - }, - } - f := &Flasher{ - conn: mc, - opts: &FlasherOptions{}, - } - - rev, err := esp32c3ChipRevision(f) - require.NoError(t, err) - assert.Equal(t, uint32(101), rev, "revision should be 101 for major=1, minor=1") +// stubPortVIDPID replaces USB port enumeration for the duration of a test. +func stubPortVIDPID(t *testing.T, vid, pid string, ok bool) { + t.Helper() + prev := portVIDPID + portVIDPID = func(string) (string, string, bool) { return vid, pid, ok } + t.Cleanup(func() { portVIDPID = prev }) } -func TestESP32C3UARTDevAddrRev0(t *testing.T) { - // Return major=0, minor=0 from efuse - mc := &mockConnection{ - readRegFunc: func(addr uint32) (uint32, error) { - return 0x00000000, nil - }, - } - f := &Flasher{ - conn: mc, - opts: &FlasherOptions{}, - } - - addr := esp32c3UARTDevAddr(f) - assert.Equal(t, esp32c3UARTDevBufNoRev0, addr, "should use Rev0 address") -} +func TestESP32C3PostConnectUSBJTAG(t *testing.T) { + stubPortVIDPID(t, "", "", false) // host reports no VID/PID + writeCount := 0 -func TestESP32C3UARTDevAddrRev101(t *testing.T) { - // Return major=1, minor=1 from efuse (revision 101) mc := &mockConnection{ readRegFunc: func(addr uint32) (uint32, error) { - if addr == esp32c3EfuseRdMacSpiSys1 { - // major=1: (1 << 22) = 0x00400000 - // minor=1: (1 << 20) = 0x00100000 - // combined: 0x00500000 - return 0x00500000, nil + if addr == esp32c3UARTDevBufNo { + return esp32c3UARTDevBufNoUSBJTAGSerial, nil } + // Return 0 for SWD conf read return 0, nil }, - } - f := &Flasher{ - conn: mc, - opts: &FlasherOptions{}, - } - - addr := esp32c3UARTDevAddr(f) - assert.Equal(t, esp32c3UARTDevBufNoRev101, addr, "should use Rev101 address") -} - -func TestESP32C3UARTDevAddrReadError(t *testing.T) { - // Efuse read fails, should default to Rev0 - mc := &mockConnection{ - readRegFunc: func(addr uint32) (uint32, error) { - return 0, errors.New("efuse read error") + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + writeCount++ + return nil }, } f := &Flasher{ @@ -104,60 +38,42 @@ func TestESP32C3UARTDevAddrReadError(t *testing.T) { opts: &FlasherOptions{}, } - addr := esp32c3UARTDevAddr(f) - assert.Equal(t, esp32c3UARTDevBufNoRev0, addr, "should default to Rev0 on read error") + err := esp32c3PostConnect(f) + require.NoError(t, err) + assert.True(t, f.usesUSB, "usesUSB should be true for USB-JTAG/Serial") + assert.Greater(t, writeCount, 0, "should have written registers to disable watchdog") } -func TestESP32C3PostConnectUSBJTAGRev0(t *testing.T) { - writeCount := 0 +// TestESP32C3PostConnectUSBJTAGByVIDPID verifies a VID/PID match is detected +// even when UARTDEV_BUF_NO reads back a non-USB value, e.g. because this ROM +// revision keeps the variable elsewhere. +func TestESP32C3PostConnectUSBJTAGByVIDPID(t *testing.T) { + stubPortVIDPID(t, espressifUSBVID, usbSerialJTAGPID, true) readCount := 0 mc := &mockConnection{ readRegFunc: func(addr uint32) (uint32, error) { readCount++ - if addr == esp32c3EfuseRdMacSpiSys1 { - return 0x00000000, nil // Major=0, Minor=0 -> Rev0 - } - if addr == esp32c3UARTDevBufNoRev0 { - return esp32c3UARTDevBufNoUSBJTAGSerial, nil - } - // Return 0 for SWD conf read - return 0, nil - }, - writeRegFunc: func(addr, value, mask, delayUS uint32) error { - writeCount++ - return nil + return 0, nil // UART value, and 0 for the SWD conf read }, } f := &Flasher{ - conn: mc, - opts: &FlasherOptions{}, + conn: mc, + opts: &FlasherOptions{}, + portStr: "/dev/ttyACM0", } err := esp32c3PostConnect(f) require.NoError(t, err) - assert.True(t, f.usesUSB, "usesUSB should be true for USB-JTAG/Serial") - assert.Greater(t, writeCount, 0, "should have written registers to disable watchdog") + assert.True(t, f.usesUSB, "VID/PID match should detect USB-JTAG/Serial") + assert.NotEqual(t, 0, readCount, "should still disable watchdogs") } -func TestESP32C3PostConnectUSBJTAGRev101(t *testing.T) { - writeCount := 0 - +func TestESP32C3PostConnectUART(t *testing.T) { + stubPortVIDPID(t, "10C4", "EA60", true) // CP2102 USB-UART bridge mc := &mockConnection{ readRegFunc: func(addr uint32) (uint32, error) { - if addr == esp32c3EfuseRdMacSpiSys1 { - // major=1, minor=1: (1 << 22) | (1 << 20) = 0x00500000 - return 0x00500000, nil // Major=1, Minor=1 -> Rev101 - } - if addr == esp32c3UARTDevBufNoRev101 { - return esp32c3UARTDevBufNoUSBJTAGSerial, nil - } - // Return 0 for SWD conf read - return 0, nil - }, - writeRegFunc: func(addr, value, mask, delayUS uint32) error { - writeCount++ - return nil + return 0, nil // Not USB, return UART value }, } f := &Flasher{ @@ -167,17 +83,14 @@ func TestESP32C3PostConnectUSBJTAGRev101(t *testing.T) { err := esp32c3PostConnect(f) require.NoError(t, err) - assert.True(t, f.usesUSB, "usesUSB should be true for USB-JTAG/Serial") - assert.Greater(t, writeCount, 0, "should have written registers to disable watchdog") + assert.False(t, f.usesUSB, "usesUSB should be false for UART") } -func TestESP32C3PostConnectUART(t *testing.T) { +func TestESP32C3PostConnectReadError(t *testing.T) { + stubPortVIDPID(t, "", "", false) mc := &mockConnection{ readRegFunc: func(addr uint32) (uint32, error) { - if addr == esp32c3EfuseRdMacSpiSys1 { - return 0x00000000, nil - } - return 0, nil // Not USB, return UART value + return 0, errors.New("secure download mode") }, } f := &Flasher{ @@ -186,8 +99,8 @@ func TestESP32C3PostConnectUART(t *testing.T) { } err := esp32c3PostConnect(f) - require.NoError(t, err) - assert.False(t, f.usesUSB, "usesUSB should be false for UART") + require.NoError(t, err, "an unreadable register must fall back to non-USB, not fail") + assert.False(t, f.usesUSB) } func TestESP32C3MAC(t *testing.T) { diff --git a/pkg/espflasher/target_esp32c5.go b/pkg/espflasher/target_esp32c5.go index e14b5cf..689dab5 100644 --- a/pkg/espflasher/target_esp32c5.go +++ b/pkg/espflasher/target_esp32c5.go @@ -72,14 +72,20 @@ var defESP32C5 = &chipDef{ // during flash and resets the chip mid-operation. // Reference: esptool/targets/esp32c5.py _post_connect() func esp32c5PostConnect(f *Flasher) error { - uartDev, err := f.ReadRegister(esp32c5UARTDevBufNo) - if err != nil { - // In secure download mode, the register may be unreadable. - // Default to non-USB behavior (safe fallback). - return nil + // Prefer VID/PID (as esptool does); fall back to the ROM variable when + // the host reports none. + usbJTAG := f.usbInterfaceFromPort() == usbInterfaceSerialJTAG + if !usbJTAG { + uartDev, err := f.ReadRegister(esp32c5UARTDevBufNo) + if err != nil { + // In secure download mode, the register may be unreadable. + // Default to non-USB behavior (safe fallback). + return nil + } + usbJTAG = uartDev == esp32c5UARTDevBufNoUSBJTAGSerial } - if uartDev == esp32c5UARTDevBufNoUSBJTAGSerial { + if usbJTAG { f.usesUSB = true f.logf("USB-JTAG/Serial interface detected, disabling watchdogs") return disableWatchdogsLP(f, esp32c5LPWDTConfig0, esp32c5LPWDTWProtect, esp32c5LPWDTSWDConf, esp32c5LPWDTSWDWProtect) diff --git a/pkg/espflasher/target_esp32c6.go b/pkg/espflasher/target_esp32c6.go index edde3c0..8357736 100644 --- a/pkg/espflasher/target_esp32c6.go +++ b/pkg/espflasher/target_esp32c6.go @@ -73,14 +73,20 @@ var defESP32C6 = &chipDef{ // during flash and resets the chip mid-operation. // Reference: esptool/targets/esp32c6.py _post_connect() func esp32c6PostConnect(f *Flasher) error { - uartDev, err := f.ReadRegister(esp32c6UARTDevBufNo) - if err != nil { - // In secure download mode, the register may be unreadable. - // Default to non-USB behavior (safe fallback). - return nil + // Prefer VID/PID (as esptool does); fall back to the ROM variable when + // the host reports none. + usbJTAG := f.usbInterfaceFromPort() == usbInterfaceSerialJTAG + if !usbJTAG { + uartDev, err := f.ReadRegister(esp32c6UARTDevBufNo) + if err != nil { + // In secure download mode, the register may be unreadable. + // Default to non-USB behavior (safe fallback). + return nil + } + usbJTAG = uartDev == esp32c6UARTDevBufNoUSBJTAGSerial } - if uartDev == esp32c6UARTDevBufNoUSBJTAGSerial { + if usbJTAG { f.usesUSB = true f.logf("USB-JTAG/Serial interface detected, disabling watchdogs") return disableWatchdogsLP(f, esp32c6LPWDTConfig0, esp32c6LPWDTWProtect, esp32c6LPWDTSWDConf, esp32c6LPWDTSWDWProtect) diff --git a/pkg/espflasher/target_esp32h2.go b/pkg/espflasher/target_esp32h2.go index 1f9cc37..006648f 100644 --- a/pkg/espflasher/target_esp32h2.go +++ b/pkg/espflasher/target_esp32h2.go @@ -75,14 +75,20 @@ var defESP32H2 = &chipDef{ // during flash and resets the chip mid-operation. // Reference: esptool/targets/esp32h2.py _post_connect() func esp32h2PostConnect(f *Flasher) error { - uartDev, err := f.ReadRegister(esp32h2UARTDevBufNo) - if err != nil { - // In secure download mode, the register may be unreadable. - // Default to non-USB behavior (safe fallback). - return nil + // Prefer VID/PID (as esptool does); fall back to the ROM variable when + // the host reports none. + usbJTAG := f.usbInterfaceFromPort() == usbInterfaceSerialJTAG + if !usbJTAG { + uartDev, err := f.ReadRegister(esp32h2UARTDevBufNo) + if err != nil { + // In secure download mode, the register may be unreadable. + // Default to non-USB behavior (safe fallback). + return nil + } + usbJTAG = uartDev == esp32h2UARTDevBufNoUSBJTAGSerial } - if uartDev == esp32h2UARTDevBufNoUSBJTAGSerial { + if usbJTAG { f.usesUSB = true f.logf("USB-JTAG/Serial interface detected, disabling watchdogs") return disableWatchdogsLP(f, esp32h2LPWDTConfig0, esp32h2LPWDTWProtect, esp32h2LPWDTSWDConf, esp32h2LPWDTSWDWProtect) diff --git a/pkg/espflasher/target_esp32p4_rev1.go b/pkg/espflasher/target_esp32p4_rev1.go index c70dc5b..9164057 100644 --- a/pkg/espflasher/target_esp32p4_rev1.go +++ b/pkg/espflasher/target_esp32p4_rev1.go @@ -79,12 +79,18 @@ var defESP32P4Rev1 = &chipDef{ } func esp32p4Rev1PostConnect(f *Flasher) error { - uartDev, err := f.ReadRegister(esp32p4Rev1UARTDevBufNo) - if err != nil { - return nil + // Prefer VID/PID (as esptool does); fall back to the ROM variable when + // the host reports none. + usbJTAG := f.usbInterfaceFromPort() == usbInterfaceSerialJTAG + if !usbJTAG { + uartDev, err := f.ReadRegister(esp32p4Rev1UARTDevBufNo) + if err != nil { + return nil + } + usbJTAG = uartDev == esp32p4UARTDevBufNoUSBJTAGSerial } - if uartDev == esp32p4UARTDevBufNoUSBJTAGSerial { + if usbJTAG { f.usesUSB = true f.logf("USB-JTAG/Serial interface detected (ESP32-P4 rev1), disabling watchdogs") return disableWatchdogsLP(f, esp32p4LPWDTConfig0, esp32p4LPWDTWProtect, esp32p4LPWDTSWDConf, esp32p4LPWDTSWDWProtect) diff --git a/pkg/espflasher/target_esp32s2.go b/pkg/espflasher/target_esp32s2.go index bf565e3..7462083 100644 --- a/pkg/espflasher/target_esp32s2.go +++ b/pkg/espflasher/target_esp32s2.go @@ -108,14 +108,20 @@ var defESP32S2 = &chipDef{ // watchdog disable for USB operation. // Reference: esptool/targets/esp32s2.py _post_connect() func esp32s2PostConnect(f *Flasher) error { - uartDev, err := f.ReadRegister(esp32s2UARTDevBufNo) - if err != nil { - // In secure download mode, the register may be unreadable. - // Default to non-USB behavior (safe fallback). - return nil + // Prefer VID/PID (as esptool does); fall back to the ROM variable when + // the host reports none. + usbOTG := f.usbInterfaceFromPort() == usbInterfaceOTG + if !usbOTG { + uartDev, err := f.ReadRegister(esp32s2UARTDevBufNo) + if err != nil { + // In secure download mode, the register may be unreadable. + // Default to non-USB behavior (safe fallback). + return nil + } + usbOTG = uartDev == esp32s2UARTDevBufNoUSBOTG } - if uartDev == esp32s2UARTDevBufNoUSBOTG { + if usbOTG { f.usesUSB = true f.logf("USB-OTG interface detected") } diff --git a/pkg/espflasher/target_esp32s3.go b/pkg/espflasher/target_esp32s3.go index 6f73015..2192d4b 100644 --- a/pkg/espflasher/target_esp32s3.go +++ b/pkg/espflasher/target_esp32s3.go @@ -12,6 +12,12 @@ const ( esp32s3UARTDevBufNoUSBOTG uint32 = 3 // USB-OTG (CDC) active esp32s3UARTDevBufNoUSBJTAGSerial uint32 = 4 // USB-JTAG/Serial active + // RTC_CNTL_OPTION1 force-download-boot bit, set by the ROM on entering + // download mode over native USB. Cleared before reset. + // Reference: esptool/targets/esp32s3.py hard_reset(). + esp32s3RTCCntlOption1Reg uint32 = 0x6000812C + esp32s3RTCCntlForceDownloadBoot uint32 = 0x1 + esp32s3RTCCntlWDTConfig0 uint32 = 0x60008098 esp32s3RTCCntlWDTWProtect uint32 = 0x600080B0 esp32s3RTCCntlWDTWKey uint32 = 0x50D83AA1 @@ -75,6 +81,9 @@ var defESP32S3 = &chipDef{ PostConnect: esp32s3PostConnect, + ForceDownloadBootReg: esp32s3RTCCntlOption1Reg, + ForceDownloadBootMask: esp32s3RTCCntlForceDownloadBoot, + ReadMAC: esp32s3ReadMAC, ReadChipRevision: esp32s3ReadChipRevision, ReadChipFeatures: esp32s3ReadChipFeatures, @@ -85,11 +94,22 @@ var defESP32S3 = &chipDef{ // during flash and resets the chip mid-operation. // Reference: esptool/targets/esp32s3.py _post_connect() func esp32s3PostConnect(f *Flasher) error { - val, err := f.conn.readReg(esp32s3UARTDevBufNo) - if err != nil { - // In secure download mode, the register may be unreadable. - // Default to non-USB behavior (safe fallback). - return nil + // Prefer VID/PID (as esptool does); fall back to the ROM variable when + // the host reports none. + val := uint32(0) + switch f.usbInterfaceFromPort() { + case usbInterfaceSerialJTAG: + val = esp32s3UARTDevBufNoUSBJTAGSerial + case usbInterfaceOTG: + val = esp32s3UARTDevBufNoUSBOTG + default: + var err error + val, err = f.conn.readReg(esp32s3UARTDevBufNo) + if err != nil { + // In secure download mode, the register may be unreadable. + // Default to non-USB behavior (safe fallback). + return nil + } } switch val { diff --git a/pkg/espflasher/usbjtag.go b/pkg/espflasher/usbjtag.go new file mode 100644 index 0000000..07ce723 --- /dev/null +++ b/pkg/espflasher/usbjtag.go @@ -0,0 +1,58 @@ +package espflasher + +import ( + "fmt" + "strings" +) + +// USB VID/PID of the Espressif USB-Serial/JTAG peripheral (ESP32-C3, C5, +// C6, H2, P4, S3), as matched by esptool's uses_usb_jtag_serial(). +const ( + espressifUSBVID = "303A" + usbSerialJTAGPID = "1001" +) + +// samePortName reports whether two port names are the same device. macOS +// exposes one device as both /dev/cu.* and /dev/tty.*. +func samePortName(a, b string) bool { + return normalizePortName(a) == normalizePortName(b) +} + +func normalizePortName(name string) string { + name = strings.TrimPrefix(name, "/dev/") + name = strings.TrimPrefix(name, "cu.") + name = strings.TrimPrefix(name, "tty.") + return strings.ToLower(name) +} + +// usbInterfaceKind identifies the native USB peripheral a serial port +// belongs to, as seen by the host USB stack. +type usbInterfaceKind int + +const ( + // usbInterfaceUnknown covers UART bridges, unreported VID/PIDs and + // ports that couldn't be found. + usbInterfaceUnknown usbInterfaceKind = iota + usbInterfaceSerialJTAG + // usbInterfaceOTG is native USB-OTG (CDC); its PID is the image chip ID. + usbInterfaceOTG +) + +// usbInterfaceFromPort reports which native USB peripheral the port belongs +// to, mirroring esptool's uses_usb_jtag_serial()/uses_usb_otg(). USB-OTG is +// only reported once the chip has been detected, since its PID is the +// chip's image ID. +func (f *Flasher) usbInterfaceFromPort() usbInterfaceKind { + vid, pid, ok := portVIDPID(f.portStr) + vid, pid = strings.ToUpper(vid), strings.ToUpper(pid) + if !ok || vid != espressifUSBVID { + return usbInterfaceUnknown + } + if pid == usbSerialJTAGPID { + return usbInterfaceSerialJTAG + } + if f.chip != nil && pid == fmt.Sprintf("%04X", f.chip.ImageChipID) { + return usbInterfaceOTG + } + return usbInterfaceUnknown +} diff --git a/pkg/espflasher/usbjtag_enumerate.go b/pkg/espflasher/usbjtag_enumerate.go new file mode 100644 index 0000000..106eadc --- /dev/null +++ b/pkg/espflasher/usbjtag_enumerate.go @@ -0,0 +1,33 @@ +//go:build !darwin || cgo + +package espflasher + +import ( + "strings" + + "go.bug.st/serial/enumerator" +) + +// portVIDPID looks up a port's USB VID/PID by name. A variable so tests can +// stub enumeration. Excluded on macOS without cgo, where enumerator needs +// IOKit; see usbjtag_nocgo.go. +var portVIDPID = enumeratePortVIDPID + +// enumeratePortVIDPID returns the upper-case VID/PID of the named port. ok +// is false if it isn't found, isn't USB, or reports no VID/PID. +func enumeratePortVIDPID(name string) (vid, pid string, ok bool) { + ports, err := enumerator.GetDetailedPortsList() + if err != nil { + return "", "", false + } + for _, p := range ports { + if !p.IsUSB || !samePortName(p.Name, name) { + continue + } + if p.VID == "" || p.PID == "" { + return "", "", false + } + return strings.ToUpper(p.VID), strings.ToUpper(p.PID), true + } + return "", "", false +} diff --git a/pkg/espflasher/usbjtag_nocgo.go b/pkg/espflasher/usbjtag_nocgo.go new file mode 100644 index 0000000..3231c53 --- /dev/null +++ b/pkg/espflasher/usbjtag_nocgo.go @@ -0,0 +1,7 @@ +//go:build darwin && !cgo + +package espflasher + +// portVIDPID can't enumerate USB devices on macOS without cgo; callers fall +// back to reading UARTDEV_BUF_NO out of ROM. +var portVIDPID = func(name string) (vid, pid string, ok bool) { return "", "", false } diff --git a/pkg/espflasher/usbjtag_test.go b/pkg/espflasher/usbjtag_test.go new file mode 100644 index 0000000..f6c98e5 --- /dev/null +++ b/pkg/espflasher/usbjtag_test.go @@ -0,0 +1,97 @@ +package espflasher + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUSBInterfaceFromPort(t *testing.T) { + tests := []struct { + name string + vid, pid string + found bool + chip *chipDef + want usbInterfaceKind + }{ + {"usb-serial-jtag", "303A", "1001", true, defESP32C3, usbInterfaceSerialJTAG}, + {"lower case vid/pid", "303a", "1001", true, defESP32C3, usbInterfaceSerialJTAG}, + {"usb-otg s3", "303A", "0009", true, defESP32S3, usbInterfaceOTG}, + {"usb-otg s2", "303A", "0002", true, defESP32S2, usbInterfaceOTG}, + {"otg pid of another chip", "303A", "0002", true, defESP32S3, usbInterfaceUnknown}, + {"otg pid without chip", "303A", "0009", true, nil, usbInterfaceUnknown}, + {"usb-uart bridge", "10C4", "EA60", true, defESP32C3, usbInterfaceUnknown}, + {"port not found", "", "", false, defESP32C3, usbInterfaceUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stubPortVIDPID(t, tt.vid, tt.pid, tt.found) + f := &Flasher{chip: tt.chip, portStr: "/dev/ttyACM0"} + assert.Equal(t, tt.want, f.usbInterfaceFromPort()) + }) + } +} + +// TestNormalizePortName covers matching a port against the enumerated list. +func TestNormalizePortName(t *testing.T) { + // macOS exposes the same device as both cu.* and tty.*. + assert.True(t, samePortName("/dev/cu.usbmodem1101", "/dev/tty.usbmodem1101")) + assert.True(t, samePortName("/dev/ttyACM0", "ttyACM0")) + assert.True(t, samePortName("COM3", "com3")) + assert.False(t, samePortName("/dev/ttyACM0", "/dev/ttyACM1")) + assert.False(t, samePortName("/dev/ttyUSB0", "/dev/ttyACM0")) +} + +// TestESP32S3PostConnectUSBJTAGByVIDPID verifies the S3 takes the +// USB-Serial/JTAG branch on a VID/PID match, even when the register reads as +// a UART. +func TestESP32S3PostConnectUSBJTAGByVIDPID(t *testing.T) { + stubPortVIDPID(t, espressifUSBVID, usbSerialJTAGPID, true) + writes := 0 + mc := &mockConnection{ + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + writes++ + return nil + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, chip: defESP32S3, portStr: "/dev/ttyACM0"} + + err := esp32s3PostConnect(f) + assert.NoError(t, err) + assert.True(t, f.usesUSB) + assert.Greater(t, writes, 0, "USB-Serial/JTAG must disable the watchdogs") +} + +// TestESP32S3PostConnectOTGByVIDPID verifies the OTG PID takes the USB-OTG +// branch, which leaves the watchdogs alone. +func TestESP32S3PostConnectOTGByVIDPID(t *testing.T) { + stubPortVIDPID(t, espressifUSBVID, "0009", true) + writes := 0 + mc := &mockConnection{ + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + writes++ + return nil + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, chip: defESP32S3, portStr: "/dev/ttyACM0"} + + err := esp32s3PostConnect(f) + assert.NoError(t, err) + assert.True(t, f.usesUSB) + assert.Equal(t, 0, writes, "USB-OTG must not touch the watchdogs") +} + +// TestESP32S2PostConnectOTGByVIDPID verifies USB-OTG detection by VID/PID. +func TestESP32S2PostConnectOTGByVIDPID(t *testing.T) { + stubPortVIDPID(t, espressifUSBVID, "0002", true) + f := &Flasher{ + conn: &mockConnection{}, + opts: &FlasherOptions{}, + chip: defESP32S2, + portStr: "/dev/ttyACM0", + } + + err := esp32s2PostConnect(f) + assert.NoError(t, err) + assert.True(t, f.usesUSB) +}