Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0677b04
feat(uf2): serve the UF2 drive when cabled with no app, and invalidat…
jamesarich Oct 23, 2026
c38eb14
fix(uf2): return to the drive, not the BLE wait, when a cabled DFU ex…
jamesarich Oct 23, 2026
b981e67
fix(uf2): let VBUS settle before the cabled gate decides
jamesarich Oct 23, 2026
9f2164d
fix(uf2): leave the drive for the BLE wait when VBUS is lost with no app
jamesarich Oct 23, 2026
e39d389
docs: describe the no-app drive and BLE fallback as they work now
jamesarich Oct 23, 2026
e13c6ea
feat(uf2): bring up the drive when a cable is plugged into a no-app b…
jamesarich Oct 24, 2026
770ac16
fix(uf2): seed the OTA cable watch from a settled VBUS read and never…
jamesarich Oct 24, 2026
8b3017a
fix(uf2): take the OTA cable watch seed from the gate's settled read …
jamesarich Oct 24, 2026
ff68608
refactor(uf2): leave the app-valid check to the debounce handler
jamesarich Oct 24, 2026
af0856a
fix(dfu): never let a reset request replace an image save in progress
jamesarich Oct 24, 2026
fed6cc4
fix(dfu): ignore non-flash SoC events in pstorage
jamesarich Oct 24, 2026
10d10e7
fix(dfu): restart a stalled pstorage queue from the store path
jamesarich Oct 24, 2026
5673883
fix(uf2): look for a cable again when the BLE central disconnects
jamesarich Oct 24, 2026
2a95f05
fix(uf2): guard the VBUS settle read for builds without USB
jamesarich Oct 25, 2026
8cf7bb1
fix(uf2): keep a cable present at BLE OTA entry from ending the wait …
jamesarich Oct 25, 2026
44c11d0
docs: correct the battery and eject behavior of the no-app drive
jamesarich Oct 25, 2026
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
20 changes: 11 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,14 @@ itself; a human has to edit it too.
otherwise only serial DFU sets it. Lose either and the restored device hangs
on boot. If you change either file, re-run dump-and-restore on hardware.
- **Flashing the bootloader+SoftDevice package over serial DFU zeros
`bank_0` — the device then boots into BLE-OTA wait mode, not the app,
on its own.** After
`adafruit-nrfutil dfu serial --package <board>_bootloader-..._s140_...zip`
reports "Device programmed", the board goes USB-silent (no serial port,
no UF2 drive) because `bank_0_size` is now 0 and this bootloader's
documented OTAFIX 2.0+ behavior is to default to BLE-OTA rather than get
stuck. This is expected, not a brick: double-press reset to force UF2
mode and drag the app's `.uf2` across, or complete a BLE OTA DFU to the
same effect. Don't reflash blind expecting the app to still be running.
`bank_0` — the device then has no app and does not boot on its own.**
After `adafruit-nrfutil dfu serial --package <board>_bootloader-..._s140_...zip`
reports "Device programmed", a board on a USB host comes up as a UF2 drive
showing NO FIRMWARE; drag the app's `.uf2` across. On battery (no VBUS) the
gate's 500 ms settle fails and it goes straight to the BLE-OTA wait; VBUS
with no host (a charger or power bank) gets the drive for 3 s first, and
unplugging the cable while the drive is up goes to the wait too. Plugging a cable into a no-app board that is
waiting for BLE, with no phone connected, brings the drive up (VBUS 0 to 1
during the wait, held 250 ms; `ota_vbus_event` in `main.c`). A cable already
present when the wait began does not: double-press reset for the drive, or
complete a BLE OTA DFU.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ With the radio connected over **USB/serial** (not Bluetooth), open the connected

If the device does not show up on your computer after flashing the bootloader or performing an OTA update, it may be **waiting in OTA DFU mode**.

