diff --git a/pkg/espflasher/chip.go b/pkg/espflasher/chip.go index 37b809b..a622a35 100644 --- a/pkg/espflasher/chip.go +++ b/pkg/espflasher/chip.go @@ -125,6 +125,16 @@ type chipDef struct { // initialization (e.g. USB interface detection, watchdog disable). // May set Flasher fields like usesUSB. PostConnect func(f *Flasher) error + + // HardResetOTG is an optional chip-specific hook tried first by + // Flasher.Reset(). It distinguishes chips with a native USB-OTG + // peripheral (e.g. ESP32-S2), where the DTR/RTS latch used by + // hardResetUSB is a no-op, from chips with a USB-Serial-JTAG bridge + // (S3, C3, C6, H2, C5), which still use that latch. It returns true + // if it performed the reset; false to fall back to the standard + // usesUSB/hardReset path. Nil for chips without a native-OTG reset + // mechanism. + HardResetOTG func(f *Flasher) bool } // chipDetectMagicRegAddr is the register address that has a different diff --git a/pkg/espflasher/flasher.go b/pkg/espflasher/flasher.go index 3a4c85d..c48d4ff 100644 --- a/pkg/espflasher/flasher.go +++ b/pkg/espflasher/flasher.go @@ -895,14 +895,22 @@ func (f *Flasher) Reset() { if f.conn.isStub() { // Tell the stub to cleanly exit flash mode and reboot. // flashEnd(true) triggers a software reboot inside the stub. + // 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 time.Sleep(50 * time.Millisecond) } - // 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. + // Chips with a chip-specific hard reset (e.g. the ESP32-S2 USB-OTG + // watchdog reset) use it here; if it's unavailable or returns false, + // fall through to the DTR/RTS toggling below. + if f.chip != nil && f.chip.HardResetOTG != nil && f.chip.HardResetOTG(f) { + f.logf("Device reset.") + return + } + if f.usesUSB { hardResetUSB(f.port) } else { diff --git a/pkg/espflasher/reset_test.go b/pkg/espflasher/reset_test.go index 89ed0b6..6770715 100644 --- a/pkg/espflasher/reset_test.go +++ b/pkg/espflasher/reset_test.go @@ -1,6 +1,7 @@ package espflasher import ( + "errors" "testing" "time" @@ -212,3 +213,80 @@ func TestHardResetUSBDeassertsDTRFirst(t *testing.T) { assert.Equal(t, "DTR", first.line, "first call must be SetDTR on USB path") assert.False(t, first.value, "first SetDTR must be false (release GPIO0)") } + +// TestFlasherResetESP32S2UsesWatchdog verifies that Flasher.Reset() takes +// the chip's HardResetOTG hook (RTC watchdog) for an ESP32-S2 flasher over +// native USB-OTG, instead of the DTR/RTS hardResetUSB path used by chips +// with a USB-Serial-JTAG bridge. +func TestFlasherResetESP32S2UsesWatchdog(t *testing.T) { + port := &recordingPort{} + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + return 0, nil // strap clear, force-download not set + }, + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + return nil + }, + } + f := &Flasher{ + conn: mc, + port: port, + opts: &FlasherOptions{}, + chip: defESP32S2, + usesUSB: true, + } + + f.Reset() + + assert.Empty(t, port.calls, "watchdog reset path must not toggle DTR/RTS") +} + +// TestFlasherResetESP32S2WatchdogWriteFailureFallsBack verifies that when a +// watchdog register write fails, Flasher.Reset() falls back to the DTR/RTS +// hardResetUSB path instead of treating the failed watchdog arm as a +// successful reset. +func TestFlasherResetESP32S2WatchdogWriteFailureFallsBack(t *testing.T) { + port := &recordingPort{} + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + return 0, nil // strap clear, force-download not set + }, + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + if addr == esp32s2RTCCntlWDTConfig0 { + return errors.New("write failed") + } + return nil + }, + } + f := &Flasher{ + conn: mc, + port: port, + opts: &FlasherOptions{}, + chip: defESP32S2, + usesUSB: true, + } + + f.Reset() + + assert.NotEmpty(t, port.calls, "watchdog write failure must fall back to DTR/RTS reset") +} + +// TestFlasherResetUSBJTAGChipUnchanged verifies that chips using the +// USB-Serial-JTAG bridge (no HardResetOTG hook, e.g. S3/C3/C6/H2/C5) still +// take the existing DTR/RTS hardResetUSB path, unaffected by the S2 +// watchdog-reset addition. +func TestFlasherResetUSBJTAGChipUnchanged(t *testing.T) { + port := &recordingPort{} + mc := &mockConnection{} + f := &Flasher{ + conn: mc, + port: port, + opts: &FlasherOptions{}, + chip: defESP32S3, + usesUSB: true, + } + + f.Reset() + + assert.NotEmpty(t, port.calls, "USB-Serial-JTAG chips must still use the DTR/RTS reset path") +} diff --git a/pkg/espflasher/target_esp32s2.go b/pkg/espflasher/target_esp32s2.go index a55453f..0aec549 100644 --- a/pkg/espflasher/target_esp32s2.go +++ b/pkg/espflasher/target_esp32s2.go @@ -1,10 +1,46 @@ package espflasher -// ESP32-S2 register addresses for USB interface detection. -// Reference: esptool/targets/esp32s2.py +import ( + "fmt" + "time" +) + +// ESP32-S2 register addresses for USB interface detection and the RTC +// watchdog reset used to exit native USB-OTG download mode. +// Reference: esptool/targets/esp32s2.py (ESP32S2ROM) const ( esp32s2UARTDevBufNo uint32 = 0x3FFFFD14 // ROM .bss: active console interface esp32s2UARTDevBufNoUSBOTG uint32 = 2 // USB-OTG active + + // RTC_CNTL watchdog registers used by watchdog_reset() to force a + // system reset. ESP32-S2 has no USB-Serial-JTAG bridge, so the + // DTR/RTS latch trick used on S3/C3/C6/H2/C5 (hardResetUSB) is a + // no-op here; esptool instead arms and lets the RTC WDT fire. + esp32s2RTCCntlWDTConfig0 uint32 = 0x3F408094 + esp32s2RTCCntlWDTConfig1 uint32 = 0x3F408098 + esp32s2RTCCntlWDTWProtect uint32 = 0x3F4080AC + esp32s2RTCCntlWDTWKey uint32 = 0x50D83AA1 + + // esp32s2WDTConfig0EnableValue is the RTC_CNTL_WDTCONFIG0 value used to + // arm the watchdog for a system reset. It is copied verbatim from + // esptool's ESP32S2ROM.watchdog_reset() and treated as an opaque, + // HW-validated magic value rather than a precise bitfield breakdown of + // the ESP32-S2 TRM register layout. + esp32s2WDTConfig0EnableValue uint32 = (1 << 31) | (5 << 28) | (1 << 8) | 2 + // esp32s2WDTConfig1TimeoutTicks is the stage 0 timeout, in RTC_CLK + // ticks (esptool uses 2000, i.e. a fast timeout since XTAL/RTC_CLK + // runs the RTC watchdog). + esp32s2WDTConfig1TimeoutTicks uint32 = 2000 + + // GPIO strapping / RTC_CNTL_OPTION1 registers used to gate the + // watchdog reset: if the chip is strapped for download boot, or + // download mode is force-enabled, a watchdog reset would just put + // it right back into the bootloader, so the caller must fall back + // to the DTR/RTS path instead. + esp32s2GPIOStrapReg uint32 = 0x3F404038 + esp32s2GPIOStrapSPIBootMask uint32 = 1 << 3 + esp32s2RTCCntlOption1Reg uint32 = 0x3F408128 + esp32s2RTCCntlForceDownloadBoot uint32 = 0x1 ) // ESP32-S2 target definition. @@ -49,7 +85,8 @@ var defESP32S2 = &chipDef{ FlashSizes: defaultFlashSizes(), - PostConnect: esp32s2PostConnect, + PostConnect: esp32s2PostConnect, + HardResetOTG: esp32s2HardReset, } // esp32s2PostConnect detects the USB interface type. @@ -71,3 +108,63 @@ func esp32s2PostConnect(f *Flasher) error { return nil } + +// esp32s2HardReset attempts an RTC watchdog reset to exit native USB-OTG +// download mode, mirroring esptool's ESP32S2ROM.hard_reset(). It is only +// applicable when the USB-OTG interface was detected (f.usesUSB); it +// returns false (fall back to the DTR/RTS hardReset path) if the interface +// isn't OTG, if the strap/force-download safety gate indicates a watchdog +// reset wouldn't reliably exit download mode, or if the register access +// fails (e.g. secure download mode). +func esp32s2HardReset(f *Flasher) bool { + if !f.usesUSB { + return false + } + + strap, err := f.ReadRegister(esp32s2GPIOStrapReg) + if err != nil { + return false + } + option1, err := f.ReadRegister(esp32s2RTCCntlOption1Reg) + if err != nil { + return false + } + + if strap&esp32s2GPIOStrapSPIBootMask != 0 || option1&esp32s2RTCCntlForceDownloadBoot != 0 { + // GPIO0 is strapped high (SPI-boot strap set), or RTC_CNTL force-download-boot + // is set: either condition means a watchdog reset would not cleanly exit to + // the app and would land back in the bootloader instead, so fall back to + // the DTR/RTS reset path. + return false + } + + if err := esp32s2WatchdogReset(f); err != nil { + f.logf("watchdog reset failed, falling back to DTR/RTS reset: %v", err) + return false + } + + return true +} + +// esp32s2WatchdogReset arms the RTC watchdog to force a system reset, +// mirroring esptool's ESP32S2ROM.watchdog_reset(). Reference: +// esptool/targets/esp32s2.py watchdog_reset(). +func esp32s2WatchdogReset(f *Flasher) error { + if err := f.WriteRegister(esp32s2RTCCntlWDTWProtect, esp32s2RTCCntlWDTWKey); err != nil { + return fmt.Errorf("unlock RTC WDT: %w", err) + } + if err := f.WriteRegister(esp32s2RTCCntlWDTConfig1, esp32s2WDTConfig1TimeoutTicks); err != nil { + return fmt.Errorf("set RTC WDT timeout: %w", err) + } + if err := f.WriteRegister(esp32s2RTCCntlWDTConfig0, esp32s2WDTConfig0EnableValue); err != nil { + return fmt.Errorf("enable RTC WDT: %w", err) + } + if err := f.WriteRegister(esp32s2RTCCntlWDTWProtect, 0); err != nil { + return fmt.Errorf("lock RTC WDT: %w", err) + } + + f.logf("Hard resetting with a watchdog...") + time.Sleep(500 * time.Millisecond) // wait for reset to take effect + + return nil +} diff --git a/pkg/espflasher/target_esp32s2_test.go b/pkg/espflasher/target_esp32s2_test.go index bb205fd..c8666c6 100644 --- a/pkg/espflasher/target_esp32s2_test.go +++ b/pkg/espflasher/target_esp32s2_test.go @@ -59,3 +59,149 @@ func TestESP32S2PostConnectSecureMode(t *testing.T) { require.NoError(t, err, "should gracefully handle read error") assert.False(t, f.usesUSB, "should default to non-USB on read error") } + +// TestESP32S2HardResetWatchdogSequence verifies that, when USB-OTG was +// detected and the strap/force-download gate is clear, esp32s2HardReset +// issues the exact unlock->config1->config0->relock RTC watchdog sequence +// esptool's ESP32S2ROM.watchdog_reset() uses. +func TestESP32S2HardResetWatchdogSequence(t *testing.T) { + var writes []struct { + addr, value uint32 + } + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + switch addr { + case esp32s2GPIOStrapReg, esp32s2RTCCntlOption1Reg: + return 0, nil // strap clear, force-download not set + } + return 0, nil + }, + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + writes = append(writes, struct{ addr, value uint32 }{addr, value}) + return nil + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, usesUSB: true} + + ok := esp32s2HardReset(f) + require.True(t, ok, "watchdog reset should be taken when gate is clear") + + require.Len(t, writes, 4, "expected unlock, config1, config0, relock writes") + assert.Equal(t, esp32s2RTCCntlWDTWProtect, writes[0].addr, "write 1: unlock WDT") + assert.Equal(t, esp32s2RTCCntlWDTWKey, writes[0].value) + assert.Equal(t, esp32s2RTCCntlWDTConfig1, writes[1].addr, "write 2: set WDT timeout") + assert.Equal(t, esp32s2WDTConfig1TimeoutTicks, writes[1].value) + assert.Equal(t, esp32s2RTCCntlWDTConfig0, writes[2].addr, "write 3: enable WDT") + assert.Equal(t, esp32s2WDTConfig0EnableValue, writes[2].value) + assert.Equal(t, esp32s2RTCCntlWDTWProtect, writes[3].addr, "write 4: relock WDT") + assert.Equal(t, uint32(0), writes[3].value) +} + +// TestESP32S2HardResetNotUSB verifies esp32s2HardReset falls back +// (returns false, issues no writes) when USB-OTG was not detected. +func TestESP32S2HardResetNotUSB(t *testing.T) { + mc := &mockConnection{ + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + t.Fatalf("unexpected write to 0x%08X", addr) + return nil + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, usesUSB: false} + + assert.False(t, esp32s2HardReset(f), "should fall back when not USB-OTG") +} + +// TestESP32S2HardResetStrapGateBlocksWatchdog verifies that when the strap +// register indicates the chip is strapped for download boot, esp32s2HardReset +// falls back to the DTR/RTS path instead of arming the watchdog. +func TestESP32S2HardResetStrapGateBlocksWatchdog(t *testing.T) { + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + if addr == esp32s2GPIOStrapReg { + return esp32s2GPIOStrapSPIBootMask, nil + } + return 0, nil + }, + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + t.Fatalf("unexpected write to 0x%08X", addr) + return nil + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, usesUSB: true} + + assert.False(t, esp32s2HardReset(f), "should fall back when strapped for download boot") +} + +// TestESP32S2HardResetForceDownloadGateBlocksWatchdog verifies that when +// RTC_CNTL_OPTION1 indicates download mode is force-enabled, +// esp32s2HardReset falls back to the DTR/RTS path instead of arming the +// watchdog. +func TestESP32S2HardResetForceDownloadGateBlocksWatchdog(t *testing.T) { + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + if addr == esp32s2RTCCntlOption1Reg { + return esp32s2RTCCntlForceDownloadBoot, nil + } + return 0, nil + }, + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + t.Fatalf("unexpected write to 0x%08X", addr) + return nil + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, usesUSB: true} + + assert.False(t, esp32s2HardReset(f), "should fall back when download boot is force-enabled") +} + +// TestESP32S2HardResetGateReadError verifies esp32s2HardReset falls back +// when the strap/force-download registers cannot be read (e.g. secure +// download mode). +func TestESP32S2HardResetGateReadError(t *testing.T) { + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + return 0, errors.New("register not readable") + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, usesUSB: true} + + assert.False(t, esp32s2HardReset(f), "should fall back when gate registers are unreadable") +} + +// TestESP32S2HardResetOption1ReadError verifies esp32s2HardReset falls back +// when the strap register read succeeds but the RTC_CNTL_OPTION1 read fails. +func TestESP32S2HardResetOption1ReadError(t *testing.T) { + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + if addr == esp32s2RTCCntlOption1Reg { + return 0, errors.New("register not readable") + } + return 0, nil // strap read succeeds + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, usesUSB: true} + + assert.False(t, esp32s2HardReset(f), "should fall back when OPTION1 register is unreadable") +} + +// TestESP32S2HardResetWriteError verifies esp32s2HardReset returns false when +// a WriteRegister call inside esp32s2WatchdogReset fails (e.g. the write to +// WDTConfig0), so Reset() falls through to the DTR/RTS fallback path. This is +// the feature's core safety contract: a failed watchdog arm must not be +// silently treated as success. +func TestESP32S2HardResetWriteError(t *testing.T) { + mc := &mockConnection{ + readRegFunc: func(addr uint32) (uint32, error) { + return 0, nil // strap clear, force-download not set + }, + writeRegFunc: func(addr, value, mask, delayUS uint32) error { + if addr == esp32s2RTCCntlWDTConfig0 { + return errors.New("write failed") + } + return nil + }, + } + f := &Flasher{conn: mc, opts: &FlasherOptions{}, usesUSB: true} + + assert.False(t, esp32s2HardReset(f), "should fall back when a watchdog register write fails") +}