fix(firmware): repair nRF USB firmware update and post-update reconnect (#6018)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-29 15:24:22 -05:00
committed by GitHub
parent 2030791c6c
commit ce1a9fca2b
5 changed files with 118 additions and 7 deletions

View File

@@ -30,9 +30,11 @@ import org.koin.core.annotation.Single
@Single
class ProbeTableProvider {
fun get(): ProbeTable = UsbSerialProber.getDefaultProbeTable().apply {
// RAK 4631:
// RAK 4631 (0x239A / 0x8029):
addProduct(9114, 32809, CdcAcmSerialDriver::class.java)
// LilyGo TBeam v1.1:
addProduct(6790, 21972, CdcAcmSerialDriver::class.java)
// Elecrow ThinkNode M3 / M4 / M6 and LilyGo T-Echo (0x239A / 0x4405) — native nRF52840 USB CDC:
addProduct(9114, 17413, CdcAcmSerialDriver::class.java)
}
}

View File

@@ -54,7 +54,17 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
// Key by a stable identity, NOT the raw deviceList key (the /dev/bus/usb/BBB/DDD path). Android
// reassigns that path on every re-enumeration — each reboot or replug bumps it (…/002 → …/008) — so a
// path-keyed map can't recognise the same physical node across a firmware-update reboot, and the
// auto-reconnect in SharedRadioInterfaceService (which matches the saved address against these keys)
// never re-arms. usbSerialStableKey() survives re-enumeration.
buildMap {
serialDevices.values.forEach { device ->
val driver = serialProber.probeDevice(device) ?: return@forEach
put(driver.device.usbSerialStableKey(), driver)
}
}
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@@ -90,3 +100,18 @@ class UsbRepository(
_serialDevices.emit(devices)
}
}
/**
* Stable identity for a USB serial device that survives re-enumeration.
*
* Uses vendor + product id, which is permission-free and identical before and after a reboot/replug (and before vs
* after the firmware-update DFU bounce, since the app firmware re-enumerates with the same VID/PID). Deliberately does
* NOT use [UsbDevice.getSerialNumber] — that requires an active USB permission grant, which is exactly what is missing
* immediately after a re-enumeration, so a serial-based key would read back null mid-recovery and break the very
* reconnect this is meant to enable.
*
* ponytail: vendor:product collides if two *identical* boards are attached at once (last one wins in the map). Append
* getSerialNumber() — gated on usbManager.hasPermission(device) — only if multi-identical-device support is ever
* needed.
*/
internal fun UsbDevice.usbSerialStableKey(): String = "$vendorId-$productId"

View File

@@ -76,14 +76,16 @@ class AndroidGetDiscoveredDevicesUseCase(
val usbDevicesFlow =
usbRepository.serialDevices.map { usb ->
usb.map { (_, d) ->
usb.map { (stableKey, d) ->
DeviceListEntry.Usb(
usbData = AndroidUsbDeviceData(d),
name = d.device.deviceName,
// Address must be the stable map key, not the device path: the path (deviceName) is reassigned
// on every re-enumeration, so a path-based saved address can't survive a reboot/replug.
fullAddress =
radioInterfaceService.toInterfaceAddress(
org.meshtastic.core.model.InterfaceId.SERIAL,
d.device.deviceName,
stableKey,
),
bonded = usbManagerLazy.value.hasPermission(d.device),
)

View File

@@ -46,6 +46,7 @@ import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.NodeRepository
@@ -248,6 +249,11 @@ class FirmwareUpdateViewModel(
is FirmwareUpdateState.Success ->
verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer)
// USB/UF2 path intentionally pauses here: the UI launches the file picker and
// saveDfuFile() resumes the flow. Leave the state intact (tempFirmwareFile holds
// the artifact for cleanup after the copy completes).
is FirmwareUpdateState.AwaitingFileSave -> Unit
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
@@ -337,6 +343,9 @@ class FirmwareUpdateViewModel(
is FirmwareUpdateState.Success ->
verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer)
// USB/UF2 path pauses here for the user to pick a save location; saveDfuFile() resumes it.
is FirmwareUpdateState.AwaitingFileSave -> Unit
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
@@ -367,10 +376,21 @@ class FirmwareUpdateViewModel(
private suspend fun verifyUpdateResult(address: String?, wasLowSpeedTransfer: Boolean = false) {
_state.value = FirmwareUpdateState.Verifying
// Trigger a fresh connection attempt by MeshService using the original prefixed address
// Trigger a fresh connection attempt by MeshService using the original prefixed address.
//
// For USB/serial, do NOT force this: a USB node re-enumerates several times through the bootloader over
// ~20s, so a one-shot setDeviceAddress fires into an enumeration gap, fails ("Serial device not found"),
// and lands the transport in Disconnected — which preempts the dedicated USB auto-recovery in
// SharedRadioInterfaceService (observeUsbRecoveryTriggers), since that only arms from DeviceSleep. Leaving
// the transport in DeviceSleep lets that recovery reconnect the moment the device re-attaches on its new
// (stable-keyed) address. BLE/TCP have no such hot-plug recovery, so they still need the explicit re-address.
address?.let { fullAddr ->
Logger.i { "Post-update: Requesting MeshService to reconnect to $fullAddr" }
radioController.setDeviceAddress(fullAddr)
if (radioPrefs.isSerial()) {
Logger.i { "Post-update: leaving USB reconnect to USB auto-recovery for ${fullAddr.anonymize}" }
} else {
Logger.i { "Post-update: Requesting MeshService to reconnect to ${fullAddr.anonymize}" }
radioController.setDeviceAddress(fullAddr)
}
}
// Wait for device to reconnect and settle

View File

@@ -292,6 +292,68 @@ class FirmwareUpdateViewModelFileTest {
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
}
@Test
fun `startUpdate keeps AwaitingFileSave state for USB path`() = runTest {
// Regression: the UF2/USB flow ends at AwaitingFileSave — a deliberate pause for the file picker, not a
// missing terminal state. startUpdate() must leave it intact; previously it fell into the else branch and
// clobbered it to Error, so tapping "Update via USB File Transfer" always failed.
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
viewModel = createViewModel()
advanceUntilIdle()
val ready = viewModel.state.value
assertIs<FirmwareUpdateState.Ready>(ready)
assertIs<FirmwareUpdateMethod.Usb>(ready.updateMethod)
val artifact =
FirmwareArtifact(
uri = CommonUri.parse("file:///tmp/firmware.uf2"),
fileName = "firmware.uf2",
isTemporary = true,
)
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) }
.calls {
@Suppress("UNCHECKED_CAST")
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
updateState(FirmwareUpdateState.AwaitingFileSave(artifact, "firmware.uf2"))
artifact
}
viewModel.startUpdate()
advanceUntilIdle()
assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
}
@Test
fun `startUpdateFromFile keeps AwaitingFileSave state for USB path`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
viewModel = createViewModel()
advanceUntilIdle()
assertIs<FirmwareUpdateMethod.Usb>((viewModel.state.value as FirmwareUpdateState.Ready).updateMethod)
val artifact =
FirmwareArtifact(
uri = CommonUri.parse("file:///tmp/extracted.uf2"),
fileName = "extracted.uf2",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns artifact
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
.calls {
@Suppress("UNCHECKED_CAST")
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
updateState(FirmwareUpdateState.AwaitingFileSave(artifact, "extracted.uf2"))
artifact
}
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
advanceUntilIdle()
assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
}
@Test
fun `startUpdateFromFile cleans up on manager error state`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")