Skip to content

dfu: recover serial/USB DFU automatically after a failed flash - #47

Open
fkallay1 wants to merge 1 commit into
oltaco:devfrom
fkallay1:feature/dfu-recover-on-usb
Open

dfu: recover serial/USB DFU automatically after a failed flash#47
fkallay1 wants to merge 1 commit into
oltaco:devfrom
fkallay1:feature/dfu-recover-on-usb

Conversation

@fkallay1

@fkallay1 fkallay1 commented Jul 28, 2026

Copy link
Copy Markdown

An interrupted serial/USB DFU left the device reachable only over BLE. The aborted transfer
correctly marks the application invalid (bootloader.c writes bank_0 = BANK_INVALID_APP on
DFU_BANK_0_ERASED), but check_dfu_mode() then defaulted !valid_app to OTA: ble_stack_init()
ran and usb_init() never did, so the device disappeared from USB completely and needed a
physical double-tap to come back. BLE OTA recovered by itself; serial/USB DFU did not.

Now, when nothing but a missing application forces DFU, the bootloader comes up on USB DFU
and lets USB enumeration pick the transport: a host that enumerates us keeps us in USB DFU,
immediately re-flashable with no double-tap, and if nobody answers within
DFU_USB_ENUM_TIMEOUT (battery, or a dumb charger with no data host) bootloader_dfu_start()
returns on timeout and main() reboots into BLE OTA, as it already did when no valid
application exists.

Explicit requests keep priority and are excluded: GPREGRET magics, double reset, DFU+FRESET
buttons. Parts without a USB peripheral keep BLE-only behaviour via the existing NRF_USBD
guard. Enumeration is re-read on every boot, so recovery is power-loss safe - unlike
GPREGRET, which a power-on reset clears.

Gating on NRF_POWER->USBREGSTATUS.VBUSDETECT to skip the wait on battery was tried and does
not work: measured over SWD on a ProMicro nRF52840, VBUSDETECT still reads 0 at this point
in boot and only asserts tens of ms later, so an early read reports 'battery' while a host
is attached and sends the device to BLE - the exact behaviour being fixed. Enumeration
already subsumes VBUS: no VBUS and charger-only both simply fail to enumerate.

The commit also makes an interrupted update resume on the transport it was using, so that
preferring USB cannot hijack a BLE OTA that died mid-flash. The transport is recorded in
GPREGRET at DFU_BANK_0_ERASED, reusing magics the bootloader already understands, so
check_dfu_mode() consumes it with no new decision logic. On the OTA path the SoftDevice is
enabled, so it goes through sd_power_gpregret_clr/set; is_ota() distinguishes the cases. The
hint is dropped on DFU_UPDATE_APP_COMPLETE and before bootloader_app_start(), and re-armed
on BLE entry only while !valid_app, which keeps a device with a working application from
being trapped in DFU. The UF2/MSC path never reports DFU_BANK_0_ERASED, so no hint is set
for it.

One existing value changes, and it is worth calling out rather than leaving in the diff: the
literal 3000 passed to bootloader_dfu_start() becomes DFU_USB_ENUM_TIMEOUT at 10000. That call is
already used by the single-tap reset, UF2 and serial-only paths, so those get the longer wait too.

It is only observable when nothing enumerates at all - cancel_timeout_on_usb stops the timer the
moment a host answers - so the practical effect is that a device put into DFU deliberately while on
battery takes 10 s instead of 3 s to give up and return to the application. 3 s turned out to be
too tight to rely on: measured on a ProMicro nRF52840, a host re-enumerating after repeated resets
is not always ready inside 3 s, and recovery then flapped to BLE with a good USB host attached,
which is the failure this PR exists to remove. If you would rather not touch the existing paths,
the narrower change is to keep 3000 for them and use DFU_USB_ENUM_TIMEOUT only for the recovery
added here - say so and I will split it.

The DFU magics move to bootloader_types.h next to BOOTLOADER_DFU_START so main.c and the
bootloader core share one definition.

Verified on a ProMicro nRF52840 with a J-Link, reading the settings page and GPREGRET over
SWD rather than inferring state from LED blink rates: valid app boots; normal serial flash;
abort + reset lands on USB DFU and re-flashes with no double-tap; same with GPREGRET
cleared; charger-only falls back to BLE; an interrupted BLE OTA resumes on BLE with no USB
device; full BLE OTA still completes; a valid app plus a BLE OTA request is not made sticky;
and nrfjprog --recover plus reflash restores a bad bootloader. Also verified end to end on a
XIAO nRF52840 Sense over an ESP32 USB/IP web-flash bridge: six abort-then-recover cycles,
all six recovered on the first retry with no human action.

