From 6708cc5148f7502646abbe24e28f8cf2ee36a46d Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 24 Sep 2026 09:03:19 +0000
Subject: [PATCH] refactor(model): parse the selected device address once
(#7328)
---
.../kotlin/org/meshtastic/app/MainActivity.kt | 5 +-
.../main/kotlin/org/meshtastic/app/ui/Main.kt | 6 +-
.../meshtastic/core/model/DeviceAddress.kt | 56 ++++++++++++++
.../core/model/DeviceAddressTest.kt | 75 +++++++++++++++++++
.../core/repository/AppPreferences.kt | 14 ++--
.../core/repository/AppPreferencesTest.kt | 14 ++++
.../core/service/BootReconnectDecision.kt | 7 +-
.../core/service/MeshServiceOrchestrator.kt | 3 +-
.../service/SharedRadioInterfaceService.kt | 9 ++-
.../core/service/BootReconnectDecisionTest.kt | 8 ++
...SharedRadioInterfaceServiceLivenessTest.kt | 22 ++++++
.../core/ui/viewmodel/ConnectionsViewModel.kt | 21 ++++--
.../ui/viewmodel/ConnectionsViewModelTest.kt | 26 +++++++
.../kotlin/org/meshtastic/desktop/Main.kt | 3 +-
.../firmware/FirmwareUpdateViewModel.kt | 6 +-
15 files changed, 247 insertions(+), 28 deletions(-)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt
create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index d9b393676d..4f599e76bb 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -60,6 +60,7 @@ import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcEmulatorEffect
@@ -101,7 +102,6 @@ import org.meshtastic.core.ui.util.brandHighlightOrNull
import org.meshtastic.core.ui.util.brandPalette
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
-import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
@@ -408,8 +408,7 @@ class MainActivity : AppCompatActivity() {
}
private fun showConnectionsPageIfNoDeviceSelected() {
- val selectedAddress = model.currentDeviceAddressFlow.value
- if (!selectedAddress.isNullOrBlank() && selectedAddress != NO_DEVICE_SELECTED) return
+ if (DeviceAddress.parse(model.currentDeviceAddressFlow.value) != null) return
handleMeshtasticUri("$DEEP_LINK_BASE_URI/connections".toUri())
}
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index a8a4f2aea7..d2f1b14383 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -34,6 +34,7 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.navigation.TopLevelDestination
@@ -122,10 +123,7 @@ fun MainScreen() {
}
private fun initialRoute(deviceAddress: String?): NavKey =
- if (deviceAddress.isNullOrSelectedNone()) TopLevelDestination.Connect.route else NodesRoute.Nodes
-
-/** True when no device address is persisted, or the address is the "none" sentinel (`"n"`). */
-private fun String?.isNullOrSelectedNone(): Boolean = isNullOrBlank() || this == "n"
+ if (DeviceAddress.parse(deviceAddress) == null) TopLevelDestination.Connect.route else NodesRoute.Nodes
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt
new file mode 100644
index 0000000000..be7e19c466
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+import org.meshtastic.core.common.util.isValidDeviceAddress
+
+/**
+ * A selected-radio address, parsed from its stored form: one [InterfaceId] character followed by the transport's own
+ * identity for the device.
+ *
+ * [raw] is the persisted string and stays the key for the per-device database and every per-device preference, so it is
+ * never rewritten. The legacy `!` BLE prefix therefore keeps its `!` in [raw] and only reads as
+ * [InterfaceId.BLUETOOTH].
+ */
+class DeviceAddress private constructor(val raw: String, val interfaceId: InterfaceId) {
+
+ /** The address without its transport prefix: a BLE MAC, a `host:port`, a USB serial key. */
+ val identity: String
+ get() = raw.substring(1)
+
+ override fun equals(other: Any?): Boolean = other is DeviceAddress && other.raw == raw
+
+ override fun hashCode(): Int = raw.hashCode()
+
+ override fun toString(): String = "DeviceAddress($interfaceId)"
+
+ companion object {
+ private const val LEGACY_BLUETOOTH_PREFIX = '!'
+
+ /**
+ * Parses [raw], or returns `null` for no selection: `null`, blank, any no-device sentinel, or an unknown
+ * prefix.
+ */
+ fun parse(raw: String?): DeviceAddress? {
+ if (raw == null || !isValidDeviceAddress(raw)) return null
+ val prefix = raw.first()
+ val interfaceId =
+ if (prefix == LEGACY_BLUETOOTH_PREFIX) InterfaceId.BLUETOOTH else InterfaceId.forIdChar(prefix)
+ return interfaceId?.takeUnless { it == InterfaceId.NOP }?.let { DeviceAddress(raw, it) }
+ }
+ }
+}
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt
new file mode 100644
index 0000000000..37e08487fe
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+
+class DeviceAddressTest {
+
+ @Test
+ fun `every no-device spelling parses to no selection`() {
+ listOf(null, "", " ", "n", "N", "null", ".n", "default", "DEFAULT").forEach { raw ->
+ assertNull(DeviceAddress.parse(raw), "expected no selection for '$raw'")
+ }
+ }
+
+ @Test
+ fun `an unknown prefix parses to no selection`() {
+ assertNull(DeviceAddress.parse("z12:34"))
+ }
+
+ @Test
+ fun `each prefix names its transport and keeps the rest as identity`() {
+ val cases =
+ mapOf(
+ "x11:22:33:44:55:66" to InterfaceId.BLUETOOTH,
+ "s1027:29987:0" to InterfaceId.SERIAL,
+ "t192.168.1.20:4403" to InterfaceId.TCP,
+ "m" to InterfaceId.MOCK,
+ "r" to InterfaceId.REPLAY,
+ )
+ cases.forEach { (raw, interfaceId) ->
+ val address = checkNotNull(DeviceAddress.parse(raw))
+ assertEquals(interfaceId, address.interfaceId)
+ assertEquals(raw, address.raw)
+ assertEquals(raw.drop(1), address.identity)
+ }
+ }
+
+ @Test
+ fun `the legacy bang prefix reads as BLE and keeps its stored form`() {
+ val address = checkNotNull(DeviceAddress.parse("!11:22:33:44:55:66"))
+
+ assertEquals(InterfaceId.BLUETOOTH, address.interfaceId)
+ assertEquals("!11:22:33:44:55:66", address.raw)
+ assertEquals("11:22:33:44:55:66", address.identity)
+ }
+
+ @Test
+ fun `addresses are equal only when their stored form is`() {
+ assertEquals(DeviceAddress.parse("xAA"), DeviceAddress.parse("xAA"))
+ assertNotEquals(DeviceAddress.parse("xAA"), DeviceAddress.parse("!AA"))
+ }
+
+ @Test
+ fun `toString does not reveal the identity`() {
+ assertEquals("DeviceAddress(BLUETOOTH)", DeviceAddress.parse("x11:22:33:44:55:66").toString())
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index f97acd2a2b..4c315129f5 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -17,7 +17,9 @@
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.model.InterfaceId
/** Reactive interface for analytics-related preferences. */
interface AnalyticsPrefs {
@@ -342,15 +344,17 @@ interface RadioPrefs {
fun setDevName(name: String?)
}
-fun RadioPrefs.isBle() = devAddr.value?.startsWith("x") == true
+/** The saved selection, parsed; `null` when nothing is selected. */
+val RadioPrefs.selectedDevice: DeviceAddress?
+ get() = DeviceAddress.parse(devAddr.value)
-fun RadioPrefs.isSerial() = devAddr.value?.startsWith("s") == true
+fun RadioPrefs.isBle() = selectedDevice?.interfaceId == InterfaceId.BLUETOOTH
-fun RadioPrefs.isMock() = devAddr.value?.startsWith("m") == true
+fun RadioPrefs.isSerial() = selectedDevice?.interfaceId == InterfaceId.SERIAL
-fun RadioPrefs.isTcp() = devAddr.value?.startsWith("t") == true
+fun RadioPrefs.isMock() = selectedDevice?.interfaceId == InterfaceId.MOCK
-fun RadioPrefs.isNoop() = devAddr.value?.startsWith("n") == true
+fun RadioPrefs.isTcp() = selectedDevice?.interfaceId == InterfaceId.TCP
/** Reactive interface for mesh connection settings. */
interface MeshPrefs {
diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt
index d1eb7b2e92..a97fb30a99 100644
--- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt
+++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt
@@ -30,6 +30,20 @@ class AppPreferencesTest {
assertTrue(prefs.isBle())
}
+ @Test
+ fun `RadioPrefs isBle returns true for the legacy bang prefix`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("!12345678")
+ assertTrue(prefs.isBle())
+ }
+
+ @Test
+ fun `RadioPrefs reports no transport for the none sentinel`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("n")
+ assertFalse(prefs.isBle() || prefs.isSerial() || prefs.isTcp() || prefs.isMock())
+ }
+
@Test
fun `RadioPrefs isBle returns false for other prefix`() {
val prefs = FakeRadioPrefs()
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt
index ce9b4716c8..dbc2d95391 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt
@@ -17,9 +17,8 @@
package org.meshtastic.core.service
import org.meshtastic.core.common.util.isValidDeviceAddress
-
-/** Prefix the app uses for a persisted BLE device address. TCP is `t`, USB is `s`. */
-private const val BLE_ADDRESS_PREFIX = 'x'
+import org.meshtastic.core.model.DeviceAddress
+import org.meshtastic.core.model.InterfaceId
/** What a boot / package-replaced broadcast should do about the previously selected device. */
enum class BootReconnectDecision {
@@ -53,7 +52,7 @@ enum class BootReconnectDecision {
fun bootReconnectDecision(address: String?, hasBluetoothPermission: Boolean): BootReconnectDecision = when {
!isValidDeviceAddress(address) -> BootReconnectDecision.NO_DEVICE
- address?.firstOrNull() == BLE_ADDRESS_PREFIX && !hasBluetoothPermission ->
+ DeviceAddress.parse(address)?.interfaceId == InterfaceId.BLUETOOTH && !hasBluetoothPermission ->
BootReconnectDecision.BLE_PERMISSION_MISSING
else -> BootReconnectDecision.START_SERVICE
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
index 5a6d225896..f6c68021e0 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
@@ -34,6 +34,7 @@ import org.meshtastic.core.common.util.isValidDeviceAddress
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.common.util.safeCatchingAll
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
@@ -112,7 +113,7 @@ class MeshServiceOrchestrator(
// later successful manual connect with no recovery layer for the life of the process. The OS enforces the
// permission at the socket, so a genuinely local connect fails exactly as it would have; the difference is
// the user now has an explanation and the fix in hand.
- if (address?.firstOrNull() == InterfaceId.TCP.id && !localNetworkAccess.isGranted()) {
+ if (DeviceAddress.parse(address)?.interfaceId == InterfaceId.TCP && !localNetworkAccess.isGranted()) {
Logger.w { "Local network access not granted; the persisted TCP reconnect may time out" }
serviceStateWriter.setErrorMessage(
// Same shape as ScannerViewModel's scan-failure messages: resource lookup can fail outside a
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
index b2817151df..68c4a61b10 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
@@ -66,6 +66,7 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
@@ -113,7 +114,11 @@ private data class UsbRecoveryTriggerState(
)
private fun selectedSerialPresence(address: String?, keys: Set): SelectedSerialPresence {
- val key = address?.takeIf { it.firstOrNull() == InterfaceId.SERIAL.id }?.drop(1)?.takeIf { it.isNotEmpty() }
+ val key =
+ DeviceAddress.parse(address)
+ ?.takeIf { it.interfaceId == InterfaceId.SERIAL }
+ ?.identity
+ ?.takeIf { it.isNotEmpty() }
return SelectedSerialPresence(key = key, present = key != null && key in keys)
}
@@ -929,7 +934,7 @@ class SharedRadioInterfaceService(
}
throw publicationFailure
}
- runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
+ runningTransportId = DeviceAddress.parse(address)?.interfaceId
isStarted = true
startHeartbeat()
}
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt
index ac3ec89497..f1682cc71e 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt
@@ -53,6 +53,14 @@ class BootReconnectDecisionTest {
)
}
+ @Test
+ fun `a legacy bang BLE device is not reconnected without the Bluetooth permission`() {
+ assertEquals(
+ BootReconnectDecision.BLE_PERMISSION_MISSING,
+ bootReconnectDecision("!AA:BB:CC:DD:EE:FF", hasBluetoothPermission = false),
+ )
+ }
+
@Test
fun `TCP and USB devices reconnect regardless of the Bluetooth permission`() {
// Gating these would break reconnection for users who have deliberately never granted Bluetooth access.
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
index f1177fa62c..25090abaa2 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
@@ -647,6 +647,28 @@ class SharedRadioInterfaceServiceLivenessTest {
}
}
+ @Test
+ fun `BLE liveness timeout restarts a transport saved with the legacy bang prefix`() = runTest(testDispatcher) {
+ clock = 0L
+ val service = createConnectedService("!AA:BB:CC:DD:EE:FF")
+ try {
+ clock = 65_000L
+ service.checkLiveness()
+ testDispatcher.scheduler.runCurrent()
+ advanceTimeBy(1_000L)
+
+ assertEquals(
+ 2,
+ createdTransports.size,
+ "A silent legacy BLE link should be restarted like any BLE link",
+ )
+ assertTrue(createdTransports.first().closeCalled, "Old transport must be closed")
+ } finally {
+ service.disconnect()
+ advanceTimeBy(1_000L)
+ }
+ }
+
@Test
fun `BLE liveness restart contains factory failure and a later connect can retry`() = runTest(testDispatcher) {
clock = 0L
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
index 8ebf7e34a0..4ff7890392 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
@@ -35,10 +35,12 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.FirmwareUpdateNotice
import org.meshtastic.core.model.FirmwareUpdateNoticePolicy
import org.meshtastic.core.model.FirmwareUpdateTransport
+import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.TimeConstants
@@ -245,7 +247,8 @@ class ConnectionsViewModel(
combine(firmwareUpdateInputs, localHardware) { inputs, hardware ->
val state = inputs.connectionState
if (state !is ConnectionState.Connected) return@combine null
- val transport = inputs.address?.firstOrNull()?.toFirmwareUpdateTransport() ?: return@combine null
+ val transport =
+ DeviceAddress.parse(inputs.address)?.interfaceId?.toFirmwareUpdateTransport() ?: return@combine null
val stableRelease = inputs.stableRelease ?: return@combine null
val deviceHardware = hardware ?: return@combine null
FirmwareUpdateCandidate(
@@ -353,9 +356,15 @@ private data class FirmwareUpdateCandidate(
val transport: FirmwareUpdateTransport,
)
-private fun Char.toFirmwareUpdateTransport(): FirmwareUpdateTransport? = when (this) {
- 'x' -> FirmwareUpdateTransport.Bluetooth
- 's' -> FirmwareUpdateTransport.Serial
- 't' -> FirmwareUpdateTransport.Tcp
- else -> null
+private fun InterfaceId.toFirmwareUpdateTransport(): FirmwareUpdateTransport? = when (this) {
+ InterfaceId.BLUETOOTH -> FirmwareUpdateTransport.Bluetooth
+
+ InterfaceId.SERIAL -> FirmwareUpdateTransport.Serial
+
+ InterfaceId.TCP -> FirmwareUpdateTransport.Tcp
+
+ InterfaceId.MOCK,
+ InterfaceId.NOP,
+ InterfaceId.REPLAY,
+ -> null
}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
index dee52c9a89..2f521d3e9c 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
@@ -370,6 +370,32 @@ class ConnectionsViewModelTest {
assertEquals("meshtastic:///firmware/update", dispatchedNotifications.single().deepLinkUri)
}
+ @Test
+ fun `a device saved with the legacy bang prefix gets the firmware update notice`() = runTest {
+ val target = "tbeam"
+ deviceHardwareRepository.setHardware(
+ hwModel = HardwareModel.TBEAM.value,
+ target = target,
+ device = DeviceHardware(architecture = "esp32", platformioTarget = target),
+ )
+ nodeRepository.setMyId("!local")
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target))
+ nodeRepository.setOurNode(
+ org.meshtastic.core.model.Node(
+ num = 1,
+ user = User.Builder().also { wb -> wb.hw_model = HardwareModel.TBEAM }.build(),
+ ),
+ )
+ radioPrefs.setDevAddr("!AA:BB:CC:DD:EE:FF")
+ firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target))
+ firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0"))
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+
+ advanceUntilIdle()
+
+ assertNotNull(viewModel.firmwareUpdateNotice.value)
+ }
+
@Test
fun `does not persist firmware notification dedupe when scheduling is unavailable`() = runTest {
val hardwareModel = HardwareModel.TBEAM.value
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 84681d7ace..c23c87d83e 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -80,6 +80,7 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.log.InMemoryLogBuffer
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.desktopDataDir
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.TopLevelDestination
@@ -396,7 +397,7 @@ private fun ApplicationScope.MeshtasticWindow(
val multiBackstack =
rememberMultiBackstack(
// Land on Connections for first-run / no-device-selected; otherwise on Nodes.
- if (uiViewModel.currentDeviceAddressFlow.value.let { it.isNullOrBlank() || it == "n" }) {
+ if (DeviceAddress.parse(uiViewModel.currentDeviceAddressFlow.value) == null) {
TopLevelDestination.Connect.route
} else {
TopLevelDestination.Nodes.route
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 55925d957b..b26f3eec38 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
@@ -52,6 +52,7 @@ import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MyNodeInfo
@@ -67,6 +68,7 @@ import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
+import org.meshtastic.core.repository.selectedDevice
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.firmware_maintenance_cdc_unblock_failed
@@ -274,7 +276,7 @@ class FirmwareUpdateViewModel(
_state.value = FirmwareUpdateState.Checking
safeCatching {
val ourNode = nodeRepository.myNodeInfo.value
- val address = radioPrefs.devAddr.value?.drop(1)
+ val address = radioPrefs.selectedDevice?.identity
if (address == null || ourNode == null) {
// Not connected: offer to re-flash a device stranded in bootloader mode if we saved a
// recovery record when its (now-interrupted) update was triggered. Otherwise, no device.
@@ -1329,7 +1331,7 @@ private fun isValidBluetoothAddress(address: String?): Boolean =
address != null && BLUETOOTH_ADDRESS_REGEX.matches(address)
private fun isBluetoothInterfaceAddress(address: String): Boolean =
- address.startsWith(InterfaceId.BLUETOOTH.id) || address.startsWith("!")
+ DeviceAddress.parse(address)?.interfaceId == InterfaceId.BLUETOOTH
private fun FirmwareReleaseRepository.getReleaseFlow(type: FirmwareReleaseType): Flow = when (type) {
FirmwareReleaseType.STABLE -> stableRelease