In **OTAFIX 2.0** and above, OTA DFU is the default state when no valid application is present.
In this mode:
In **OTAFIX 2.0** and above, OTA DFU is the default state when no valid application is present. From **OTAFIX 2.6**, a device with no valid application on a USB host comes up as a UF2 drive showing NO FIRMWARE instead, and goes straight to OTA DFU on battery (no VBUS). With VBUS but no host (a charger or power bank) it falls through to OTA DFU after 3 seconds, as it does as soon as the cable is pulled while the drive is up. Plugging a cable into a device with no valid application that is waiting in OTA DFU, with no phone connected, brings the drive up again.
In OTA DFU mode:
- No UF2 drive is exposed
- No serial port is available
- The device is waiting for an OTA firmware update over BLE
Expand All @@ -166,7 +166,7 @@ In this mode:
- Perform an OTA update using a supported DFU app, **or**
- Explicitly request UF2/serial mode using **double-reset** (or the button hold on a [single-button board](#notes-on-single-button-boards-t1000-e-and-meshtracker-x1)).

This behaviour is intentional and prevents devices from getting stuck in UF2 mode after failed OTA updates.
The OTA fallback is intentional and prevents devices from getting stuck in UF2 mode after failed OTA updates.

---

Expand Down
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ it and is upstream Adafruit history, kept for provenance.

## OTAFIX 2.6

- A device with no valid application that is plugged into a USB host comes up as a UF2 drive showing NO FIRMWARE instead of waiting for a phone. On battery (no VBUS) it goes straight to the BLE OTA wait, and with VBUS but no host enumerating within 3 s (a charger or power bank) it falls through to the same wait. A cabled board with no application returns to the drive after an aborted copy, and after an eject on boards built with `UF2_EXIT_ON_EJECT` (`mesh_tracker_x1`, `t1000_e`). When the cable is pulled while the drive is up (VBUS gone for 250 ms), it goes to the BLE OTA wait. Plugging a cable into a board with no application that is waiting for BLE, with no phone connected, brings the drive up (VBUS present for 250 ms); a cable already present when the wait began, or a phone connected, leaves the wait alone.
- A UF2 application copy marks `bank_0` invalid when its first application block arrives, so a cable pulled mid-copy leaves the device with no application instead of an old `bank_0` marked valid over a half-written image. The completion path records the real application size as before.
- DFU screens on I2C mono OLEDs (SSD1306 and SH1106), with a four-line layout: title, mode, version, and banner. A board opts in by defining `DISPLAY_PIN_SDA` in its `board.h`; `boards.h` rejects a board that also defines `DISPLAY_PIN_SCK`, since TWIM0 and SPIM0 share a peripheral. The bootloader probes 0x3C and 0x3D and reads the controller type off the panel, and a board with no panel shows nothing and otherwise behaves as before. Enabled on `wiscore_rak4631_board`, `wiscore_rak3401`, `wio_tracker_l1`, `promicro_nrf52840`, `xiao_nrf52840_ble`, and `xiao_nrf52840_ble_sense`, for about 2 KB of bootloader flash each. Verified on the RAK4631 and the Wio Tracker L1; the other four follow their firmware variants' pins. On the RAK4631 and RAK3401, DFU raises the switched 3V3_S rail (WB_IO2) that feeds the WisBlock slots, and lowers it again when no panel answers.
- Fixed an out-of-bounds write in `printicon()` on the 160x80 `heltec_t096` and `heltec_t1`, where the third drag icon ran past the end of `frame_buf`. Those boards place it inside the panel, and `printicon()` skips any icon that would land outside.
- Fixed BLE OTA started from an application's buttonless DFU falling back to the application after about 10 s. The 0xB1 entry now restarts through the 0xA8 path, so the bootloader initializes the SoftDevice itself; the peer data for reconnection survives the reset.
Expand Down
11 changes: 11 additions & 0 deletions lib/sdk11/components/drivers_nrf/pstorage/pstorage_raw.c
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,12 @@ void pstorage_sys_event_handler(uint32_t sys_evt)
{
uint32_t retval = NRF_SUCCESS;

// Only flash completions may clear flash_access; any other SoC event would drop the pending one.
if (sys_evt != NRF_EVT_FLASH_OPERATION_SUCCESS && sys_evt != NRF_EVT_FLASH_OPERATION_ERROR)
{
return;
}

// The event shall only be processed if requested by this module.
if (m_cmd_queue.flash_access == true)
{
Expand Down Expand Up @@ -750,6 +756,11 @@ uint32_t pstorage_store(pstorage_handle_t * p_dest,
{
return NRF_ERROR_NO_MEM;
}
// flash_access is true only while the SoftDevice holds our request, so an idle queue with elements was never issued.
if (!m_cmd_queue.flash_access && m_cmd_queue.count > 0)
{
cmd_queue_dequeue();
}
return NRF_SUCCESS;
}

Expand Down
3 changes: 2 additions & 1 deletion lib/sdk11/components/libraries/bootloader_dfu/bootloader.c
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ void bootloader_dfu_update_process(dfu_update_status_t update_status)
}
else if (update_status.status_code == DFU_RESET)
{
m_update_status = BOOTLOADER_RESET;
// A completed image is still being saved: let the store callback finish it.
if (m_update_status != BOOTLOADER_SETTINGS_SAVING) m_update_status = BOOTLOADER_RESET;
}
else
{
Expand Down
87 changes: 86 additions & 1 deletion src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@
#include "uf2/uf2.h"
#include "nrf_usbd.h"
#include "tusb.h"
#include "app_timer.h"

void usb_init(bool cdc_only);
void usb_teardown(void);
bool msc_factory_erase_pending(void);
bool usb_host_mounted(void);

// tinyusb function that handles power event (detected, ready, removed)
// We must call it within SD's SOC event handler, or set it as power event handler if SD is not enabled.
Expand All @@ -87,6 +89,7 @@ extern void tusb_hal_nrf_power_event(uint32_t event);
#define usb_init(x) led_state(STATE_USB_MOUNTED) // mark nrf52832 as mounted
#define usb_teardown()
#define msc_factory_erase_pending() false
#define usb_host_mounted() false

#endif

Expand Down Expand Up @@ -130,6 +133,8 @@ uint32_t* dbl_reset_mem = ((uint32_t*) DFU_DBL_RESET_MEM);
// true if ble, false if serial
bool _ota_dfu = false;
bool _ota_connected = false;
// Set when the OTA wait ends because a cable arrived, so main resets plain into the drive.
static bool _ota_cable_exit = false;
// Set when the OTA session initializes the SoftDevice, so the app jump does not initialize it twice.
bool _sd_inited = false;

Expand Down Expand Up @@ -211,10 +216,76 @@ int main(void) {
bootloader_app_start();
}

NRF_POWER->GPREGRET = DFU_MAGIC_OTA_RESET; // No application was loaded, reset the system with the OTA DFU update
// A host mounted the drive this session or a cable arrived in the BLE wait: reset plain and let the gate decide. Otherwise the OTA wait.
NRF_POWER->GPREGRET = (usb_host_mounted() || _ota_cable_exit) ? 0 : DFU_MAGIC_OTA_RESET;
NVIC_SystemReset();
}

#ifdef NRF_USBD
// VBUSDETECT can read 0 for a short time after a reset that followed usb_teardown().
static bool vbus_detected_settled(void) {
for (uint32_t waited_ms = 0;; waited_ms += 10) {
if (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) return true;
if (waited_ms >= 500) return false;
NRFX_DELAY_MS(10);
}
}
Comment thread
jamesarich marked this conversation as resolved.
Resolved
#else
// No USB peripheral (nRF52832): never take the cabled gate.
#define vbus_detected_settled() false
#endif

#ifdef NRF_USBD
// No app, nothing connected, VBUS 0 -> 1 held 250 ms in the OTA wait: leave it so the cabled gate runs.
#define OTA_VBUS_DEBOUNCE_MS 250
APP_TIMER_DEF(_ota_vbus_timer);
static bool _ota_vbus_was_low;

static void ota_vbus_timer_handler(void* p_context) {
(void) p_context;
uint32_t usb_reg = 0;
sd_power_usbregstatus_get(&usb_reg);
if ((usb_reg & POWER_USBREGSTATUS_VBUSDETECT_Msk) && !_ota_connected && !bootloader_app_is_valid()) {
_ota_cable_exit = true;
dfu_update_status_t update_status = { .status_code = DFU_RESET };
bootloader_dfu_update_process(update_status);
}
}

// A DETECTED event during a connection is dropped, so look again once the central leaves.
static void ota_vbus_recheck(void) {
uint32_t usb_reg = 0;
sd_power_usbregstatus_get(&usb_reg);
if (_ota_vbus_was_low && (usb_reg & POWER_USBREGSTATUS_VBUSDETECT_Msk)) {
app_timer_stop(_ota_vbus_timer);
app_timer_start(_ota_vbus_timer, APP_TIMER_TICKS(OTA_VBUS_DEBOUNCE_MS), NULL);
}
}

// vbus_present must come from a settled read taken before the SoftDevice starts, so a late VBUSDETECT is not seen as a plug.
static void ota_vbus_watch_init(bool vbus_present) {
_ota_vbus_was_low = !vbus_present;
sd_power_usbdetected_enable(true);
sd_power_usbremoved_enable(true);
app_timer_create(&_ota_vbus_timer, APP_TIMER_MODE_SINGLE_SHOT, ota_vbus_timer_handler);
ota_vbus_recheck(); // catches a cable plugged in after the settled read
}

static void ota_vbus_event(uint32_t soc_evt) {
if (soc_evt == NRF_EVT_POWER_USB_REMOVED) {
_ota_vbus_was_low = true;
app_timer_stop(_ota_vbus_timer);
} else if (soc_evt == NRF_EVT_POWER_USB_DETECTED && _ota_vbus_was_low && !_ota_connected) {
app_timer_stop(_ota_vbus_timer);
app_timer_start(_ota_vbus_timer, APP_TIMER_TICKS(OTA_VBUS_DEBOUNCE_MS), NULL);
}
}
#else
#define ota_vbus_watch_init(vbus_present)
#define ota_vbus_event(evt)
#define ota_vbus_recheck()
#endif

static void check_dfu_mode(void) {
uint32_t const gpregret = NRF_POWER->GPREGRET;

Expand Down Expand Up @@ -308,6 +379,12 @@ static void check_dfu_mode(void) {
(*dbl_reset_mem) = 0;
}

// No valid app and a cable present: serve the UF2 drive instead of waiting for a phone.
// Without a host the 3 s timeout below falls through to the OTA wait.
// An OTA entry with a valid app takes the settled read too, only to seed the cable watch.
bool const vbus_present = (!valid_app || _ota_dfu) && vbus_detected_settled();
if (vbus_present && !dfu_start) uf2_dfu = true;

if ((dfu_start || !valid_app) && !serial_only_dfu && !uf2_dfu && !double_reset) {
_ota_dfu = true; // set default to OTA only when no explicit UF2/serial/dbl-reset
}
Expand All @@ -325,6 +402,7 @@ static void check_dfu_mode(void) {
mbr_init_sd();
_sd_inited = true;
ble_stack_init();
ota_vbus_watch_init(vbus_present);
} else {
led_state(STATE_USB_UNMOUNTED);
usb_init(serial_only_dfu);
Expand Down Expand Up @@ -457,6 +535,7 @@ uint32_t proc_ble(void) {
case BLE_GAP_EVT_DISCONNECTED:
_ota_connected = false;
led_state(STATE_BLE_DISCONNECTED);
ota_vbus_recheck();
break;

default:
Expand All @@ -479,6 +558,12 @@ uint32_t proc_soc(void) {
if (NRF_SUCCESS == err) {
pstorage_sys_event_handler(soc_evt);

// USB is not inited in the OTA wait; its power events only feed the cable watch.
if (_ota_dfu) {
ota_vbus_event(soc_evt);
return err;
}

#ifdef NRF_USBD
/*------------- usb power event handler -------------*/
int32_t usbevt = (soc_evt == NRF_EVT_POWER_USB_DETECTED) ? NRFX_POWER_USB_EVT_DETECTED :
Expand Down
7 changes: 7 additions & 0 deletions src/usb/uf2/ghostfat.c
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,13 @@ int write_block (uint32_t block_no, uint8_t *data, WriteState *state)
if ( state->wrong_layout ) goto reject;

screen_set_title(NULL); // a good block after a rejected one clears WRONG FILE
if ( !state->app_invalidated )
{
// A cable pulled mid-copy must not leave the old bank_0 valid over a half-written image.
dfu_update_status_t s = { .status_code = DFU_BANK_0_ERASED };
bootloader_dfu_update_process(s);
state->app_invalidated = true;
}
PRINTF("Write addr = 0x%08lX, block = %ld (%ld of %ld)\r\n", bl->targetAddr, bl->blockNo, state->numWritten, bl->numBlocks);
flash_nrf5x_write(bl->targetAddr, bl->data, bl->payloadSize, true);
if ( bl->targetAddr + bl->payloadSize > state->appEnd ) state->appEnd = bl->targetAddr + bl->payloadSize;
Expand Down
1 change: 1 addition & 0 deletions src/usb/uf2/uf2.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ typedef struct {
bool factory_erase; // if a CFG_UF2_MESHTASTIC_ERASE_ID block was received
bool sd_start_seen; // if the SoftDevice's first page (USER_FLASH_START) was received
bool wrong_layout; // if this file was built for another SoftDevice layout
bool app_invalidated; // bank_0 already marked invalid for this copy

uint8_t writtenMask[MAX_BLOCKS / 8 + 1];
} WriteState;
Expand Down
36 changes: 36 additions & 0 deletions src/usb/usb.c
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
#include "nrf_sdm.h"
#include "nrf_soc.h"

#include "app_timer.h"
#include "bootloader.h"

#include "nrf_usbd.h"
#include "tusb.h"
#include "usb_desc.h"
Expand Down Expand Up @@ -112,10 +115,43 @@ void usb_teardown(void) {
//--------------------------------------------------------------------+
// tinyusb callbacks
//--------------------------------------------------------------------+
static bool _host_mounted;

bool usb_host_mounted(void) {
return _host_mounted;
}

void tud_mount_cb(void) {
_host_mounted = true;
led_state(STATE_USB_MOUNTED);
}

// Cable pulled with no app to boot: end the session so main resets to the BLE OTA wait.
#define VBUS_LOSS_DELAY_MS 250
APP_TIMER_DEF(_vbus_loss_timer);
static bool _vbus_loss_timer_created;

static bool vbus_lost_without_app(void) {
return !(NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) && !bootloader_app_is_valid();
}

static void vbus_loss_timer_handler(void *p_context) {
(void) p_context;
if (vbus_lost_without_app()) {
dfu_update_status_t update_status = { .status_code = DFU_RESET };
bootloader_dfu_update_process(update_status);
}
}

void tud_umount_cb(void) {
led_state(STATE_USB_UNMOUNTED);

if (vbus_lost_without_app()) {
if (!_vbus_loss_timer_created) {
app_timer_create(&_vbus_loss_timer, APP_TIMER_MODE_SINGLE_SHOT, vbus_loss_timer_handler);
_vbus_loss_timer_created = true;
}
app_timer_stop(_vbus_loss_timer);
app_timer_start(_vbus_loss_timer, APP_TIMER_TICKS(VBUS_LOSS_DELAY_MS), NULL);
}
}
Loading