Skip to content

fix: nRF52 DFU flashing and frequency band detection#508

Merged
torlando-tech merged 9 commits intorelease/v0.8.xfrom
fix/rnode-dfu-flashing-v0.8.x
Feb 20, 2026
Merged

fix: nRF52 DFU flashing and frequency band detection#508
torlando-tech merged 9 commits intorelease/v0.8.xfrom
fix/rnode-dfu-flashing-v0.8.x

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

Cherry-pick of #506 to release/v0.8.x.

  • Fix Nordic DFU protocol to match the adafruit-nrfutil reference implementation — fixes init packet padding, adds flash page write waits, and corrects post-DFU disconnect ordering
  • Fix USB re-enumeration handling during the nRF52 DFU lifecycle (1200-baud touch → bootloader → flash → reboot → provisioning), including permission retries and bootloader device scanning
  • Register nRF52 bootloader PIDs (0x0029, 0x0071, 0x00BA) in usb_device_filter.xml so Android auto-grants USB permission after the 1200-baud touch re-enumeration — this was the root cause of consistent flash failures when upgrading from running RNode firmware
  • Fix frequency band detectionFrequencyBand.fromModelCode() now uses full-byte lookup table matching rnodeconf, and getModelForBoardAndBand() returns per-board model codes
  • Add post-flash firmware verification — version and hash checks after provisioning
  • Improve post-DFU UX — correct progress messages for nRF52, prevent late USB re-enumeration from navigating away from the success screen

Test plan

  • Flash nRF52 (Heltec T114) from bootloader mode (factory screen) — succeeds
  • Flash nRF52 from running RNode firmware (upgrade/reflash) — succeeds with auto-permission
  • Post-flash provisioning detects re-enumerated device and verifies firmware version + hash
  • Success screen stays visible (no navigation to USB device selection after reboot)
  • Detekt lint passes
  • Flash ESP32-based RNode (no nRF52 changes should affect ESP32 path)
  • Flash RAK4630 and T-Echo (untested but bootloader PIDs added to filter)

🤖 Generated with Claude Code

torlando-tech and others added 7 commits February 19, 2026 16:27
After flashing, provisionDevice() now compares the device's reported
firmware version against the expected version from the FirmwarePackage.
If the device didn't actually reboot (e.g. DTR/RTS not wired), this
catches the stale version and reports a clear error instead of false
success. Also verifies firmware hash after writing it to EEPROM.

Closes #489

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tation

Three protocol divergences caused repeated DFU failures that bricked
a Heltec T114. After reflashing with rnodeconf (which uses
adafruit-nrfutil), a byte-level comparison revealed the issues:

1. Init packet missing unconditional 2-byte suffix (int16_to_bytes(0x0000))
   - Old: conditional odd-length padding (0 or 1 byte)
   - New: always append [0x00, 0x00] matching reference
   - Impact: wrong HCI payload length, header checksum, and CRC16

2. Missing flash page write waits after every 8 data packets
   - The nRF52840 CPU is blocked during NVMC page writes (~103ms)
   - HCI ACKs arrive before the write starts, so without the delay
     we flood USB while the CPU can't service it
   - Added FLASH_PAGE_WRITE_TIME_MS (103ms) delay every 8 packets
     and after the last packet before DFU Stop

3. Post-DFU close-before-wait order reversed
   - Reference: close port, then sleep (prevents USB interference
     during bootloader CRC validation)
   - Old: sleep first, then close
   - New: disconnect before settle wait, finally block as safety net

Also simplified sendStartDfu() erase wait (2x → 1x, matching reference),
removed dead drainSerialBuffer() method, and added PACKETS_PER_PAGE and
FLASH_PAGE_WRITE_TIME_MS constants.

Critical USB fix: added startIoManager parameter to KotlinUSBBridge.connect().
The ioManager calls port.read() in a loop, and on timeout CdcAcmSerialDriver
calls testConnection() which sends USB GET_STATUS — a control transfer the
nRF52840 DFU bootloader's TinyUSB stack cannot handle, causing a USB bus
reset. NordicDFUFlasher now passes startIoManager=false to prevent the
ioManager from ever starting during DFU, using readBlockingDirect() and
writeBlockingDirect() (direct bulkTransfer) for all I/O.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The nRF52 DFU process involves two USB re-enumerations that Android
treats as new devices (new device IDs, dropped permissions):

1. Pre-DFU: 1200-baud touch resets from app mode (PID 0x8071) to
   bootloader mode (PID 0x0071). Added findBootloaderDevice() to
   scan for the re-enumerated bootloader. Also detects when the
   device is already in bootloader mode and skips the touch.

2. Post-DFU: bootloader validates firmware and reboots back to app
   mode with yet another new device ID. Broadened provisionDevice()
   fallback scan to find any supported USB device (was ESP32-S3 only).

Additional fixes:
- Use startIoManager=false for 1200-baud touch connection (the
  ioManager's testConnection() crashes the nRF52840's USB stack)
- Swap disconnect/disableRawMode order to prevent spurious ioManager
  restart on port close
- Set bootloaderFlashModeActive=true during flash to suppress Android
  USB auto-navigation when device re-enumerates
- Fix FrequencyBand.fromModelCode() to use full-byte lookup table
  matching rnodeconf reference (was nibble-based, wrong for many boards)
- Fix RNodeDetector.getModelForBoardAndBand() to return per-board
  model codes matching rnodeconf (was returning same code for all boards)
- Add FrequencyBand and model code unit tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…er re-enumeration

After the 1200-baud touch, the device re-enumerates in bootloader mode
with a new USB device ID. Android revokes the old permission and shows
"Open with Columba?" — until the user taps it, connect() fails with
"No permission". Previously this was a single attempt that failed
immediately. Now retries up to 10 times (1s apart) to give the user
time to grant permission.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Show "Verifying firmware..." instead of "Waiting for device reboot..."
  for nRF52, since the device already rebooted during DFU
- Reduce post-DFU delay from 5s to 3s for nRF52 (only need USB re-enum time)
- Keep bootloaderFlashModeActive through completion to prevent late USB
  re-enumeration from navigating away from the success screen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mission

The USB device filter only had application-mode PIDs (0x8029, 0x8071,
0x80BA). When a 1200-baud touch resets an nRF52 into bootloader mode,
the PID changes (e.g. 0x8071 → 0x0071). Without the bootloader PID in
the filter, Android required manual "Open with Columba?" permission —
causing DFU to fail when upgrading from running RNode firmware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tatements

Replace continue + inner loop continue with nested if-block and
firstOrNull to reduce jump statements from 3 to 2 (break + return).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 19, 2026

Greptile Summary

Fixes nRF52 DFU flashing by implementing proper Nordic DFU protocol with ACK-based flow control, USB re-enumeration handling, and raw mode communication to prevent USB controller crashes during flash operations.

Key changes:

  • Registers nRF52 bootloader PIDs (0x0029, 0x0071, 0x00BA) in USB device filter for Android auto-permission after 1200-baud touch re-enumeration
  • Adds direct USB bulk transfer methods (readBlockingDirect/writeBlockingDirect) that bypass testConnection() and its USB GET_STATUS control transfer, which crashes the nRF52840 bootloader's minimal TinyUSB stack during NVMC flash operations
  • Implements proper DFU protocol flow: Start (with erase wait) → Init → Data packets (with ACK for each) → Stop, matching the adafruit-nrfutil reference
  • Adds USB re-enumeration detection and permission retry logic for both the 1200-baud touch transition (application → bootloader) and post-DFU reboot (bootloader → application)
  • Fixes frequency band detection to use full-byte lookup table (matching rnodeconf) instead of nibble-based pattern
  • Updates getModelForBoardAndBand() to return per-board model codes instead of generic band-based codes
  • Adds post-flash firmware verification (version + hash checks) to detect failed flashes
  • Improves bootloaderFlashModeActive flag lifecycle to prevent premature navigation away from success screen during USB re-enumeration

Confidence Score: 4/5

  • This PR is safe to merge with careful testing on target hardware
  • Score reflects extensive low-level USB and firmware protocol changes that fix critical nRF52 flashing issues. One minor logic issue found in timeout calculation (line 657) that could cause a brief extra iteration but won't break functionality. The changes are well-documented and match reference implementations, but the complexity and hardware-specific nature require thorough testing on actual nRF52 devices before production deployment.
  • Pay close attention to NordicDFUFlasher.kt (line 657 timeout logic) and verify hardware testing covers all nRF52 boards (RAK4630, T-Echo untested per test plan)

Important Files Changed

Filename Overview
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/NordicDFUFlasher.kt Major refactoring of DFU protocol to match reference implementation, adds ACK handling, USB re-enumeration support, and raw mode communication
reticulum/src/main/java/com/lxmf/messenger/reticulum/usb/KotlinUSBBridge.kt Adds direct USB bulk transfer methods to bypass testConnection, connection retry logic, and DTR control
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/FirmwarePackage.kt Fixed frequency band detection to use full-byte lookup table matching rnodeconf reference
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/RNodeDetector.kt Updated model code mapping to return per-board model codes instead of generic band-based codes
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/RNodeFlasher.kt Added post-flash firmware verification and improved USB re-enumeration handling during provisioning

Sequence Diagram

