mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-30 08:25:43 -04:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user