Skip to content

fix(firmware): Confirm ESP32 OTA mode before disconnecting#6080

Draft
jeremiah-k wants to merge 4 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/esp32-ota-preflight
Draft

fix(firmware): Confirm ESP32 OTA mode before disconnecting#6080
jeremiah-k wants to merge 4 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/esp32-ota-preflight

Conversation

@jeremiah-k

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

Copy link
Copy Markdown
Contributor

Overview

This PR makes ESP32 OTA startup wait for firmware confirmation before disconnecting the normal mesh connection.

Previously, the app could treat delivery of the reboot request as enough to continue into OTA mode. If firmware rejected the requested OTA mode because the installed OTA loader did not support BLE or Wi-Fi, the app could still disconnect and enter a doomed retry loop.

This change waits for a fresh firmware-side OTA response before switching away from the normal connection.

Key Changes

  • Cleared stale OTA notifications before sending the ESP32 OTA request.
  • Waited for a fresh firmware confirmation or rejection before disconnecting mesh.
  • Treated OTA-loader rejection messages as a startup failure.
  • Treated missing confirmation within the bounded wait as a timeout.
  • Kept the normal mesh connection active on rejection or timeout.
  • Continued into OTA transport setup only after firmware confirms the requested OTA mode.
  • Suppressed duplicate generic OTA status popups.
  • Added clearer user-facing errors for rejected and timed-out OTA startup.
  • Wrapped firmware string lookup safely for headless test environments.
  • Added focused coverage for confirmed, rejected, and timed-out startup paths.

Testing

  • Added coverage proving mesh delivery alone is not treated as OTA-mode success.
  • Added coverage proving unsupported-loader rejection does not disconnect mesh.
  • Added coverage proving timeout does not disconnect mesh.
  • Added coverage proving confirmed startup continues into the existing OTA flow.
  • Verified git diff --check passes.

Migration Notes

  • No user data migration is required.
  • Existing device connections and saved addresses are unchanged.
  • This only changes ESP32 OTA startup gating before the temporary OTA connection begins.

Summary by CodeRabbit

  • New Features
    • Added new firmware recovery and OTA update messages, including timeout and unsupported-device wording.
    • Introduced an OTA preflight confirmation step that waits for the device’s OTA entry-mode confirmation before continuing.
  • Bug Fixes
    • Prevented duplicate generic alerts from being shown for OTA status notifications.
    • Improved handling of OTA rejection and timeouts with clearer terminal error states.
  • Tests
    • Added/expanded unit coverage for OTA confirmation, rejection, and timeout 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: bd051a02-a7ff-4b5d-9bc8-d5619935451b

📥 Commits

Reviewing files that changed from the base of the PR and between 5741015 and 867d8e5.

📒 Files selected for processing (8)
  • .skills/compose-ui/strings-index.txt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
  • core/resources/src/commonMain/composeResources/values/strings.xml
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt
🚧 Files skipped from review as they are similar to previous changes (8)
  • .skills/compose-ui/strings-index.txt
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
  • core/resources/src/commonMain/composeResources/values/strings.xml
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt

📝 Walkthrough

Walkthrough

Adds OTA client-notification filtering, a notification-gated OTA preflight flow in Esp32OtaUpdateHandler, new OTA-related strings, and unit coverage for the updated behavior.

Changes

OTA Preflight Confirmation and Notification Handling

Layer / File(s) Summary
OTA client notification detection
core/data/src/commonMain/.../FromRadioPacketHandlerImpl.kt, core/data/src/commonTest/.../FromRadioPacketHandlerImplTest.kt
Adds isOtaStatusNotification() and an early return in handleClientNotification to skip generic alert dispatch for OTA messages, with new unit tests.
OTA and firmware recovery strings
core/resources/src/commonMain/composeResources/values/strings.xml, .skills/compose-ui/strings-index.txt
Adds firmware_update_ota_timeout and firmware_update_ota_unsupported_reason, reorders recovery strings, and updates bootloader hint text escaping.
OTA mode and timeout constants
feature/firmware/src/commonMain/.../Esp32OtaUpdateHandler.kt, feature/firmware/src/commonMain/.../BleOtaTransport.kt
Introduces REBOOT_MODE_BLE/REBOOT_MODE_WIFI, the preflight timeout and message-matching constants, otaPreflightTimeoutMs, import updates, and lint suppression changes.
performUpdate preflight integration
feature/firmware/src/commonMain/.../Esp32OtaUpdateHandler.kt
Reworks performUpdate to snapshot a baseline notification, trigger reboot, run the preflight gate, and branch chunk-size/connect logic on reboot mode; wraps download message formatting in safeCatchingAll.
Preflight confirmation subsystem
feature/firmware/src/commonMain/.../Esp32OtaUpdateHandler.kt
Adds OtaPreflightResult, runOtaPreflight, awaitOtaConfirmation, and otaModeName to race client notifications against a timeout and validate confirmation prefixes.
Preflight gate tests and wiring
feature/firmware/src/commonTest/.../DefaultFirmwareUpdateManagerTest.kt, feature/firmware/src/commonTest/.../Esp32OtaUpdateHandlerTest.kt
Wires a mocked ServiceRepository into handler construction and adds tests for Confirmed, Rejected, and Timeout preflight outcomes.

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

Sequence Diagram(s)

sequenceDiagram
  participant Esp32OtaUpdateHandler
  participant MeshTransport
  participant Firmware

  Esp32OtaUpdateHandler->>Firmware: trigger reboot OTA (BLE/WiFi mode)
  Esp32OtaUpdateHandler->>Esp32OtaUpdateHandler: runOtaPreflight(otaPreflightTimeoutMs)
  Esp32OtaUpdateHandler->>Firmware: awaitOtaConfirmation via clientNotification
  Firmware-->>Esp32OtaUpdateHandler: ClientNotification message
  alt Confirmed
    Esp32OtaUpdateHandler->>MeshTransport: disconnectMeshService() + delay
  else Rejected
    Esp32OtaUpdateHandler->>Esp32OtaUpdateHandler: emit FirmwareUpdateState.Error(rejection reason)
  else Timeout
    Esp32OtaUpdateHandler->>Esp32OtaUpdateHandler: emit FirmwareUpdateState.Error(timeout)
  end
Loading

Related issues: None found.
Related PRs: None found.
Suggested labels: bug
Suggested reviewers: None found.

🚥 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 accurately captures the main change: waiting for ESP32 OTA confirmation before disconnecting.
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:53

@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 (1)
core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt (1)

179-189: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

OTA classification duplicated (and narrower) than Esp32OtaUpdateHandler's matcher.

isOtaStatusNotification() only suppresses the generic alert when the message starts with "Rebooting to" or one of three hardcoded rejection prefixes. Esp32OtaUpdateHandler.awaitOtaConfirmation() (feature/firmware) classifies any non-baseline message containing "OTA" as preflight-relevant (Confirmed or Rejected). The two files also independently redefine identical OTA_KEYWORD/OTA_CONFIRM_PREFIX constants.

Today the enumerated prefixes here happen to match every rejection message documented in Esp32OtaUpdateHandler's comments, so there's no active bug — but if firmware ever adds a new OTA-related rejection string not in this list, Esp32OtaUpdateHandler will correctly classify it as Rejected and show its own error, while this function will fail to suppress the generic alert, reintroducing exactly the duplicate-popup bug this PR sets out to fix. Consider loosening this check to mirror the broader contains("OTA") criterion (or extracting a single shared classifier both files call), so the two consumers can't drift.

♻️ Proposed simplification to avoid drift
-private const val OTA_KEYWORD = "OTA"
-private const val OTA_CONFIRM_PREFIX = "Rebooting to"
-private val OTA_REJECTION_PREFIXES = listOf("Cannot start OTA", "OTA Loader", "Unable to switch to the OTA partition")
-
 internal fun ClientNotification.isOtaStatusNotification(): Boolean {
     val message = message.trim()
-    if (message.isBlank() || !message.contains(OTA_KEYWORD)) return false
-
-    return message.startsWith(OTA_CONFIRM_PREFIX) || OTA_REJECTION_PREFIXES.any { message.startsWith(it) }
+    return message.contains("OTA")
 }
