Skip to content

fix: nRF52 DFU flashing and frequency band detection#506

Merged
torlando-tech merged 9 commits intomainfrom
fix/rnode-frequency-band-detection
Feb 20, 2026
Merged

fix: nRF52 DFU flashing and frequency band detection#506
torlando-tech merged 9 commits intomainfrom
fix/rnode-frequency-band-detection

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

  • 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

Commits

  1. fix: add post-flash firmware version and hash verification
  2. fix: match Nordic DFU protocol to adafruit-nrfutil reference implementation
  3. fix: handle USB re-enumeration during nRF52 DFU flash and provisioning
  4. fix: retry bootloader connection while waiting for USB permission after re-enumeration
  5. fix: improve post-DFU UX for nRF52 flash completion
  6. fix: register nRF52 bootloader PIDs in USB device filter for auto-permission
  7. style: refactor readAckNr loop to satisfy detekt LoopWithTooManyJumpStatements

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 18, 2026 13:48
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

Comprehensive fix for nRF52 DFU flashing and device detection issues, addressing multiple root causes of flash failures:

Nordic DFU Protocol Corrections:

  • Fixed init packet padding (unconditional 2-byte suffix) matching adafruit-nrfutil reference
  • Added flash page write delays (103ms per 4KB page after every 8 data packets) to prevent data loss during NVMC operations
  • Corrected post-DFU disconnect ordering (disconnect → wait, not wait → disconnect) allowing bootloader to finalize CRC validation

USB Re-enumeration Handling:

  • Registered nRF52 bootloader PIDs (0x0029, 0x0071, 0x00BA) in usb_device_filter.xml for Android auto-permission after 1200-baud touch
  • Added connectWithRetry() with 10-attempt retry loop (1s delays) waiting for USB permission grant
  • Implemented bootloaderFlashModeActive flag in FlasherViewModel to suppress spurious navigation during post-DFU USB re-enumeration

USB Controller Protection:

  • Added readBlockingDirect()/writeBlockingDirect() methods in KotlinUSBBridge that bypass testConnection() to prevent USB GET_STATUS control transfers
  • DFU now uses connect(startIoManager=false) to avoid ioManager's port.read() loop which triggers testConnection() on timeout
  • Prevents USB bus resets that crash the nRF52840 bootloader's minimal TinyUSB CDC-ACM stack during NVMC flash operations

Device Detection Fixes:

  • Fixed FrequencyBand.fromModelCode() to use full-byte lookup table (was incorrectly using nibble-level 0x0F mask)
  • Fixed RNodeDetector.getModelForBoardAndBand() to return correct per-board model codes instead of generic band-only codes

