diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt index a63d96229..774be8eef 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt @@ -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) } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt index 30a2928a8..84ff5f9ce 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt @@ -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" diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt index f330d3633..ddae634e4 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt @@ -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), ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 98681d861..3e43325c5 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -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 diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index 241cce4de..422ec0dfb 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -292,6 +292,68 @@ class FirmwareUpdateViewModelFileTest { assertIs(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(ready) + assertIs(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(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((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(viewModel.state.value) + } + @Test fun `startUpdateFromFile cleans up on manager error state`() = runTest { every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")