🤖 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
`@core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt`
around lines 179 - 189, `isOtaStatusNotification()` is narrower than
`Esp32OtaUpdateHandler.awaitOtaConfirmation()` and duplicates OTA constants,
which can let new OTA rejection messages slip through and trigger duplicate
alerts. Update the notification classifier in `FromRadioPacketHandlerImpl` to
use the same broader OTA-matching rule as the handler, or extract a shared OTA
status classifier/constant set that both `isOtaStatusNotification()` and
`awaitOtaConfirmation()` call so their behavior cannot drift.
🤖 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/Esp32OtaUpdateHandler.kt`:
- Around line 250-256: The downloading message fallback in
Esp32OtaUpdateHandler’s update flow is too broad because safeCatchingAll
swallows every Throwable and can hide real production resource-loading failures
by returning an empty string. Narrow this handling to the specific Skiko
headless test case around
getStringSuspend(Res.string.firmware_update_downloading_percent, 0), or add
logging before falling back so failures are visible while still preserving the
blank-message test behavior. Keep the fix localized to the downloadingMsg
computation and the surrounding OTA status emission path.

---

Nitpick comments:
In
`@core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt`:
- Around line 179-189: `isOtaStatusNotification()` is narrower than
`Esp32OtaUpdateHandler.awaitOtaConfirmation()` and duplicates OTA constants,
which can let new OTA rejection messages slip through and trigger duplicate
alerts. Update the notification classifier in `FromRadioPacketHandlerImpl` to
use the same broader OTA-matching rule as the handler, or extract a shared OTA
status classifier/constant set that both `isOtaStatusNotification()` and
`awaitOtaConfirmation()` call so their behavior cannot drift.
🪄 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: 893c0951-f53b-4ec1-83da-5c5fb6ea3776

📥 Commits

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

📒 Files selected for processing (8)
  • .skills/compose-ui/strings-index.txt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
  • core/resources/src/commonMain/composeResources/values/strings.xml
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt

@jeremiah-k jeremiah-k marked this pull request as draft July 5, 2026 15:04
…ation

Replace the fire-and-forget triggerRebootOta + fixed delay + mesh
disconnect flow with a preflight gate that waits for the firmware's
ClientNotification response before tearing down the mesh transport.

The firmware emits a single meshtastic.ClientNotification before its
scheduled reboot covering all cases:
- Success: 'Rebooting to BLE/WiFi OTA'
- Rejection: 'OTA Loader does not support BLE/WiFi', partition
  missing, switch failed, etc.

Previously, the app sent the OTA reboot command, waited a fixed 2s
for packet delivery, then unconditionally disconnected the mesh
service and entered the OTA transport retry loop — even when the
device's OTA loader rejected the request. This caused repeated
failed connection attempts to a service that was never listening.

Now: after triggerRebootOta, the app races the ClientNotification
StateFlow against a 5s timeout. On confirmed reboot → disconnect
mesh and proceed with OTA transport. On rejection → fail immediately
with actionable error text, mesh transport preserved. On timeout →
fail with 'device did not enter OTA mode', mesh transport preserved.

The baseline notification message is captured BEFORE the OTA trigger
is sent so a stale notification already held in the StateFlow cannot
be mistaken for the firmware response. The confirmation match
requires both the canonical 'Rebooting to' prefix and the requested
mode name (BLE/WiFi), guarding against a wrong-mode ack.

New strings:
- firmware_update_ota_unsupported
- firmware_update_ota_timeout

Tests cover the three preflight outcomes (Confirmed, Rejected,
Timeout) verifying mesh transport is released only on confirmation.
Keep stale ClientNotification clearing before the OTA trigger and route preflight timeout handling through an overridable timeout value so timeout coverage does not pay the production wait.

Replace raw reboot-mode values with named BLE and WiFi reboot mode constants and use a single helper to map reboot modes to firmware notification mode names.

Surface firmware rejection messages through a localized error resource so unsupported-loader and partition failures can show the firmware-provided reason instead of only the generic unsupported update message.
@jeremiah-k jeremiah-k force-pushed the bugfix/esp32-ota-preflight branch from 5741015 to 867d8e5 Compare July 5, 2026 17:44
@jeremiah-k jeremiah-k marked this pull request as ready for review July 5, 2026 18:36

@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. The preflight gating is a good idea, but a few things block merge as-is — most importantly the PR's own headline claim isn't delivered by this branch alone.

The "suppress duplicate OTA status alerts" commit only gates the wrong path. It suppresses notificationManager.dispatch in FromRadioPacketHandlerImpl, but setClientNotification(cn) still fires and UIViewModel's alert collector — untouched on this branch — still pops the generic dialog for every OTA notification. The UIViewModel gate exists only in #6081's extra commits, so #6080 merged on its own still shows the duplicate popup it says it removes. Either fold the UIViewModel gate into this PR or reorder the stack.

Other blockers and notes inline. The older-firmware concern (item on line 193) needs a firmware-history check before this can ship — if any released firmware handles ota_request but predates the confirmation notification, this permanently breaks OTA for it.

serviceStateWriter.setClientNotification(cn)

scope.handledLaunch {
if (cn.isOtaStatusNotification()) {

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.

This suppresses only the OS-notification dispatch. setClientNotification(cn) above still runs, and UIViewModel (not modified on this branch) still calls showAlert for every non-null clientNotification — so the generic "Rebooting to BLE OTA" popup this PR claims to remove still fires when #6080 is merged standalone. The gate belongs on the UIViewModel alert path too (it's in #6081 — pull it forward or restack).

// meshtastic.ClientNotification warning ("OTA Loader does not support <mode>", etc.) emitted before
// its scheduled reboot, so we race that signal against a bounded timeout. On rejection or timeout we
// fail fast and leave the mesh connection intact — no disconnect, no OTA-transport retry loop.
if (runOtaPreflight(rebootMode, baselineMessage, updateState) !is OtaPreflightResult.Confirmed) {

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.

OTA is hard-gated on a Confirmed notification within 5s with no firmware-version fallback. If any released firmware handles the ota_request admin message but predates the "Rebooting to OTA" emission, it reboots into OTA yet never sends the notification → permanent Timeout, and OTA that worked before this PR is now broken for it. Please confirm the emission shipped no later than ota_request handling; otherwise gate the preflight on firmware version (fall back to the old fixed-delay path below the threshold).

when (val preflight = awaitOtaConfirmation(otaPreflightTimeoutMs, rebootMode, baselineMessage)) {
is OtaPreflightResult.Confirmed -> {
Logger.i { "ESP32 OTA: Preflight confirmed; releasing mesh transport for OTA" }
disconnectMeshService()

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.

New: on the Confirmed path you disconnectMeshService() but never clear the confirmation notification, so "Rebooting to BLE OTA" stays resident in clientNotification. Combined with the ungated UIViewModel alert, that means a spurious popup on the success path this PR was meant to make silent. Clear it after a confirmed match.

withTimeoutOrNull(timeoutMs) {
serviceRepository.clientNotification.first { cn ->
val msg = cn?.message
msg != null && msg != baselineMessage && msg.contains(OTA_KEYWORD)

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.

The timeout message (firmware_update_ota_timeout, "Device did not enter OTA mode…") is inverted: triggerRebootOta was already sent, so on a lost/late confirmation the device may well be in OTA mode. Also this predicate is an untrimmed contains("OTA") catch-all while the isOtaStatusNotification suppression uses a trimmed prefix allowlist — the two classifiers disagree on whitespace and on any OTA-containing message outside the four prefixes. Worth extracting one shared classifier.

private val firmwareFileHandler: FirmwareFileHandler,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,

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.

Redundant DI: RadioController (already injected) exposes clientNotification and clearClientNotification delegating to this same ServiceRepository. Dropping the extra param would also undo the serviceRepository-mock churn the tests had to add.

// compose-resources can't load native libs. Production resolves the localized string normally;
// tests fall back to empty and the Downloading state still emits with a blank message.
val downloadingMsg =
safeCatchingAll { getStringSuspend(Res.string.firmware_update_downloading_percent, 0) }

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.

This safeCatchingAll is a production behavior change (silent blank label on any resource failure) that the comment admits exists only to dodge a headless-test Skiko init crash — and it's the sole guarded getStringSuspend in the file, so it's inconsistent anyway. Better to stub string resolution in the test than swallow it in prod.

@jeremiah-k jeremiah-k marked this pull request as draft July 5, 2026 20:26
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