Skip to content

fix(dfu): Detect legacy bootloaders before secure fallback#6079

Draft
jeremiah-k wants to merge 2 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/dfu-protocol-fallback
Draft

fix(dfu): Detect legacy bootloaders before secure fallback#6079
jeremiah-k wants to merge 2 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/dfu-protocol-fallback

Conversation

@jeremiah-k

@jeremiah-k jeremiah-k commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR makes BLE DFU protocol detection more reliable when a device reboots into a Legacy DFU bootloader.

Previously, if the app missed the Legacy DFU service during the initial scan, the update flow could continue down the Secure DFU path and keep retrying the wrong service. That made OTA updates fail even though the device was advertising a valid bootloader protocol.

This change keeps bootloader detection explicit, preserves inconclusive scan results, and only falls back to the alternate protocol before a DFU session has actually connected.

Key Changes

  • Added explicit bootloader detection results for Legacy, Secure, and Unknown.
  • Kept inconclusive detection from being treated as Secure-only.
  • Moved protocol ordering and fallback decisions into a dedicated coordinator.
  • Tried Legacy first when Legacy is observed.
  • Tried Secure first when Secure is observed.
  • Tried Legacy first when detection is inconclusive, since Unknown can mean the Legacy advertisement was missed.
  • Allowed alternate-protocol fallback only before protocol connection succeeds.
  • Preserved the full Legacy session retry budget for observed Legacy and inconclusive Legacy-first attempts.
  • Preserved Legacy stale-session reset behavior.
  • Preserved existing bond handling.
  • Kept failed transport cleanup non-cancellable.
  • Added focused coverage for protocol ordering, fallback, retry limits, and post-connect fallback blocking.

Testing

  • Added unit coverage for Legacy, Secure, and Unknown ordering.
  • Added coverage for fallback before connection.
  • Added coverage proving fallback is blocked after protocol connection.
  • Added coverage for retry-budget behavior.
  • Added coverage proving inconclusive detection gives the Legacy-first attempt the Legacy recovery retry budget.
  • Verified git diff --check passes.

Migration Notes

  • No user data migration is required.
  • Existing BLE bonds are unchanged.
  • This only changes DFU bootloader protocol detection and fallback behavior.

Summary by CodeRabbit

  • New Features
    • Enhanced firmware (DFU) update flow to detect bootloader type more reliably and select the best update protocol.
    • Added ordered fallback behavior with bounded retry attempts, only switching protocols when appropriate.
  • Bug Fixes
    • Prevents unnecessary or repeated update-session attempts by tracking whether the update session was actually engaged.
    • Improved “unknown bootloader” handling by trying the safer legacy-first path, then falling back only when warranted.
  • Tests
    • Added coverage for protocol selection, fallback ordering, and retry budget behavior.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 298f1248-c4cc-466e-8c60-cd95d5585e1c

📥 Commits

Reviewing files that changed from the base of the PR and between 3336489 and 83a2d17.

📒 Files selected for processing (2)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt

📝 Walkthrough

Walkthrough

SecureDfuHandler now detects bootloader protocol with an explicit Unknown outcome, routes uploads through DfuFallbackCoordinator, and returns DFU result objects from upload and connection paths. Added tests cover protocol ordering, fallback gating, and session-attempt behavior.

Changes

DFU Fallback and Detection

Layer / File(s) Summary
New protocol and fallback coordinator
feature/firmware/.../SecureDfuHandler.kt
Adds DfuProtocolKind, BootloaderDetection, DfuUploadResult, and DfuFallbackCoordinator with ordered fallback and protocol-to-UUID mapping.
startUpdate wiring to detection and coordinator
feature/firmware/.../SecureDfuHandler.kt
startUpdate computes BootloaderDetection, logs it, and delegates protocol execution to DfuFallbackCoordinator.execute.
Detection scan and upload retry rework
feature/firmware/.../SecureDfuHandler.kt
detectBootloaderProtocol scans Legacy then Secure and returns Unknown; runDfuUploadWithRetry and runUploadSession return DfuUploadResult and track protocolEngaged.
Connection retry protocol-aware logging
feature/firmware/.../SecureDfuHandler.kt
connectWithRetry accepts the selected protocol, logs protocol and service UUID context, and throws DfuException.ConnectionFailed with protocol-specific messaging.
DfuFallbackCoordinator unit tests
feature/firmware/.../DfuFallbackCoordinatorTest.kt
New tests verify protocol selection order, fallback behavior, protocolEngaged gating, and per-protocol session attempt counts.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: detecting Legacy DFU bootloaders before Secure DFU fallback.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the bugfix PR tag label Jul 5, 2026
@jeremiah-k jeremiah-k marked this pull request as ready for review July 5, 2026 14:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt (2)

51-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Misleading test name for the assertion made.