Re-verified on the final branch, flashed on its own as well as merged with the others, since you
may take only one of these. On a ProMicro nRF52840 with a J-Link, this branch alone: recovery comes
up as CDC + mass storage with the PROMICRO volume; an interrupted BLE OTA leaves 0xA8 in GPREGRET
and the boot after it stays on BLE instead of switching to USB. The same recovery result merged with
the stall timeout, merged with the retry fix, and with all four together.

Also on a XIAO nRF52840 Sense, which runs S140 7.3.0 rather than 6.1.1 and has no debugger, so the
verdict comes from USB identity instead: after an interrupted serial DFU the board came back in UF2
mode on USB - CDC + mass storage, with INFO_UF2.TXT reporting this branch's build - and not on BLE.
With all four merged, the boot following the stall timeout's self-reset also came up on USB with
mass storage.


Related to #40, which asks for exactly this decision to be made by checking whether a USB host is
there at all. It suggests NRF_USBD->USBADDR > 0 or a VBUS read; this waits for the host to
actually enumerate instead, because the register route does not survive contact with hardware:
measured on a ProMicro nRF52840, USBREGSTATUS.VBUSDETECT still reads 0 this early in boot and only
asserts some tens of ms later, so an early read reports "battery" even with a host attached - which
would send the device to BLE exactly when USB was available. Enumeration subsumes VBUS anyway: no
VBUS and a charger with no data host both simply fail to enumerate.

Scope, so the issue is not closed on a half answer: the case that report starts from - no valid
application - now tries USB DFU first and falls back to BLE OTA when nothing enumerates. The
explicit paths (single tap, UF2, serial-only) have had an enumeration timeout all along; this only
lengthens it, from 3 s to 10 s. No general "is a host present" gate is added anywhere else.


One of four independent DFU fixes, listed in the order they matter:

  1. dfu: recover serial/USB DFU automatically after a failed flash #47 recover serial/USB DFU automatically after a failed flash
  2. dfu: reboot when a started transfer goes silent #48 reboot when a started transfer goes silent
  3. dfu: accept a new session after an interrupted transfer #49 accept a new session after an interrupted transfer
  4. dfu serial: add a reboot command so a host can ask for a specific DFU mode #50 add a reboot command so a host can ask for a specific DFU mode

Any subset can be taken, in any order. Verified: all four merge cleanly in five different orders and
in all twelve ordered pairs, and each was flashed and tested on hardware on its own as well as in
combination - 26 of 26 checks on a ProMicro nRF52840 with verdicts read over SWD, plus a XIAO
nRF52840 Sense on a different SoftDevice with no debugger.

An interrupted serial/USB DFU left the device reachable only over BLE. The aborted transfer
correctly marks the application invalid (bootloader.c writes bank_0 = BANK_INVALID_APP on
DFU_BANK_0_ERASED), but check_dfu_mode() then defaulted !valid_app to OTA: ble_stack_init()
ran and usb_init() never did, so the device disappeared from USB completely and needed a
physical double-tap to come back. BLE OTA recovered by itself; serial/USB DFU did not.

Now, when nothing but a missing application forces DFU, the bootloader comes up on USB DFU
and lets USB enumeration pick the transport: a host that enumerates us keeps us in USB DFU,
immediately re-flashable with no double-tap, and if nobody answers within
DFU_USB_ENUM_TIMEOUT (battery, or a dumb charger with no data host) bootloader_dfu_start()
returns on timeout and main() reboots into BLE OTA, as it already did when no valid
application exists.

Explicit requests keep priority and are excluded: GPREGRET magics, double reset, DFU+FRESET
buttons. Parts without a USB peripheral keep BLE-only behaviour via the existing NRF_USBD
guard. Enumeration is re-read on every boot, so recovery is power-loss safe - unlike
GPREGRET, which a power-on reset clears.

Gating on NRF_POWER->USBREGSTATUS.VBUSDETECT to skip the wait on battery was tried and does
not work: measured over SWD on a ProMicro nRF52840, VBUSDETECT still reads 0 at this point
in boot and only asserts tens of ms later, so an early read reports 'battery' while a host
is attached and sends the device to BLE - the exact behaviour being fixed. Enumeration
already subsumes VBUS: no VBUS and charger-only both simply fail to enumerate.

The commit also makes an interrupted update resume on the transport it was using, so that
preferring USB cannot hijack a BLE OTA that died mid-flash. The transport is recorded in
GPREGRET at DFU_BANK_0_ERASED, reusing magics the bootloader already understands, so
check_dfu_mode() consumes it with no new decision logic. On the OTA path the SoftDevice is
enabled, so it goes through sd_power_gpregret_clr/set; is_ota() distinguishes the cases. The
hint is dropped on DFU_UPDATE_APP_COMPLETE and before bootloader_app_start(), and re-armed
on BLE entry only while !valid_app, which keeps a device with a working application from
being trapped in DFU. The UF2/MSC path never reports DFU_BANK_0_ERASED, so no hint is set
for it.

One existing value changes, and it is worth calling out rather than leaving in the diff: the
literal 3000 passed to bootloader_dfu_start() becomes DFU_USB_ENUM_TIMEOUT at 10000. That call is
already used by the single-tap reset, UF2 and serial-only paths, so those get the longer wait too.

It is only observable when nothing enumerates at all - cancel_timeout_on_usb stops the timer the
moment a host answers - so the practical effect is that a device put into DFU deliberately while on
battery takes 10 s instead of 3 s to give up and return to the application. 3 s turned out to be
too tight to rely on: measured on a ProMicro nRF52840, a host re-enumerating after repeated resets
is not always ready inside 3 s, and recovery then flapped to BLE with a good USB host attached,
which is the failure this PR exists to remove. If you would rather not touch the existing paths,
the narrower change is to keep 3000 for them and use DFU_USB_ENUM_TIMEOUT only for the recovery
added here - say so and I will split it.

The DFU magics move to bootloader_types.h next to BOOTLOADER_DFU_START so main.c and the
bootloader core share one definition.

Verified on a ProMicro nRF52840 with a J-Link, reading the settings page and GPREGRET over
SWD rather than inferring state from LED blink rates: valid app boots; normal serial flash;
abort + reset lands on USB DFU and re-flashes with no double-tap; same with GPREGRET
cleared; charger-only falls back to BLE; an interrupted BLE OTA resumes on BLE with no USB
device; full BLE OTA still completes; a valid app plus a BLE OTA request is not made sticky;
and nrfjprog --recover plus reflash restores a bad bootloader. Also verified end to end on a
XIAO nRF52840 Sense over an ESP32 USB/IP web-flash bridge: six abort-then-recover cycles,
all six recovered on the first retry with no human action.

Re-verified on the final branch, flashed on its own as well as merged with the others, since you
may take only one of these. On a ProMicro nRF52840 with a J-Link, this branch alone: recovery comes
up as CDC + mass storage with the PROMICRO volume; an interrupted BLE OTA leaves 0xA8 in GPREGRET
and the boot after it stays on BLE instead of switching to USB. The same recovery result merged with
the stall timeout, merged with the retry fix, and with all four together.

Also on a XIAO nRF52840 Sense, which runs S140 7.3.0 rather than 6.1.1 and has no debugger, so the
verdict comes from USB identity instead: after an interrupted serial DFU the board came back in UF2
mode on USB - CDC + mass storage, with INFO_UF2.TXT reporting this branch's build - and not on BLE.
With all four merged, the boot following the stall timeout's self-reset also came up on USB with
mass storage.
fkallay1 added a commit to fkallay1/Adafruit_nRF52_Bootloader_OTAFIX that referenced this pull request Jul 29, 2026
…h handler

enterUf2Dfu() is declared in the core's public cores/nRF5/wiring.h, so an application can call it
directly - a MeshCore command "reboot to UF2" needs no core change at all. That is strictly better
than patching TinyUSB_Port_EnterDFU(), which changes what a 1200-baud touch means for every
application built on that core; since the touch is the normal Arduino upload path, on Windows a
removable drive would appear on every upload, with an indexer or antivirus scanning a freshly mounted
FAT volume while a serial DFU is in flight.

Recorded the measurement that argument needs: serial DFU does work with MSC mounted - the XIAO was
flashed from UF2 mode in 28.33 s, 928 marks, Device programmed. So MSC is a nuisance there, not a
breakage.

Also corrected an overstatement from the previous note. meshcore-dev/MeshCore is as much an upstream
as oltaco's bootloader; a command added there needs its own PR. The asymmetry that survives is the
cost of carrying the change until it lands - the command sits in a fork already maintained and built
from, while the core patch would need a fork of meshcore-dev/Adafruit_nRF52_Arduino and a re-pinned
platformio.ini, or PlatformIO overwrites it.

And the three routes are not redundant: an application command covers a running application, PR oltaco#47's
recovery covers an invalid one, and only PR oltaco#50 covers a bootloader already sitting in serial-only DFU.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant