Skip to content

feat(firmware): add nRF52/RP2040 factory erase and OTAFIX bootloader upgrade over USB - #6526

Draft
jamesarich wants to merge 20 commits into
mainfrom
claude/meshtastic-firmware-erase-9b6cb3
Draft

feat(firmware): add nRF52/RP2040 factory erase and OTAFIX bootloader upgrade over USB#6526
jamesarich wants to merge 20 commits into
mainfrom
claude/meshtastic-firmware-erase-9b6cb3

Conversation

@jamesarich

Copy link
Copy Markdown
Collaborator

Wires the Meshtastic web-flasher's nRF52/RP2040 factory-erase UF2s and OTAFIX's bootloader-upgrade UF2 into :feature:firmware's existing USB/UF2 update path, so a device can be erased or have its bootloader upgraded without leaving the app.

The two safety-critical properties baked into the design:

  • The nRF52 erase image is SoftDevice-version-specific, and writing the wrong one corrupts the SoftDevice with no on-device recovery. The mounted volume's own INFO_UF2.TXT SoftDevice: line is treated as authoritative over the bundled hardware-catalog hint — the two must agree or the app refuses outright (never guesses).
  • OTAFIX bootloaders are resolved by the device-reported Board-ID, not by build-target name or USB VID/PID — both of the latter collide across multiple boards.

🌟 New Features

  • Factory erase and OTAFIX bootloader upgrade over USB/UF2 for nRF52840 (both) and RP2040 (erase only), surfaced as a low-emphasis destructive action on the firmware screen's Ready state, behind an explicit confirmation dialog.
  • A fail-closed SoftDevice-variant map (device_bootloader_ota_quirks.json) covering all 32 nRF52840 hardware-list entries, derived from meshtastic/firmware's board ldscripts — with the drive's own report always taking precedence at runtime.
  • INFO_UF2.TXT parsing (Board-ID, SoftDevice) and a pinned, SHA-256-verified table of erase/OTAFIX UF2 assets (MaintenanceUf2.kt).
  • Re-entrant two-pass USB file-save sequencing (maintenance image → device re-enters DFU → release firmware), including a CDC port DTR/RTS poke so the erase firmware's while (!Serial) gate unblocks headlessly.
  • A directory-tree (SAF) picker launcher alongside the existing save-file launcher, for pointing the app at the mounted UF2 volume.

🛠️ Refactoring & Architecture

  • New FirmwareMaintenanceLock in :core:common (a Koin singleton, kotlinx.atomicfu-backed) suppresses SharedRadioInterfaceService's environmental-recovery listeners for the duration of a maintenance sequence, so the mesh transport doesn't claim the erase firmware's bare CDC port out from under the flow.
  • DeviceHardware gained a softDeviceVariant field; DeviceHardwareRepositoryImpl resolves it from the quirks map.
  • Deduplicated the UsbMaintenanceRefusal → copy mapping (previously duplicated between the ViewModel and the Composable card) into a single usbMaintenanceRefusalMessage().

🐛 Bug Fixes

  • DfuZipParser now rejects a DFU package whose manifest has no application entry instead of silently promoting softdevice_bootloader/bootloader/softdevice and flashing it as an application image.
  • The RAK4631 bootloader-hint string and docs/en/user/firmware.md no longer claim that copying a .uf2 can never update the bootloader — true for the SD+BL .zip, false for OTAFIX's boot-family update-*_nosd.uf2.
  • FirmwareMaintenanceLock was never released on a successful maintenance sequence (the terminal pass completes through the pre-existing saveDfuFile, which had no knowledge of the lock) — every successful erase/upgrade would silently suppress BLE/network reconnection for the rest of the app session. Found and fixed during review, with a regression test that drives a full two-pass sequence and asserts the lock releases.
  • startUsbMaintenance guarded FactoryErase against a resolvable-SoftDevice refusal but had no equivalent guard for BootloaderUpgrade; a stray call on a device the UI would never show the button for would reboot it into DFU mode before being refused at write time. Closed with the same guard pattern plus a regression test.

🧹 Chores

  • parseUf2BoardId now trims leading whitespace before matching, matching parseUf2SoftDevice's existing tolerance.
  • feature/firmware/README.md documents the new USB maintenance capability (sequence diagram + the two safety properties above).

Hardware Validation

This PR is opened as a draft because the destructive erase/upgrade flow has not yet been run end-to-end through the app's UI on physical hardware — that's the one gate left before merge. What has been validated directly against real devices (reading INFO_UF2.TXT and raw flash off the mounted volume, not through the app):

Device Bootloader SoftDevice Confirms
Seeed Wio Tracker L1 (hwModel 99) stock 0.9.2 S140 7.3.0 Matches its map row; CURRENT.UF2 first block at 0x00001000 confirms the SoftDevice region is writable over UF2 (the premise the whole safety design rests on); Board-ID matches OTAFIX's string for this board.
RAK4631 (hwModel 9), already on OTAFIX 2.2-BP1.3 OTAFIX 0.9.2 S140 6.1.1 Matches its map row; CURRENT.UF2 at 0x26000 holds a valid ARM vector table, confirming both the destructive direction (7.3.0 device → that address is the SoftDevice's last page) and the benign one.
Spare RAK4631, stock bootloader from May 2023 stock 0.4.3 S140 6.1.1 SoftDevice: line goes back to this vintage (no stock-bootloader blind spot); Board-ID is identical to the OTAFIX-flashed unit above, so the OTAFIX veto resolves correctly on a never-upgraded device — the case that decides whether the upgrade can be offered at all.

The variant-mismatch direction (writing the wrong SoftDevice's erase image) was deliberately never tested — there is no recovery from it on real hardware, and the refusal path that prevents it is covered by unit tests instead (resolveNrfEraseImage's Conflict case).

Testing Performed

  • ./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests — green.
  • New/changed test files: UsbMaintenanceGateTest, CommonFirmwareRetrieverTest, CommonMaintenanceVolumeTest, CommonPerformUsbUpdateTest, FirmwareUpdateIntegrationTest, FirmwareUpdateViewModelTest, FirmwareUpdateViewModelFileTest, MaintenanceVolumeTest, DfuZipParserTest, DeviceHardwareRepositoryImplTest, SoftDeviceQuirkCoverageTest, SharedRadioInterfaceServiceLivenessTest.
  • Covers: the drive-vs-map SoftDevice resolution (agreement, map-only, drive-only, conflict, unresolved), OTAFIX Board-ID resolution and the "shares a build target but isn't OTAFIX-supported" refusal, digest/target-address artifact verification, the FirmwareMaintenanceLock release path across both success and abandon-mid-sequence, and the BootloaderUpgrade defense-in-depth guard.

🤖 Generated with Claude Code

jamesarich and others added 18 commits July 30, 2026 07:33
…nance UF2s

Foundation for USB/UF2 factory erase and OTAFIX bootloader upgrade.

The erase images are linked for a specific application start address
(0x26000 for S140 6.1.1, 0x27000 for 7.3.0) and the UF2 bootloader's write
guard begins at MBR_SIZE, so the SoftDevice region is writable — flashing the
wrong variant erases a SoftDevice page and needs SWD or serial DFU to recover.
The variant therefore has to be known, never inferred.

- Add SoftDeviceVariant + SoftDeviceVariantEntry as a separate array in the
  bundled quirks asset. BootloaderOtaQuirk is left untouched: it is an advisory
  warning that may safely fail open, and one record carrying both postures
  would invite a future edit that relaxes the wrong one.
- Resolve the variant target-strictly. hwModel alone is not unique (hwModel 94
  HELTEC_MESH_POCKET has two nRF52840 targets), so the device-reported target
  must appear in the row. Asset absent, asset malformed, model unmapped and
  target unrecognised all converge on null, and callers refuse on null.
- Wire type is String? rather than the enum because the shared Json sets
  coerceInputValues = true, which would coerce a typo'd enum to a default.
- Map 31 nRF52840 models, generated from the firmware repo's board ldscripts
  rather than hand-typed. MESH_TRACKER_X1 (128) is 7.3.0 — the web flasher's
  allowlist omits it and serves it the 6.1.1 image. THINKNODE_M8 (130) has no
  firmware variant on master and is present-but-unmapped so its refusal is
  deliberate and greppable.
- Pin the erase and OTAFIX images by commit/tag with SHA-256 digests, plus a
  UF2 first-target-address cross-check that catches a swapped URL/digest row —
  the one authoring mistake a digest alone cannot catch.
- Reject DFU packages with no 'application' image. A bootloader-only package
  has imageCount == 1 so it passed the existing multi-image warning and was
  uploaded with START_DFU's type hard-coded to APPLICATION and sd/bl sizes
  zeroed; only validateNrf52LocalFirmware's suffix check stood in the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ability

Adds the retrieval and decision layer for USB/UF2 factory erase and OTAFIX
bootloader upgrade. No user-visible flow yet — the mechanism lands next.

- FirmwareRetriever.retrieveMaintenanceUf2 is the module's first absolute-URL
  entry point. Verification is terminal: a digest or UF2 target-address
  mismatch deletes the download and returns null with no release-zip fallback,
  because "some other file with the right name" is exactly the failure being
  guarded against when the payload is destructive.
- usbMaintenanceGate is a pure, total decision: nRF52840/RP2040 on a serial
  connection with a release selected. An unresolved SoftDevice keeps the action
  visible but refused so the reason can be explained; an unmapped OTAFIX board
  hides it, since that is a coverage gap rather than something a user can act
  on. There is no branch that can select a default erase image.
- OTAFIX is mapped for rak4631 and tracker-t1000-e only. The OTAFIX assets are
  named after their own PlatformIO boards, which match Meshtastic's by no
  mechanical rule, and six distinct products (WISMESH Hub/Tap/Tag, Nomadstar
  Meteor Pro, RAK3401, RAK4631) share the wiscore_rak4631 board — so a
  board-level match would offer one product's bootloader to five others. A
  bootloader built for other hardware is as unrecoverable as a wrong-SoftDevice
  erase, so entries are added only where the pairing is unambiguous and
  hardware-validatable. All 14 digests are known; widening is a data edit.
- FirmwareFileHandler gains isRemovableDestination and isDestinationReadable.
  These close the loop on a destructive write: SAF hands back whatever the user
  picked, and copyToUri will happily write a UF2 to Downloads. Only the
  maintenance flow consults them — the single-pass update path is unchanged,
  since its worst case is "nothing happened, replug".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… reports

Replaces a guessed name correspondence with the device's own answer, and in
doing so widens OTAFIX coverage from 2 boards to all 14.

Every Adafruit-family bootloader writes INFO_UF2.TXT to its mass-storage
volume with a "Board-ID:" line (ghostfat.c, from each board's UF2_BOARD_ID).
Reading that off the mounted drive identifies the hardware authoritatively, so
nothing depends on correlating project names — which was never going to work:
heltec_t114 vs heltec-mesh-node-t114, thinknode_m1 vs ThinkNode-M1, t1000_e vs
tracker-t1000-e.

USB identity was the obvious alternative and is unusable: four OTAFIX boards
share 239A/0029, all three ThinkNodes share 239A/00DA, and SenseCAP Solar P1
collides with XIAO nRF52840 BLE on 2886/0044. Board-IDs are unique across all
14, which a test asserts. Board-ID is also the only way to resolve the XIAO
BLE / BLE Sense split that OTAFIX's own README warns about.

The supported-target set is now a visibility hint only — it decides whether the
action is offered, never which image is written. Being wrong there costs a tap
and an "unsupported board" message after the drive is read; it cannot flash
anything. An unrecognized Board-ID refuses, which is correct even on a
supported product: it means the installed bootloader is not a pairing we have
verified, and a bootloader built for other hardware needs SWD to undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…f trusting the map

Verified against a stock Seeed Wio Tracker L1 (hwModel 99, firmware 2.8.0):

    Board-ID: TRACKER L1
    SoftDevice: S140 7.3.0

uf2_init() appends that SoftDevice line at boot from SD_ID_GET(MBR_SIZE) and
SD_VERSION_GET(MBR_SIZE) — read out of the MBR's own registers. It is present
in upstream Adafruit, in OTAFIX, and now confirmed on a stock Seeed bootloader.
So the device can tell us not what its firmware was built against, but which
SoftDevice is actually in flash — which is the question that decides whether an
erase image lands in the application region or in the SoftDevice.

The bundled 31-row map is therefore demoted to a pre-flight hint: it decides
whether to offer the action before any drive is mounted, and cannot by itself
cause a write. Once the drive is readable its report wins. A disagreement
between the two refuses rather than picking a side, which also makes the map
self-checking — a wrong row surfaces the first time anyone uses it instead of
corrupting a SoftDevice silently. A device with no SoftDevice line (bootloader
older than that uf2_init) still falls back to the map, and a drive report can
now rescue a model the map has no row for at all, such as THINKNODE_M8.

Also verified while the L1 was mounted, both of which de-risk earlier
assumptions rather than changing code:
- CURRENT.UF2's first block targets 0x00001000, empirically confirming
  USER_FLASH_START == MBR_SIZE — the reason a wrong-variant erase can reach the
  SoftDevice at all.
- ghostfat.c falls CFG_UF2_BOARD_APP_ID (VID<<16|PID, why CURRENT.UF2 is tagged
  0x28861667 and thus board-locked) and CFG_UF2_FAMILY_APP_ID (0xADA52840)
  through to the same write path, so the Adafruit-family erase images are
  accepted by Seeed's bootloader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…payloads

Hardware verification on two real devices, 2026-07-30.

RAK4631 (hwModel 9), already running OTAFIX 2.2-BP1.3:
    Board-ID: WisBlock-RAK4631-Board
    SoftDevice: S140 6.1.1

Seeed Wio Tracker L1 (hwModel 99), stock Seeed bootloader 0.9.2:
    Board-ID: TRACKER L1
    SoftDevice: S140 7.3.0

Both Board-IDs match the shipped map keys exactly, and both SoftDevice
readings match their map rows — so the two sides of the variant split are now
anchored to captured payloads rather than to my derivation from board
ldscripts. Both payloads are test fixtures.

The app-start constants are verified from the flash dump rather than assumed:
on the 6.1.1 RAK, CURRENT.UF2 at 0x26000 holds a valid ARM vector table
(sp=0x20040000, exactly the top of nRF52840 RAM; reset odd, Thumb bit set),
while 0x27000 holds mid-application bytes. On the 7.3.0 device the app starts
at 0x27000 instead, which is what makes 0x26000 the SoftDevice's last page and
that mismatch direction destructive. The benign direction is confirmed benign
for the same reason: a 7.3.0 image on a 6.1.1 device lands inside the app.

Also confirmed empirically: OTAFIX's uf2_init does emit the SoftDevice line, so
an upgraded device keeps reporting its variant; and an OTAFIX-flashed device
still resolves its own image, making a repeat upgrade idempotent rather than an
error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lity

Third device, a stock RAK4631 on a 0.4.3 bootloader (May 2023), closes the last
open verification from the drive-authority design:

    UF2 Bootloader 0.4.3
    Board-ID: WisBlock-RAK4631-Board
    Ver: 0.4.3
    SoftDevice: S140 6.1.1

- The SoftDevice line goes back at least to 0.4.3, so the bundled-map fallback
  is belt-and-braces rather than the common path for older hardware.
- This vintage emits an extra "Ver:" line that 0.9.x dropped. The parser scans
  lines by prefix so it already tolerates it; a fixture now pins that.
- Board-ID is identical on stock 0.4.3 and on OTAFIX 2.2, so the OTAFIX veto
  resolves correctly on a device that has never been upgraded — which is the
  case that decides whether the upgrade can be offered at all. That was the
  assumption flagged as unverified when the veto was introduced.
- 0x26000 is confirmed as the 6.1.1 app start on a second, different-vintage
  device: same sp=0x20040000 signature at that address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading INFO_UF2.TXT off a mounted UF2 volume needs sibling access, which a
single-document URI from ACTION_CREATE_DOCUMENT does not grant — hence tree
operations. readSiblingText fetches the file that both identifies the board and
reports the installed SoftDevice, and whose mere presence is positive proof the
picked volume is an Adafruit-family bootloader drive rather than Downloads.
createDocumentInTree is the counterpart: once the volume is vetted, the app
names the file instead of asking the user to.

isRemovableDestination now accepts a tree URI as well as a document URI, since
the maintenance flow vets the volume rather than a filename.

The desktop actuals are implemented against directories rather than stubbed —
unreachable today because isRemovableDestination refuses first, but obvious to
whoever builds a desktop flow later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ncher

A tree URI grants access to the picked directory's contents; a single-document
URI from ACTION_CREATE_DOCUMENT does not. The firmware maintenance flow has to
inspect a volume before writing to it — reading a UF2 bootloader's INFO_UF2.TXT
to confirm which board it is and that the volume is a bootloader drive at all.

Deliberately a sibling of rememberSaveFileLauncher rather than a change to it:
the plain firmware update path still wants the file-naming dialog, and its
behaviour is covered by shipped tests.

Desktop uses JFileChooser since AWT FileDialog cannot portably select
directories. iOS is a no-op stub like its neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reports

The two decisions that gate every destructive write, as pure functions with
hardware-shaped fixtures.

inspectMaintenanceVolume runs two checks, cheapest first: the destination must
be removable, and it must expose a readable INFO_UF2.TXT carrying a Board-ID.
The second is the load-bearing one — positive proof of an Adafruit-family
bootloader drive, where removability only makes "somewhere on internal storage"
less likely. This is what stops the common mis-tap, saving into Downloads, from
being indistinguishable from a successful flash. It also covers the CDC-only
bootloader mode, where no mass-storage volume exists at all, and it works
unchanged for RP2040 BOOTSEL volumes, which publish an INFO_UF2.TXT with no
SoftDevice line.

chooseMaintenanceImage prefers what the mounted volume reports over what the
bundled map predicted, and is total over both requests with no default image on
any path: agreement resolves, disagreement refuses as SoftDeviceConflict, a
volume that reports nothing falls back to the map, and neither knowing refuses.
Bootloader upgrades resolve purely from the reported Board-ID, so a device that
identifies as a T-Echo gets the T-Echo bootloader even if the catalog target
said otherwise.

Both run before anything is written, so every refusal costs a message rather
than a half-flashed device.

NoopFirmwareFileHandler defaults to inert rather than plausible — a test that
forgets to override what it depends on fails instead of passing against a
permissive default, which here would mean "yes, that is a bootloader volume".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires erase and bootloader upgrade end to end, less the Ready-state UI.

Sequencing. performUsbMaintenance downloads and verifies the release firmware,
reboots to DFU, and publishes the first pass. The ordering is deliberate and
differs from the original plan: the firmware is fetched before rebooting because
it is what restores the device after a destructive write, but the maintenance
image cannot be — it is chosen from the Board-ID and SoftDevice the mounted
volume reports, and no volume exists until the device has rebooted. It is
fetched after the volume is vetted and still before any write, so the property
that matters holds: never write a destructive image without already holding the
firmware to put back.

AwaitingFileSave now carries the step and an optional retry message, and its
artifact is nullable because a maintenance pass genuinely has no image yet. Each
pass re-picks the volume, since the device re-enumerates between passes and the
previous grant no longer refers to the mounted drive. The instruction dialog is
keyed on the step, so pass two shows its own instructions rather than silently
rendering a bare button.

No abort edge after the first destructive write: once the device has no
application, a failure re-offers the same pass with an explanation instead of
dropping the user on an error screen. Before that point failures surface
normally.

UsbRepository.pokeDtr opens a port, asserts DTR and closes, with no reader
thread and no listener — the erase image blocks in while(!Serial) before
formatting and there is no Meshtastic protocol on the other end. The port is
identified by diffing against a snapshot taken before the write, because
bootloader-mode VID/PIDs collide across boards and serial numbers need a
permission grant we may not hold. Permission is requested in-flow, since
device_filter.xml lists no bootloader ids at all.

FirmwareRetriever becomes open so tests can double it, matching
FirmwareRecoveryDataSource's existing precedent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Formatting from spotlessApply, plus fixes for every detekt finding the new code
introduced. Two were real:

- UsbPassWriter.write exceeded both the length and complexity limits, so image
  resolution is extracted into resolveImage returning Ready/Failed. That reads
  better than a suppression would: the write path now states its steps without
  the download branch inlined mid-function.
- BYTE_MASK had to be 0xFFL, not 0xFF — `Long and Int` does not typecheck, which
  assembleDebug caught after the JVM target had already compiled.

The rest are guard-clause ReturnCount suppressions on functions where an early
return per failed precondition is the clear form, and named constants for the
UF2 header decode and the hex radix in diagnostics.

Two commonTest names lost their commas: commonTest also compiles for iOS, and
Kotlin/Native rejects commas in backticked identifiers. Only `test allTests`
surfaces that — the JVM target accepts them.

The MultipleEmitters baseline entry for AwaitingFileSaveState is relabelled
private -> internal. Same pre-existing finding; the id changed only because the
composable is now internal so the upcoming preview can reach it.

Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests —
1512 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eady state

Makes the maintenance flow reachable. The gate was already computed and the
actions already wired; nothing rendered them.

Both actions are low-emphasis error-tinted text buttons behind confirmations,
the same treatment BootloaderWarningCard uses, so neither competes with the
primary update button. The erase confirmation says outright that channels, keys
and settings are destroyed with no backup, and that the drive must be selected
twice — once for the erase image, once for the firmware — because that is
surprising if you meet it halfway through.

A refused erase stays visible but disabled with its reason shown, since the
reason is the useful part: it tells the user this app cannot confirm their
device's SoftDevice and points them at the web flasher. An unmapped bootloader
image is different and is simply absent — a coverage gap is not something a
user can act on.

AwaitingFileSave now keeps the screen on when the pass is destructive or is
being retried. The pass queue lives in the ViewModel, so letting the screen
sleep and the ViewModel clear mid-sequence would strand a device that already
has no application.

Previews cover the card available and refused, plus the erase pass with a retry
message. They are internal rather than public so PreviewPublic passes without a
new baseline entry.

Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shipped RAK4631 hint and the user docs both asserted that copying a `.uf2`
will not update the bootloader. That is true of a vendor bootloader supplied as
a SoftDevice+bootloader `.zip`, which really does need serial DFU — but false of
a bootloader supplied as an `update-….uf2`, which the UF2 bootloader consumes
itself. Verified: those images carry UF2 family id 0xD663823C
(CFG_UF2_FAMILY_BOOT_ID) and ghostfat.c routes that family to its bootloader
self-update branch.

The claim is now scoped to the `.zip`, and both places point at the in-app
bootloader upgrade over USB as the alternative. The docs also gain a section for
factory erase and bootloader upgrade: what erasing destroys, that the drive is
selected twice, and that the app reads INFO_UF2.TXT to confirm the volume and
identify the board before writing — refusing, and pointing at the web flasher,
when it cannot establish which SoftDevice is installed.

Only the base strings.xml is touched; translations follow via Crowdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nce gate

The map is the one piece of this feature that fails silently. An unmapped or
mistyped row does not break anything at runtime — it just makes factory erase
quietly unavailable for that device — so nothing except CI would ever notice.

SoftDeviceQuirkCoverageTest cross-checks the asset against the bundled hardware
catalog: every nRF52840 model has a variant except an explicit known-unmapped
allow-list (THINKNODE_M8, which has no firmware variant upstream to derive one
from), every value maps to an image the app actually ships, every listed target
exists in the catalog, and every catalog target is covered by its model's row —
resolution is target-strict, so a stale target name is a silent refusal. It also
pins MESH_TRACKER_X1 to 7.3.0, the entry the web flasher's allowlist omits.

The guard was verified to fail, not just pass: dropping RAK4631's softDevice
makes it fail with the missing hwModel named, and it passes again once restored.

DeviceHardwareRepositoryImplTest covers all six resolution outcomes — resolved,
absent asset, malformed asset, unmapped model, reported target not in the row,
and an unrecognised value. The target-mismatch case is the important one: that
is exactly where borrowing a sibling row's variant would write an erase image
into a SoftDevice.

ViewModel tests cover the gate per transport and architecture, and that starting
a refused erase performs no download. Since performUsbMaintenance downloads
before rebooting to DFU, no download also proves no reboot — FakeRadioController
records nothing for rebootToDfu, and adding a counter to a shared double for one
assertion was not worth it.

Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tenance

Closes the last of the four P0 mitigations. A maintenance sequence leaves the
device enumerating as bare erase or bootloader firmware with no Meshtastic
protocol on it, and two paths would bind a transport to it anyway: the
environmental-recovery listeners re-enter startTransportLocked whenever
Bluetooth or the network flips, and SerialRadioTransport.connect() falls back to
`deviceMap.values.firstOrNull()` rather than requiring the saved address.

FirmwareMaintenanceLock lives in :core:common because the two parties cannot see
each other — the flow that takes it is in :feature:firmware, the code that must
respect it is in :core:service. Checked alongside connectionRequested at both
environmental-recovery sites and inside observeUsbRecoveryTriggers.

The ViewModel takes it for the whole sequence and releases on every exit:
completion (before verify, so the normal reconnect can run), preparation failure
that produced no passes, and giving up before anything destructive happened. It
is deliberately held across a retry, since the sequence is still live.

Not destructive if it were missing — a transport that claims the port asserts
DTR itself, which happens to unblock the erase — but it made the flow's own
success signal unreliable and put a mesh handshake against erase firmware in the
logs.

Tested at the ViewModel level: a refused erase never takes the lock, and a
failed preparation releases it. A service-level integration test was attempted
and abandoned: driving environmental recovery through that harness hangs, and
the file already carries a comment warning about a mutex deadlock in exactly
that area. Recorded as deferred rather than left hanging in the suite.

Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… pass completes

Found in review: FirmwareMaintenanceLock was acquired for every erase/upgrade
sequence but only ever released from advancePastPass, which is reachable
solely through writeMaintenancePass — the path for a FromVolume pass whose
image is chosen from the mounted drive. The sequence's terminal pass (the
release firmware, a Prepared pass with a known filename) is saved through the
pre-existing saveDfuFile instead, which had no idea the lock existed.

The result: every SUCCESSFUL factory erase or bootloader upgrade leaked the
lock for the rest of the process. verifyUpdateResult deliberately does not
force-reconnect over USB/serial — it relies entirely on
SharedRadioInterfaceService.observeUsbRecoveryTriggers noticing the
re-enumerated device, and that is exactly the recovery path the lock
suppresses. So the app would sit at Verifying until timeout and report
VerificationFailed on every successful run, and — because the lock is a Koin
singleton — silently block BLE/network-triggered reconnection for any other
device for the rest of the session, not just the one just flashed.

saveDfuFile's finally now releases the lock and clears the sequence's
bookkeeping (pendingUsbPasses, maintenanceHardware) unconditionally; it is a
no-op for a plain single-pass update, which never acquires the lock in the
first place. onCleared() also releases it, closing the secondary leak where a
user abandons the flow between passes.

Added a regression test that drives a full two-pass FactoryErase sequence
through writeMaintenancePass then saveDfuFile and asserts the lock is released
afterward — confirmed to fail at the exact assertion with the fix reverted,
and pass with it restored, before landing this commit.

Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… offered

startUsbMaintenance already refused a FactoryErase request when eraseRefusal
was set, but had no equivalent check for BootloaderUpgrade against
showBootloaderUpgrade. A stray call on a device the UI would never show the
button for (ESP32, or any nRF board OTAFIX ships no bootloader for) would
download firmware and reboot the device into DFU mode before the write-time
chooseMaintenanceImage check finally refused it — a pointless reboot cycle,
found during the sequential in-session correctness review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- parseUf2BoardId now trims leading whitespace before matching Board-ID:,
  matching parseUf2SoftDevice's existing behavior for SoftDevice: (F-6).
- Document the USB maintenance (factory erase / OTAFIX bootloader upgrade)
  capability in feature/firmware/README.md, which had no mention of it
  despite ~10 new files (F-7).
- Deduplicate the UsbMaintenanceRefusal -> copy mapping that existed twice
  (once in the ViewModel, once in the Composable card) into a single
  usbMaintenanceRefusalMessage() in UsbMaintenance.kt (F-8).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the enhancement New feature or request label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0995e114-c9ad-4475-a2b3-3e31473edab8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

jamesarich and others added 2 commits July 30, 2026 15:55
Compose Multiplatform's string-resource loader only strips \n/\t/\uXXXX/\\ --
unlike Android's AAPT, it does not strip \" or \', so a backslash-escaped
apostrophe rendered literally in the UI. Caught by the "Check Store Metadata"
CI job's check-string-escapes.py guard (added after PR #6357's regression).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mware-erase-9b6cb3

# Conflicts:
#	core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
#	core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant