diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
deleted file mode 100644
index d8f76f7f0..000000000
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * 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.data.manager
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import org.koin.core.annotation.Named
-import org.koin.core.annotation.Single
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.repository.MeshConfigHandler
-import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.RadioConfigRepository
-import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.proto.Channel
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.DeviceUIConfig
-import org.meshtastic.proto.LocalConfig
-import org.meshtastic.proto.LocalModuleConfig
-import org.meshtastic.proto.ModuleConfig
-
-@Single
-class MeshConfigHandlerImpl(
- private val radioConfigRepository: RadioConfigRepository,
- private val serviceRepository: ServiceRepository,
- private val nodeManager: NodeManager,
- @Named("ServiceScope") private val scope: CoroutineScope,
-) : MeshConfigHandler {
-
- private val _localConfig = MutableStateFlow(LocalConfig())
- override val localConfig = _localConfig.asStateFlow()
-
- private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
- override val moduleConfig = _moduleConfig.asStateFlow()
-
- init {
- radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
- radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
- }
-
- override fun handleDeviceConfig(config: Config) {
- Logger.d { "Device config received: ${config.summarize()}" }
- scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
- serviceRepository.setConnectionProgress("Device config received")
- }
-
- override fun handleModuleConfig(config: ModuleConfig) {
- Logger.d { "Module config received: ${config.summarize()}" }
- scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
- serviceRepository.setConnectionProgress("Module config received")
-
- config.statusmessage?.let { sm ->
- nodeManager.myNodeNum.value?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
- }
- }
-
- override fun handleChannel(channel: Channel) {
- // We always want to save channel settings we receive from the radio
- scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) }
-
- // Update status message if we have node info, otherwise use a generic one
- val mi = nodeManager.getMyNodeInfo()
- val index = channel.index
- if (mi != null) {
- serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
- } else {
- serviceRepository.setConnectionProgress("Channels (${index + 1})")
- }
- }
-
- override fun handleDeviceUIConfig(config: DeviceUIConfig) {
- Logger.d { "DeviceUI config received" }
- scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
- }
-}
-
-/** Returns a short summary of which Config variant is set. */
-private fun Config.summarize(): String = when {
- device != null -> "device"
- position != null -> "position"
- power != null -> "power"
- network != null -> "network"
- display != null -> "display"
- lora != null -> "lora"
- bluetooth != null -> "bluetooth"
- security != null -> "security"
- else -> "unknown"
-}
-
-/** Returns a short summary of which ModuleConfig variant is set. */
-@Suppress("CyclomaticComplexMethod")
-private fun ModuleConfig.summarize(): String = when {
- mqtt != null -> "mqtt"
- serial != null -> "serial"
- external_notification != null -> "external_notification"
- store_forward != null -> "store_forward"
- range_test != null -> "range_test"
- telemetry != null -> "telemetry"
- canned_message != null -> "canned_message"
- audio != null -> "audio"
- remote_hardware != null -> "remote_hardware"
- neighbor_info != null -> "neighbor_info"
- ambient_lighting != null -> "ambient_lighting"
- detection_sensor != null -> "detection_sensor"
- paxcounter != null -> "paxcounter"
- statusmessage != null -> "statusmessage"
- else -> "unknown"
-}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt
new file mode 100644
index 000000000..a3ed6ea57
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.data.radio
+
+import kotlinx.coroutines.flow.StateFlow
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
+
+/**
+ * SDK-backed implementation of [RadioInterfaceService].
+ *
+ * Delegates device-address management to [RadioPrefs] and connection lifecycle to [RadioClientAccessor].
+ * The heavy transport work (BLE, TCP, Serial) is handled by the SDK internally.
+ */
+@Single(binds = [RadioInterfaceService::class])
+class SdkRadioInterfaceService(
+ private val radioPrefs: RadioPrefs,
+ private val accessor: RadioClientAccessor,
+) : RadioInterfaceService {
+
+ override val supportedDeviceTypes: List =
+ listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
+
+ override val currentDeviceAddressFlow: StateFlow = radioPrefs.devAddr
+
+ override fun isMockTransport(): Boolean {
+ val addr = radioPrefs.devAddr.value ?: return false
+ return addr.firstOrNull() == InterfaceId.MOCK.id
+ }
+
+ override fun getDeviceAddress(): String? = radioPrefs.devAddr.value
+
+ override fun setDeviceAddress(deviceAddr: String?): Boolean {
+ val current = radioPrefs.devAddr.value
+ if (current == deviceAddr) return false
+ radioPrefs.setDevAddr(deviceAddr)
+ return true
+ }
+
+ override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
+ "${interfaceId.id}$rest"
+
+ override fun connect() {
+ accessor.rebuildAndConnectAsync()
+ }
+
+ override suspend fun disconnect() {
+ accessor.disconnect()
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
deleted file mode 100644
index bf3247815..000000000
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
+++ /dev/null
@@ -1,231 +0,0 @@
-/*
- * 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.data.manager
-
-import dev.mokkery.MockMode
-import dev.mokkery.answering.returns
-import dev.mokkery.every
-import dev.mokkery.matcher.any
-import dev.mokkery.mock
-import dev.mokkery.verify
-import dev.mokkery.verifySuspend
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.model.MyNodeInfo
-import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.RadioConfigRepository
-import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.proto.Channel
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.DeviceUIConfig
-import org.meshtastic.proto.LocalConfig
-import org.meshtastic.proto.LocalModuleConfig
-import org.meshtastic.proto.ModuleConfig
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class MeshConfigHandlerImplTest {
-
- private val radioConfigRepository = mock(MockMode.autofill)
- private val serviceRepository = mock(MockMode.autofill)
- private val nodeManager = mock(MockMode.autofill)
-
- private val localConfigFlow = MutableStateFlow(LocalConfig())
- private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
-
- private val testDispatcher = UnconfinedTestDispatcher()
-
- private lateinit var handler: MeshConfigHandlerImpl
-
- @BeforeTest
- fun setUp() {
- every { radioConfigRepository.localConfigFlow } returns localConfigFlow
- every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
- }
-
- private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl(
- radioConfigRepository = radioConfigRepository,
- serviceRepository = serviceRepository,
- nodeManager = nodeManager,
- scope = scope,
- )
-
- // ---------- start and flow wiring ----------
-
- @Test
- fun `start wires localConfig flow from repository`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
- localConfigFlow.value = config
- advanceUntilIdle()
-
- assertEquals(config, handler.localConfig.value)
- }
-
- @Test
- fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
- moduleConfigFlow.value = config
- advanceUntilIdle()
-
- assertEquals(config, handler.moduleConfig.value)
- }
-
- // ---------- handleDeviceConfig ----------
-
- @Test
- fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
- handler.handleDeviceConfig(config)
- advanceUntilIdle()
-
- verifySuspend { radioConfigRepository.setLocalConfig(config) }
- verify { serviceRepository.setConnectionProgress("Device config received") }
- }
-
- @Test
- fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val configs =
- listOf(
- Config(position = Config.PositionConfig()),
- Config(power = Config.PowerConfig()),
- Config(network = Config.NetworkConfig()),
- Config(display = Config.DisplayConfig()),
- Config(lora = Config.LoRaConfig()),
- Config(bluetooth = Config.BluetoothConfig()),
- Config(security = Config.SecurityConfig()),
- )
-
- for (config in configs) {
- handler.handleDeviceConfig(config)
- advanceUntilIdle()
- }
-
- // All should have been persisted (7 configs)
- verifySuspend { radioConfigRepository.setLocalConfig(any()) }
- }
-
- // ---------- handleModuleConfig ----------
-
- @Test
- fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
- handler.handleModuleConfig(config)
- advanceUntilIdle()
-
- verifySuspend { radioConfigRepository.setLocalModuleConfig(config) }
- verify { serviceRepository.setConnectionProgress("Module config received") }
- }
-
- @Test
- fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val myNum = 123
- every { nodeManager.myNodeNum } returns MutableStateFlow(myNum)
-
- val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
- handler.handleModuleConfig(config)
- advanceUntilIdle()
-
- verify { nodeManager.updateNodeStatus(myNum, "Active") }
- }
-
- @Test
- fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- every { nodeManager.myNodeNum } returns MutableStateFlow(null)
-
- val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
- handler.handleModuleConfig(config)
- advanceUntilIdle()
- // No crash — updateNodeStatus should not be called
- }
-
- // ---------- handleChannel ----------
-
- @Test
- fun `handleChannel persists channel settings`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val channel = Channel(index = 0)
- handler.handleChannel(channel)
- advanceUntilIdle()
-
- verifySuspend { radioConfigRepository.updateChannelSettings(channel) }
- }
-
- @Test
- fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- every { nodeManager.getMyNodeInfo() } returns
- MyNodeInfo(
- myNodeNum = 123,
- hasGPS = false,
- model = null,
- firmwareVersion = null,
- couldUpdate = false,
- shouldUpdate = false,
- currentPacketId = 0L,
- messageTimeoutMsec = 0,
- minAppVersion = 0,
- maxChannels = 8,
- hasWifi = false,
- channelUtilization = 0f,
- airUtilTx = 0f,
- deviceId = null,
- )
-
- val channel = Channel(index = 2)
- handler.handleChannel(channel)
- advanceUntilIdle()
-
- verify { serviceRepository.setConnectionProgress("Channels (3 / 8)") }
- }
-
- @Test
- fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- every { nodeManager.getMyNodeInfo() } returns null
-
- val channel = Channel(index = 0)
- handler.handleChannel(channel)
- advanceUntilIdle()
-
- verify { serviceRepository.setConnectionProgress("Channels (1)") }
- }
-
- // ---------- handleDeviceUIConfig ----------
-
- @Test
- fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
- val config = DeviceUIConfig()
- handler.handleDeviceUIConfig(config)
- advanceUntilIdle()
-
- verifySuspend { radioConfigRepository.setDeviceUIConfig(config) }
- }
-}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt
deleted file mode 100644
index 8e25fc0f9..000000000
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-/*
- * 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.domain.usecase.session
-
-import dev.mokkery.MockMode
-import dev.mokkery.answering.calls
-import dev.mokkery.answering.returns
-import dev.mokkery.every
-import dev.mokkery.everySuspend
-import dev.mokkery.matcher.any
-import dev.mokkery.mock
-import dev.mokkery.verifySuspend
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.advanceTimeBy
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import okio.ByteString
-import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.model.SessionStatus
-import org.meshtastic.core.model.service.ServiceAction
-import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.core.repository.SessionManager
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.time.Clock
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class EnsureRemoteAdminSessionUseCaseTest {
-
- private val destNum = 0xCAFE
-
- private fun stubSessionManager(
- initialStatus: SessionStatus = SessionStatus.NoSession,
- refreshFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 8),
- ): SessionManager {
- val mgr = mock(MockMode.autofill)
- every { mgr.observeSessionStatus(any()) } returns flowOf(initialStatus)
- every { mgr.sessionRefreshFlow } returns refreshFlow
- every { mgr.getPasskey(any()) } returns ByteString.EMPTY
- return mgr
- }
-
- private fun connectedRepo(state: ConnectionState = ConnectionState.Connected): ServiceRepository {
- val repo = mock(MockMode.autofill)
- every { repo.connectionState } returns MutableStateFlow(state)
- return repo
- }
-
- @Test
- fun `returns Disconnected without dispatching when not connected`() = runTest {
- val sessionManager = stubSessionManager()
- val useCase =
- EnsureRemoteAdminSessionUseCase(sessionManager, connectedRepo(ConnectionState.Disconnected), this)
-
- val result = useCase(destNum)
-
- assertEquals(EnsureSessionResult.Disconnected, result)
- }
-
- @Test
- fun `returns AlreadyActive without dispatching when status already Active`() = runTest {
- val active = SessionStatus.Active(Clock.System.now())
- val sessionManager = stubSessionManager(initialStatus = active)
- val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, connectedRepo(), this)
-
- val result = useCase(destNum)
-
- assertEquals(EnsureSessionResult.AlreadyActive, result)
- }
-
- @Test
- fun `dispatches metadata request and returns Refreshed when refresh flow emits`() = runTest {
- val refresh = MutableSharedFlow(extraBufferCapacity = 8)
- val sessionManager = stubSessionManager(refreshFlow = refresh)
- val repo = connectedRepo()
- // Simulate the radio responding by emitting on the refresh flow when the metadata request fires.
- everySuspend { repo.onServiceAction(any()) } calls
- {
- refresh.tryEmit(destNum)
- Unit
- }
-
- val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, repo, this)
-
- val result = useCase(destNum)
-
- assertEquals(EnsureSessionResult.Refreshed, result)
- verifySuspend { repo.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) }
- }
-
- @Test
- fun `returns Timeout when no refresh arrives within deadline`() = runTest {
- val refresh = MutableSharedFlow(extraBufferCapacity = 8)
- val sessionManager = stubSessionManager(refreshFlow = refresh)
- val repo = connectedRepo()
- everySuspend { repo.onServiceAction(any()) } returns Unit
-
- val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, repo, this)
-
- var observed: EnsureSessionResult? = null
- val job = launch { observed = useCase(destNum) }
- advanceTimeBy(EnsureRemoteAdminSessionUseCase.UX_TIMEOUT.inWholeMilliseconds + 100)
- advanceUntilIdle()
- job.join()
-
- assertEquals(EnsureSessionResult.Timeout, observed)
- }
-}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
deleted file mode 100644
index a2bea7756..000000000
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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.domain.usecase.settings
-
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.testing.FakeNodeRepository
-import org.meshtastic.core.testing.FakeRadioController
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-class AdminActionsUseCaseTest {
-
- private lateinit var radioController: FakeRadioController
- private lateinit var nodeRepository: FakeNodeRepository
- private lateinit var useCase: AdminActionsUseCase
-
- @BeforeTest
- fun setUp() {
- radioController = FakeRadioController()
- nodeRepository = FakeNodeRepository()
- useCase = AdminActionsUseCase(radioController, nodeRepository)
- }
-
- @Test
- fun `reboot calls radioController`() = runTest {
- val packetId = useCase.reboot(1234)
- assertEquals(1, packetId)
- }
-
- @Test
- fun `shutdown calls radioController`() = runTest {
- val packetId = useCase.shutdown(1234)
- assertEquals(1, packetId)
- }
-
- @Test
- fun `factoryReset local node clears local NodeDB`() = runTest {
- nodeRepository.upsert(Node(num = 1))
- useCase.factoryReset(1234, isLocal = true)
- assertTrue(nodeRepository.nodeDBbyNum.value.isEmpty())
- }
-
- @Test
- fun `nodedbReset local node clears local NodeDB with preserveFavorites`() = runTest {
- nodeRepository.setNodes(listOf(Node(num = 1, isFavorite = true), Node(num = 2, isFavorite = false)))
- useCase.nodedbReset(1234, preserveFavorites = true, isLocal = true)
- assertEquals(1, nodeRepository.nodeDBbyNum.value.size)
- assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(1))
- }
-}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
deleted file mode 100644
index 47013e461..000000000
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * 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.domain.usecase.settings
-
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.testing.FakeNodeRepository
-import org.meshtastic.core.testing.FakeRadioController
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-import kotlin.time.Duration.Companion.days
-
-class CleanNodeDatabaseUseCaseTest {
-
- private lateinit var nodeRepository: FakeNodeRepository
- private lateinit var radioController: FakeRadioController
- private lateinit var useCase: CleanNodeDatabaseUseCase
-
- @BeforeTest
- fun setUp() {
- nodeRepository = FakeNodeRepository()
- radioController = FakeRadioController()
- useCase = CleanNodeDatabaseUseCase(nodeRepository, radioController)
- }
-
- @Test
- fun `getNodesToClean returns nodes older than threshold`() = runTest {
- val now = 1000000000L
- val olderThan = now - 30.days.inWholeSeconds
- val node1 = Node(num = 1, lastHeard = (olderThan - 100).toInt())
- val node2 = Node(num = 2, lastHeard = (olderThan + 100).toInt())
- nodeRepository.setNodes(listOf(node1, node2))
-
- val result = useCase.getNodesToClean(30f, false, now)
-
- assertEquals(1, result.size)
- assertEquals(1, result[0].num)
- }
-
- @Test
- fun `getNodesToClean filters out favorites and ignored`() = runTest {
- val now = 1000000000L
- val olderThan = now - 30.days.inWholeSeconds
- val node1 = Node(num = 1, lastHeard = (olderThan - 100).toInt(), isFavorite = true)
- val node2 = Node(num = 2, lastHeard = (olderThan - 100).toInt(), isIgnored = true)
- nodeRepository.setNodes(listOf(node1, node2))
-
- val result = useCase.getNodesToClean(30f, false, now)
-
- assertTrue(result.isEmpty())
- }
-
- @Test
- fun `cleanNodes deletes from repo and controller`() = runTest {
- nodeRepository.setNodes(listOf(Node(num = 1), Node(num = 2)))
- useCase.cleanNodes(listOf(1))
-
- assertEquals(1, nodeRepository.nodeDBbyNum.value.size)
- assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(2))
- }
-}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
deleted file mode 100644
index 78e402ce2..000000000
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- * 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.domain.usecase.settings
-
-import app.cash.turbine.test
-import dev.mokkery.MockMode
-import dev.mokkery.answering.returns
-import dev.mokkery.everySuspend
-import dev.mokkery.matcher.any
-import dev.mokkery.mock
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.model.DeviceHardware
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.repository.DeviceHardwareRepository
-import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.RadioPrefs
-import org.meshtastic.proto.HardwareModel
-import org.meshtastic.proto.User
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-class IsOtaCapableUseCaseTest {
-
- private lateinit var nodeRepository: NodeRepository
- private lateinit var radioController: RadioController
- private lateinit var deviceHardwareRepository: DeviceHardwareRepository
- private lateinit var radioPrefs: RadioPrefs
- private lateinit var useCase: IsOtaCapableUseCase
-
- @BeforeTest
- fun setUp() {
- nodeRepository = mock(MockMode.autofill)
- radioController = mock(MockMode.autofill)
- deviceHardwareRepository = mock(MockMode.autofill)
- radioPrefs = mock(MockMode.autofill)
-
- useCase =
- IsOtaCapableUseCaseImpl(
- nodeRepository = nodeRepository,
- radioController = radioController,
- radioPrefs = radioPrefs,
- deviceHardwareRepository = deviceHardwareRepository,
- )
- }
-
- @Test
- fun `invoke returns true when ota capable`() = runTest {
- // Arrange
- val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM))
- dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
- dev.mokkery.every { radioController.connectionState } returns
- MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
- dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE
-
- val hw =
- DeviceHardware(
- activelySupported = true,
- architecture = "esp32",
- hwModel = HardwareModel.TBEAM.value,
- requiresDfu = false,
- )
- everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
-
- useCase().test {
- assertTrue(awaitItem())
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `invoke returns false when ota not capable`() = runTest {
- // Arrange
- val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM))
- dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
- dev.mokkery.every { radioController.connectionState } returns
- MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
- dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE
-
- val hw = DeviceHardware(activelySupported = false, hwModel = HardwareModel.TBEAM.value)
- everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
-
- useCase().test {
- assertFalse(awaitItem())
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `invoke returns true when requires Dfu and actively supported`() = runTest {
- // Arrange
- val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM))
- dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
- dev.mokkery.every { radioController.connectionState } returns
- MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
- dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE
-
- val hw =
- DeviceHardware(
- activelySupported = true,
- architecture = "nrf52840",
- hwModel = HardwareModel.TBEAM.value,
- requiresDfu = true,
- )
- everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
-
- useCase().test {
- assertTrue(awaitItem())
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `invoke returns false when hardware model is UNSET`() = runTest {
- // Arrange
- val node = Node(num = 123, user = User(hw_model = HardwareModel.UNSET))
- dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
- dev.mokkery.every { radioController.connectionState } returns
- MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
- dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE
-
- everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.failure(Exception())
-
- useCase().test {
- assertFalse(awaitItem())
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `invoke returns false when disconnected`() = runTest {
- dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(Node(num = 123))
- dev.mokkery.every { radioController.connectionState } returns
- MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected)
-
- useCase().test {
- assertFalse(awaitItem())
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `invoke returns false when node is null`() = runTest {
- dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
- dev.mokkery.every { radioController.connectionState } returns
- MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
-
- useCase().test {
- assertFalse(awaitItem())
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `invoke returns false when address is not ota capable`() = runTest {
- val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM))
- dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
- dev.mokkery.every { radioController.connectionState } returns
- MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
- dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("mqtt://example.com")
-
- useCase().test {
- assertFalse(awaitItem())
- cancelAndIgnoreRemainingEvents()
- }
- }
-}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
deleted file mode 100644
index 2ad33cad5..000000000
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
+++ /dev/null
@@ -1,193 +0,0 @@
-/*
- * 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.domain.usecase.settings
-
-import org.meshtastic.proto.AdminMessage
-import org.meshtastic.proto.Data
-import org.meshtastic.proto.DeviceMetadata
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.PortNum
-import org.meshtastic.proto.Routing
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-class ProcessRadioResponseUseCaseTest {
-
- private lateinit var useCase: ProcessRadioResponseUseCase
-
- @BeforeTest
- fun setUp() {
- useCase = ProcessRadioResponseUseCase()
- }
-
- @Test
- fun `invoke with routing error returns error result`() {
- // Arrange
- val packet =
- MeshPacket(
- from = 123,
- decoded =
- Data(
- portnum = PortNum.ROUTING_APP,
- request_id = 42,
- payload = Routing(error_reason = Routing.Error.NO_ROUTE).encode().toByteString(),
- ),
- )
-
- // Act
- val result = useCase(packet, 123, setOf(42))
-
- // Assert
- assertTrue(result is RadioResponseResult.Error)
- }
-
- @Test
- fun `invoke with metadata response returns metadata result`() {
- // Arrange
- val metadata = DeviceMetadata(firmware_version = "2.5.0")
- val adminMsg = AdminMessage(get_device_metadata_response = metadata)
- val packet =
- MeshPacket(
- from = 123,
- decoded = Data(
- portnum = PortNum.ADMIN_APP,
- request_id = 42,
- payload = adminMsg.encode().toByteString(),
- ),
- )
-
- // Act
- val result = useCase(packet, 123, setOf(42))
-
- // Assert
- assertTrue(result is RadioResponseResult.Metadata)
- assertEquals("2.5.0", result.metadata.firmware_version)
- }
-
- @Test
- fun `invoke with canned messages response returns canned messages result`() {
- // Arrange
- val adminMsg = AdminMessage(get_canned_message_module_messages_response = "Hello World")
- val packet =
- MeshPacket(
- from = 123,
- decoded = Data(
- portnum = PortNum.ADMIN_APP,
- request_id = 42,
- payload = adminMsg.encode().toByteString(),
- ),
- )
-
- // Act
- val result = useCase(packet, 123, setOf(42))
-
- // Assert
- assertTrue(result is RadioResponseResult.CannedMessages)
- assertEquals("Hello World", result.messages)
- }
-
- @Test
- fun `invoke with unexpected sender returns error`() {
- val adminMsg = AdminMessage()
- val packet =
- MeshPacket(
- from = 456,
- decoded = Data(
- portnum = PortNum.ADMIN_APP,
- request_id = 42,
- payload = adminMsg.encode().toByteString(),
- ),
- )
- val result = useCase(packet, 123, setOf(42))
- assertTrue(result is RadioResponseResult.Error)
- }
-
- @Test
- fun `invoke with owner response returns owner result`() {
- val owner = org.meshtastic.proto.User(long_name = "Owner")
- val adminMsg = AdminMessage(get_owner_response = owner)
- val packet =
- MeshPacket(
- from = 123,
- decoded = Data(
- portnum = PortNum.ADMIN_APP,
- request_id = 42,
- payload = adminMsg.encode().toByteString(),
- ),
- )
- val result = useCase(packet, 123, setOf(42))
- assertTrue(result is RadioResponseResult.Owner)
- assertEquals("Owner", result.user.long_name)
- }
-
- @Test
- fun `invoke with config response returns config result`() {
- val config = org.meshtastic.proto.Config(lora = org.meshtastic.proto.Config.LoRaConfig(use_preset = true))
- val adminMsg = AdminMessage(get_config_response = config)
- val packet =
- MeshPacket(
- from = 123,
- decoded = Data(
- portnum = PortNum.ADMIN_APP,
- request_id = 42,
- payload = adminMsg.encode().toByteString(),
- ),
- )
- val result = useCase(packet, 123, setOf(42))
- assertTrue(result is RadioResponseResult.ConfigResponse)
- }
-
- @Test
- fun `invoke with module config response returns module config result`() {
- val config =
- org.meshtastic.proto.ModuleConfig(mqtt = org.meshtastic.proto.ModuleConfig.MQTTConfig(enabled = true))
- val adminMsg = AdminMessage(get_module_config_response = config)
- val packet =
- MeshPacket(
- from = 123,
- decoded = Data(
- portnum = PortNum.ADMIN_APP,
- request_id = 42,
- payload = adminMsg.encode().toByteString(),
- ),
- )
- val result = useCase(packet, 123, setOf(42))
- assertTrue(result is RadioResponseResult.ModuleConfigResponse)
- }
-
- @Test
- fun `invoke with channel response returns channel result`() {
- val channel = org.meshtastic.proto.Channel(settings = org.meshtastic.proto.ChannelSettings(name = "Main"))
- val adminMsg = AdminMessage(get_channel_response = channel)
- val packet =
- MeshPacket(
- from = 123,
- decoded = Data(
- portnum = PortNum.ADMIN_APP,
- request_id = 42,
- payload = adminMsg.encode().toByteString(),
- ),
- )
- val result = useCase(packet, 123, setOf(42))
- assertTrue(result is RadioResponseResult.ChannelResponse)
- assertEquals("Main", result.channel.settings?.name)
- }
-
- private fun ByteArray.toByteString() = okio.ByteString.of(*this)
-}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
deleted file mode 100644
index 20bf1a13f..000000000
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * 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.domain.usecase.settings
-
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.testing.FakeMeshLogPrefs
-import org.meshtastic.core.testing.FakeMeshLogRepository
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-class SetMeshLogSettingsUseCaseTest {
-
- private lateinit var meshLogRepository: FakeMeshLogRepository
- private lateinit var meshLogPrefs: FakeMeshLogPrefs
- private lateinit var useCase: SetMeshLogSettingsUseCase
-
- @BeforeTest
- fun setUp() {
- meshLogRepository = FakeMeshLogRepository()
- meshLogPrefs = FakeMeshLogPrefs()
- useCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
- }
-
- @Test
- fun `setRetentionDays clamps value and deletes old logs`() = runTest {
- useCase.setRetentionDays(500) // Max is 365
- assertEquals(365, meshLogPrefs.retentionDays.value)
- assertEquals(365, meshLogRepository.lastDeletedOlderThan)
- }
-
- @Test
- fun `setLoggingEnabled false deletes all logs`() = runTest {
- useCase.setLoggingEnabled(false)
- assertEquals(false, meshLogPrefs.loggingEnabled.value)
- assertEquals(true, meshLogRepository.deleteAllCalled)
- }
-
- @Test
- fun `setLoggingEnabled true deletes logs older than retention`() = runTest {
- meshLogPrefs.setRetentionDays(15)
- useCase.setLoggingEnabled(true)
- assertEquals(true, meshLogPrefs.loggingEnabled.value)
- assertEquals(15, meshLogRepository.lastDeletedOlderThan)
- }
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
deleted file mode 100644
index cb529ad73..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * 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.network.radio
-
-import android.content.Context
-import android.hardware.usb.UsbManager
-import android.provider.Settings
-import org.koin.core.annotation.Single
-import org.meshtastic.core.ble.BleConnectionFactory
-import org.meshtastic.core.ble.BleScanner
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.model.DeviceType
-import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.network.repository.UsbRepository
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportFactory
-
-/**
- * Android implementation of [RadioTransportFactory]. Handles pure-KMP transports (BLE) via [BaseRadioTransportFactory]
- * while creating platform-specific connections (TCP, USB/Serial, Mock, NOP) directly in [createPlatformTransport].
- */
-@Single(binds = [RadioTransportFactory::class])
-@Suppress("LongParameterList")
-class AndroidRadioTransportFactory(
- private val context: Context,
- private val buildConfigProvider: BuildConfigProvider,
- private val usbRepository: UsbRepository,
- private val usbManager: UsbManager,
- scanner: BleScanner,
- bluetoothRepository: BluetoothRepository,
- connectionFactory: BleConnectionFactory,
- dispatchers: CoroutineDispatchers,
-) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) {
-
- override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
-
- override fun isMockTransport(): Boolean =
- buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
-
- override fun isPlatformAddressValid(address: String): Boolean {
- val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } ?: return false
- val rest = address.substring(1)
- return when (interfaceId) {
- InterfaceId.MOCK,
- InterfaceId.NOP,
- InterfaceId.TCP,
- -> true
-
- InterfaceId.SERIAL -> {
- val deviceMap = usbRepository.serialDevices.value
- val driver = deviceMap[rest] ?: deviceMap.values.firstOrNull()
- driver != null && usbManager.hasPermission(driver.device)
- }
-
- InterfaceId.BLUETOOTH -> true // Handled by base class
- }
- }
-
- override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport {
- val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
- val rest = address.substring(1)
-
- return when (interfaceId) {
- InterfaceId.MOCK -> MockRadioTransport(callback = service, scope = service.serviceScope, address = rest)
-
- InterfaceId.TCP ->
- TcpRadioTransport(
- callback = service,
- scope = service.serviceScope,
- dispatchers = dispatchers,
- address = rest,
- )
-
- InterfaceId.SERIAL ->
- SerialRadioTransport(
- callback = service,
- scope = service.serviceScope,
- usbRepository = usbRepository,
- address = rest,
- )
-
- InterfaceId.NOP,
- null,
- -> NopRadioTransport(rest)
-
- InterfaceId.BLUETOOTH -> error("BLE addresses should be handled by BaseRadioTransportFactory")
- }
- }
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
deleted file mode 100644
index c8489efd3..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
+++ /dev/null
@@ -1,140 +0,0 @@
-/*
- * 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.network.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.network.repository.SerialConnection
-import org.meshtastic.core.network.repository.SerialConnectionListener
-import org.meshtastic.core.network.repository.UsbRepository
-import org.meshtastic.core.network.transport.HeartbeatSender
-import org.meshtastic.core.repository.RadioTransportCallback
-import java.util.concurrent.atomic.AtomicReference
-
-/** An Android USB/serial [RadioTransport] implementation. */
-class SerialRadioTransport(
- callback: RadioTransportCallback,
- scope: CoroutineScope,
- private val usbRepository: UsbRepository,
- private val address: String,
-) : StreamTransport(callback, scope) {
- private var connRef = AtomicReference()
-
- private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]")
-
- override fun start() {
- connect()
- }
-
- override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) {
- connRef.get()?.close(waitForStopped)
- super.onDeviceDisconnect(waitForStopped, isPermanent)
- }
-
- override fun connect() {
- val deviceMap = usbRepository.serialDevices.value
- val device = deviceMap[address] ?: deviceMap.values.firstOrNull()
- if (device == null) {
- Logger.e { "[$address] Serial device not found at address" }
- } else {
- val connectStart = nowMillis
- Logger.i { "[$address] Opening serial device: $device" }
-
- var packetsReceived = 0
- var bytesReceived = 0L
- var connectionStartTime = 0L
-
- val onConnect: () -> Unit = {
- connectionStartTime = nowMillis
- val connectionTime = connectionStartTime - connectStart
- Logger.i { "[$address] Serial device connected in ${connectionTime}ms" }
- super.connect()
- }
-
- usbRepository
- .createSerialConnection(
- device,
- object : SerialConnectionListener {
- override fun onMissingPermission() {
- Logger.e {
- "[$address] Serial connection failed - missing USB permissions for device: $device"
- }
- }
-
- override fun onConnected() {
- onConnect.invoke()
- }
-
- override fun onDataReceived(bytes: ByteArray) {
- packetsReceived++
- bytesReceived += bytes.size
- Logger.d {
- "[$address] Serial received packet #$packetsReceived - " +
- "${bytes.size} byte(s) (Total RX: $bytesReceived bytes)"
- }
- bytes.forEach(::readChar)
- }
-
- override fun onDisconnected(thrown: Exception?) {
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- thrown?.let { e ->
- // USB errors are common when unplugging; log as warning to avoid Crashlytics noise
- Logger.w(e) { "[$address] Serial error after ${uptime}ms: ${e.message}" }
- }
- Logger.w {
- "[$address] Serial device disconnected - " +
- "Device: $device, " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes)"
- }
- // USB unplug / cable error is transient — the transport will reconnect when
- // the device is replugged or the OS re-enumerates the port. Only an explicit
- // close() (user disconnects) should signal a permanent disconnect.
- onDeviceDisconnect(waitForStopped = false, isPermanent = false)
- }
- },
- )
- .also { conn ->
- connRef.set(conn)
- conn.connect()
- }
- }
- }
-
- override fun keepAlive() {
- // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the serial
- // link is alive and keep the local node's lastHeard timestamp current.
- scope.handledLaunch { heartbeatSender.sendHeartbeat() }
- }
-
- override fun sendBytes(p: ByteArray) {
- val conn = connRef.get()
- if (conn != null) {
- Logger.d { "[$address] Serial sending ${p.size} bytes" }
- conn.sendBytes(p)
- } else {
- Logger.w { "[$address] Serial connection not available, cannot send ${p.size} bytes" }
- }
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
deleted file mode 100644
index 2b40daaa4..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * 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.network.radio
-
-import org.meshtastic.core.ble.BleConnectionFactory
-import org.meshtastic.core.ble.BleScanner
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportFactory
-
-/**
- * Common base class for platform [RadioTransportFactory] implementations. Handles KMP-friendly transports (BLE) while
- * delegating platform-specific ones (like TCP, USB/Serial and Mocks) to the abstract [createPlatformTransport].
- */
-abstract class BaseRadioTransportFactory(
- protected val scanner: BleScanner,
- protected val bluetoothRepository: BluetoothRepository,
- protected val connectionFactory: BleConnectionFactory,
- protected val dispatchers: CoroutineDispatchers,
-) : RadioTransportFactory {
-
- override fun isAddressValid(address: String?): Boolean {
- val spec = address?.firstOrNull() ?: return false
- return when (spec) {
- InterfaceId.TCP.id,
- InterfaceId.SERIAL.id,
- InterfaceId.BLUETOOTH.id,
- InterfaceId.MOCK.id,
- '!',
- -> true
-
- else -> isPlatformAddressValid(address)
- }
- }
-
- protected open fun isPlatformAddressValid(address: String): Boolean = false
-
- override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
-
- override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport {
- val transport =
- when {
- address.startsWith(InterfaceId.BLUETOOTH.id) || address.startsWith("!") -> {
- val bleAddress = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()).removePrefix("!")
- BleRadioTransport(
- scope = service.serviceScope,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = bleAddress,
- )
- }
-
- else -> createPlatformTransport(address, service)
- }
- transport.start()
- return transport
- }
-
- /** Delegate to platform for Mock, TCP, or Serial/USB transports. */
- protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
deleted file mode 100644
index 95512ecf4..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
+++ /dev/null
@@ -1,505 +0,0 @@
-/*
- * 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 .
- */
-@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
-
-package org.meshtastic.core.network.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.NonCancellable
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.job
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeoutOrNull
-import org.meshtastic.core.ble.BleConnection
-import org.meshtastic.core.ble.BleConnectionFactory
-import org.meshtastic.core.ble.BleConnectionState
-import org.meshtastic.core.ble.BleDevice
-import org.meshtastic.core.ble.BleScanner
-import org.meshtastic.core.ble.BleWriteType
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.ble.DisconnectReason
-import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
-import org.meshtastic.core.ble.MeshtasticRadioProfile
-import org.meshtastic.core.ble.classifyBleException
-import org.meshtastic.core.ble.retryBleOperation
-import org.meshtastic.core.ble.toMeshtasticRadioProfile
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.model.RadioNotConnectedException
-import org.meshtastic.core.network.transport.HeartbeatSender
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportCallback
-import kotlin.concurrent.Volatile
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-
-private const val SCAN_RETRY_COUNT = 3
-private val SCAN_RETRY_DELAY = 1.seconds
-private val CONNECTION_TIMEOUT = 15.seconds
-
-/**
- * Delay after writing a heartbeat before re-polling FROMRADIO.
- *
- * The ESP32 firmware processes TORADIO writes asynchronously (NimBLE callback → FreeRTOS main task queue →
- * `handleToRadio()` → `heartbeatReceived = true`). The immediate drain trigger in
- * [KableMeshtasticRadioProfile.sendToRadio] fires before this completes, so the `queueStatus` response is not yet
- * available. 200 ms is well above observed ESP32 task scheduling latency (~10–50 ms) while remaining imperceptible to
- * the user.
- */
-private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds
-
-private val SCAN_TIMEOUT = 5.seconds
-private val GATT_CLEANUP_TIMEOUT = 5.seconds
-
-/**
- * A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
- *
- * This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
- * - Bonding and discovery.
- * - Automatic reconnection logic.
- * - MTU and connection parameter monitoring.
- * - Routing raw byte packets between the radio and [RadioTransportCallback].
- *
- * @param scope The coroutine scope to use for launching coroutines.
- * @param scanner The BLE scanner.
- * @param bluetoothRepository The Bluetooth repository.
- * @param connectionFactory The BLE connection factory.
- * @param callback The [RadioTransportCallback] to use for handling radio events.
- * @param address The BLE address of the device to connect to.
- */
-class BleRadioTransport(
- private val scope: CoroutineScope,
- private val scanner: BleScanner,
- private val bluetoothRepository: BluetoothRepository,
- private val connectionFactory: BleConnectionFactory,
- private val callback: RadioTransportCallback,
- internal val address: String,
-) : RadioTransport {
-
- // Detached cleanup scope for last-ditch GATT teardown from the exception handler.
- // Must NOT be a child of `scope`: when an uncaught exception fires in connectionScope,
- // upper layers often tear down `scope` immediately. Launching cleanup on `scope` then
- // races the cancellation and may never start, leaking BluetoothGatt (status 133 on
- // the next reconnect). This scope is cancelled in close() once our own disconnect
- // has completed and the safety net is no longer needed.
- private val cleanupScope: CoroutineScope = CoroutineScope(SupervisorJob() + scope.coroutineContext.minusKey(Job))
-
- private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
- Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
- cleanupScope.launch {
- try {
- bleConnection.disconnect()
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
- }
- }
- val (isPermanent, msg) = throwable.toDisconnectReason()
- callback.onDisconnect(isPermanent, errorMessage = msg)
- }
-
- private val connectionScope: CoroutineScope =
- CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler)
- private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
- private val writeMutex: Mutex = Mutex()
-
- @Volatile private var connectionStartTime: Long = 0
-
- @Volatile private var packetsReceived: Int = 0
-
- @Volatile private var packetsSent: Int = 0
-
- @Volatile private var bytesReceived: Long = 0
-
- @Volatile private var bytesSent: Long = 0
-
- @Volatile private var isFullyConnected = false
- private var connectionJob: Job? = null
-
- // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
- // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
- // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
- private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)
-
- private val heartbeatSender =
- HeartbeatSender(
- sendToRadio = ::handleSendToRadio,
- afterHeartbeat = {
- delay(HEARTBEAT_DRAIN_DELAY)
- radioService?.requestDrain()
- },
- logTag = address,
- )
-
- override fun start() {
- connect()
- }
-
- // --- Connection & Discovery Logic ---
-
- /** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
- private suspend fun findDevice(): BleDevice {
- bluetoothRepository.state.value.bondedDevices
- .firstOrNull { it.address.equals(address, ignoreCase = true) }
- ?.let {
- return it
- }
-
- Logger.i { "[$address] Device not found in bonded list, scanning" }
-
- repeat(SCAN_RETRY_COUNT) { attempt ->
- try {
- val d =
- withTimeoutOrNull(SCAN_TIMEOUT) {
- // Pass both service UUID and address so the scanner can apply the most
- // efficient platform filter. Android uses address (OS-level HW filter),
- // while CoreBluetooth (macOS) needs the service UUID because it caches
- // peripheral identifiers and may not re-report by address alone.
- scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
- it.address.equals(address, ignoreCase = true)
- }
- }
- if (d != null) return d
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Logger.v(e) { "[$address] Scan attempt failed or timed out" }
- }
-
- if (attempt < SCAN_RETRY_COUNT - 1) {
- delay(SCAN_RETRY_DELAY)
- }
- }
-
- throw RadioNotConnectedException("Device not found at address $address")
- }
-
- private fun connect() {
- connectionJob =
- connectionScope.launch {
- reconnectPolicy.execute(
- attempt = {
- try {
- attemptConnection()
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- val failureTime = (nowMillis - connectionStartTime).milliseconds
- Logger.w(e) { "[$address] Failed to connect after $failureTime" }
- BleReconnectPolicy.Outcome.Failed(e)
- }
- },
- onTransientDisconnect = { error ->
- val msg = error?.toDisconnectReason()?.second ?: "Device unreachable"
- callback.onDisconnect(isPermanent = false, errorMessage = msg)
- },
- onPermanentDisconnect = { error ->
- val msg = error?.toDisconnectReason()?.second ?: "Device unreachable"
- callback.onDisconnect(isPermanent = true, errorMessage = msg)
- },
- )
- }
- }
-
- /**
- * Performs a single BLE connect-and-wait cycle.
- *
- * Finds the device, bonds if needed, connects, discovers services, and waits for disconnect. Returns a
- * [BleReconnectPolicy.Outcome] describing how the connection ended.
- */
- @Suppress("CyclomaticComplexMethod")
- private suspend fun attemptConnection(): BleReconnectPolicy.Outcome {
- connectionStartTime = nowMillis
- Logger.i { "[$address] BLE connection attempt started" }
-
- val device = findDevice()
-
- // Bond before connecting: firmware may require an encrypted link,
- // and without a bond Android fails with status 5 or 133.
- // No-op on Desktop/JVM where the OS handles pairing automatically.
- if (!bluetoothRepository.isBonded(address)) {
- Logger.i { "[$address] Device not bonded, initiating bonding" }
- @Suppress("TooGenericExceptionCaught")
- try {
- bluetoothRepository.bond(device)
- Logger.i { "[$address] Bonding successful" }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
- }
- }
-
- val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
-
- if (state !is BleConnectionState.Connected) {
- throw RadioNotConnectedException("Failed to connect to device at address $address")
- }
-
- val gattConnectedAt = nowMillis
- isFullyConnected = true
- onConnected()
-
- discoverServicesAndSetupCharacteristics()
-
- // Wait for the StateFlow to actually reflect Connected before watching for the next
- // Disconnected. connectAndAwait returns synchronously based on the underlying Kable
- // peripheral state, but our _connectionState observer runs on a separate coroutine and
- // may lag. Without this gate the next .first { Disconnected } below could match the
- // *previous* cycle's stale Disconnected value and fire immediately, breaking reconnect.
- bleConnection.connectionState.first { it is BleConnectionState.Connected }
-
- // Suspend until the next Disconnected emission. We deliberately do NOT wrap this in a
- // coroutineScope { launchIn(...); first(...) } pattern: launching a hot StateFlow
- // collector inside coroutineScope hangs the scope after .first returns (the launched
- // collector never completes naturally, and coroutineScope waits for all children).
- val disconnectedState =
- bleConnection.connectionState.filterIsInstance().first()
- val disconnectReason = disconnectedState.reason
- if (isFullyConnected) {
- isFullyConnected = false
- onDisconnected()
- }
-
- Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" }
-
- val wasIntentional = disconnectReason is DisconnectReason.LocalDisconnect
- val connectionUptime = (nowMillis - gattConnectedAt).milliseconds
- val wasStable = connectionUptime >= reconnectPolicy.minStableConnection
-
- if (!wasStable && !wasIntentional) {
- Logger.w {
- "[$address] Connection lasted only $connectionUptime " +
- "(< ${reconnectPolicy.minStableConnection}) — treating as unstable"
- }
- }
-
- return BleReconnectPolicy.Outcome.Disconnected(wasStable = wasStable, wasIntentional = wasIntentional)
- }
-
- private suspend fun onConnected() {
- try {
- bleConnection.deviceFlow.first()?.let { device ->
- val rssi = retryBleOperation(tag = address) { device.readRssi() }
- Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
- }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
- }
- }
-
- private fun onDisconnected() {
- radioService = null
- Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" }
- // Signal immediately so the UI reflects the disconnect while reconnect continues.
- callback.onDisconnect(isPermanent = false)
- }
-
- private suspend fun discoverServicesAndSetupCharacteristics() {
- try {
- bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
- val radioService = service.toMeshtasticRadioProfile()
-
- radioService.fromRadio
- .onEach { packet ->
- Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
- dispatchPacket(packet)
- }
- .catch { e ->
- Logger.w(e) { "[$address] Error in fromRadio flow" }
- handleFailure(e)
- }
- .launchIn(this)
-
- radioService.logRadio
- .onEach { packet ->
- Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
- dispatchPacket(packet)
- }
- .catch { e ->
- Logger.w(e) { "[$address] Error in logRadio flow" }
- handleFailure(e)
- }
- .launchIn(this)
-
- this@BleRadioTransport.radioService = radioService
-
- Logger.i { "[$address] Profile service active and characteristics subscribed" }
-
- // Wait for FROMNUM CCCD write before triggering the Meshtastic handshake.
- radioService.awaitSubscriptionReady()
-
- // Log negotiated MTU for diagnostics
- val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
- Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
-
- // Ask the platform for a low-latency / high-throughput connection interval
- // (~7.5 ms on Android). The Meshtastic firmware happily accepts this and it
- // materially speeds up the initial config drain and any bulk fromRadio reads.
- if (bleConnection.requestHighConnectionPriority()) {
- Logger.d { "[$address] Requested high BLE connection priority" }
- // Wait for the connection parameter update to succeed before starting the heavy traffic
- // in onConnect(). Otherwise, the Android BLE stack may disconnect with GATT 147.
- delay(1.seconds)
- }
-
- this@BleRadioTransport.callback.onConnect()
- }
- } catch (e: CancellationException) {
- // Scope was cancelled externally — still ensure GATT cleanup runs so we don't
- // leak a BluetoothGatt handle and trigger GATT status 133 on the next attempt.
- withContext(NonCancellable) {
- try {
- bleConnection.disconnect()
- } catch (ignored: Exception) {
- Logger.w(ignored) { "[$address] disconnect() failed during cancellation cleanup" }
- }
- }
- throw e
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Profile service discovery or operation failed" }
- // Disconnect to let the outer reconnect loop see a clean Disconnected state.
- // Do NOT call handleFailure here — the reconnect loop owns failure counting.
- withContext(NonCancellable) {
- try {
- bleConnection.disconnect()
- } catch (ignored: Exception) {
- Logger.w(ignored) { "[$address] disconnect() failed after profile error" }
- }
- }
- }
- }
-
- @Volatile private var radioService: MeshtasticRadioProfile? = null
-
- // --- RadioTransport Implementation ---
-
- /**
- * Sends a packet to the radio with retry support.
- *
- * @param p The packet to send.
- */
- override fun handleSendToRadio(p: ByteArray) {
- val currentService = radioService
- if (currentService != null) {
- connectionScope.launch {
- writeMutex.withLock {
- try {
- retryBleOperation(tag = address) { currentService.sendToRadio(p) }
- packetsSent++
- bytesSent += p.size
- Logger.v {
- "[$address] Wrote packet #$packetsSent " +
- "to toRadio (${p.size} bytes, total TX: $bytesSent bytes)"
- }
- } catch (e: Exception) {
- Logger.w(e) {
- "[$address] Failed to write packet to toRadioCharacteristic after " +
- "$packetsSent successful writes"
- }
- handleFailure(e)
- }
- }
- }
- } else {
- Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
- }
- }
-
- override fun keepAlive() {
- // Delegate to HeartbeatSender which sends a ToRadio heartbeat with a unique nonce
- // so the firmware resets its power-saving idle timer. After sending, it schedules
- // a delayed re-drain to pick up the queueStatus response.
- connectionScope.launch { heartbeatSender.sendHeartbeat() }
- }
-
- /** Closes the connection to the device. */
- override suspend fun close() {
- Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" }
- connectionScope.cancel()
- // GATT cleanup must run under NonCancellable so a cancelled caller cannot skip it,
- // which would leak BluetoothGatt and trigger status 133 on the next reconnect.
- // Using withContext (not runBlocking) keeps the caller's thread free — this is
- // critical when close() is invoked from the main thread during process shutdown.
- withContext(NonCancellable) {
- try {
- withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[$address] Failed to disconnect in close()" }
- }
- }
- // Our own disconnect succeeded — the exception-handler safety net is no longer
- // needed. Cancel the detached cleanup scope so it doesn't outlive us in tests
- // or process lifetime.
- cleanupScope.cancel()
- }
-
- private fun dispatchPacket(packet: ByteArray) {
- packetsReceived++
- bytesReceived += packet.size
- Logger.v {
- "[$address] Dispatching packet #$packetsReceived " +
- "(${packet.size} bytes, total RX: $bytesReceived bytes)"
- }
- callback.handleFromRadio(packet)
- }
-
- private fun handleFailure(throwable: Throwable) {
- val (isPermanent, msg) = throwable.toDisconnectReason()
- callback.onDisconnect(isPermanent, errorMessage = msg)
- }
-
- /** Formats a one-line session statistics summary for logging. */
- private fun formatSessionStats(): String {
- val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
- return "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes), " +
- "Packets TX: $packetsSent ($bytesSent bytes)"
- }
-
- private fun Throwable.toDisconnectReason(): Pair {
- classifyBleException()?.let {
- return it.isPermanent to it.message
- }
-
- val msg =
- when (this) {
- is RadioNotConnectedException -> this.message ?: "Device not found"
-
- is NoSuchElementException,
- is IllegalArgumentException,
- -> "Required characteristic missing"
-
- else -> this.message ?: this::class.simpleName ?: "Unknown"
- }
- return false to msg
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
deleted file mode 100644
index 38767a0ef..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * 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.network.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import kotlin.coroutines.coroutineContext
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.seconds
-
-/**
- * Encapsulates the BLE reconnection policy with exponential backoff.
- *
- * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep).
- * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns;
- * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely.
- *
- * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely
- * @param failureThreshold after this many consecutive failures, signal a transient disconnect
- * @param settleDelay delay before each connection attempt to let the BLE stack settle
- * @param minStableConnection minimum time a connection must stay up to be considered "stable"
- * @param backoffStrategy computes the backoff delay for a given failure count
- */
-class BleReconnectPolicy(
- private val maxFailures: Int = DEFAULT_MAX_FAILURES,
- private val failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD,
- private val settleDelay: Duration = DEFAULT_SETTLE_DELAY,
- /** Minimum time a connection must stay up to be considered "stable". Exposed for callers to compare uptime. */
- val minStableConnection: Duration = DEFAULT_MIN_STABLE_CONNECTION,
- private val backoffStrategy: (attempt: Int) -> Duration = ::computeReconnectBackoff,
-) {
- /** Outcome of a single reconnect iteration. */
- sealed interface Outcome {
- /** Connection attempt succeeded and then eventually disconnected. */
- data class Disconnected(val wasStable: Boolean, val wasIntentional: Boolean) : Outcome
-
- /** Connection attempt failed with an exception. */
- data class Failed(val error: Throwable) : Outcome
- }
-
- /** Action the caller should take after the policy processes an outcome. */
- sealed interface Action {
- /** Retry the connection after the specified backoff delay. */
- data class Retry(val backoff: Duration) : Action
-
- /** Signal a transient disconnect to higher layers. */
- data class SignalTransient(val backoff: Duration) : Action
-
- /** Give up permanently. */
- data object GiveUp : Action
-
- /** Continue immediately (e.g. after an intentional disconnect). */
- data object Continue : Action
- }
-
- internal var consecutiveFailures: Int = 0
- private set
-
- /** Processes the outcome of a connection attempt and returns the action the caller should take. */
- fun processOutcome(outcome: Outcome): Action = when (outcome) {
- is Outcome.Disconnected -> {
- if (outcome.wasIntentional) {
- consecutiveFailures = 0
- Action.Continue
- } else if (outcome.wasStable) {
- consecutiveFailures = 0
- Action.Continue
- } else {
- consecutiveFailures++
- Logger.w { "Unstable connection (consecutive failures: $consecutiveFailures)" }
- evaluateFailure()
- }
- }
-
- is Outcome.Failed -> {
- consecutiveFailures++
- Logger.w { "Connection failed (consecutive failures: $consecutiveFailures)" }
- evaluateFailure()
- }
- }
-
- private fun evaluateFailure(): Action {
- if (consecutiveFailures >= maxFailures) {
- return Action.GiveUp
- }
- val backoff = backoffStrategy(consecutiveFailures)
- return if (consecutiveFailures >= failureThreshold) {
- Action.SignalTransient(backoff)
- } else {
- Action.Retry(backoff)
- }
- }
-
- /**
- * Runs the reconnect loop, calling [attempt] for each iteration.
- *
- * The [attempt] lambda should perform a single connect-and-wait cycle and return the [Outcome] when the connection
- * drops or an error occurs.
- *
- * @param attempt performs a single connection attempt and returns the outcome
- * @param onTransientDisconnect called when the policy decides to signal a transient disconnect
- * @param onPermanentDisconnect called when the policy gives up permanently
- */
- suspend fun execute(
- attempt: suspend () -> Outcome,
- onTransientDisconnect: suspend (Throwable?) -> Unit,
- onPermanentDisconnect: suspend (Throwable?) -> Unit,
- ) {
- while (coroutineContext.isActive) {
- delay(settleDelay)
-
- val outcome = attempt()
- val lastError = (outcome as? Outcome.Failed)?.error
-
- when (val action = processOutcome(outcome)) {
- is Action.Continue -> continue
-
- is Action.Retry -> {
- Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" }
- delay(action.backoff)
- }
-
- is Action.SignalTransient -> {
- onTransientDisconnect(lastError)
- Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" }
- delay(action.backoff)
- }
-
- is Action.GiveUp -> {
- Logger.e { "Giving up after $consecutiveFailures consecutive failures" }
- onPermanentDisconnect(lastError)
- return
- }
- }
- }
- }
-
- companion object {
- const val DEFAULT_MAX_FAILURES = 10
- const val DEFAULT_FAILURE_THRESHOLD = 3
-
- /**
- * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side
- * GATT session have time to settle.
- *
- * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between
- * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the
- * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose
- * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more
- * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same.
- */
- val DEFAULT_SETTLE_DELAY = 3.seconds
- val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds
-
- internal val RECONNECT_BASE_DELAY = 5.seconds
- internal val RECONNECT_MAX_DELAY = 60.seconds
- internal const val BACKOFF_MAX_EXPONENT = 4
- }
-}
-
-/**
- * Returns the reconnect backoff delay for a given consecutive failure count.
- *
- * Backoff schedule: 1 failure → 5 s, 2 failures → 10 s, 3 failures → 20 s, 4 failures → 40 s, 5+ failures → 60 s
- * (capped).
- */
-internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration {
- if (consecutiveFailures <= 0) return BleReconnectPolicy.RECONNECT_BASE_DELAY
- val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(BleReconnectPolicy.BACKOFF_MAX_EXPONENT)
- return minOf(BleReconnectPolicy.RECONNECT_BASE_DELAY * multiplier, BleReconnectPolicy.RECONNECT_MAX_DELAY)
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
deleted file mode 100644
index b94eeffbf..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
+++ /dev/null
@@ -1,379 +0,0 @@
-/*
- * 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.network.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import okio.ByteString.Companion.encodeUtf8
-import okio.ByteString.Companion.toByteString
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.model.Channel
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.util.getInitials
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportCallback
-import org.meshtastic.proto.AdminMessage
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.Data
-import org.meshtastic.proto.DeviceMetadata
-import org.meshtastic.proto.DeviceMetrics
-import org.meshtastic.proto.FromRadio
-import org.meshtastic.proto.HardwareModel
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.ModuleConfig
-import org.meshtastic.proto.Neighbor
-import org.meshtastic.proto.NeighborInfo
-import org.meshtastic.proto.NodeInfo
-import org.meshtastic.proto.PortNum
-import org.meshtastic.proto.QueueStatus
-import org.meshtastic.proto.Routing
-import org.meshtastic.proto.StatusMessage
-import org.meshtastic.proto.Telemetry
-import org.meshtastic.proto.ToRadio
-import org.meshtastic.proto.User
-import kotlin.random.Random
-import org.meshtastic.proto.Channel as ProtoChannel
-import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
-import org.meshtastic.proto.Position as ProtoPosition
-
-private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.TW)
-
-private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY)
-
-/** A simulated transport that is used for testing in the simulator. */
-@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber")
-class MockRadioTransport(
- private val callback: RadioTransportCallback,
- private val scope: CoroutineScope,
- val address: String,
-) : RadioTransport {
-
- companion object {
- private const val MY_NODE = 0x42424242
-
- @Suppress("MagicNumber")
- private val FAKE_SESSION_PASSKEY: okio.ByteString =
- okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77)
- }
-
- private var currentPacketId = 50
-
- // an infinite sequence of ints
- private val packetIdSequence = generateSequence { currentPacketId++ }.iterator()
-
- override fun start() {
- Logger.i { "Starting the mock transport" }
- callback.onConnect() // Tell clients they can use the API
- }
-
- override fun handleSendToRadio(p: ByteArray) {
- val pr = ToRadio.ADAPTER.decode(p)
-
- // Intercept want_config handshake — send config response only when requested,
- // mirroring the behaviour of real firmware which waits for want_config_id.
- val wantConfigId = pr.want_config_id ?: 0
- if (wantConfigId != 0) {
- sendConfigResponse(wantConfigId)
- return
- }
-
- val packet = pr.packet
- if (packet != null) {
- sendQueueStatus(packet.id)
- }
-
- val data = packet?.decoded
-
- when {
- data != null && data.portnum == PortNum.ADMIN_APP ->
- handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload))
-
- packet != null && packet.want_ack == true -> sendFakeAck(pr)
-
- else -> Logger.i { "Ignoring data sent to mock transport $pr" }
- }
- }
-
- private fun handleAdminPacket(pr: ToRadio, d: AdminMessage) {
- val packet = pr.packet ?: return
- when {
- d.get_config_request == AdminMessage.ConfigType.LORA_CONFIG ->
- sendAdmin(packet.to, packet.from, packet.id) {
- copy(get_config_response = Config(lora = defaultLoRaConfig))
- }
-
- (d.get_channel_request ?: 0) != 0 ->
- sendAdmin(packet.to, packet.from, packet.id) {
- copy(
- get_channel_response =
- ProtoChannel(
- index = (d.get_channel_request ?: 0) - 1, // 0 based on the response
- settings = if (d.get_channel_request == 1) Channel.default.settings else null,
- role =
- if (d.get_channel_request == 1) {
- ProtoChannel.Role.PRIMARY
- } else {
- ProtoChannel.Role.DISABLED
- },
- ),
- )
- }
-
- d.get_module_config_request == AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG ->
- sendAdmin(packet.to, packet.from, packet.id) {
- copy(
- get_module_config_response =
- ModuleConfig(
- statusmessage =
- ModuleConfig.StatusMessageConfig(node_status = "Going to the farm.. to grow wheat."),
- ),
- )
- }
-
- else -> Logger.i { "Ignoring admin sent to mock transport $d" }
- }
- }
-
- override suspend fun close() {
- Logger.i { "Closing the mock transport" }
- }
-
- // / Generate a fake text message from a node
- private fun makeTextMessage(numIn: Int) = FromRadio(
- packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
- Data(
- portnum = PortNum.TEXT_MESSAGE_APP,
- payload = "This simulated node sends Hi!".encodeUtf8(),
- ),
- ),
- )
-
- private fun makeNeighborInfo(numIn: Int) = FromRadio(
- packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
- Data(
- portnum = PortNum.NEIGHBORINFO_APP,
- payload =
- NeighborInfo(
- node_id = numIn,
- last_sent_by_id = numIn,
- node_broadcast_interval_secs = 60,
- neighbors =
- listOf(
- Neighbor(
- node_id = numIn + 1,
- snr = 10.0f,
- last_rx_time = nowSeconds.toInt(),
- node_broadcast_interval_secs = 60,
- ),
- Neighbor(
- node_id = numIn + 2,
- snr = 12.0f,
- last_rx_time = nowSeconds.toInt(),
- node_broadcast_interval_secs = 60,
- ),
- ),
- )
- .encode()
- .toByteString(),
- ),
- ),
- )
-
- private fun makePosition(numIn: Int) = FromRadio(
- packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
- Data(
- portnum = PortNum.POSITION_APP,
- payload =
- ProtoPosition(
- latitude_i = org.meshtastic.core.model.Position.degI(32.776665),
- longitude_i = org.meshtastic.core.model.Position.degI(-96.796989),
- altitude = 150,
- time = nowSeconds.toInt(),
- precision_bits = 15,
- )
- .encode()
- .toByteString(),
- ),
- ),
- )
-
- private fun makeTelemetry(numIn: Int) = FromRadio(
- packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
- Data(
- portnum = PortNum.TELEMETRY_APP,
- payload =
- Telemetry(
- time = nowSeconds.toInt(),
- device_metrics =
- DeviceMetrics(
- battery_level = 85,
- voltage = 4.1f,
- channel_utilization = 0.12f,
- air_util_tx = 0.05f,
- uptime_seconds = 123456,
- ),
- )
- .encode()
- .toByteString(),
- ),
- ),
- )
-
- private fun makeNodeStatus(numIn: Int) = FromRadio(
- packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
- Data(
- portnum = PortNum.NODE_STATUS_APP,
- payload =
- StatusMessage(status = "Going to the farm.. to grow wheat.").encode().toByteString(),
- ),
- ),
- )
-
- private fun makeDataPacket(fromIn: Int, toIn: Int, data: Data) = FromRadio(
- packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = fromIn,
- to = toIn,
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded = data,
- ),
- )
-
- private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = makeDataPacket(
- fromIn,
- toIn,
- Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId),
- )
-
- private fun sendQueueStatus(msgId: Int) = callback.handleFromRadio(
- FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(),
- )
-
- private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) {
- // Embed a deterministic 8-byte fake passkey so SessionManager can record a session refresh — mirrors what real
- // firmware always attaches to admin responses (see firmware/src/modules/AdminModule.cpp:1460-1481).
- val adminMsg = AdminMessage().initFn().copy(session_passkey = FAKE_SESSION_PASSKEY)
- val p =
- makeDataPacket(
- fromIn,
- toIn,
- Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId),
- )
- callback.handleFromRadio(p.encode())
- }
-
- // / Send a fake ack packet back if the sender asked for want_ack
- private fun sendFakeAck(pr: ToRadio) = scope.handledLaunch {
- val packet = pr.packet ?: return@handledLaunch
- delay(2000)
- callback.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode())
- }
-
- private fun sendConfigResponse(configId: Int) {
- Logger.d { "Sending mock config response" }
-
- // / Generate a fake node info entry
- @Suppress("MagicNumber")
- fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio(
- node_info =
- NodeInfo(
- num = numIn,
- user =
- User(
- id = DataPacket.nodeNumToDefaultId(numIn),
- long_name = "Sim ${numIn.toString(16)}",
- short_name = getInitials("Sim ${numIn.toString(16)}"),
- hw_model = HardwareModel.ANDROID_SIM,
- ),
- position =
- ProtoPosition(
- latitude_i = org.meshtastic.core.model.Position.degI(lat),
- longitude_i = org.meshtastic.core.model.Position.degI(lon),
- altitude = 35,
- time = nowSeconds.toInt(),
- precision_bits = Random.nextInt(10, 19),
- ),
- ),
- )
-
- // Simulated network data to feed to our app
- val packets =
- arrayOf(
- // MyNodeInfo
- FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)),
- FromRadio(
- metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM),
- ),
-
- // Fake NodeDB
- makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas
- makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson
- FromRadio(config = Config(lora = defaultLoRaConfig)),
- FromRadio(config = Config(lora = defaultLoRaConfig)),
- FromRadio(channel = defaultChannel),
- FromRadio(config_complete_id = configId),
-
- // Done with config response, now pretend to receive some text messages
- makeTextMessage(MY_NODE + 1),
- makeNeighborInfo(MY_NODE + 1),
- makePosition(MY_NODE + 1),
- makeTelemetry(MY_NODE + 1),
- makeNodeStatus(MY_NODE + 1),
- )
-
- packets.forEach { p -> callback.handleFromRadio(p.encode()) }
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt
deleted file mode 100644
index 9ed224d3f..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * 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.network.radio
-
-import org.meshtastic.core.repository.RadioTransport
-
-/**
- * An intentionally inert [RadioTransport] that silently discards all operations.
- *
- * Used as a safe default when no valid device address is configured or when the requested transport type is
- * unsupported. All method calls are no-ops — it never connects, never sends data, and never signals lifecycle events to
- * the service layer.
- */
-class NopRadioTransport(val address: String) : RadioTransport {
- override fun handleSendToRadio(p: ByteArray) {
- // No-op
- }
-
- override suspend fun close() {
- // No-op
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
deleted file mode 100644
index 4ac839d83..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * 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.network.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.network.transport.StreamFrameCodec
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportCallback
-
-/**
- * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
- * probably).
- *
- * Delegates framing logic to [StreamFrameCodec] from `core:network`.
- */
-abstract class StreamTransport(protected val callback: RadioTransportCallback, protected val scope: CoroutineScope) :
- RadioTransport {
-
- private val codec =
- StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport")
-
- override suspend fun close() {
- Logger.d { "Closing stream for good" }
- onDeviceDisconnect(waitForStopped = true, isPermanent = true)
- }
-
- /**
- * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop.
- *
- * @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside
- * transport callbacks
- * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O
- * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS
- * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to
- * signal a user-initiated terminal disconnect.
- */
- protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) {
- callback.onDisconnect(isPermanent = isPermanent)
- }
-
- protected open fun connect() {
- // Before connecting, send a few START1s to wake a sleeping device
- sendBytes(StreamFrameCodec.WAKE_BYTES)
-
- // Now tell clients they can (finally use the api)
- callback.onConnect()
- }
-
- /** Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). */
- abstract fun sendBytes(p: ByteArray)
-
- /** Flushes buffered bytes to the underlying stream. No-op by default. */
- open fun flushBytes() {}
-
- override fun handleSendToRadio(p: ByteArray) {
- // This method is called from a continuation and it might show up late, so check for uart being null
- scope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) }
- }
-
- /** Process a single incoming byte through the stream framing state machine. */
- protected fun readChar(c: Byte) {
- codec.processInputByte(c)
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt
deleted file mode 100644
index 045d3b7ec..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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.network.transport
-
-import co.touchlab.kermit.Logger
-import org.meshtastic.proto.Heartbeat
-import org.meshtastic.proto.ToRadio
-import kotlin.concurrent.atomics.AtomicInt
-import kotlin.concurrent.atomics.ExperimentalAtomicApi
-
-/**
- * Shared heartbeat sender for Meshtastic radio transports.
- *
- * Constructs and sends a `ToRadio(heartbeat = Heartbeat(nonce = ...))` message to keep the firmware's idle timer from
- * expiring. Each call uses a monotonically increasing nonce to prevent the firmware's per-connection duplicate-write
- * filter from silently dropping it.
- *
- * @param sendToRadio callback to transmit the encoded heartbeat bytes to the radio
- * @param afterHeartbeat optional suspend callback invoked after sending (e.g. to schedule a drain)
- * @param logTag tag for log messages
- */
-class HeartbeatSender(
- private val sendToRadio: (ByteArray) -> Unit,
- private val afterHeartbeat: (suspend () -> Unit)? = null,
- private val logTag: String = "HeartbeatSender",
-) {
- @OptIn(ExperimentalAtomicApi::class)
- private val nonce = AtomicInt(0)
-
- /**
- * Sends a heartbeat to the radio.
- *
- * The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet, proving the link is alive and
- * keeping the local node's lastHeard timestamp current.
- */
- @OptIn(ExperimentalAtomicApi::class)
- suspend fun sendHeartbeat() {
- val n = nonce.fetchAndAdd(1)
- Logger.v { "[$logTag] Sending ToRadio heartbeat (nonce=$n)" }
- sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)).encode())
- afterHeartbeat?.invoke()
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt
deleted file mode 100644
index 31483cb16..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * 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.network.transport
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-
-/**
- * Meshtastic stream framing codec — pure Kotlin, no platform dependencies.
- *
- * Implements the START1/START2 + 2-byte-length + payload framing protocol used for serial and TCP communication with
- * Meshtastic radios.
- *
- * Shared across Android, Desktop, and iOS via `SharedRadioInterfaceService`.
- */
-@Suppress("MagicNumber")
-class StreamFrameCodec(
- /** Called when a complete packet has been decoded from the byte stream. */
- private val onPacketReceived: (ByteArray) -> Unit,
- /** Optional log tag for debug output. */
- private val logTag: String = "StreamCodec",
-) {
- companion object {
- const val START1: Byte = 0x94.toByte()
- const val START2: Byte = 0xc3.toByte()
- const val MAX_TO_FROM_RADIO_SIZE = 512
- const val HEADER_SIZE = 4
-
- /** Default Meshtastic TCP service port. */
- const val DEFAULT_TCP_PORT = 4403
-
- /** Wake bytes to send before connecting to rouse a sleeping device. */
- val WAKE_BYTES = byteArrayOf(START1, START1, START1, START1)
- }
-
- private val writeMutex = Mutex()
-
- // Framing state machine
- private var ptr = 0
- private var msb = 0
- private var lsb = 0
- private var packetLen = 0
- private val rxPacket = ByteArray(MAX_TO_FROM_RADIO_SIZE)
- private val debugLineBuf = StringBuilder()
-
- /**
- * Process a single incoming byte through the stream framing state machine.
- *
- * Call this repeatedly with bytes from the transport (serial, TCP, etc). When a complete packet is decoded,
- * [onPacketReceived] is invoked.
- */
- fun processInputByte(c: Byte) {
- var nextPtr = ptr + 1
-
- fun lostSync() {
- Logger.e { "$logTag: Lost protocol sync" }
- nextPtr = 0
- }
-
- fun deliverPacket() {
- val buf = rxPacket.copyOf(packetLen)
- onPacketReceived(buf)
- nextPtr = 0
- }
-
- when (ptr) {
- 0 ->
- if (c != START1) {
- debugOut(c)
- nextPtr = 0
- }
-
- 1 -> if (c != START2) lostSync()
-
- 2 -> msb = c.toInt() and 0xff
-
- 3 -> {
- lsb = c.toInt() and 0xff
- packetLen = (msb shl 8) or lsb
- if (packetLen > MAX_TO_FROM_RADIO_SIZE) {
- lostSync()
- } else if (packetLen == 0) {
- deliverPacket()
- }
- }
-
- else -> {
- rxPacket[ptr - HEADER_SIZE] = c
- if (ptr - HEADER_SIZE + 1 == packetLen) {
- deliverPacket()
- }
- }
- }
- ptr = nextPtr
- }
-
- /**
- * Frames a payload into the Meshtastic stream protocol format: [START1][START2][MSB len][LSB len][payload].
- *
- * Thread-safe via an internal mutex — multiple callers can call this concurrently.
- */
- suspend fun frameAndSend(payload: ByteArray, sendBytes: (ByteArray) -> Unit, flush: () -> Unit = {}) {
- writeMutex.withLock {
- val header = ByteArray(HEADER_SIZE)
- header[0] = START1
- header[1] = START2
- header[2] = (payload.size shr 8).toByte()
- header[3] = (payload.size and 0xff).toByte()
-
- sendBytes(header)
- sendBytes(payload)
- flush()
- }
- }
-
- /** Resets the framing state machine. Call when reconnecting. */
- fun reset() {
- ptr = 0
- msb = 0
- lsb = 0
- packetLen = 0
- debugLineBuf.clear()
- }
-
- /** Print device serial debug output to the logger. */
- private fun debugOut(b: Byte) {
- when (val c = b.toInt().toChar()) {
- '\r' -> {}
-
- '\n' -> {
- Logger.d { "$logTag DeviceLog: $debugLineBuf" }
- debugLineBuf.clear()
- }
-
- else -> debugLineBuf.append(c)
- }
- }
-}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt
deleted file mode 100644
index c1835e788..000000000
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt
+++ /dev/null
@@ -1,331 +0,0 @@
-/*
- * 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.network.radio
-
-import dev.mokkery.MockMode
-import dev.mokkery.answering.returns
-import dev.mokkery.every
-import dev.mokkery.matcher.any
-import dev.mokkery.mock
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.test.advanceTimeBy
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.ble.BleConnection
-import org.meshtastic.core.ble.BleConnectionFactory
-import org.meshtastic.core.ble.BleConnectionState
-import org.meshtastic.core.ble.BleDevice
-import org.meshtastic.core.ble.BleService
-import org.meshtastic.core.ble.BleWriteType
-import org.meshtastic.core.ble.DisconnectReason
-import org.meshtastic.core.testing.FakeBleConnection
-import org.meshtastic.core.testing.FakeBleConnectionFactory
-import org.meshtastic.core.testing.FakeBleDevice
-import org.meshtastic.core.testing.FakeBleScanner
-import org.meshtastic.core.testing.FakeBluetoothRepository
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertTrue
-import kotlin.time.Duration
-
-/**
- * Tests covering the BLE reconnect crash fixes in [BleRadioTransport]:
- * 1. **CancellationException / GATT 133 fix**: [discoverServicesAndSetupCharacteristics] previously had a bare `catch
- * (e: Exception)` that silently swallowed [CancellationException], meaning [BleConnection.disconnect] was never
- * called when the scope was cancelled. This leaked the underlying BluetoothGatt handle and caused GATT status 133 on
- * every subsequent reconnect. The fix adds an explicit `if (e is CancellationException)` branch that calls
- * [disconnect] under [NonCancellable] before re-throwing.
- * 2. **close() calls disconnect**: Verifies that calling [BleRadioTransport.close] triggers [BleConnection.disconnect]
- * exactly once so the GATT handle is always released.
- * 3. **Reconnect after failure respects policy backoff**: After a configurable number of consecutive failures the
- * transport signals a transient (non-permanent) disconnect to the callback.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class BleRadioTransportReconnectCrashTest {
-
- private val scanner = FakeBleScanner()
- private val bluetoothRepository = FakeBluetoothRepository()
- private val connection = FakeBleConnection()
- private val connectionFactory = FakeBleConnectionFactory(connection)
- private val service = mock(MockMode.autofill)
- private val address = "AA:BB:CC:DD:EE:FF"
-
- @BeforeTest
- fun setup() {
- bluetoothRepository.setHasPermissions(true)
- bluetoothRepository.setBluetoothEnabled(true)
- }
-
- // ─── close() triggers disconnect ─────────────────────────────────────────────────────────────
-
- /**
- * After [BleRadioTransport.close], [FakeBleConnection.disconnect] must be called.
- *
- * This validates the primary invariant introduced by the fix: GATT cleanup (disconnect) always runs — even when the
- * coroutine scope is cancelled — by wrapping the call in [NonCancellable].
- */
- @Test
- fun `close calls disconnect to clean up GATT handle`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Radio")
- bluetoothRepository.bond(device)
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- // Allow the connection loop to reach the connected state.
- advanceTimeBy(4_000L)
-
- bleTransport.close()
-
- // disconnect() must be called: once by the connection loop teardown + once by close() itself.
- // We only assert it was called at least once — the exact count depends on timing.
- assertTrue(connection.disconnectCalls >= 1, "Expected disconnect() to be called at least once")
- }
-
- // ─── disconnect called on connection failure ──────────────────────────────────────────────────
-
- /**
- * When [FakeBleConnection.connectAndAwait] always returns [BleConnectionState.Disconnected], the transport must
- * still eventually call [BleConnection.disconnect] to ensure the GATT handle state machine is reset before the next
- * attempt.
- *
- * Virtual-time budget: DEFAULT_FAILURE_THRESHOLD (3) × (3 s settle + backoff) ≈ 24 s.
- */
- @Test
- fun `disconnect is called on connection failure`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Radio")
- bluetoothRepository.bond(device)
-
- // Make every connection attempt fail.
- connection.failNextN = Int.MAX_VALUE
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- advanceTimeBy(30_000L)
-
- bleTransport.close()
-
- // Each failed connectAndAwait round-trips through the reconnect loop; close() always disconnects.
- assertTrue(connection.disconnectCalls >= 1, "disconnect() not called after connection failure")
- }
-
- // ─── transient onDisconnect after failure threshold ──────────────────────────────────────────
-
- /**
- * Mirrors [BleRadioTransportTest.`onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`] but
- * focuses specifically on the *reconnect* scenario introduced by the fix: after enough consecutive failures, the
- * callback receives `isPermanent = false` — the transport keeps retrying rather than giving up permanently.
- *
- * Virtual time: 3 failures × (3 s settle + backoff starting at 5 s) ≈ 24 s.
- */
- @Test
- fun `transient onDisconnect is signalled after failure threshold without giving up`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Radio")
- bluetoothRepository.bond(device)
-
- connection.connectException = org.meshtastic.core.model.RadioNotConnectedException("simulated GATT failure")
-
- every { service.onDisconnect(any(), any()) } returns Unit
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- advanceTimeBy(24_001L)
-
- // Transient disconnect must have been signalled.
- dev.mokkery.verify { service.onDisconnect(isPermanent = false, errorMessage = any()) }
- // Permanent disconnect must NEVER be called by the transport on its own.
- dev.mokkery.verify(mode = dev.mokkery.verify.VerifyMode.not) {
- service.onDisconnect(isPermanent = true, errorMessage = any())
- }
-
- bleTransport.close()
- }
-
- // ─── CancellationException is not silently swallowed ─────────────────────────────────────────
-
- /**
- * [BleRadioTransport.close] cancels the [connectionScope]. The cancellation propagates as a [CancellationException]
- * through the active coroutines in [discoverServicesAndSetupCharacteristics].
- *
- * Before the fix, `catch (e: Exception)` swallowed the [CancellationException] and the `disconnect()` call was
- * skipped. After the fix, [disconnect] is called under [NonCancellable].
- *
- * This test uses a dedicated fake that throws [CancellationException] from [BleConnection.profile] to simulate the
- * scope-cancellation path without races.
- */
- @Test
- fun `disconnect is called when profile setup throws CancellationException`() = runTest {
- val throwingConnection = CancellingProfileBleConnection()
- val throwingFactory =
- object : BleConnectionFactory {
- override fun create(scope: CoroutineScope, tag: String): BleConnection = throwingConnection
- }
- val device = FakeBleDevice(address = address, name = "Test Radio")
- bluetoothRepository.bond(device)
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = throwingFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- // Allow one connection attempt to reach profile() and be cancelled.
- advanceTimeBy(4_000L)
-
- bleTransport.close()
-
- assertTrue(
- throwingConnection.disconnectCalls >= 1,
- "disconnect() must be called after CancellationException in profile() — GATT leak fix",
- )
- }
-
- // ─── Reconnect after a stable connection drops ───────────────────────────────────────────────
-
- /**
- * Regression test for the BLE reconnect hang.
- *
- * Symptom: after a stable connection (uptime > minStableConnection) was terminated by a remote disconnect (e.g.
- * node power-cycle), the transport's reconnect loop never iterated — `attemptConnection` ran exactly once, the GATT
- * disconnect callback fired, and then nothing.
- *
- * Root cause: `attemptConnection` wrapped its disconnect-watcher in a `coroutineScope {
- * connectionState.onEach{...}.launchIn(this); connectionState.first { Disconnected } }` block. `coroutineScope`
- * waits for ALL launched children before returning, but the `.launchIn` collector on a hot `StateFlow` (or
- * `SharedFlow(replay=1)`) never completes naturally. After `.first` returned, the scope hung forever, blocking
- * `BleReconnectPolicy.execute` from issuing the next attempt.
- *
- * This test exercises the full happy-path reconnect cycle: connect → stable uptime → external disconnect → expect a
- * second `connectAndAwait` call. With the bug present, only one `connectAndAwait` call ever happens.
- */
- @Test
- fun `transport reconnects after a stable connection is dropped remotely`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Radio")
- bluetoothRepository.bond(device)
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- // Settle delay (3 s) + connect + handshake.
- advanceTimeBy(4_000L)
- assertTrue(connection.connectAndAwaitCalls == 1, "First connect must happen during initial start window")
-
- // Stay connected long enough to be considered stable (> minStableConnection = 5 s).
- advanceTimeBy(10_000L)
-
- // Simulate the firmware dying mid-session — the same path a node power-cycle takes.
- connection.simulateRemoteDisconnect(reason = DisconnectReason.Timeout)
-
- // Settle delay (3 s) before the next attempt + re-connect window. Generous to absorb
- // the policy retry backoff (5 s on first failure) plus another 3 s settle delay.
- advanceTimeBy(30_000L)
-
- assertTrue(
- connection.connectAndAwaitCalls >= 2,
- "Reconnect loop must call connectAndAwait again after a remote disconnect " +
- "(actual calls: ${connection.connectAndAwaitCalls})",
- )
-
- bleTransport.close()
- }
-}
-
-// ─── Test doubles ────────────────────────────────────────────────────────────────────────────────
-
-/**
- * A [BleConnection] that succeeds at [connectAndAwait] but throws [CancellationException] from [profile]. This
- * simulates what happens when the owning coroutine scope is cancelled while GATT service discovery is in progress.
- */
-private class CancellingProfileBleConnection : BleConnection {
-
- private val _deviceFlow = MutableStateFlow(null)
- override val deviceFlow: StateFlow = _deviceFlow.asStateFlow()
-
- private val _connectionState = MutableStateFlow(BleConnectionState.Disconnected())
- override val connectionState: StateFlow = _connectionState.asStateFlow()
-
- override val device: BleDevice? = null
-
- var disconnectCalls = 0
-
- override suspend fun connect(device: BleDevice) {
- _deviceFlow.value = device
- _connectionState.value = BleConnectionState.Connected
- }
-
- override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState {
- connect(device)
- return BleConnectionState.Connected
- }
-
- override suspend fun disconnect() {
- disconnectCalls++
- _connectionState.value = BleConnectionState.Disconnected()
- _deviceFlow.value = null
- }
-
- override suspend fun profile(
- serviceUuid: kotlin.uuid.Uuid,
- timeout: Duration,
- setup: suspend CoroutineScope.(BleService) -> T,
- ): T = throw CancellationException("Simulated scope cancellation during service discovery")
-
- override fun maximumWriteValueLength(writeType: BleWriteType): Int? = null
-}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
deleted file mode 100644
index 09e5ede0a..000000000
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * 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.network.radio
-
-import dev.mokkery.MockMode
-import dev.mokkery.answering.returns
-import dev.mokkery.every
-import dev.mokkery.matcher.any
-import dev.mokkery.mock
-import dev.mokkery.verify
-import dev.mokkery.verify.VerifyMode
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.advanceTimeBy
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
-import org.meshtastic.core.model.RadioNotConnectedException
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.testing.FakeBleConnection
-import org.meshtastic.core.testing.FakeBleConnectionFactory
-import org.meshtastic.core.testing.FakeBleDevice
-import org.meshtastic.core.testing.FakeBleScanner
-import org.meshtastic.core.testing.FakeBluetoothRepository
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class BleRadioTransportTest {
-
- private val testScope = TestScope()
- private val scanner = FakeBleScanner()
- private val bluetoothRepository = FakeBluetoothRepository()
- private val connection = FakeBleConnection()
- private val connectionFactory = FakeBleConnectionFactory(connection)
- private val service: RadioInterfaceService = mock(MockMode.autofill)
- private val address = "00:11:22:33:44:55"
-
- @BeforeTest
- fun setup() {
- bluetoothRepository.setHasPermissions(true)
- bluetoothRepository.setBluetoothEnabled(true)
- }
-
- @Test
- fun `connect attempts to scan and connect via start`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Device")
- scanner.emitDevice(device)
-
- val bleTransport =
- BleRadioTransport(
- scope = testScope,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- // start() begins connect() which is async
- // In a real test we'd verify the connection state,
- // but for now this confirms it works with the fakes.
- assertEquals(address, bleTransport.address)
- }
-
- @Test
- fun `address returns correct value`() {
- val bleTransport =
- BleRadioTransport(
- scope = testScope,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- assertEquals(address, bleTransport.address)
- }
-
- /**
- * After [BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD] consecutive connection failures,
- * [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep
- * timeout in [MeshConnectionManagerImpl]).
- *
- * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1
- * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms —
- * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24
- * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called
- */
- @Test
- fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Device")
- bluetoothRepository.bond(device) // skip BLE scan — device is already bonded
-
- // Make every connectAndAwait call throw so each iteration counts as one failure.
- connection.connectException = RadioNotConnectedException("simulated failure")
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- // Advance through exactly 3 failure iterations (≈24 001 ms virtual time).
- // The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended
- // and advanceTimeBy returns cleanly.
- advanceTimeBy(24_001L)
-
- verify { service.onDisconnect(any(), any()) }
-
- // Cancel the reconnect loop so runTest can complete.
- bleTransport.close()
- }
-
- /**
- * Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected
- * device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm —
- * well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must
- * never call `onDisconnect(isPermanent = true)` from the give-up path.
- *
- * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw +
- * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s
- * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance.
- */
- @Test
- fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Device")
- bluetoothRepository.bond(device)
-
- connection.connectException = RadioNotConnectedException("simulated failure")
- every { service.onDisconnect(any(), any()) } returns Unit
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
-
- // Run well past where the legacy policy (maxFailures = 10) would have given up.
- advanceTimeBy(800_001L)
-
- // Transient disconnects (isPermanent = false) are expected once the failure threshold is hit;
- // the policy must NEVER signal a permanent disconnect on its own. Only explicit close()
- // (verified separately by the service layer) may emit isPermanent = true.
- verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) }
-
- bleTransport.close()
- }
-
- @Test
- fun `findDevice scans with both service UUID and address`() = runTest {
- val device = FakeBleDevice(address = address, name = "Test Device")
- scanner.emitDevice(device)
-
- val bleTransport =
- BleRadioTransport(
- scope = this,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- callback = service,
- address = address,
- )
- bleTransport.start()
- advanceTimeBy(3_001)
-
- assertNotNull(scanner.lastScanServiceUuid, "scan must include serviceUuid")
- assertEquals(SERVICE_UUID, scanner.lastScanServiceUuid)
- assertEquals(address, scanner.lastScanAddress)
-
- bleTransport.close()
- }
-}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt
deleted file mode 100644
index a6a7aa82c..000000000
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt
+++ /dev/null
@@ -1,277 +0,0 @@
-/*
- * 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.network.radio
-
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.advanceTimeBy
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-
-class BleReconnectPolicyTest {
-
- @Test
- fun `stable disconnect resets failures and returns Continue`() {
- val policy = BleReconnectPolicy()
- // Simulate one prior failure
- policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
- assertEquals(1, policy.consecutiveFailures)
-
- // Now a stable disconnect should reset
- val action =
- policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false))
- assertEquals(BleReconnectPolicy.Action.Continue, action)
- assertEquals(0, policy.consecutiveFailures)
- }
-
- @Test
- fun `intentional disconnect resets failures and returns Continue`() {
- val policy = BleReconnectPolicy()
- policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
-
- val action =
- policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = true))
- assertEquals(BleReconnectPolicy.Action.Continue, action)
- assertEquals(0, policy.consecutiveFailures)
- }
-
- @Test
- fun `unstable disconnect increments failures`() {
- val policy = BleReconnectPolicy()
- val action =
- policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false))
- assertEquals(1, policy.consecutiveFailures)
- assertTrue(action is BleReconnectPolicy.Action.Retry)
- }
-
- @Test
- fun `failure at threshold signals transient disconnect`() {
- val policy = BleReconnectPolicy(failureThreshold = 3)
- // Accumulate failures up to threshold
- repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) }
- val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
- assertEquals(3, policy.consecutiveFailures)
- assertTrue(action is BleReconnectPolicy.Action.SignalTransient)
- }
-
- @Test
- fun `failure at max gives up permanently`() {
- val policy = BleReconnectPolicy(maxFailures = 3)
- repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) }
- val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
- assertEquals(BleReconnectPolicy.Action.GiveUp, action)
- }
-
- @Test
- fun `backoff increases with consecutive failures`() {
- val policy = BleReconnectPolicy()
- val backoffs =
- (1..5).map { i ->
- val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
- when (action) {
- is BleReconnectPolicy.Action.Retry -> action.backoff
- is BleReconnectPolicy.Action.SignalTransient -> action.backoff
- else -> error("Unexpected action: $action")
- }
- }
- // Verify backoffs are non-decreasing
- for (i in 0 until backoffs.size - 1) {
- assertTrue(backoffs[i] <= backoffs[i + 1], "Expected ${backoffs[i]} <= ${backoffs[i + 1]}")
- }
- }
-
- @Test
- fun `custom backoff strategy is used`() {
- val customBackoff = 42.seconds
- val policy = BleReconnectPolicy(backoffStrategy = { customBackoff })
- val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
- assertTrue(action is BleReconnectPolicy.Action.Retry)
- assertEquals(customBackoff, action.backoff)
- }
-
- @Test
- fun `maxFailures equal to failureThreshold gives up without signalling transient`() {
- val policy = BleReconnectPolicy(maxFailures = 3, failureThreshold = 3)
- repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) }
- val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
- // GiveUp takes priority over SignalTransient when both thresholds are the same
- assertEquals(BleReconnectPolicy.Action.GiveUp, action)
- }
-
- @Test
- fun `failure count resets after stable disconnect then re-increments`() {
- val policy = BleReconnectPolicy()
- // Accumulate two failures
- repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) }
- assertEquals(2, policy.consecutiveFailures)
-
- // Stable disconnect resets
- policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false))
- assertEquals(0, policy.consecutiveFailures)
-
- // New failure starts from 1
- policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test")))
- assertEquals(1, policy.consecutiveFailures)
- }
-
- // region execute() loop tests
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `execute gives up after maxFailures and calls onPermanentDisconnect`() = runTest {
- val policy =
- BleReconnectPolicy(maxFailures = 3, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds })
- var permanentError: Throwable? = null
- var permanentCalled = false
- var transientCalled = false
-
- policy.execute(
- attempt = { BleReconnectPolicy.Outcome.Failed(RuntimeException("connection failed")) },
- onTransientDisconnect = { transientCalled = true },
- onPermanentDisconnect = { error ->
- permanentCalled = true
- permanentError = error
- },
- )
-
- assertTrue(permanentCalled, "onPermanentDisconnect should have been called")
- assertNotNull(permanentError, "error should be passed to onPermanentDisconnect")
- assertEquals("connection failed", permanentError?.message)
- assertEquals(3, policy.consecutiveFailures)
- // failureThreshold defaults to 3, same as maxFailures here, so GiveUp takes priority
- assertTrue(!transientCalled, "onTransientDisconnect should not be called when GiveUp fires first")
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `execute calls onTransientDisconnect at threshold then continues retrying`() = runTest {
- var attemptCount = 0
- val policy =
- BleReconnectPolicy(
- maxFailures = 5,
- failureThreshold = 2,
- settleDelay = 1.milliseconds,
- backoffStrategy = { 1.milliseconds },
- )
- var transientCount = 0
-
- policy.execute(
- attempt = {
- attemptCount++
- BleReconnectPolicy.Outcome.Failed(RuntimeException("fail #$attemptCount"))
- },
- onTransientDisconnect = { transientCount++ },
- onPermanentDisconnect = {},
- )
-
- assertEquals(5, attemptCount, "should attempt exactly maxFailures times")
- // Transient is signalled for failures 2, 3, 4 (at or above threshold, below maxFailures)
- assertEquals(3, transientCount, "should signal transient for each failure at or above threshold")
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `execute continues immediately after stable disconnect`() = runTest {
- var attemptCount = 0
- val policy =
- BleReconnectPolicy(maxFailures = 5, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds })
-
- policy.execute(
- attempt = {
- attemptCount++
- if (attemptCount <= 2) {
- // First two attempts connect briefly and disconnect stably
- BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)
- } else {
- // Then fail until maxFailures
- BleReconnectPolicy.Outcome.Failed(RuntimeException("fail"))
- }
- },
- onTransientDisconnect = {},
- onPermanentDisconnect = {},
- )
-
- // 2 stable disconnects + 5 failures (counter resets after each stable, so needs 5 more to hit max)
- assertEquals(7, attemptCount)
- assertEquals(5, policy.consecutiveFailures)
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `execute passes null error for unstable disconnect at threshold`() = runTest {
- val policy =
- BleReconnectPolicy(
- maxFailures = 5,
- failureThreshold = 2,
- settleDelay = 1.milliseconds,
- backoffStrategy = { 1.milliseconds },
- )
- val transientErrors = mutableListOf()
- var attemptCount = 0
-
- policy.execute(
- attempt = {
- attemptCount++
- // Use unstable disconnects (not Failed) so lastError is null
- BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false)
- },
- onTransientDisconnect = { error -> transientErrors.add(error) },
- onPermanentDisconnect = {},
- )
-
- // Disconnected outcomes don't have errors, so all transient callbacks get null
- assertTrue(transientErrors.all { it == null }, "Disconnected outcomes should pass null error")
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- @Test
- fun `execute stops when coroutine is cancelled`() = runTest {
- var attemptCount = 0
- val policy =
- BleReconnectPolicy(maxFailures = 100, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds })
-
- val job =
- backgroundScope.launch {
- policy.execute(
- attempt = {
- attemptCount++
- // Always succeed stably — loop should run until cancelled
- BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)
- },
- onTransientDisconnect = {},
- onPermanentDisconnect = {},
- )
- }
-
- // Let a few iterations run, then cancel
- advanceTimeBy(50)
- job.cancel()
- advanceUntilIdle()
-
- // Should have made some attempts but not reached maxFailures
- assertTrue(attemptCount > 0, "should have attempted at least once")
- assertTrue(attemptCount < 100, "should not have exhausted all failures — was cancelled")
- }
-
- // endregion
-}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt
deleted file mode 100644
index f3514c752..000000000
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * 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.network.radio
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-import kotlin.time.Duration.Companion.seconds
-
-/**
- * Tests the exponential backoff schedule used by [BleRadioTransport] when consecutive connection attempts fail. The
- * schedule is: failure #1 → 5 s failure #2 → 10 s failure #3 → 20 s failure #4 → 40 s failure #5+ → 60 s (capped)
- */
-class ReconnectBackoffTest {
-
- @Test
- fun `zero failures yields base delay`() {
- assertEquals(5.seconds, computeReconnectBackoff(0))
- }
-
- @Test
- fun `first failure yields 5s`() {
- assertEquals(5.seconds, computeReconnectBackoff(1))
- }
-
- @Test
- fun `second failure yields 10s`() {
- assertEquals(10.seconds, computeReconnectBackoff(2))
- }
-
- @Test
- fun `third failure yields 20s`() {
- assertEquals(20.seconds, computeReconnectBackoff(3))
- }
-
- @Test
- fun `fourth failure yields 40s`() {
- assertEquals(40.seconds, computeReconnectBackoff(4))
- }
-
- @Test
- fun `fifth failure is capped at 60s`() {
- assertEquals(60.seconds, computeReconnectBackoff(5))
- }
-
- @Test
- fun `large failure count stays capped at 60s`() {
- assertEquals(60.seconds, computeReconnectBackoff(100))
- }
-
- @Test
- fun `backoff is strictly increasing up to the cap`() {
- val values = (1..5).map { computeReconnectBackoff(it) }
- for (i in 0 until values.size - 1) {
- assertTrue(
- values[i] < values[i + 1],
- "Expected backoff[${i + 1}] (${values[i]}) < backoff[${i + 2}] (${values[i + 1]})",
- )
- }
- }
-}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt
deleted file mode 100644
index 6faa69217..000000000
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * 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.network.radio
-
-import dev.mokkery.MockMode
-import dev.mokkery.mock
-import dev.mokkery.verify
-import io.kotest.property.Arb
-import io.kotest.property.arbitrary.byte
-import io.kotest.property.arbitrary.byteArray
-import io.kotest.property.arbitrary.int
-import io.kotest.property.checkAll
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.network.transport.StreamFrameCodec
-import org.meshtastic.core.repository.RadioTransportCallback
-import kotlin.test.Test
-import kotlin.test.assertTrue
-
-class StreamTransportTest {
-
- private val callback: RadioTransportCallback = mock(MockMode.autofill)
- private lateinit var fakeStream: FakeStreamTransport
-
- class FakeStreamTransport(callback: RadioTransportCallback, scope: TestScope) : StreamTransport(callback, scope) {
- val sentBytes = mutableListOf()
-
- override fun sendBytes(p: ByteArray) {
- sentBytes.add(p)
- }
-
- override fun flushBytes() {
- /* no-op */
- }
-
- override fun keepAlive() {
- /* no-op */
- }
-
- fun feed(b: Byte) = readChar(b)
-
- public override fun connect() = super.connect()
- }
-
- private val testScope = TestScope()
-
- @Test
- fun `handleSendToRadio property test`() = runTest {
- fakeStream = FakeStreamTransport(callback, testScope)
-
- checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) }
- }
-
- @Test
- fun `readChar property test`() = runTest {
- fakeStream = FakeStreamTransport(callback, testScope)
-
- checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data ->
- data.forEach { fakeStream.feed(it) }
- // Ensure no crash
- }
- }
-
- @Test
- fun `connect sends wake bytes`() {
- fakeStream = FakeStreamTransport(callback, testScope)
- fakeStream.connect()
-
- assertTrue(fakeStream.sentBytes.isNotEmpty())
- assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES))
- verify { callback.onConnect() }
- }
-}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt
deleted file mode 100644
index 5313fd17a..000000000
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- * 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.network.transport
-
-import io.kotest.matchers.nulls.shouldNotBeNull
-import io.kotest.matchers.shouldBe
-import io.kotest.property.Arb
-import io.kotest.property.arbitrary.byte
-import io.kotest.property.arbitrary.byteArray
-import io.kotest.property.arbitrary.int
-import io.kotest.property.checkAll
-import kotlinx.coroutines.test.runTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-class StreamFrameCodecTest {
-
- private val receivedPackets = mutableListOf()
- private val codec = StreamFrameCodec(onPacketReceived = { receivedPackets.add(it) }, logTag = "Test")
-
- @Test
- fun `processInputByte delivers a 1-byte packet`() {
- val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x42)
-
- packet.forEach { codec.processInputByte(it) }
-
- assertEquals(1, receivedPackets.size)
- assertEquals(listOf(0x42.toByte()), receivedPackets[0].toList())
- }
-
- @Test
- fun `processInputByte handles zero length packet`() {
- val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x00)
-
- packet.forEach { codec.processInputByte(it) }
-
- assertEquals(1, receivedPackets.size)
- assertTrue(receivedPackets[0].isEmpty())
- }
-
- @Test
- fun `processInputByte loses sync on invalid START2`() {
- // START1, wrong START2, START1, START2, LenMSB=0, LenLSB=1, payload
- val data = byteArrayOf(0x94.toByte(), 0x00, 0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x55)
-
- data.forEach { codec.processInputByte(it) }
-
- assertEquals(1, receivedPackets.size)
- assertEquals(listOf(0x55.toByte()), receivedPackets[0].toList())
- }
-
- @Test
- fun `frameAndSend and processInputByte are inverse`() = runTest {
- checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload ->
- var received: ByteArray? = null
- val codec = StreamFrameCodec(onPacketReceived = { received = it })
-
- val bytes = mutableListOf()
- codec.frameAndSend(payload, sendBytes = { bytes.add(it) })
-
- bytes.forEach { arr -> arr.forEach { codec.processInputByte(it) } }
-
- received.shouldNotBeNull()
- received.shouldBe(payload)
- }
- }
-
- @Test
- fun `processInputByte is robust against random noise`() = runTest {
- checkAll(Arb.byteArray(Arb.int(0, 1000), Arb.byte())) { noise ->
- val codec = StreamFrameCodec(onPacketReceived = { /* ignore */ })
- noise.forEach { codec.processInputByte(it) }
- // Should not crash
- }
- }
-
- @Test
- fun `processInputByte handles multiple packets sequentially`() {
- val packet1 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x11)
- val packet2 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x22)
-
- packet1.forEach { codec.processInputByte(it) }
- packet2.forEach { codec.processInputByte(it) }
-
- assertEquals(2, receivedPackets.size)
- assertEquals(listOf(0x11.toByte()), receivedPackets[0].toList())
- assertEquals(listOf(0x22.toByte()), receivedPackets[1].toList())
- }
-
- @Test
- fun `processInputByte handles large packet up to MAX_TO_FROM_RADIO_SIZE`() {
- val size = 512
- val payload = ByteArray(size) { it.toByte() }
- val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), (size shr 8).toByte(), (size and 0xff).toByte())
-
- header.forEach { codec.processInputByte(it) }
- payload.forEach { codec.processInputByte(it) }
-
- assertEquals(1, receivedPackets.size)
- assertEquals(payload.toList(), receivedPackets[0].toList())
- }
-
- @Test
- fun `processInputByte loses sync on overly large packet length`() {
- // 513 bytes is > 512
- val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x02, 0x01)
-
- header.forEach { codec.processInputByte(it) }
-
- assertTrue(receivedPackets.isEmpty())
- }
-
- @Test
- fun `processInputByte handles multi-byte payload`() {
- val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05)
- val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x05)
-
- header.forEach { codec.processInputByte(it) }
- payload.forEach { codec.processInputByte(it) }
-
- assertEquals(1, receivedPackets.size)
- assertEquals(payload.toList(), receivedPackets[0].toList())
- }
-
- @Test
- fun `reset clears framing state`() {
- // Feed partial header
- codec.processInputByte(0x94.toByte())
- codec.processInputByte(0xc3.toByte())
-
- // Reset mid-stream
- codec.reset()
-
- // Now feed a complete packet — should work from scratch
- val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0xAA.toByte())
- packet.forEach { codec.processInputByte(it) }
-
- assertEquals(1, receivedPackets.size)
- assertEquals(listOf(0xAA.toByte()), receivedPackets[0].toList())
- }
-
- @Test
- fun `frameAndSend produces correct header for 1-byte payload`() = runTest {
- val payload = byteArrayOf(0x42.toByte())
- val sentBytes = mutableListOf()
-
- codec.frameAndSend(payload, sendBytes = { sentBytes.add(it) })
-
- // First sent bytes are the 4-byte header, second is the payload
- assertEquals(2, sentBytes.size)
- val header = sentBytes[0]
- assertEquals(4, header.size)
- assertEquals(0x94.toByte(), header[0])
- assertEquals(0xc3.toByte(), header[1])
- assertEquals(0x00.toByte(), header[2])
- assertEquals(0x01.toByte(), header[3])
-
- val sentPayload = sentBytes[1]
- assertEquals(payload.toList(), sentPayload.toList())
- }
-
- @Test
- fun `WAKE_BYTES is four START1 bytes`() {
- assertEquals(4, StreamFrameCodec.WAKE_BYTES.size)
- StreamFrameCodec.WAKE_BYTES.forEach { assertEquals(0x94.toByte(), it) }
- }
-
- @Test
- fun `DEFAULT_TCP_PORT is 4403`() {
- assertEquals(4403, StreamFrameCodec.DEFAULT_TCP_PORT)
- }
-}
diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
deleted file mode 100644
index 9a0bd278e..000000000
--- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * 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.network.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.network.transport.StreamFrameCodec
-import org.meshtastic.core.network.transport.TcpTransport
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportCallback
-import kotlin.concurrent.Volatile
-
-/**
- * TCP radio transport — thin adapter over the shared [TcpTransport] from `core:network`.
- *
- * Implements [RadioTransport] directly via composition over [TcpTransport], delegating send/receive to the transport
- * and calling [RadioTransportCallback] for lifecycle events. This avoids the previous inheritance from
- * [StreamTransport] which created a dead [StreamFrameCodec] and required overriding `sendBytes` as a no-op.
- */
-open class TcpRadioTransport(
- private val callback: RadioTransportCallback,
- private val scope: CoroutineScope,
- private val dispatchers: CoroutineDispatchers,
- private val address: String,
-) : RadioTransport {
-
- companion object {
- const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT
- }
-
- /** Guards against a double [RadioTransportCallback.onDisconnect] when [close] triggers [TcpTransport.stop]. */
- @Volatile private var closing = false
-
- private val transport =
- TcpTransport(
- dispatchers = dispatchers,
- scope = scope,
- listener =
- object : TcpTransport.Listener {
- override fun onConnected() {
- callback.onConnect()
- }
-
- override fun onDisconnected() {
- if (closing) return // close() will fire the permanent disconnect itself
- // TCP disconnects are transient (not permanent) — the transport will auto-reconnect.
- callback.onDisconnect(isPermanent = false)
- }
-
- override fun onPacketReceived(bytes: ByteArray) {
- callback.handleFromRadio(bytes)
- }
- },
- logTag = "TcpRadioTransport[$address]",
- )
-
- override fun start() {
- transport.start(address)
- }
-
- override suspend fun close() {
- Logger.d { "[$address] Closing TCP transport" }
- closing = true
- transport.stop()
- // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the
- // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting
- // it from close() caused a double-disconnect and prevented the auto-reconnect loop from
- // owning its own lifecycle. The `closing` guard above suppresses the listener's transient
- // disconnect during teardown.
- }
-
- override fun keepAlive() {
- Logger.d { "[$address] TCP keepAlive" }
- scope.handledLaunch { transport.sendHeartbeat() }
- }
-
- override fun handleSendToRadio(p: ByteArray) {
- scope.handledLaunch { transport.sendPacket(p) }
- }
-}
diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt
deleted file mode 100644
index ea32c7474..000000000
--- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt
+++ /dev/null
@@ -1,333 +0,0 @@
-/*
- * 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.network.transport
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.withContext
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.proto.ToRadio
-import java.io.BufferedInputStream
-import java.io.BufferedOutputStream
-import java.io.IOException
-import java.io.OutputStream
-import java.net.InetAddress
-import java.net.Socket
-import java.net.SocketTimeoutException
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicInteger
-
-/**
- * Shared JVM TCP transport for Meshtastic radios.
- *
- * Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff) and uses [StreamFrameCodec] for the
- * START1/START2 stream framing protocol. [sendHeartbeat] sends a heartbeat with a monotonically-increasing nonce so the
- * firmware's per-connection duplicate-write filter does not silently drop it.
- *
- * Used by Android and Desktop via the shared `SharedRadioInterfaceService`.
- */
-@Suppress("TooManyFunctions", "MagicNumber")
-class TcpTransport(
- private val dispatchers: CoroutineDispatchers,
- private val scope: CoroutineScope,
- private val listener: Listener,
- private val logTag: String = "TcpTransport",
-) {
-
- /** Callbacks from the transport to the owning radio interface. */
- interface Listener {
- /** Called when the TCP connection is established and wake bytes have been sent. */
- fun onConnected()
-
- /** Called when the TCP connection is lost. */
- fun onDisconnected()
-
- /** Called when a decoded Meshtastic packet arrives. */
- fun onPacketReceived(bytes: ByteArray)
- }
-
- companion object {
- /**
- * Maximum reconnect retries. Set to [Int.MAX_VALUE] to retry indefinitely — the caller ([TcpTransport.stop])
- * owns the cancellation lifecycle.
- */
- const val MAX_RECONNECT_RETRIES = Int.MAX_VALUE
- const val MIN_BACKOFF_MILLIS = 1_000L
- const val MAX_BACKOFF_MILLIS = 5 * 60 * 1_000L
- const val SOCKET_TIMEOUT_MS = 5_000
- const val SOCKET_RETRIES = 18 // 18 * 5s = 90s inactivity before disconnect
- const val TIMEOUT_LOG_INTERVAL = 5
- private const val MILLIS_PER_SECOND = 1_000L
- }
-
- private val codec =
- StreamFrameCodec(
- onPacketReceived = {
- packetsReceived++
- listener.onPacketReceived(it)
- },
- logTag = logTag,
- )
-
- // TCP socket state
- @Volatile private var socket: Socket? = null
-
- @Volatile private var outStream: OutputStream? = null
-
- @Volatile private var connectionJob: Job? = null
-
- @Volatile private var currentAddress: String? = null
-
- // Metrics
- @Volatile private var connectionStartTime: Long = 0
-
- @Volatile private var packetsReceived: Int = 0
-
- @Volatile private var packetsSent: Int = 0
-
- @Volatile private var bytesReceived: Long = 0
-
- @Volatile private var bytesSent: Long = 0
-
- @Volatile private var timeoutEvents: Int = 0
-
- private val heartbeatNonce = AtomicInteger(0)
-
- /** Whether the transport is currently connected. */
- val isConnected: Boolean
- get() {
- val s = socket ?: return false
- return s.isConnected && !s.isClosed
- }
-
- /**
- * Start a TCP connection to the given address with automatic reconnect.
- *
- * @param address host or host:port string
- */
- fun start(address: String) {
- stop()
- currentAddress = address
- connectionJob = scope.handledLaunch { connectWithRetry(address) }
- }
-
- /** Stop the transport and close the socket. */
- fun stop() {
- connectionJob?.cancel()
- connectionJob = null
- disconnectSocket()
- currentAddress = null
- }
-
- /**
- * Send a raw framed Meshtastic packet.
- *
- * The payload is wrapped with the START1/START2 header by the codec.
- */
- suspend fun sendPacket(payload: ByteArray) {
- codec.frameAndSend(payload = payload, sendBytes = ::sendBytesRaw, flush = ::flushBytes)
- packetsSent++
- bytesSent += payload.size
- }
-
- /** Send a heartbeat packet with a monotonically-increasing nonce to keep the connection alive. */
- suspend fun sendHeartbeat() {
- val nonce = heartbeatNonce.getAndIncrement()
- val heartbeat = ToRadio(heartbeat = org.meshtastic.proto.Heartbeat(nonce = nonce))
- sendPacket(heartbeat.encode())
- }
-
- // region Connection lifecycle
-
- @Suppress("NestedBlockDepth")
- private suspend fun connectWithRetry(address: String) {
- var retryCount = 1
- var backoff = MIN_BACKOFF_MILLIS
-
- while (retryCount <= MAX_RECONNECT_RETRIES) {
- val hadData =
- try {
- connectAndRead(address)
- } catch (ex: IOException) {
- Logger.w { "$logTag: [$address] TCP connection error" }
- disconnectSocket()
- false
- } catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) {
- Logger.e(ex) { "$logTag: [$address] TCP exception" }
- disconnectSocket()
- false
- }
-
- // Reset backoff after a connection that successfully exchanged data,
- // so transient firmware-side disconnects recover quickly.
- if (hadData) {
- Logger.d { "$logTag: [$address] Resetting backoff after successful data exchange" }
- retryCount = 1
- backoff = MIN_BACKOFF_MILLIS
- }
-
- val delaySec = backoff / MILLIS_PER_SECOND
- Logger.i { "$logTag: [$address] Reconnect #$retryCount in ${delaySec}s" }
- delay(backoff)
- retryCount++
- backoff = minOf(backoff * 2, MAX_BACKOFF_MILLIS)
- }
- }
-
- /**
- * Connect to the given address, read data until the connection is lost, and return whether any bytes were
- * successfully received (used by [connectWithRetry] to decide whether to reset backoff).
- */
- @Suppress("NestedBlockDepth")
- private suspend fun connectAndRead(address: String): Boolean = withContext(dispatchers.io) {
- val parts = address.split(":", limit = 2)
- val host = parts[0]
- val port = parts.getOrNull(1)?.toIntOrNull() ?: StreamFrameCodec.DEFAULT_TCP_PORT
-
- Logger.i { "$logTag: [$address] Connecting to $host:$port" }
- val attemptStart = nowMillis
-
- Socket(InetAddress.getByName(host), port).use { sock ->
- sock.tcpNoDelay = true
- sock.keepAlive = true
- sock.soTimeout = SOCKET_TIMEOUT_MS
- socket = sock
-
- val connectTime = nowMillis - attemptStart
- connectionStartTime = nowMillis
- resetMetrics()
- codec.reset()
-
- Logger.i { "$logTag: [$address] Socket connected in ${connectTime}ms" }
-
- BufferedOutputStream(sock.getOutputStream()).use { output ->
- outStream = output
-
- BufferedInputStream(sock.getInputStream()).use { input ->
- // Send wake bytes and signal connected
- sendBytesRaw(StreamFrameCodec.WAKE_BYTES)
- listener.onConnected()
-
- // Read loop
- var timeoutCount = 0
- while (timeoutCount < SOCKET_RETRIES) {
- try {
- val c = input.read()
- if (c == -1) {
- Logger.i { "$logTag: [$address] EOF after $packetsReceived packets" }
- break
- }
- timeoutCount = 0
- bytesReceived++
- codec.processInputByte(c.toByte())
- } catch (_: SocketTimeoutException) {
- timeoutCount++
- timeoutEvents++
- if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) {
- Logger.d { "$logTag: [$address] Timeout $timeoutCount/$SOCKET_RETRIES" }
- }
- }
- }
-
- if (timeoutCount >= SOCKET_RETRIES) {
- Logger.w { "$logTag: [$address] Closing after $SOCKET_RETRIES consecutive timeouts" }
- }
- }
- }
- val hadData = bytesReceived > 0
- disconnectSocket()
- hadData
- }
- }
-
- // Guards against recursive disconnects triggered by listener callbacks.
- private val isDisconnecting = AtomicBoolean(false)
-
- private fun disconnectSocket() {
- if (!isDisconnecting.compareAndSet(false, true)) return
-
- try {
- val s = socket
- val hadConnection = s != null || outStream != null
- if (s != null) {
- val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
- Logger.i {
- "$logTag: [$currentAddress] Disconnecting - Uptime: ${uptime}ms, " +
- "RX: $packetsReceived ($bytesReceived bytes), " +
- "TX: $packetsSent ($bytesSent bytes)"
- }
- try {
- s.close()
- } catch (_: IOException) {
- // Ignore close errors
- }
- }
-
- socket = null
- outStream = null
-
- if (hadConnection) {
- listener.onDisconnected()
- }
- } finally {
- isDisconnecting.set(false)
- }
- }
-
- // endregion
-
- // region Byte I/O
-
- private fun sendBytesRaw(p: ByteArray) {
- val stream =
- outStream
- ?: run {
- Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" }
- return
- }
- try {
- stream.write(p)
- } catch (ex: IOException) {
- Logger.w(ex) { "$logTag: [$currentAddress] TCP write error" }
- disconnectSocket()
- }
- }
-
- private fun flushBytes() {
- val stream = outStream ?: return
- try {
- stream.flush()
- } catch (ex: IOException) {
- Logger.w(ex) { "$logTag: [$currentAddress] TCP flush error" }
- disconnectSocket()
- }
- }
-
- // endregion
-
- private fun resetMetrics() {
- packetsReceived = 0
- packetsSent = 0
- bytesReceived = 0
- bytesSent = 0
- timeoutEvents = 0
- }
-}
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
deleted file mode 100644
index 45ba70eb7..000000000
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
+++ /dev/null
@@ -1,242 +0,0 @@
-/*
- * 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.network
-
-import co.touchlab.kermit.Logger
-import com.fazecast.jSerialComm.SerialPort
-import com.fazecast.jSerialComm.SerialPortTimeoutException
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.network.radio.StreamTransport
-import org.meshtastic.core.network.transport.HeartbeatSender
-import org.meshtastic.core.repository.RadioTransportCallback
-import java.io.File
-
-/**
- * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamTransport] for START1/START2 packet
- * framing.
- *
- * Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read
- * loop is started.
- */
-class SerialTransport
-private constructor(
- private val portName: String,
- private val baudRate: Int = DEFAULT_BAUD_RATE,
- callback: RadioTransportCallback,
- scope: CoroutineScope,
- private val dispatchers: CoroutineDispatchers,
-) : StreamTransport(callback, scope) {
- private var serialPort: SerialPort? = null
- private var readJob: Job? = null
-
- private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$portName]")
-
- /** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */
- private fun startConnection(): Boolean {
- return try {
- val port = SerialPort.getCommPort(portName) ?: return false
- port.setComPortParameters(baudRate, DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY)
- port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, READ_TIMEOUT_MS, 0)
- if (port.openPort()) {
- serialPort = port
- port.setDTR()
- port.setRTS()
- Logger.i { "[$portName] Serial port opened (baud=$baudRate)" }
- super.connect() // Sends WAKE_BYTES and signals callback.onConnect()
- startReadLoop(port)
- true
- } else {
- Logger.w { "[$portName] Serial port openPort() returned false" }
- false
- }
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[$portName] Serial connection failed" }
- false
- }
- }
-
- @Suppress("CyclomaticComplexMethod")
- private fun startReadLoop(port: SerialPort) {
- Logger.d { "[$portName] Starting serial read loop" }
- readJob =
- scope.launch(dispatchers.io) {
- val input = port.inputStream
- val buffer = ByteArray(READ_BUFFER_SIZE)
- try {
- var reading = true
- while (isActive && port.isOpen && reading) {
- try {
- val numRead = input.read(buffer)
- if (numRead == -1) {
- reading = false
- } else if (numRead > 0) {
- for (i in 0 until numRead) {
- readChar(buffer[i])
- }
- }
- } catch (_: SerialPortTimeoutException) {
- // Expected timeout when no data is available
- } catch (e: CancellationException) {
- throw e
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- if (isActive) {
- Logger.w(e) { "[$portName] Serial read error" }
- } else {
- Logger.d { "[$portName] Serial read interrupted by cancellation" }
- }
- reading = false
- }
- }
- } catch (e: CancellationException) {
- throw e
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- if (isActive) {
- Logger.w(e) { "[$portName] Serial read loop outer error" }
- } else {
- Logger.d { "[$portName] Serial read loop interrupted by cancellation" }
- }
- } finally {
- Logger.d { "[$portName] Serial read loop exiting" }
- try {
- input.close()
- } catch (_: Exception) {
- // Ignore errors during input stream close
- }
- try {
- if (port.isOpen) {
- port.closePort()
- }
- } catch (_: Exception) {
- // Ignore errors during port close
- }
- if (isActive) {
- // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as
- // transient — the user did not explicitly disconnect, and the port may come
- // back when the device is replugged or the OS re-enumerates it.
- onDeviceDisconnect(waitForStopped = true, isPermanent = false)
- }
- }
- }
- }
-
- override fun sendBytes(p: ByteArray) {
- serialPort?.takeIf { it.isOpen }?.outputStream?.write(p)
- }
-
- override fun flushBytes() {
- serialPort?.takeIf { it.isOpen }?.outputStream?.flush()
- }
-
- override fun keepAlive() {
- // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the
- // serial link is alive.
- scope.launch { heartbeatSender.sendHeartbeat() }
- }
-
- private fun closePortResources() {
- serialPort?.takeIf { it.isOpen }?.closePort()
- serialPort = null
- }
-
- override suspend fun close() {
- Logger.d { "[$portName] Closing serial transport" }
- readJob?.cancel()
- readJob = null
- closePortResources()
- super.close()
- }
-
- companion object {
- private const val DEFAULT_BAUD_RATE = 115200
- private const val DATA_BITS = 8
- private const val READ_BUFFER_SIZE = 1024
- private const val READ_TIMEOUT_MS = 100
-
- /**
- * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient
- * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as
- * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the
- * user grants permission); only an explicit close should signal a permanent disconnect.
- */
- fun open(
- portName: String,
- baudRate: Int = DEFAULT_BAUD_RATE,
- callback: RadioTransportCallback,
- scope: CoroutineScope,
- dispatchers: CoroutineDispatchers,
- ): SerialTransport {
- val transport = SerialTransport(portName, baudRate, callback, scope, dispatchers)
- if (!transport.startConnection()) {
- val errorMessage = diagnoseOpenFailure(portName)
- Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
- callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
- }
- return transport
- }
-
- /**
- * Discovers and returns a list of available serial ports. Returns a list of the system port names (e.g.,
- * "COM3", "/dev/ttyUSB0").
- */
- fun getAvailablePorts(): List = SerialPort.getCommPorts().map { it.systemPortName }
-
- /**
- * Diagnoses why a serial port could not be opened and returns a user-facing error message. On Linux, checks
- * file permissions and suggests the appropriate group fix.
- */
- @Suppress("ReturnCount")
- private fun diagnoseOpenFailure(portName: String): String {
- val osName = System.getProperty("os.name", "").lowercase()
- if (!osName.contains("linux")) {
- return "Could not open serial port: $portName"
- }
-
- // jSerialComm resolves bare names like "ttyUSB0" to "/dev/ttyUSB0"
- val devPath = if (portName.startsWith("/")) portName else "/dev/$portName"
- val portFile = File(devPath)
- if (!portFile.exists()) {
- return "Serial port $portName not found. Is the device still connected?"
- }
- if (!portFile.canRead() || !portFile.canWrite()) {
- val group = detectSerialGroup(devPath)
- val user = System.getProperty("user.name", "your_user")
- return "Permission denied for $devPath. " +
- "Run: sudo usermod -aG $group $user — then log out and back in."
- }
- return "Could not open serial port: $portName"
- }
-
- /**
- * Attempts to detect the group that owns the serial device file. Falls back to "dialout" (Debian/Ubuntu
- * default) if detection fails.
- */
- @Suppress("SwallowedException", "TooGenericExceptionCaught")
- private fun detectSerialGroup(devPath: String): String = try {
- val process = ProcessBuilder("stat", "-c", "%G", devPath).redirectErrorStream(true).start()
- val group = process.inputStream.bufferedReader().readText().trim()
- process.waitFor()
- group.ifEmpty { "dialout" }
- } catch (e: Exception) {
- "dialout"
- }
- }
-}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt
deleted file mode 100644
index dabd463dc..000000000
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * 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.repository
-
-import kotlinx.coroutines.flow.StateFlow
-import org.meshtastic.proto.Channel
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.DeviceUIConfig
-import org.meshtastic.proto.LocalConfig
-import org.meshtastic.proto.LocalModuleConfig
-import org.meshtastic.proto.ModuleConfig
-
-/** Interface for handling device and module configuration updates. */
-interface MeshConfigHandler {
- /** Reactive local configuration. */
- val localConfig: StateFlow
-
- /** Reactive local module configuration. */
- val moduleConfig: StateFlow
-
- /** Handles a received device configuration. */
- fun handleDeviceConfig(config: Config)
-
- /** Handles a received module configuration. */
- fun handleModuleConfig(config: ModuleConfig)
-
- /** Handles a received channel configuration. */
- fun handleChannel(channel: Channel)
-
- /**
- * Handles the [DeviceUIConfig] received during the config handshake (STATE_SEND_UIDATA). This arrives as the 2nd
- * packet in every handshake, immediately after my_info.
- */
- fun handleDeviceUIConfig(config: DeviceUIConfig)
-}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
index 51d855494..4162a9e79 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
@@ -16,103 +16,38 @@
*/
package org.meshtastic.core.repository
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
-import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.model.MeshActivity
/**
- * Interface for the low-level radio interface that handles raw byte communication.
+ * Thin interface exposing device-address and connection-management operations to feature modules.
*
- * This is the **transport layer** — it manages the raw hardware connection (BLE, TCP, Serial, USB) to a Meshtastic
- * radio. Its [connectionState] reflects whether the physical link is up or down, **before** any handshake or
- * config-loading logic is applied.
- *
- * **Important:** UI and feature modules should **never** observe [connectionState] directly. Instead, they should use
- * [ServiceRepository.connectionState], which is the canonical app-level connection state that accounts for handshake
- * progress, light-sleep policy, and other higher-level concerns. The only legitimate consumer of this transport-level
- * flow is [MeshConnectionManager], which bridges transport state changes into the app-level
- * [ServiceRepository.connectionState].
- *
- * @see ServiceRepository.connectionState
+ * The SDK now owns the raw transport (BLE, TCP, Serial). This interface retains only the device-selection
+ * surface that Scanner and connection UIs require.
*/
-interface RadioInterfaceService : RadioTransportCallback {
+interface RadioInterfaceService {
/** The device types supported by this platform's radio interface. */
val supportedDeviceTypes: List
- /**
- * Transport-level connection state of the radio hardware.
- *
- * This flow reflects the raw state of the physical link (BLE, TCP, Serial, USB):
- * - [ConnectionState.Connected] — the transport link is established
- * - [ConnectionState.Disconnected] — the transport link is down (permanent)
- * - [ConnectionState.DeviceSleep] — the transport link is down (transient, device sleeping)
- *
- * **This is NOT the canonical app-level connection state.** The transport may report [ConnectionState.Connected]
- * while the app is still performing the mesh handshake (config + node-info exchange), during which the app-level
- * state remains [ConnectionState.Connecting].
- *
- * Only [MeshConnectionManager] should observe this flow. All other consumers (ViewModels, feature modules, UI) must
- * use [ServiceRepository.connectionState].
- *
- * @see ServiceRepository.connectionState
- */
- val connectionState: StateFlow
-
- /** Flow of the current device address. */
+ /** Flow of the current device address (e.g. "x0123456789AB" for BLE, "tTCP:192.168.1.1" for TCP). */
val currentDeviceAddressFlow: StateFlow
/** Whether we are currently using a mock transport. */
fun isMockTransport(): Boolean
- /**
- * Flow of raw data received from the radio.
- *
- * Emissions preserve the order in which bytes arrived from the hardware — this is required because the firmware
- * handshake (initial config packet ordering) depends on strict FIFO delivery. Implementations MUST guarantee
- * ordering; do not swap in a [SharedFlow] without preserving order.
- */
- val receivedData: Flow
-
- /** Flow of radio activity events. */
- val meshActivity: Flow
-
- /**
- * Drains any bytes currently buffered in [receivedData] without emitting them to collectors.
- *
- * Callers invoke this before attaching a fresh collector after a stop/start cycle so stale bytes buffered while no
- * collector was attached do not get replayed ahead of the next session's handshake.
- */
- fun resetReceivedBuffer()
-
- /** Sends a raw byte array to the radio. */
- fun sendToRadio(bytes: ByteArray)
-
- /** Initiates the connection to the radio. */
- fun connect()
-
- /**
- * Explicitly tears down the active transport, sending a polite `ToRadio(disconnect = true)` goodbye frame first
- * when a transport is live. Safe to call when nothing is connected — implementations must no-op in that case.
- * Suspends until the teardown completes.
- */
- suspend fun disconnect()
-
/** Returns the current device address. */
fun getDeviceAddress(): String?
- /** Sets the device address to connect to. */
+ /** Sets the device address to connect to. Returns true if the address changed. */
fun setDeviceAddress(deviceAddr: String?): Boolean
/** Constructs a full radio address for the specific interface type. */
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String
- /** Flow of user-facing connection error messages (e.g. permission failures). */
- val connectionError: Flow
+ /** Initiates connection to the radio at the current device address. */
+ fun connect()
- /** The scope in which interface-related coroutines should run. */
- val serviceScope: CoroutineScope
+ /** Disconnects from the radio. */
+ suspend fun disconnect()
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt
deleted file mode 100644
index c0572f83f..000000000
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * 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.repository
-
-/**
- * Interface for hardware transports (BLE, Serial, TCP, etc.) that handles raw byte communication. This is the
- * KMP-compatible replacement for the legacy Android-specific IRadioInterface.
- */
-interface RadioTransport {
- /** Sends a raw byte array to the radio hardware. */
- fun handleSendToRadio(p: ByteArray)
-
- /**
- * Initializes the transport after construction. Called by the factory once the transport has been fully created.
- *
- * This separates construction from side effects (connecting, launching coroutines), making transports easier to
- * test and reason about.
- */
- fun start() {}
-
- /**
- * If we think we are connected, but we don't hear anything from the device, we might be in a zombie state. This
- * function can be implemented by transports to see if we are really connected.
- */
- fun keepAlive() {}
-
- /**
- * Closes the connection to the device.
- *
- * Implementations that perform potentially-blocking teardown (e.g. BLE GATT disconnect) MUST run that work inside
- * `withContext(NonCancellable)` so a cancelled caller cannot skip cleanup, leaving the underlying resource leaked.
- * Callers must invoke this from a coroutine — it must never be called from a blocking context (no `runBlocking`).
- */
- suspend fun close()
-}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt
deleted file mode 100644
index 9771062a5..000000000
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * 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.repository
-
-/**
- * Narrow callback interface for transport → service communication.
- *
- * Transport implementations ([RadioTransport]) need only these three methods to report lifecycle events and deliver
- * data. This replaces the previous pattern of passing the full [RadioInterfaceService] to transport constructors,
- * decoupling transports from the service layer.
- */
-interface RadioTransportCallback {
- /** Called when the transport has successfully established a connection. */
- fun onConnect()
-
- /**
- * Called when the transport has disconnected.
- *
- * @param isPermanent true if the device is definitely gone (e.g. USB unplugged, max retries exhausted), false if it
- * may come back (e.g. BLE range, TCP transient).
- * @param errorMessage optional user-facing error message describing the disconnect reason.
- */
- fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null)
-
- /** Called when the transport has received raw data from the radio. */
- fun handleFromRadio(bytes: ByteArray)
-}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt
deleted file mode 100644
index c3d2abff1..000000000
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * 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.repository
-
-import org.meshtastic.core.model.DeviceType
-import org.meshtastic.core.model.InterfaceId
-
-/**
- * Creates [RadioTransport] instances for specific device addresses.
- *
- * Implemented per-platform to provide the correct hardware transport (BLE, Serial, TCP).
- */
-interface RadioTransportFactory {
- /** The device types supported by this factory. */
- val supportedDeviceTypes: List
-
- /** Whether we are currently forced into using a mock transport (e.g., Firebase Test Lab). */
- fun isMockTransport(): Boolean
-
- /** Creates a transport for the given [address], or a NOP implementation if invalid/unsupported. */
- fun createTransport(address: String, service: RadioInterfaceService): RadioTransport
-
- /** Checks if the given [address] represents a valid, supported transport type. */
- fun isAddressValid(address: String?): Boolean
-
- /** Constructs a full radio address for the specific [interfaceId] and [rest] identifier. */
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String
-}
diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt
deleted file mode 100644
index 303b8a4ad..000000000
--- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * 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.repository
-
-import kotlinx.coroutines.test.runTest
-import kotlin.test.Test
-import kotlin.test.assertTrue
-
-class RadioTransportTest {
-
- @Test
- fun `RadioTransport can be implemented`() = runTest {
- var sentData: ByteArray? = null
- var closed = false
- var keepAliveCalled = false
-
- val transport =
- object : RadioTransport {
- override fun handleSendToRadio(p: ByteArray) {
- sentData = p
- }
-
- override fun keepAlive() {
- keepAliveCalled = true
- }
-
- override suspend fun close() {
- closed = true
- }
- }
-
- val testData = byteArrayOf(1, 2, 3)
- transport.handleSendToRadio(testData)
- transport.keepAlive()
- transport.close()
-
- assertTrue(sentData!!.contentEquals(testData))
- assertTrue(keepAliveCalled)
- assertTrue(closed)
- }
-}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 3a401a0c3..e0bfcd64a 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -31,7 +31,7 @@ import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.MeshServiceNotifications
-import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.PortNum
@@ -45,7 +45,7 @@ import org.meshtastic.proto.PortNum
*/
class MeshService : Service() {
- private val radioInterfaceService: RadioInterfaceService by inject()
+ private val radioPrefs: RadioPrefs by inject()
private val notifications: MeshServiceNotifications by inject()
@@ -109,7 +109,7 @@ class MeshService : Service() {
return START_NOT_STICKY
}
- val a = radioInterfaceService.getDeviceAddress()
+ val a = radioPrefs.devAddr.value
val wantForeground = a != null && a != "n"
notifications.updateServiceStateNotification(serviceRepository.connectionState.value, null)
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 a6644e444..07a14aa74 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
@@ -30,7 +30,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.takserver.TAKMeshIntegration
@@ -47,7 +47,7 @@ import org.meshtastic.core.takserver.TAKServerManager
@Suppress("LongParameterList")
@Single
class MeshServiceOrchestrator(
- private val radioInterfaceService: RadioInterfaceService,
+ private val radioPrefs: RadioPrefs,
private val nodeManager: NodeManager,
private val serviceNotifications: MeshServiceNotifications,
private val takServerManager: TAKServerManager,
@@ -119,7 +119,7 @@ class MeshServiceOrchestrator(
newScope.handledLaunch {
// Ensure the per-device database is active before SDK connects.
- databaseManager.switchActiveDatabase(radioInterfaceService.getDeviceAddress())
+ databaseManager.switchActiveDatabase(radioPrefs.devAddr.value)
Logger.i { "Per-device database initialized" }
}
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
deleted file mode 100644
index 68a3573e9..000000000
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ /dev/null
@@ -1,417 +0,0 @@
-/*
- * 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.service
-
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
-import co.touchlab.kermit.Logger
-import kotlinx.atomicfu.atomic
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.receiveAsFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import org.koin.core.annotation.Named
-import org.koin.core.annotation.Single
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.ignoreExceptionSuspend
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.model.DeviceType
-import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.model.MeshActivity
-import org.meshtastic.core.model.util.anonymize
-import org.meshtastic.core.network.repository.NetworkRepository
-import org.meshtastic.core.repository.PlatformAnalytics
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioPrefs
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportFactory
-import org.meshtastic.proto.ToRadio
-import kotlin.concurrent.Volatile
-
-/**
- * Shared multiplatform connection orchestrator for Meshtastic radios.
- *
- * Manages the connection lifecycle (connect, active, disconnect, reconnect loop), device address state flows, and
- * hardware state observability (BLE/Network toggles). Delegates the actual raw byte transport mapping to a
- * platform-specific [RadioTransportFactory].
- */
-@Suppress("LongParameterList", "TooManyFunctions")
-@Single
-class SharedRadioInterfaceService(
- private val dispatchers: CoroutineDispatchers,
- private val bluetoothRepository: BluetoothRepository,
- private val networkRepository: NetworkRepository,
- @Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
- private val radioPrefs: RadioPrefs,
- private val transportFactory: RadioTransportFactory,
- private val analytics: PlatformAnalytics,
-) : RadioInterfaceService {
-
- override val supportedDeviceTypes: List
- get() = transportFactory.supportedDeviceTypes
-
- /**
- * Transport-level connection state reflecting the raw hardware link status.
- *
- * Updated directly by [onConnect] and [onDisconnect] when the physical transport (BLE, TCP, Serial) connects or
- * disconnects. This is consumed exclusively by
- * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager], which reconciles it into the
- * canonical app-level
- * [ServiceRepository.connectionState][org.meshtastic.core.repository.ServiceRepository.connectionState].
- */
- private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
- override val connectionState: StateFlow = _connectionState.asStateFlow()
-
- private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value)
- override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow()
-
- // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the
- // firmware handshake depends on (initial config packet ordering). A SharedFlow with
- // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load.
- // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can
- // remain a non-suspend synchronous callback.
- private val _receivedData = Channel(Channel.UNLIMITED)
- override val receivedData: Flow = _receivedData.receiveAsFlow()
-
- private val _meshActivity =
- MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
- override val meshActivity: Flow = _meshActivity.asFlow()
-
- private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64)
- override val connectionError: Flow = _connectionError.asFlow()
-
- override val serviceScope: CoroutineScope
- get() = _serviceScope
-
- private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
- private var radioTransport: RadioTransport? = null
- private var runningTransportId: InterfaceId? = null
- private var isStarted = false
-
- /**
- * Set while [stopTransportLocked] is draining the polite disconnect frame. [sendToRadio] checks this so any late
- * traffic submitted after we've announced disconnection is dropped rather than racing in front of the firmware-side
- * link teardown.
- */
- @Volatile private var isStopping = false
-
- private val listenersInitialized = atomic(false)
- private var heartbeatJob: Job? = null
- private var lastHeartbeatMillis = 0L
-
- @Volatile private var lastDataReceivedMillis = 0L
-
- companion object {
- private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
-
- // If we haven't received any data from the radio within this window after sending a
- // heartbeat while the connection is nominally "Connected", the connection is likely a
- // zombie (BLE stack didn't report disconnect). Two missed heartbeat intervals gives
- // the firmware a reasonable window to respond or send telemetry.
- private const val LIVENESS_TIMEOUT_MILLIS = HEARTBEAT_INTERVAL_MILLIS * 2
-
- /**
- * Upper bound on how long we wait for the polite `ToRadio(disconnect = true)` frame to flush before tearing the
- * transport down. 500ms gives BLE's write-retry path (`BleRetry` backs off 500ms) room for one attempt on a
- * flaky GATT connection. Serial and TCP typically flush well under this window.
- */
- private const val POLITE_DISCONNECT_DRAIN_MS = 500L
- }
-
- private val initLock = Mutex()
- private val transportMutex = Mutex()
-
- private fun initStateListeners() {
- if (listenersInitialized.value) return
- processLifecycle.coroutineScope.launch {
- initLock.withLock {
- if (listenersInitialized.value) return@withLock
- listenersInitialized.value = true
-
- radioPrefs.devAddr
- .onEach { addr ->
- transportMutex.withLock {
- if (_currentDeviceAddressFlow.value != addr) {
- _currentDeviceAddressFlow.value = addr
- startTransportLocked()
- }
- }
- }
- .catch { Logger.e(it) { "devAddr flow crashed" } }
- .launchIn(processLifecycle.coroutineScope)
-
- bluetoothRepository.state
- .onEach { state ->
- transportMutex.withLock {
- if (state.enabled) {
- startTransportLocked()
- } else if (runningTransportId == InterfaceId.BLUETOOTH) {
- stopTransportLocked()
- }
- }
- }
- .catch { Logger.e(it) { "bluetoothRepository.state flow crashed" } }
- .launchIn(processLifecycle.coroutineScope)
-
- networkRepository.networkAvailable
- .onEach { state ->
- transportMutex.withLock {
- if (state) {
- startTransportLocked()
- } else if (runningTransportId == InterfaceId.TCP) {
- stopTransportLocked()
- }
- }
- }
- .catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed" } }
- .launchIn(processLifecycle.coroutineScope)
- }
- }
- }
-
- override fun connect() {
- processLifecycle.coroutineScope.launch { transportMutex.withLock { startTransportLocked() } }
- initStateListeners()
- }
-
- override suspend fun disconnect() {
- transportMutex.withLock { ignoreExceptionSuspend { stopTransportLocked() } }
- }
-
- override fun isMockTransport(): Boolean = transportFactory.isMockTransport()
-
- override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
- transportFactory.toInterfaceAddress(interfaceId, rest)
-
- override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
-
- private fun getBondedDeviceAddress(): String? {
- val address = getDeviceAddress()
- return if (transportFactory.isAddressValid(address)) {
- address
- } else {
- null
- }
- }
-
- override fun setDeviceAddress(deviceAddr: String?): Boolean {
- val sanitized = if (deviceAddr == "n" || deviceAddr.isNullOrBlank()) null else deviceAddr
-
- if (getBondedDeviceAddress() == sanitized && isStarted && _connectionState.value == ConnectionState.Connected) {
- Logger.w { "Ignoring setBondedDevice ${sanitized?.anonymize}, already using that device" }
- return false
- }
-
- analytics.track("mesh_bond")
-
- Logger.d { "Setting bonded device to ${sanitized?.anonymize}" }
- radioPrefs.setDevAddr(sanitized)
- _currentDeviceAddressFlow.value = sanitized
-
- processLifecycle.coroutineScope.launch {
- transportMutex.withLock {
- ignoreExceptionSuspend { stopTransportLocked() }
- startTransportLocked()
- }
- }
- return true
- }
-
- /** Must be called under [transportMutex]. */
- private fun startTransportLocked() {
- if (radioTransport != null) return
-
- // Never autoconnect to the simulated node. The mock transport may be offered in the
- // device-picker UI on debug builds, but it must only connect when the user explicitly
- // selects it (i.e. its address is stored in radioPrefs).
- val address = getBondedDeviceAddress()
-
- if (address == null) {
- Logger.d { "No valid address to connect to" }
- return
- }
-
- Logger.i { "Starting radio transport for ${address.anonymize}" }
- isStarted = true
- runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
- radioTransport = transportFactory.createTransport(address, this)
- startHeartbeat()
- }
-
- /** Must be called under [transportMutex]. */
- private suspend fun stopTransportLocked() {
- val currentTransport = radioTransport
- Logger.i { "Stopping transport $currentTransport" }
- // Best-effort polite goodbye: tell the firmware we're disconnecting on purpose so it can
- // tear down its side of the link cleanly instead of relying on timeouts / hardware events.
- // Flip isStopping before sending so any concurrent sendToRadio() drops incoming traffic —
- // we don't want normal packets racing behind the disconnect frame. Skip only when already
- // Disconnected; firmware can still consume the goodbye while handshaking or sleeping, so
- // it's worth sending in every other state. The send is fire-and-forget through the
- // transport's own scope; the drain delay gives async transports a window to flush before
- // close() cancels their write scope. BLE's retry path backs off 500ms, so this window
- // also covers one retry on flaky GATT links.
- if (currentTransport != null && _connectionState.value != ConnectionState.Disconnected) {
- isStopping = true
- ignoreExceptionSuspend {
- currentTransport.handleSendToRadio(ToRadio(disconnect = true).encode())
- delay(POLITE_DISCONNECT_DRAIN_MS)
- }
- }
- isStarted = false
- radioTransport = null
- runningTransportId = null
- isStopping = false
- currentTransport?.close()
-
- _serviceScope.cancel("stopping transport")
- _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
-
- if (currentTransport != null) {
- onDisconnect(isPermanent = true)
- }
- }
-
- private fun startHeartbeat() {
- heartbeatJob?.cancel()
- lastDataReceivedMillis = nowMillis
- heartbeatJob =
- serviceScope.launch {
- while (true) {
- delay(HEARTBEAT_INTERVAL_MILLIS)
- keepAlive()
- checkLiveness()
- }
- }
- }
-
- /**
- * Detects zombie connections where the BLE stack didn't report a disconnect.
- *
- * If we believe we're connected but haven't received any data from the radio within [LIVENESS_TIMEOUT_MILLIS], the
- * connection is likely dead. Signal a non-permanent disconnect so the reconnect machinery can take over.
- */
- private fun checkLiveness() {
- if (_connectionState.value != ConnectionState.Connected) return
-
- val silenceMs = nowMillis - lastDataReceivedMillis
- if (silenceMs > LIVENESS_TIMEOUT_MILLIS) {
- Logger.w {
- "Liveness check failed: no data received for ${silenceMs}ms " +
- "(threshold: ${LIVENESS_TIMEOUT_MILLIS}ms). Treating as disconnect."
- }
- onDisconnect(isPermanent = false, errorMessage = "Connection timeout — no data received")
- }
- }
-
- fun keepAlive(now: Long = nowMillis) {
- if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
- radioTransport?.keepAlive()
- lastHeartbeatMillis = now
- }
- }
-
- override fun sendToRadio(bytes: ByteArray) {
- if (isStopping) {
- Logger.d { "sendToRadio: transport stopping, dropping ${bytes.size} bytes" }
- return
- }
- // Snapshot the transport to avoid calling handleSendToRadio on a null reference.
- // There is still a benign race: stopTransportLocked() may cancel _serviceScope
- // between the null-check and the launch, causing the coroutine to be silently
- // dropped. This is acceptable — if the transport is shutting down, dropping the
- // send is the correct behavior.
- val currentTransport =
- radioTransport
- ?: run {
- Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" }
- return
- }
- _serviceScope.handledLaunch {
- currentTransport.handleSendToRadio(bytes)
- _meshActivity.tryEmit(MeshActivity.Send)
- }
- }
-
- @Suppress("TooGenericExceptionCaught")
- override fun handleFromRadio(bytes: ByteArray) {
- try {
- lastDataReceivedMillis = nowMillis
- // trySend synchronously onto the unbounded Channel so packet order matches arrival
- // order. The previous `launch { emit() }` pattern dispatched each packet onto a
- // fresh coroutine, letting the scheduler reorder them — which broke the firmware
- // config handshake (see PhoneAPI.cpp initial-handshake sequence).
- val result = _receivedData.trySend(bytes)
- if (result.isFailure) {
- Logger.e(result.exceptionOrNull()) { "Failed to enqueue ${bytes.size} received bytes; dropping packet" }
- }
- _meshActivity.tryEmit(MeshActivity.Receive)
- } catch (t: Throwable) {
- Logger.e(t) { "handleFromRadio failed while emitting data" }
- }
- }
-
- override fun resetReceivedBuffer() {
- // Drain any bytes buffered while no collector was attached. Without this, a stop/start cycle
- // would replay stale bytes ahead of the next session's firmware handshake, since the channel
- // outlives the orchestrator's per-start scope.
- @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody")
- while (_receivedData.tryReceive().isSuccess) Unit
- }
-
- override fun onConnect() {
- // MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than
- // launching a coroutine. The async launch pattern introduced a window where a concurrent
- // onDisconnect launch could execute AFTER an onConnect launch, leaving the service stuck
- // in Connected while the transport was actually disconnected.
- lastDataReceivedMillis = nowMillis
- if (_connectionState.value != ConnectionState.Connected) {
- Logger.d { "Broadcasting connection state change to Connected" }
- _connectionState.value = ConnectionState.Connected
- }
- }
-
- override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
- if (errorMessage != null) {
- processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
- }
- val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
- if (_connectionState.value != newTargetState) {
- Logger.d { "Broadcasting connection state change to $newTargetState" }
- _connectionState.value = newTargetState
- }
- }
-}
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
index baeaf6a4f..bdf6a3b17 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
@@ -33,11 +33,11 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.AppWidgetUpdater
-import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.takserver.TAKMeshIntegration
@@ -50,7 +50,7 @@ import kotlin.test.assertTrue
class MeshServiceOrchestratorTest {
- private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
+ private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
private val nodeManager: NodeManager = mock(MockMode.autofill)
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
@@ -62,7 +62,7 @@ class MeshServiceOrchestratorTest {
private val radioController: RadioController = mock(MockMode.autofill)
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
- private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill)
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val cotHandler: CoTHandler = mock(MockMode.autofill)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -78,7 +78,7 @@ class MeshServiceOrchestratorTest {
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
every { takServerManager.isRunning } returns takRunningFlow
every { takServerManager.inboundMessages } returns MutableSharedFlow()
- every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
+ every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
@@ -88,12 +88,12 @@ class MeshServiceOrchestratorTest {
radioController = radioController,
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
- meshConfigHandler = meshConfigHandler,
+ radioConfigRepository = radioConfigRepository,
cotHandler = cotHandler,
)
return MeshServiceOrchestrator(
- radioInterfaceService = radioInterfaceService,
+ radioPrefs = radioPrefs,
nodeManager = nodeManager,
serviceNotifications = serviceNotifications,
takServerManager = takServerManager,
@@ -146,7 +146,7 @@ class MeshServiceOrchestratorTest {
@Test
fun testStartCallsSwitchActiveDatabase() {
- every { radioInterfaceService.getDeviceAddress() } returns "tcp:192.168.1.100"
+ every { radioPrefs.devAddr } returns MutableStateFlow("tcp:192.168.1.100")
val orchestrator = createOrchestrator()
orchestrator.start()
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
index 6f496e377..4b1097cc4 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
@@ -29,8 +29,8 @@ import kotlinx.coroutines.launch
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage
import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
@@ -47,7 +47,7 @@ class TAKMeshIntegration(
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
- private val meshConfigHandler: MeshConfigHandler,
+ private val radioConfigRepository: RadioConfigRepository,
private val cotHandler: CoTHandler,
) {
@Volatile private var isRunning = false
@@ -92,7 +92,7 @@ class TAKMeshIntegration(
.collect {}
},
scope.launch {
- meshConfigHandler.moduleConfig
+ radioConfigRepository.moduleConfigFlow
.map { it.tak }
.distinctUntilChanged()
.collect { takConfig ->
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
index f0c8eedda..eccf19b47 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
@@ -20,8 +20,8 @@ import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServer
@@ -46,14 +46,14 @@ class CoreTakServerModule {
radioController: RadioController,
nodeRepository: NodeRepository,
serviceRepository: ServiceRepository,
- meshConfigHandler: MeshConfigHandler,
+ radioConfigRepository: RadioConfigRepository,
cotHandler: CoTHandler,
): TAKMeshIntegration = TAKMeshIntegration(
takServerManager,
radioController,
nodeRepository,
serviceRepository,
- meshConfigHandler,
+ radioConfigRepository,
cotHandler,
)
}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt
index cfdc64f4f..65f487fb9 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt
@@ -26,13 +26,9 @@ class FakeMeshService {
val nodeRepository = FakeNodeRepository()
val serviceRepository = FakeServiceRepository()
val radioController = FakeRadioController()
- val radioInterfaceService = FakeRadioInterfaceService()
val notifications = FakeMeshServiceNotifications()
- val transport = FakeRadioTransport()
val logRepository = FakeMeshLogRepository()
val packetRepository = FakePacketRepository()
val contactRepository = FakeContactRepository()
val locationRepository = FakeLocationRepository()
-
- // Add more as they are implemented
}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
deleted file mode 100644
index f7837e436..000000000
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- * 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.testing
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.receiveAsFlow
-import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.model.DeviceType
-import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.model.MeshActivity
-import org.meshtastic.core.repository.RadioInterfaceService
-
-/**
- * A test double for [RadioInterfaceService] that provides an in-memory implementation.
- *
- * The [connectionState] here mirrors the transport-level semantics of the real implementation. In production, only
- * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager] observes this flow; tests should verify
- * that bridging behavior rather than consuming it directly from UI/feature test code (use
- * [FakeServiceRepository.connectionState] instead).
- */
-@Suppress("TooManyFunctions")
-class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService {
-
- override val supportedDeviceTypes: List = emptyList()
-
- /** Transport-level connection state (raw hardware link status). */
- private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
- override val connectionState: StateFlow = _connectionState
-
- private val _currentDeviceAddressFlow = MutableStateFlow(null)
- override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow
-
- // Use an unbounded Channel to mirror SharedRadioInterfaceService semantics. A MutableSharedFlow would
- // hide the stop/start backlog bug that motivated the resetReceivedBuffer() API.
- private val _receivedData = Channel(Channel.UNLIMITED)
- override val receivedData: Flow = _receivedData.receiveAsFlow()
-
- private val _meshActivity = MutableSharedFlow()
- override val meshActivity: Flow = _meshActivity.asFlow()
-
- private val _connectionError = MutableSharedFlow()
- override val connectionError: Flow = _connectionError.asFlow()
-
- val sentToRadio = mutableListOf()
- var connectCalled = false
-
- override fun isMockTransport(): Boolean = true
-
- override fun sendToRadio(bytes: ByteArray) {
- sentToRadio.add(bytes)
- }
-
- override fun connect() {
- connectCalled = true
- }
-
- override suspend fun disconnect() {
- connectCalled = false
- }
-
- override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
-
- override fun setDeviceAddress(deviceAddr: String?): Boolean {
- _currentDeviceAddressFlow.value = deviceAddr
- return true
- }
-
- override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "$interfaceId:$rest"
-
- override fun onConnect() {
- _connectionState.value = ConnectionState.Connected
- }
-
- override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
- _connectionState.value = ConnectionState.Disconnected
- }
-
- override fun handleFromRadio(bytes: ByteArray) {
- _receivedData.trySend(bytes)
- }
-
- override fun resetReceivedBuffer() {
- @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody")
- while (_receivedData.tryReceive().isSuccess) Unit
- }
-
- // --- Helper methods for testing ---
-
- fun emitFromRadio(bytes: ByteArray) {
- _receivedData.trySend(bytes)
- }
-
- fun setConnectionState(state: ConnectionState) {
- _connectionState.value = state
- }
-}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt
deleted file mode 100644
index 492802426..000000000
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * 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.testing
-
-import org.meshtastic.core.repository.RadioTransport
-
-/** A test double for [RadioTransport] that tracks sent data. */
-class FakeRadioTransport : RadioTransport {
- val sentData = mutableListOf()
- var closeCalled = false
- var keepAliveCalled = false
-
- override fun handleSendToRadio(p: ByteArray) {
- sentData.add(p)
- }
-
- override fun keepAlive() {
- keepAliveCalled = true
- }
-
- override suspend fun close() {
- closeCalled = true
- }
-}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index e0d895226..bff7f1da5 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
@@ -55,7 +56,7 @@ import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
-import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.Res
@@ -81,7 +82,7 @@ class UIViewModel(
private val nodeDB: NodeRepository,
protected val serviceRepository: ServiceRepository,
private val radioController: RadioController,
- radioInterfaceService: RadioInterfaceService,
+ private val radioPrefs: RadioPrefs,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPrefs: UiPrefs,
@@ -137,9 +138,9 @@ class UIViewModel(
}
/** Emits events for mesh network send/receive activity. */
- val meshActivity: Flow = radioInterfaceService.meshActivity
+ val meshActivity: Flow = emptyFlow()
- val currentDeviceAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow
+ val currentDeviceAddressFlow: StateFlow = radioPrefs.devAddr
private val _scrollToTopEventFlow =
MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index e3f83bea1..d7a70f4ef 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -53,7 +53,6 @@ import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PlatformAnalytics
-import org.meshtastic.core.repository.RadioTransportFactory
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.SdkClientLifecycle
import org.meshtastic.core.service.ServiceRepositoryImpl
@@ -67,7 +66,6 @@ import org.meshtastic.desktop.notification.NativeNotificationSender
import org.meshtastic.desktop.notification.WindowsNotificationSender
import org.meshtastic.desktop.radio.DesktopMessageQueue
import org.meshtastic.desktop.radio.DesktopRadioClientProvider
-import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
import org.meshtastic.desktop.stub.NoopLocationRepository
@@ -150,14 +148,6 @@ fun desktopModule() = module {
@Suppress("LongMethod")
private fun desktopPlatformStubsModule() = module {
single { ServiceRepositoryImpl() }
- single {
- DesktopRadioTransportFactory(
- dispatchers = get(),
- scanner = get(),
- bluetoothRepository = get(),
- connectionFactory = get(),
- )
- }
// SDK-backed RadioClient lifecycle — replaces DirectRadioControllerImpl
single { DesktopRadioClientProvider(radioPrefs = get()) }
single { get() }
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
deleted file mode 100644
index 9d05d9905..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * 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.desktop.radio
-
-import org.meshtastic.core.ble.BleConnectionFactory
-import org.meshtastic.core.ble.BleScanner
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.model.DeviceType
-import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.network.SerialTransport
-import org.meshtastic.core.network.radio.BaseRadioTransportFactory
-import org.meshtastic.core.network.radio.TcpRadioTransport
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.core.repository.RadioTransportFactory
-
-/**
- * Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing
- * platform-specific transports (USB/Serial) via jSerialComm.
- *
- * Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid double-registration with
- * the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
- */
-class DesktopRadioTransportFactory(
- scanner: BleScanner,
- bluetoothRepository: BluetoothRepository,
- connectionFactory: BleConnectionFactory,
- dispatchers: CoroutineDispatchers,
-) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) {
-
- override val supportedDeviceTypes: List = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
-
- override fun isMockTransport(): Boolean = false
-
- override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
- address.startsWith(InterfaceId.TCP.id) -> {
- TcpRadioTransport(
- callback = service,
- scope = service.serviceScope,
- dispatchers = dispatchers,
- address = address.removePrefix(InterfaceId.TCP.id.toString()),
- )
- }
-
- address.startsWith(InterfaceId.SERIAL.id) -> {
- SerialTransport.open(
- portName = address.removePrefix(InterfaceId.SERIAL.id.toString()),
- callback = service,
- scope = service.serviceScope,
- dispatchers = dispatchers,
- )
- }
-
- else -> error("Unsupported transport for address: $address")
- }
-}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
index dbfc5b477..7a3e4b563 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
@@ -20,17 +20,11 @@ package org.meshtastic.desktop.stub
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emptyFlow
-import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.DataPair
@@ -65,23 +59,10 @@ private fun logWarn(message: String) {
class NoopRadioInterfaceService : RadioInterfaceService {
override val supportedDeviceTypes: List = emptyList()
- override val connectionState = MutableStateFlow(ConnectionState.Disconnected)
override val currentDeviceAddressFlow = MutableStateFlow(null)
override fun isMockTransport(): Boolean = false
- override val receivedData = MutableSharedFlow()
- override val meshActivity: Flow = MutableSharedFlow().asFlow()
- override val connectionError: Flow = MutableSharedFlow().asFlow()
-
- override fun sendToRadio(bytes: ByteArray) {
- logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)")
- }
-
- override fun resetReceivedBuffer() {
- // No-op: this stub never buffers bytes.
- }
-
override fun connect() {
logWarn("NoopRadioInterfaceService.connect()")
}
@@ -95,15 +76,6 @@ class NoopRadioInterfaceService : RadioInterfaceService {
override fun setDeviceAddress(deviceAddr: String?): Boolean = false
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = ""
-
- override fun onConnect() {}
-
- override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {}
-
- override fun handleFromRadio(bytes: ByteArray) {}
-
- @Suppress("InjectDispatcher")
- override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
// endregion
diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts
index 18d7673a9..09447942c 100644
--- a/feature/connections/build.gradle.kts
+++ b/feature/connections/build.gradle.kts
@@ -48,5 +48,7 @@ kotlin {
}
androidMain.dependencies { implementation(libs.usb.serial.android) }
+
+ jvmMain.dependencies { implementation(libs.sdk.transport.serial) }
}
}
diff --git a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt
index 9601ff1b2..d63f5baa1 100644
--- a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt
+++ b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt
@@ -22,9 +22,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koin.core.annotation.Single
-import org.meshtastic.core.network.SerialTransport
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.JvmUsbDeviceData
+import org.meshtastic.sdk.transport.serial.JvmSerialPorts
import kotlin.coroutines.coroutineContext
@Single
@@ -32,12 +32,12 @@ class JvmUsbScanner : UsbScanner {
override fun scanUsbDevices(): Flow> = flow {
while (coroutineContext.isActive) {
val ports =
- SerialTransport.getAvailablePorts().map { portName ->
+ JvmSerialPorts.list().map { portInfo ->
DeviceListEntry.Usb(
- usbData = JvmUsbDeviceData(portName),
- name = portName,
- fullAddress = "s$portName",
- bonded = true, // Desktop serial ports don't need Android USB permission bonding
+ usbData = JvmUsbDeviceData(portInfo.name),
+ name = portInfo.description ?: portInfo.name,
+ fullAddress = "s${portInfo.name}",
+ bonded = true,
node = null,
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index d55731818..0d54dfead 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -32,7 +32,7 @@ import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
-import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.detail.NodeManagementActions
@@ -48,7 +48,7 @@ class NodeListViewModel(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val radioController: RadioController,
- private val radioInterfaceService: RadioInterfaceService,
+ private val radioPrefs: RadioPrefs,
val nodeManagementActions: NodeManagementActions,
private val getFilteredNodesUseCase: GetFilteredNodesUseCase,
val nodeFilterPreferences: NodeFilterPreferences,
@@ -63,7 +63,7 @@ class NodeListViewModel(
val connectionState = serviceRepository.connectionState
val deviceType: StateFlow =
- radioInterfaceService.currentDeviceAddressFlow
+ radioPrefs.devAddr
.map { address -> address?.let { DeviceType.fromAddress(it) } }
.stateInWhileSubscribed(initialValue = null)
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt
index 231ca30e1..df4f8962c 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt
@@ -29,10 +29,11 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
-import org.meshtastic.core.testing.FakeRadioInterfaceService
+import org.meshtastic.core.testing.FakeAppPreferences
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
@@ -46,7 +47,7 @@ class NodeListViewModelTest {
private lateinit var viewModel: NodeListViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
- private lateinit var radioInterfaceService: FakeRadioInterfaceService
+ private val radioPrefs: RadioPrefs = FakeAppPreferences().radio
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill)
@@ -57,7 +58,6 @@ class NodeListViewModelTest {
fun setUp() {
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
- radioInterfaceService = FakeRadioInterfaceService()
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile())
@@ -82,7 +82,7 @@ class NodeListViewModelTest {
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
radioController = radioController,
- radioInterfaceService = radioInterfaceService,
+ radioPrefs = radioPrefs,
nodeManagementActions = nodeManagementActions,
getFilteredNodesUseCase = getFilteredNodesUseCase,
nodeFilterPreferences = nodeFilterPreferences,