From 3cdad0da28cce0ef04a6f907843992a0e0f64089 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 07:13:52 -0500 Subject: [PATCH] refactor: delete transport layer and dead intermediaries, slim RadioInterfaceService - Delete entire transport layer: BleRadioTransport, TcpRadioTransport, SerialRadioTransport, StreamTransport, HeartbeatSender, StreamFrameCodec, AndroidRadioTransportFactory, BaseRadioTransportFactory, MockRadioTransport, NopRadioTransport, BleReconnectPolicy, TcpTransport, SerialTransport - Delete MeshConfigHandler interface + impl (replaced by RadioConfigRepository) - Delete RadioTransportCallback, RadioTransport, RadioTransportFactory interfaces - Delete FakeRadioTransport, RadioTransportTest, MeshConfigHandlerImplTest - Delete UseCase tests (impls restored, tests for deleted patterns removed) - Slim RadioInterfaceService interface: remove transport internals, keep only device-address/connection surface needed by Scanner and connections UIs - Create SdkRadioInterfaceService: thin SDK-backed impl delegating to RadioPrefs + RadioClientAccessor - Update NoopRadioInterfaceService to match slimmed interface - Update JvmUsbScanner to use SDK's JvmSerialPorts.list() instead of deleted SerialTransport.getAvailablePorts() - Remove DesktopRadioTransportFactory from desktop DI module - Fix NodeListViewModel: replace RadioInterfaceService with RadioPrefs - Fix MeshServiceOrchestratorTest: align with updated constructor params - Fix UIViewModel: use emptyFlow() for meshActivity (SDK doesn't emit raw transport-level activity events) All targets compile clean, all JVM + Android unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/manager/MeshConfigHandlerImpl.kt | 125 ----- .../data/radio/SdkRadioInterfaceService.kt | 67 +++ .../data/manager/MeshConfigHandlerImplTest.kt | 231 -------- .../EnsureRemoteAdminSessionUseCaseTest.kt | 126 ----- .../settings/AdminActionsUseCaseTest.kt | 67 --- .../settings/CleanNodeDatabaseUseCaseTest.kt | 77 --- .../settings/IsOtaCapableUseCaseTest.kt | 184 ------- .../ProcessRadioResponseUseCaseTest.kt | 193 ------- .../settings/SetMeshLogSettingsUseCaseTest.kt | 60 --- .../radio/AndroidRadioTransportFactory.kt | 106 ---- .../network/radio/SerialRadioTransport.kt | 140 ----- .../radio/BaseRadioTransportFactory.kt | 80 --- .../core/network/radio/BleRadioTransport.kt | 505 ------------------ .../core/network/radio/BleReconnectPolicy.kt | 186 ------- .../core/network/radio/MockRadioTransport.kt | 379 ------------- .../core/network/radio/NopRadioTransport.kt | 36 -- .../core/network/radio/StreamTransport.kt | 80 --- .../core/network/transport/HeartbeatSender.kt | 57 -- .../network/transport/StreamFrameCodec.kt | 153 ------ .../BleRadioTransportReconnectCrashTest.kt | 331 ------------ .../network/radio/BleRadioTransportTest.kt | 199 ------- .../network/radio/BleReconnectPolicyTest.kt | 277 ---------- .../network/radio/ReconnectBackoffTest.kt | 75 --- .../core/network/radio/StreamTransportTest.kt | 87 --- .../network/transport/StreamFrameCodecTest.kt | 187 ------- .../core/network/radio/TcpRadioTransport.kt | 96 ---- .../core/network/transport/TcpTransport.kt | 333 ------------ .../core/network/SerialTransport.kt | 242 --------- .../core/repository/MeshConfigHandler.kt | 49 -- .../core/repository/RadioInterfaceService.kt | 85 +-- .../core/repository/RadioTransport.kt | 49 -- .../core/repository/RadioTransportCallback.kt | 41 -- .../core/repository/RadioTransportFactory.kt | 42 -- .../core/repository/RadioTransportTest.kt | 55 -- .../meshtastic/core/service/MeshService.kt | 6 +- .../core/service/MeshServiceOrchestrator.kt | 6 +- .../service/SharedRadioInterfaceService.kt | 417 --------------- .../service/MeshServiceOrchestratorTest.kt | 16 +- .../core/takserver/TAKMeshIntegration.kt | 6 +- .../core/takserver/di/CoreTakServerModule.kt | 6 +- .../core/testing/FakeMeshService.kt | 4 - .../core/testing/FakeRadioInterfaceService.kt | 117 ---- .../core/testing/FakeRadioTransport.kt | 38 -- .../core/ui/viewmodel/UIViewModel.kt | 9 +- .../desktop/di/DesktopKoinModule.kt | 10 - .../radio/DesktopRadioTransportFactory.kt | 71 --- .../org/meshtastic/desktop/stub/NoopStubs.kt | 28 - feature/connections/build.gradle.kts | 2 + .../domain/usecase/JvmUsbScanner.kt | 12 +- .../feature/node/list/NodeListViewModel.kt | 6 +- .../node/list/NodeListViewModelTest.kt | 8 +- 51 files changed, 117 insertions(+), 5645 deletions(-) delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt delete mode 100644 core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt delete mode 100644 core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt delete mode 100644 core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt delete mode 100644 core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt delete mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt delete mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt delete mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt 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,