feat(firmware): add nRF52/RP2040 factory erase and OTAFIX bootloader upgrade over USB - #6526
Draft
jamesarich wants to merge 20 commits into
Draft
feat(firmware): add nRF52/RP2040 factory erase and OTAFIX bootloader upgrade over USB#6526jamesarich wants to merge 20 commits into
jamesarich wants to merge 20 commits into
Conversation
…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>
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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
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.
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:
INFO_UF2.TXTSoftDevice:line is treated as authoritative over the bundled hardware-catalog hint — the two must agree or the app refuses outright (never guesses).Board-ID, not by build-target name or USB VID/PID — both of the latter collide across multiple boards.🌟 New Features
device_bootloader_ota_quirks.json) covering all 32 nRF52840 hardware-list entries, derived frommeshtastic/firmware's board ldscripts — with the drive's own report always taking precedence at runtime.INFO_UF2.TXTparsing (Board-ID,SoftDevice) and a pinned, SHA-256-verified table of erase/OTAFIX UF2 assets (MaintenanceUf2.kt).while (!Serial)gate unblocks headlessly.🛠️ Refactoring & Architecture
FirmwareMaintenanceLockin:core:common(a Koin singleton,kotlinx.atomicfu-backed) suppressesSharedRadioInterfaceService'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.DeviceHardwaregained asoftDeviceVariantfield;DeviceHardwareRepositoryImplresolves it from the quirks map.UsbMaintenanceRefusal→ copy mapping (previously duplicated between the ViewModel and the Composable card) into a singleusbMaintenanceRefusalMessage().🐛 Bug Fixes
DfuZipParsernow rejects a DFU package whose manifest has noapplicationentry instead of silently promotingsoftdevice_bootloader/bootloader/softdeviceand flashing it as an application image.docs/en/user/firmware.mdno longer claim that copying a.uf2can never update the bootloader — true for the SD+BL.zip, false for OTAFIX's boot-familyupdate-*_nosd.uf2.FirmwareMaintenanceLockwas never released on a successful maintenance sequence (the terminal pass completes through the pre-existingsaveDfuFile, 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.startUsbMaintenanceguardedFactoryEraseagainst a resolvable-SoftDevice refusal but had no equivalent guard forBootloaderUpgrade; 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
parseUf2BoardIdnow trims leading whitespace before matching, matchingparseUf2SoftDevice's existing tolerance.feature/firmware/README.mddocuments 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.TXTand raw flash off the mounted volume, not through the app):CURRENT.UF2first block at0x00001000confirms the SoftDevice region is writable over UF2 (the premise the whole safety design rests on); Board-ID matches OTAFIX's string for this board.CURRENT.UF2at0x26000holds 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.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'sConflictcase).Testing Performed
./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests— green.UsbMaintenanceGateTest,CommonFirmwareRetrieverTest,CommonMaintenanceVolumeTest,CommonPerformUsbUpdateTest,FirmwareUpdateIntegrationTest,FirmwareUpdateViewModelTest,FirmwareUpdateViewModelFileTest,MaintenanceVolumeTest,DfuZipParserTest,DeviceHardwareRepositoryImplTest,SoftDeviceQuirkCoverageTest,SharedRadioInterfaceServiceLivenessTest.FirmwareMaintenanceLockrelease path across both success and abandon-mid-sequence, and theBootloaderUpgradedefense-in-depth guard.🤖 Generated with Claude Code