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>
This commit is contained in:
James Rich
2026-05-05 07:13:52 -05:00
parent 74ba959b24
commit 3cdad0da28
51 changed files with 117 additions and 5645 deletions

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DeviceType> =
listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
override val currentDeviceAddressFlow: StateFlow<String?> = 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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<RadioConfigRepository>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val nodeManager = mock<NodeManager>(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<Int?>(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<Int?>(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) }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int> = MutableSharedFlow(extraBufferCapacity = 8),
): SessionManager {
val mgr = mock<SessionManager>(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<ServiceRepository>(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<Int>(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<Int>(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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DeviceType> = 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")
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<SerialConnection?>()
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" }
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@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 (~1050 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<BleConnectionState.Disconnected>().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<Boolean, String> {
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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/54/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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()) }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<org.meshtastic.core.repository.RadioInterfaceService>(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<BleDevice?>(null)
override val deviceFlow: StateFlow<BleDevice?> = _deviceFlow.asStateFlow()
private val _connectionState = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected())
override val connectionState: StateFlow<BleConnectionState> = _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 <T> 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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Throwable?>()
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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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]})",
)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ByteArray>()
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() }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ByteArray>()
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<ByteArray>()
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<ByteArray>()
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> = 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"
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<LocalConfig>
/** Reactive local module configuration. */
val moduleConfig: StateFlow<LocalModuleConfig>
/** 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)
}

View File

@@ -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<DeviceType>
/**
* 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<ConnectionState>
/** 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<String?>
/** 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<ByteArray>
/** Flow of radio activity events. */
val meshActivity: Flow<MeshActivity>
/**
* 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<String>
/** 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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DeviceType>
/** 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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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)

View File

@@ -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" }
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DeviceType>
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>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(radioPrefs.devAddr.value)
override val currentDeviceAddressFlow: StateFlow<String?> = _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<ByteArray>(Channel.UNLIMITED)
override val receivedData: Flow<ByteArray> = _receivedData.receiveAsFlow()
private val _meshActivity =
MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val meshActivity: Flow<MeshActivity> = _meshActivity.asFlow()
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
override val connectionError: Flow<String> = _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
}
}
}

View File

@@ -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()

View File

@@ -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 ->

View File

@@ -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,
)
}

View File

@@ -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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DeviceType> = emptyList()
/** Transport-level connection state (raw hardware link status). */
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(null)
override val currentDeviceAddressFlow: StateFlow<String?> = _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<ByteArray>(Channel.UNLIMITED)
override val receivedData: Flow<ByteArray> = _receivedData.receiveAsFlow()
private val _meshActivity = MutableSharedFlow<MeshActivity>()
override val meshActivity: Flow<MeshActivity> = _meshActivity.asFlow()
private val _connectionError = MutableSharedFlow<String>()
override val connectionError: Flow<String> = _connectionError.asFlow()
val sentToRadio = mutableListOf<ByteArray>()
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ByteArray>()
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
}
}

View File

@@ -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<MeshActivity> = radioInterfaceService.meshActivity
val meshActivity: Flow<MeshActivity> = emptyFlow()
val currentDeviceAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
val currentDeviceAddressFlow: StateFlow<String?> = radioPrefs.devAddr
private val _scrollToTopEventFlow =
MutableSharedFlow<ScrollToTopEvent>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)

View File

@@ -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<ServiceRepository> { ServiceRepositoryImpl() }
single<RadioTransportFactory> {
DesktopRadioTransportFactory(
dispatchers = get(),
scanner = get(),
bluetoothRepository = get(),
connectionFactory = get(),
)
}
// SDK-backed RadioClient lifecycle — replaces DirectRadioControllerImpl
single { DesktopRadioClientProvider(radioPrefs = get()) }
single<RadioClientAccessor> { get<DesktopRadioClientProvider>() }

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DeviceType> = 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")
}
}

View File

@@ -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<DeviceType> = emptyList()
override val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val currentDeviceAddressFlow = MutableStateFlow<String?>(null)
override fun isMockTransport(): Boolean = false
override val receivedData = MutableSharedFlow<ByteArray>()
override val meshActivity: Flow<MeshActivity> = MutableSharedFlow<MeshActivity>().asFlow()
override val connectionError: Flow<String> = MutableSharedFlow<String>().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

View File

@@ -48,5 +48,7 @@ kotlin {
}
androidMain.dependencies { implementation(libs.usb.serial.android) }
jvmMain.dependencies { implementation(libs.sdk.transport.serial) }
}
}

View File

@@ -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<List<DeviceListEntry.Usb>> = 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,
)
}

View File

@@ -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<DeviceType?> =
radioInterfaceService.currentDeviceAddressFlow
radioPrefs.devAddr
.map { address -> address?.let { DeviceType.fromAddress(it) } }
.stateInWhileSubscribed(initialValue = null)

View File

@@ -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,