fix: nRF52 DFU flashing and frequency band detection#506
Merged
torlando-tech merged 9 commits intomainfrom Feb 20, 2026
Merged
Conversation
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>
Contributor
Greptile SummaryComprehensive fix for nRF52 DFU flashing and device detection issues, addressing multiple root causes of flash failures: Nordic DFU Protocol Corrections:
USB Re-enumeration Handling:
USB Controller Protection:
Device Detection Fixes:
Post-Flash Verification:
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
Important Files Changed
Sequence DiagramsequenceDiagram
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
Last reviewed commit: 75de7bd |
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/NordicDFUFlasher.kt
Outdated
Show resolved
Hide resolved
reticulum/src/main/java/com/lxmf/messenger/reticulum/usb/KotlinUSBBridge.kt
Outdated
Show resolved
Hide resolved
reticulum/src/main/java/com/lxmf/messenger/reticulum/usb/KotlinUSBBridge.kt
Outdated
Show resolved
Hide resolved
This was referenced Feb 19, 2026
Contributor
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>
reticulum/src/main/java/com/lxmf/messenger/reticulum/usb/KotlinUSBBridge.kt
Show resolved
Hide resolved
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>
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
usb_device_filter.xmlso 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 firmwareFrequencyBand.fromModelCode()now uses full-byte lookup table matching rnodeconf, andgetModelForBoardAndBand()returns per-board model codesCommits
fix: add post-flash firmware version and hash verificationfix: match Nordic DFU protocol to adafruit-nrfutil reference implementationfix: handle USB re-enumeration during nRF52 DFU flash and provisioningfix: retry bootloader connection while waiting for USB permission after re-enumerationfix: improve post-DFU UX for nRF52 flash completionfix: register nRF52 bootloader PIDs in USB device filter for auto-permissionstyle: refactor readAckNr loop to satisfy detekt LoopWithTooManyJumpStatementsTest plan
🤖 Generated with Claude Code