mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
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:
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (~10–50 ms) while remaining imperceptible to
|
||||
* the user.
|
||||
*/
|
||||
private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds
|
||||
|
||||
private val SCAN_TIMEOUT = 5.seconds
|
||||
private val GATT_CLEANUP_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
|
||||
*
|
||||
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
|
||||
* - Bonding and discovery.
|
||||
* - Automatic reconnection logic.
|
||||
* - MTU and connection parameter monitoring.
|
||||
* - Routing raw byte packets between the radio and [RadioTransportCallback].
|
||||
*
|
||||
* @param scope The coroutine scope to use for launching coroutines.
|
||||
* @param scanner The BLE scanner.
|
||||
* @param bluetoothRepository The Bluetooth repository.
|
||||
* @param connectionFactory The BLE connection factory.
|
||||
* @param callback The [RadioTransportCallback] to use for handling radio events.
|
||||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
class BleRadioTransport(
|
||||
private val scope: CoroutineScope,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val callback: RadioTransportCallback,
|
||||
internal val address: String,
|
||||
) : RadioTransport {
|
||||
|
||||
// Detached cleanup scope for last-ditch GATT teardown from the exception handler.
|
||||
// Must NOT be a child of `scope`: when an uncaught exception fires in connectionScope,
|
||||
// upper layers often tear down `scope` immediately. Launching cleanup on `scope` then
|
||||
// races the cancellation and may never start, leaking BluetoothGatt (status 133 on
|
||||
// the next reconnect). This scope is cancelled in close() once our own disconnect
|
||||
// has completed and the safety net is no longer needed.
|
||||
private val cleanupScope: CoroutineScope = CoroutineScope(SupervisorJob() + scope.coroutineContext.minusKey(Job))
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
cleanupScope.launch {
|
||||
try {
|
||||
bleConnection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
|
||||
}
|
||||
}
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
callback.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler)
|
||||
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
@Volatile private var connectionStartTime: Long = 0
|
||||
|
||||
@Volatile private var packetsReceived: Int = 0
|
||||
|
||||
@Volatile private var packetsSent: Int = 0
|
||||
|
||||
@Volatile private var bytesReceived: Long = 0
|
||||
|
||||
@Volatile private var bytesSent: Long = 0
|
||||
|
||||
@Volatile private var isFullyConnected = false
|
||||
private var connectionJob: Job? = null
|
||||
|
||||
// Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
|
||||
// own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
|
||||
// toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
|
||||
private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)
|
||||
|
||||
private val heartbeatSender =
|
||||
HeartbeatSender(
|
||||
sendToRadio = ::handleSendToRadio,
|
||||
afterHeartbeat = {
|
||||
delay(HEARTBEAT_DRAIN_DELAY)
|
||||
radioService?.requestDrain()
|
||||
},
|
||||
logTag = address,
|
||||
)
|
||||
|
||||
override fun start() {
|
||||
connect()
|
||||
}
|
||||
|
||||
// --- Connection & Discovery Logic ---
|
||||
|
||||
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
|
||||
private suspend fun findDevice(): BleDevice {
|
||||
bluetoothRepository.state.value.bondedDevices
|
||||
.firstOrNull { it.address.equals(address, ignoreCase = true) }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning" }
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
try {
|
||||
val d =
|
||||
withTimeoutOrNull(SCAN_TIMEOUT) {
|
||||
// Pass both service UUID and address so the scanner can apply the most
|
||||
// efficient platform filter. Android uses address (OS-level HW filter),
|
||||
// while CoreBluetooth (macOS) needs the service UUID because it caches
|
||||
// peripheral identifiers and may not re-report by address alone.
|
||||
scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
|
||||
it.address.equals(address, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
if (d != null) return d
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.v(e) { "[$address] Scan attempt failed or timed out" }
|
||||
}
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
delay(SCAN_RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
throw RadioNotConnectedException("Device not found at address $address")
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
connectionJob =
|
||||
connectionScope.launch {
|
||||
reconnectPolicy.execute(
|
||||
attempt = {
|
||||
try {
|
||||
attemptConnection()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = (nowMillis - connectionStartTime).milliseconds
|
||||
Logger.w(e) { "[$address] Failed to connect after $failureTime" }
|
||||
BleReconnectPolicy.Outcome.Failed(e)
|
||||
}
|
||||
},
|
||||
onTransientDisconnect = { error ->
|
||||
val msg = error?.toDisconnectReason()?.second ?: "Device unreachable"
|
||||
callback.onDisconnect(isPermanent = false, errorMessage = msg)
|
||||
},
|
||||
onPermanentDisconnect = { error ->
|
||||
val msg = error?.toDisconnectReason()?.second ?: "Device unreachable"
|
||||
callback.onDisconnect(isPermanent = true, errorMessage = msg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a single BLE connect-and-wait cycle.
|
||||
*
|
||||
* Finds the device, bonds if needed, connects, discovers services, and waits for disconnect. Returns a
|
||||
* [BleReconnectPolicy.Outcome] describing how the connection ended.
|
||||
*/
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun attemptConnection(): BleReconnectPolicy.Outcome {
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
|
||||
val device = findDevice()
|
||||
|
||||
// Bond before connecting: firmware may require an encrypted link,
|
||||
// and without a bond Android fails with status 5 or 133.
|
||||
// No-op on Desktop/JVM where the OS handles pairing automatically.
|
||||
if (!bluetoothRepository.isBonded(address)) {
|
||||
Logger.i { "[$address] Device not bonded, initiating bonding" }
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
bluetoothRepository.bond(device)
|
||||
Logger.i { "[$address] Bonding successful" }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
|
||||
}
|
||||
}
|
||||
|
||||
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
val gattConnectedAt = nowMillis
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
|
||||
// Wait for the StateFlow to actually reflect Connected before watching for the next
|
||||
// Disconnected. connectAndAwait returns synchronously based on the underlying Kable
|
||||
// peripheral state, but our _connectionState observer runs on a separate coroutine and
|
||||
// may lag. Without this gate the next .first { Disconnected } below could match the
|
||||
// *previous* cycle's stale Disconnected value and fire immediately, breaking reconnect.
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Connected }
|
||||
|
||||
// Suspend until the next Disconnected emission. We deliberately do NOT wrap this in a
|
||||
// coroutineScope { launchIn(...); first(...) } pattern: launching a hot StateFlow
|
||||
// collector inside coroutineScope hangs the scope after .first returns (the launched
|
||||
// collector never completes naturally, and coroutineScope waits for all children).
|
||||
val disconnectedState =
|
||||
bleConnection.connectionState.filterIsInstance<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
|
||||
}
|
||||
}
|
||||
@@ -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/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the
|
||||
* firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose
|
||||
* to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more
|
||||
* mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same.
|
||||
*/
|
||||
val DEFAULT_SETTLE_DELAY = 3.seconds
|
||||
val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds
|
||||
|
||||
internal val RECONNECT_BASE_DELAY = 5.seconds
|
||||
internal val RECONNECT_MAX_DELAY = 60.seconds
|
||||
internal const val BACKOFF_MAX_EXPONENT = 4
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the reconnect backoff delay for a given consecutive failure count.
|
||||
*
|
||||
* Backoff schedule: 1 failure → 5 s, 2 failures → 10 s, 3 failures → 20 s, 4 failures → 40 s, 5+ failures → 60 s
|
||||
* (capped).
|
||||
*/
|
||||
internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration {
|
||||
if (consecutiveFailures <= 0) return BleReconnectPolicy.RECONNECT_BASE_DELAY
|
||||
val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(BleReconnectPolicy.BACKOFF_MAX_EXPONENT)
|
||||
return minOf(BleReconnectPolicy.RECONNECT_BASE_DELAY * multiplier, BleReconnectPolicy.RECONNECT_MAX_DELAY)
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]})",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>() }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -48,5 +48,7 @@ kotlin {
|
||||
}
|
||||
|
||||
androidMain.dependencies { implementation(libs.usb.serial.android) }
|
||||
|
||||
jvmMain.dependencies { implementation(libs.sdk.transport.serial) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user