Post-Flash Verification:

  • Added firmware version verification after provisioning (detects cases where device didn't reboot)
  • Added firmware hash verification comparing calculated hash against device-reported hash
  • Extended USB re-enumeration delays for nRF52 (3s) and improved provisioning retry logic (5 attempts, 2s delays)

The changes are well-documented with detailed comments explaining USB controller behavior, timing requirements, and references to the adafruit-nrfutil implementation.

Confidence Score: 4/5

  • Safe to merge with careful post-merge testing of nRF52 DFU across different scenarios
  • Score reflects comprehensive fixes addressing documented root causes with extensive comments and reference implementation matching. Main concerns are (1) complexity of USB re-enumeration timing and permission handling which may behave differently across Android versions/devices, (2) untested coverage for RAK4630 and T-Echo hardware variants, and (3) potential edge cases in the retry/timeout logic during USB permission grants. The test plan shows successful validation for key scenarios (flash from bootloader, flash from running firmware with auto-permission, post-flash verification), but ESP32 path and some nRF52 variants remain untested.
  • Pay close attention to NordicDFUFlasher.kt (complex timing-sensitive DFU protocol changes) and KotlinUSBBridge.kt (low-level USB bulk transfer implementation). Monitor for USB permission grant issues on different Android versions during real-world deployment.

Important Files Changed

Filename Overview
app/src/main/res/xml/usb_device_filter.xml Added nRF52 bootloader PIDs (0x0029, 0x0071, 0x00BA) to USB device filter for auto-permission after 1200-baud touch re-enumeration
app/src/main/java/com/lxmf/messenger/viewmodel/FlasherViewModel.kt Added bootloaderFlashModeActive flag management to prevent spurious navigation during USB re-enumeration after DFU
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/FirmwarePackage.kt Fixed frequency band detection using full-byte lookup table matching rnodeconf (was incorrectly using nibble-level pattern)
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/NordicDFUFlasher.kt Major DFU protocol fixes: added init packet padding, flash page write waits (103ms per 4KB page), corrected post-DFU disconnect ordering, and improved USB re-enumeration handling
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/RNodeDetector.kt Fixed getModelForBoardAndBand() to return correct per-board model codes instead of generic band-only codes
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/RNodeFlasher.kt Added post-flash firmware version and hash verification, improved USB re-enumeration handling with retries, extended reboot delays for nRF52 (3s)
reticulum/src/main/java/com/lxmf/messenger/reticulum/usb/KotlinUSBBridge.kt Added readBlockingDirect()/writeBlockingDirect() for testConnection-free USB bulk transfers during DFU (prevents USB GET_STATUS from crashing nRF52 bootloader)
reticulum/src/test/java/com/lxmf/messenger/reticulum/flasher/FirmwarePackageTest.kt Updated tests to match new full-byte frequency band lookup table

Sequence Diagram

sequenceDiagram
    participant App as FlasherViewModel
    participant DFU as NordicDFUFlasher
    participant USB as KotlinUSBBridge
    participant Device as nRF52 Device
    participant Android as Android USB

    App->>App: Set bootloaderFlashModeActive=true
    App->>DFU: flash(firmwareZip, deviceId)
    
    alt Device not in bootloader
        DFU->>USB: connect(deviceId, 1200, startIoManager=false)
        USB->>Device: SET_LINE_CODING (1200 baud)
        DFU->>USB: setDtr(false)
        USB->>Device: SET_CONTROL_LINE_STATE (DTR=false)
        DFU->>USB: disconnect()
        Device->>Device: Reset into bootloader mode
        Device->>Android: USB re-enumeration (new PID)
        Note over Android,Device: Auto-grant permission via usb_device_filter.xml
        DFU->>DFU: findBootloaderDevice()
    end
    
    DFU->>DFU: connectWithRetry(bootloaderDeviceId, 115200)
    loop Retry up to 10 times
        DFU->>USB: connect(deviceId, 115200, startIoManager=false)
        Note over USB,Device: No ioManager, no testConnection()
        alt Permission granted
            USB->>Device: USB bulk endpoints cached
        end
    end
    
    DFU->>USB: enableRawMode(drainPort=false)
    DFU->>USB: writeBlockingDirect(DFU Start packet)
    USB->>Device: bulkTransfer() - no GET_STATUS
    Device->>USB: ACK
    DFU->>USB: readBlockingDirect()
    Note over Device: Flash erase (~5-8s, CPU blocked)
    
    DFU->>USB: writeBlockingDirect(DFU Init packet with 2-byte padding)
    USB->>Device: bulkTransfer()
    Device->>USB: ACK
    DFU->>USB: readBlockingDirect()
    
    loop For each 512-byte data packet
        DFU->>USB: writeBlockingDirect(DFU Data packet)
        USB->>Device: bulkTransfer()
        Device->>USB: ACK
        DFU->>USB: readBlockingDirect()
        alt After 8 packets (4KB page)
            DFU->>DFU: delay(103ms) for flash page write
        end
    end
    
    DFU->>DFU: delay(103ms) for last page write
    DFU->>USB: writeBlockingDirect(DFU Stop packet)
    USB->>Device: bulkTransfer()
    Device->>USB: ACK
    DFU->>USB: readBlockingDirect()
    
    DFU->>USB: disconnect()
    DFU->>USB: disableRawMode()
    Note over Device: Validate CRC, write bootloader settings (~200ms)
    DFU->>DFU: delay(2000ms) POST_DFU_SETTLE_MS
    Device->>Device: Reboot with new firmware
    Device->>Android: USB re-enumeration (application PID)
    
    App->>App: delay(3000ms) for USB re-enumeration
    App->>App: provisionDevice() with retries
    loop Retry up to 5 times
        App->>USB: Try connect to original or new deviceId
        Note over App: May wait for permission grant
    end
    
    App->>Device: getDeviceInfo()
    Device->>App: Firmware version
    App->>App: Verify version matches expected
    App->>Device: getFirmwareHash()
    Device->>App: Calculated firmware hash
    App->>App: Verify hash matches flashed binary
    App->>Device: setFirmwareHash()
    
    Note over App: Keep bootloaderFlashModeActive=true on success<br/>to prevent late USB re-enumeration from navigating away
Loading

Last reviewed commit: 75de7bd

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, 3 comments

Edit Code Review Agent Settings | Greptile

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Feb 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech torlando-tech merged commit 20e0293 into main Feb 20, 2026
32 of 36 checks passed
@torlando-tech torlando-tech deleted the fix/rnode-frequency-band-detection branch February 20, 2026 01:52
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