sequenceDiagram
    participant App as FlasherViewModel
    participant Flasher as NordicDFUFlasher
    participant USB as KotlinUSBBridge
    participant Device as nRF52 Device

    App->>Flasher: flash(deviceId, firmware)
    
    alt Device in application mode
        Flasher->>USB: connect(deviceId, 1200 baud)
        Flasher->>USB: setDtr(false)
        Note over Flasher,Device: 1200-baud touch triggers reset
        Flasher->>USB: disconnect()
        Device->>Device: Re-enumerate as bootloader<br/>(PID 0x8071 → 0x0071)
        Flasher->>Flasher: findBootloaderDevice()
        Note over USB,Device: Android shows "Open with Columba?"<br/>or auto-grants via device_filter.xml
    end
    
    Flasher->>USB: connectWithRetry(bootloaderDeviceId, 115200)
    Note over USB: Retry loop waits for<br/>USB permission grant
    USB-->>Flasher: connected
    
    Flasher->>USB: enableRawMode(drainPort=false)
    Note over USB: Stop ioManager to prevent<br/>GET_STATUS during flash
    
    Flasher->>USB: writeBlockingDirect(DFU Start)
    USB->>Device: HCI packet via bulkTransfer
    Device-->>USB: ACK
    USB-->>Flasher: readBlockingDirect() → ACK
    Note over Device: Erase flash pages<br/>(CPU blocked ~5-8s)
    
    Flasher->>USB: writeBlockingDirect(DFU Init)
    USB->>Device: Init packet
    Device-->>USB: ACK
    USB-->>Flasher: readBlockingDirect() → ACK
    
    loop For each 512-byte packet
        Flasher->>USB: writeBlockingDirect(DFU Data)
        USB->>Device: Data packet
        Device-->>USB: ACK
        USB-->>Flasher: readBlockingDirect() → ACK
        alt Every 8 packets (4096 bytes)
            Note over Flasher: delay(103ms) for<br/>flash page write
        end
    end
    
    Flasher->>USB: writeBlockingDirect(DFU Stop)
    USB->>Device: Stop packet
    Device-->>USB: ACK
    USB-->>Flasher: readBlockingDirect() → ACK
    
    Flasher->>USB: disconnect()
    Flasher->>USB: disableRawMode()
    Note over Device: Validate CRC, write<br/>bootloader settings (~200ms)
    
    Device->>Device: Reset to application<br/>(PID 0x0071 → 0x8071)
    
    App->>Flasher: provisionDevice()
    Flasher->>Flasher: connectWithRetry() to re-enumerated device
    Flasher->>Device: Verify firmware version + hash
    Device-->>Flasher: version + hash match
    Flasher-->>App: FlashState.Complete
Loading

Last reviewed commit: a053586

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Remove enableDfuMode(), disableDfuMode(), stopIoManager(), and
startIoManager() — all dead code from earlier DFU iterations. DFU now
connects with startIoManager=false and uses readBlockingDirect()
throughout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the deadline elapsed between the while check and the read call,
coerceIn(1, 200) masked the negative value into a 1ms timeout instead
of exiting. Move remaining into the while condition and recompute at
loop end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit 30a7e71 into release/v0.8.x Feb 20, 2026
1 check passed
@torlando-tech torlando-tech deleted the fix/rnode-dfu-flashing-v0.8.x branch February 20, 2026 01:53
torlando-tech added a commit that referenced this pull request Feb 25, 2026
…atibility

The startIoManager parameter added for nRF52 DFU (PR #508) removed the
2-parameter Java overload that Python/Chaquopy callers depend on. Without
@jvmoverloads, Kotlin only generates connect(int, int, boolean) — so
rnode_interface.py's connect(device_id, 115200) call fails with
NoSuchMethodError, silently preventing USB RNode interfaces from starting.

Fixes #545

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
torlando-tech added a commit that referenced this pull request Feb 25, 2026
…atibility

The startIoManager parameter added for nRF52 DFU (PR #508) removed the
2-parameter Java overload that Python/Chaquopy callers depend on. Without
@jvmoverloads, Kotlin only generates connect(int, int, boolean) — so
rnode_interface.py's connect(device_id, 115200) call fails with
NoSuchMethodError, silently preventing USB RNode interfaces from starting.

Fixes #545

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
torlando-tech added a commit that referenced this pull request Feb 25, 2026
…atibility

The startIoManager parameter added for nRF52 DFU (PR #508) removed the
2-parameter Java overload that Python/Chaquopy callers depend on. Without
@jvmoverloads, Kotlin only generates connect(int, int, boolean) — so
rnode_interface.py's connect(device_id, 115200) call fails with
NoSuchMethodError, silently preventing USB RNode interfaces from starting.

Fixes #545

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

RNode flasher is asking about 433 vs 868/915 for no reason and always reporting 433 autodetected

1 participant