Test name says "Unknown tries Legacy then Secure" but the body only verifies Legacy is tried first (success on first call short-circuits fallback), never exercising the Secure path. Consider renaming to something like Unknown tries Legacy first.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`
around lines 51 - 60, The test name in DfuFallbackCoordinatorTest does not match
what the assertion actually verifies. Update the test case around
DfuFallbackCoordinator.execute so the name reflects the behavior being checked,
since it only confirms that BootloaderDetection.Unknown starts with
DfuProtocolKind.LEGACY and does not reach Secure on success. Rename the test to
something like “Unknown tries Legacy first” to align with the single-path
assertion.

63-77: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing coverage for suppressed prior-error chaining.

Per the coordinator's implementation (priorError?.let { result.error.addSuppressed(it) }), when both protocols fail before engagement, the first error should be attached as a suppressed exception on the final thrown error. None of these fallback tests assert on suppressedExceptions, so a regression in that chaining wouldn't be caught.

✅ Example additional assertion
         assertFailsWith<RuntimeException> {
             coordinator.execute { protocol, _ ->
                 protocols.add(protocol)
                 if (protocol == DfuProtocolKind.LEGACY) {
                     DfuUploadResult.Failure(RuntimeException("connect failed"), protocolEngaged = false)
                 } else {
                     DfuUploadResult.Failure(RuntimeException("also failed"), protocolEngaged = false)
                 }
             }
-        }
+        }.also { thrown ->
+            assertEquals(1, thrown.suppressedExceptions.size)
+            assertEquals("connect failed", thrown.suppressedExceptions.first().message)
+        }

Also applies to: 115-129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`
around lines 63 - 77, The fallback tests for DfuFallbackCoordinator do not
verify suppressed-error chaining, so add assertions that the final thrown error
from execute includes the first failure as a suppressed exception when both
protocol attempts fail before engagement. Update the affected test cases in
DfuFallbackCoordinatorTest to inspect the thrown RuntimeException’s
suppressedExceptions and confirm it contains the earlier error, matching the
coordinator’s priorError?.let { result.error.addSuppressed(it) } behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt`:
- Around line 167-172: sessionAttemptsFor() is not giving the Legacy primary
flow the larger retry budget when detection is Unknown, so the reset-prime
recovery path in runDfuUploadWithRetry() never gets a chance to run. Update the
retry selection in SecureDfuHandler so the Legacy primary case uses
LEGACY_SESSION_ATTEMPTS not only for BootloaderDetection.LegacyObserved but also
for the Unknown Legacy path, while keeping non-primary and non-Legacy cases on
LIMITED_SESSION_ATTEMPTS.

---

Nitpick comments:
In
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`:
- Around line 51-60: The test name in DfuFallbackCoordinatorTest does not match
what the assertion actually verifies. Update the test case around
DfuFallbackCoordinator.execute so the name reflects the behavior being checked,
since it only confirms that BootloaderDetection.Unknown starts with
DfuProtocolKind.LEGACY and does not reach Secure on success. Rename the test to
something like “Unknown tries Legacy first” to align with the single-path
assertion.
- Around line 63-77: The fallback tests for DfuFallbackCoordinator do not verify
suppressed-error chaining, so add assertions that the final thrown error from
execute includes the first failure as a suppressed exception when both protocol
attempts fail before engagement. Update the affected test cases in
DfuFallbackCoordinatorTest to inspect the thrown RuntimeException’s
suppressedExceptions and confirm it contains the earlier error, matching the
coordinator’s priorError?.let { result.error.addSuppressed(it) } behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b5aa7426-d425-4564-9ab8-8df489847ffa

📥 Commits

Reviewing files that changed from the base of the PR and between d8afe84 and f528444.

📒 Files selected for processing (2)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt

@jeremiah-k jeremiah-k marked this pull request as draft July 5, 2026 14:58
@jeremiah-k jeremiah-k force-pushed the bugfix/dfu-protocol-fallback branch from f528444 to 3336489 Compare July 5, 2026 17:43
@jeremiah-k jeremiah-k marked this pull request as ready for review July 5, 2026 18:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt (1)

96-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: add coverage for the fallback/alternate protocol's session-attempt budget.

Existing tests confirm LEGACY_SESSION_ATTEMPTS (3) for the Legacy primary case, but no test asserts LIMITED_SESSION_ATTEMPTS (1) for an alternate/fallback protocol attempt (e.g., Secure as fallback under LegacyObserved, or Legacy as fallback under SecureObserved). Not required, but would close a small coverage gap in the retry-budget contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`
around lines 96 - 116, Add a test that verifies the alternate/fallback protocol
uses the limited session-attempt budget in DfuFallbackCoordinator.execute. The
current DfuFallbackCoordinatorTest only covers LEGACY_SESSION_ATTEMPTS for
LegacyObserved and Unknown, so extend it with a case for a fallback path such as
Secure under LegacyObserved or Legacy under SecureObserved, and assert the
attempts value is LIMITED_SESSION_ATTEMPTS (1). Use the existing
DfuFallbackCoordinator and BootloaderDetection setup to locate the retry-budget
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`:
- Around line 96-116: Add a test that verifies the alternate/fallback protocol
uses the limited session-attempt budget in DfuFallbackCoordinator.execute. The
current DfuFallbackCoordinatorTest only covers LEGACY_SESSION_ATTEMPTS for
LegacyObserved and Unknown, so extend it with a case for a fallback path such as
Secure under LegacyObserved or Legacy under SecureObserved, and assert the
attempts value is LIMITED_SESSION_ATTEMPTS (1). Use the existing
DfuFallbackCoordinator and BootloaderDetection setup to locate the retry-budget
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c044a72f-7c7a-46ae-844b-e1beebc417d4

📥 Commits

Reviewing files that changed from the base of the PR and between f528444 and 3336489.

📒 Files selected for processing (2)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt

Replace the brittle single-scan Legacy DFU detection (which treated
a missed scan as proof of Secure DFU) with a resilient detection and
fallback architecture:

- Add BootloaderDetection sealed result type (LegacyObserved,
  SecureObserved, Unknown) so the inconclusive case stays
  representable instead of being silently coerced to Secure.
- Scan for Legacy DFU service first; if observed, return immediately
  without scanning for Secure DFU (Legacy wins per orderedProtocols).
- Add runDfuUploadWithFallback wrapper that resolves a detection
  into an ordered protocol list (Legacy first for Unknown) and tries
  each in turn, falling back to the alternate only on pre-transfer
  failures.
- Guard fallback with TransferProgression flag set immediately before
  firmware transfer; once streaming begins, failures remain
  protocol-specific and throw immediately with no fallback.
- Reworked fallback loop so the final throw is reachable for
  last-protocol pre-engagement failures.
- Thread protocol kind into connectWithRetry and log the scanned
  service UUID per connect attempt.
- Log detection outcome, fallback decisions, and address-set type
  using privacy-safe redacted forms (no raw BLE MAC addresses).

Preserves: bond-preservation contract, Legacy stale-session
(INVALID_STATE) reset handling, per-session NonCancellable GATT
cleanup, and Legacy retry semantics (fresh transport + reconnect
per retry).
Move DFU protocol ordering and session retry budget selection into a fallback coordinator so SecureDfuHandler delegates protocol fallback decisions instead of owning them directly.

Replace the mutable transfer progression flag with an explicit upload result that reports whether a protocol session was engaged before a failure. This keeps alternate-protocol fallback limited to pre-engagement failures while preserving Legacy retry handling, stale-session reset priming, and NonCancellable transport cleanup.
@jeremiah-k jeremiah-k force-pushed the bugfix/dfu-protocol-fallback branch from 3336489 to 83a2d17 Compare July 5, 2026 18:45

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed after the force-push. Two of the bigger items are addressed — nice:

  • ✅ Unknown detection now grants the Legacy primary the full 3-attempt budget, so the stale-session reset-prime path can actually run and a wedged legacy bootloader with a missed 1530 ad recovers instead of hard-failing.
  • DfuFallbackCoordinatorTest now covers the LegacyObserved→Secure ordering, per-leg budgets (listOf(3, 1)), and the suppressed-error shape.

A couple of things still stand (inline), plus a latency note: the 3-attempt fix means an Unknown-detected Secure device (FE59 ad missed) now burns 3× the doomed Legacy connect cycle before the Secure leg is tried — acceptable, but worth a comment so it's a deliberate choice. Not blocking.

is DfuUploadResult.Failure -> {
if (result.protocolEngaged || !hasAlternateProtocol) {
priorError?.let { result.error.addSuppressed(it) }
throw result.error

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On exhaustion the thrown error is the alternate leg's, with the primary only addSuppressed — and the UI renders e.message. So a legacy device that fails both legs shows "Failed to connect… via SECURE after 4 attempts", naming the protocol it doesn't speak. Consider throwing the primary (detection-matched) leg's error and suppressing the alternate, or composing a message that names both.

* is the alternate, attempted only if the primary fails before protocol engagement.
*/
private fun BootloaderDetection.orderedProtocols(): List<DfuProtocolKind> = when (this) {
BootloaderDetection.LegacyObserved -> listOf(DfuProtocolKind.LEGACY, DfuProtocolKind.SECURE)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Conclusive detections still carry the opposite protocol as a fallback leg, so a pre-engagement connect flake on the primary triggers a full doomed alternate pass (up to ~140s of scans+retries against a service the device never advertises). The insurance is reasonable for a wrong "conclusive" call, but gating the alternate leg on "primary failed after engaging its DFU service" (vs. never connected) would avoid the worst-case stall on a simple flake.

@jeremiah-k jeremiah-k marked this pull request as draft July 5, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants