mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(transport): complete transport architecture overhaul — extract callback, wire BleReconnectPolicy, fix safety issues (#5080)
This commit is contained in:
parent
962c619c4c
commit
e85300531e
64 changed files with 1184 additions and 1018 deletions
|
|
@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
|
@ -86,7 +87,7 @@ class AndroidBluetoothRepository(
|
|||
return
|
||||
}
|
||||
|
||||
kotlinx.coroutines.suspendCancellableCoroutine<Unit> { cont ->
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
val receiver =
|
||||
object : android.content.BroadcastReceiver() {
|
||||
@SuppressLint("MissingPermission")
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
|
|||
*
|
||||
* Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then
|
||||
* fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller
|
||||
* ([BleRadioInterface]) owns the macro-level retry/backoff loop.
|
||||
* ([BleRadioTransport]) owns the macro-level retry/backoff loop.
|
||||
*/
|
||||
class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Advertisement
|
||||
import com.juul.kable.ExperimentalApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -47,7 +48,7 @@ class MeshtasticBleDevice(
|
|||
override val isConnected: Boolean
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address
|
||||
|
||||
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||
@OptIn(ExperimentalApi::class)
|
||||
override suspend fun readRssi(): Int {
|
||||
val active = ActiveBleConnection.active
|
||||
return if (active != null && active.address == address) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package org.meshtastic.core.ble
|
|||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
|
|
@ -118,8 +119,8 @@ class KableMeshtasticRadioProfileTest {
|
|||
fun `MeshtasticRadioProfile default awaitSubscriptionReady returns immediately`() = runTest {
|
||||
val profile =
|
||||
object : MeshtasticRadioProfile {
|
||||
override val fromRadio = kotlinx.coroutines.flow.emptyFlow<ByteArray>()
|
||||
override val logRadio = kotlinx.coroutines.flow.emptyFlow<ByteArray>()
|
||||
override val fromRadio = emptyFlow<ByteArray>()
|
||||
override val logRadio = emptyFlow<ByteArray>()
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,18 +39,26 @@ import org.meshtastic.core.repository.PacketHandler
|
|||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.AirQualityMetrics
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Constants
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.HostMetrics
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Neighbor
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
import org.meshtastic.proto.Paxcount
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.PowerMetrics
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
|
||||
@Single
|
||||
|
|
@ -68,10 +76,6 @@ class CommandSenderImpl(
|
|||
private val localConfig = MutableStateFlow(LocalConfig())
|
||||
private val channelSet = MutableStateFlow(ChannelSet())
|
||||
|
||||
// We'll need a way to track connection state in shared code,
|
||||
// maybe via ServiceRepository or similar.
|
||||
// For now I'll assume it's injected or available.
|
||||
|
||||
init {
|
||||
radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope)
|
||||
radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope)
|
||||
|
|
@ -141,14 +145,11 @@ class CommandSenderImpl(
|
|||
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
|
||||
val actualSize = Data.ADAPTER.encodedSize(data)
|
||||
p.status = MessageStatus.ERROR
|
||||
// throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
|
||||
// RemoteException is Android specific. For KMP we might want a custom exception.
|
||||
error("Message too long: $actualSize bytes")
|
||||
} else {
|
||||
p.status = MessageStatus.QUEUED
|
||||
}
|
||||
|
||||
// TODO: Check connection state
|
||||
sendNow(p)
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +192,7 @@ class CommandSenderImpl(
|
|||
return packetHandler.sendToRadioAndAwait(packet)
|
||||
}
|
||||
|
||||
override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
|
||||
override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
|
||||
val myNum = nodeManager.myNodeNum.value ?: return
|
||||
val idNum = destNum ?: myNum
|
||||
Logger.d { "Sending our position/time to=$idNum $pos" }
|
||||
|
|
@ -217,7 +218,7 @@ class CommandSenderImpl(
|
|||
|
||||
override fun requestPosition(destNum: Int, currentPosition: Position) {
|
||||
val meshPosition =
|
||||
org.meshtastic.proto.Position(
|
||||
ProtoPosition(
|
||||
latitude_i = Position.degI(currentPosition.latitude),
|
||||
longitude_i = Position.degI(currentPosition.longitude),
|
||||
altitude = currentPosition.altitude,
|
||||
|
|
@ -240,7 +241,7 @@ class CommandSenderImpl(
|
|||
|
||||
override fun setFixedPosition(destNum: Int, pos: Position) {
|
||||
val meshPos =
|
||||
org.meshtastic.proto.Position(
|
||||
ProtoPosition(
|
||||
latitude_i = Position.degI(pos.latitude),
|
||||
longitude_i = Position.degI(pos.longitude),
|
||||
altitude = pos.altitude,
|
||||
|
|
@ -293,21 +294,17 @@ class CommandSenderImpl(
|
|||
|
||||
if (type == TelemetryType.PAX) {
|
||||
portNum = PortNum.PAXCOUNTER_APP
|
||||
payloadBytes = org.meshtastic.proto.Paxcount().encode().toByteString()
|
||||
payloadBytes = Paxcount().encode().toByteString()
|
||||
} else {
|
||||
portNum = PortNum.TELEMETRY_APP
|
||||
payloadBytes =
|
||||
Telemetry(
|
||||
device_metrics =
|
||||
if (type == TelemetryType.DEVICE) org.meshtastic.proto.DeviceMetrics() else null,
|
||||
environment_metrics =
|
||||
if (type == TelemetryType.ENVIRONMENT) org.meshtastic.proto.EnvironmentMetrics() else null,
|
||||
air_quality_metrics =
|
||||
if (type == TelemetryType.AIR_QUALITY) org.meshtastic.proto.AirQualityMetrics() else null,
|
||||
power_metrics = if (type == TelemetryType.POWER) org.meshtastic.proto.PowerMetrics() else null,
|
||||
local_stats =
|
||||
if (type == TelemetryType.LOCAL_STATS) org.meshtastic.proto.LocalStats() else null,
|
||||
host_metrics = if (type == TelemetryType.HOST) org.meshtastic.proto.HostMetrics() else null,
|
||||
device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null,
|
||||
environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null,
|
||||
air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null,
|
||||
power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null,
|
||||
local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null,
|
||||
host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null,
|
||||
)
|
||||
.encode()
|
||||
.toByteString()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
|
|
@ -199,7 +200,7 @@ class MeshActionHandlerImpl(
|
|||
commandSender.sendData(p)
|
||||
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
|
||||
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
|
||||
val bytes = p.bytes ?: okio.ByteString.EMPTY
|
||||
val bytes = p.bytes ?: ByteString.EMPTY
|
||||
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
|
||||
}
|
||||
|
||||
|
|
@ -356,7 +357,7 @@ class MeshActionHandlerImpl(
|
|||
override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
|
||||
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
|
||||
val otaEvent =
|
||||
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY)
|
||||
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY)
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okio.ByteString
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
|
|
@ -221,7 +222,7 @@ class MeshConnectionManagerImpl(
|
|||
|
||||
private fun tearDownConnection() {
|
||||
packetHandler.stopPacketQueue()
|
||||
commandSender.setSessionPasskey(okio.ByteString.EMPTY) // Prevent stale passkey on reconnect.
|
||||
commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect.
|
||||
locationManager.stop()
|
||||
mqttManager.stop()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.ByteString
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
|
|
@ -247,7 +248,7 @@ class MeshDataHandlerImpl(
|
|||
val payload = packet.decoded?.payload ?: return
|
||||
val u =
|
||||
User.ADAPTER.decode(payload)
|
||||
.let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it }
|
||||
.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
|
||||
.let {
|
||||
if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) {
|
||||
it.copy(long_name = "${it.long_name} (MQTT)")
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class MeshMessageProcessorImpl(
|
|||
@Volatile private var lastLocalNodeRefreshMs = 0L
|
||||
|
||||
private val earlyMutex = Mutex()
|
||||
private val earlyReceivedPackets = kotlin.collections.ArrayDeque<MeshPacket>()
|
||||
private val earlyReceivedPackets = ArrayDeque<MeshPacket>()
|
||||
private val maxEarlyPacketBuffer = 10240
|
||||
|
||||
override fun clearEarlyPackets() {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class TracerouteHandlerImpl(
|
|||
routeDiscovery.getTracerouteResponse(
|
||||
getUser = { num ->
|
||||
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
|
||||
?: "Unknown" // TODO: Use core:resources once available in core:data
|
||||
?: "Unknown"
|
||||
},
|
||||
headerTowards = "Route towards destination:",
|
||||
headerBack = "Route back to us:",
|
||||
|
|
|
|||
|
|
@ -132,13 +132,10 @@ class MeshConnectionManagerImplTest {
|
|||
scope,
|
||||
)
|
||||
|
||||
@AfterTest fun tearDown() {}
|
||||
@AfterTest fun tearDown() = Unit
|
||||
|
||||
@Test
|
||||
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceUntilIdle()
|
||||
|
|
@ -153,16 +150,6 @@ class MeshConnectionManagerImplTest {
|
|||
|
||||
@Test
|
||||
fun `Disconnected state stops services`() = runTest(testDispatcher) {
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
manager = createManager(backgroundScope)
|
||||
// Transition to Connected first so that Disconnected actually does something
|
||||
|
|
@ -191,11 +178,6 @@ class MeshConnectionManagerImplTest {
|
|||
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT),
|
||||
)
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
|
|
@ -216,11 +198,6 @@ class MeshConnectionManagerImplTest {
|
|||
// Power saving enabled
|
||||
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
advanceUntilIdle()
|
||||
|
|
@ -280,11 +257,6 @@ class MeshConnectionManagerImplTest {
|
|||
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER),
|
||||
)
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
|
|
@ -317,11 +289,6 @@ class MeshConnectionManagerImplTest {
|
|||
// Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected)
|
||||
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
// Record every state transition so we can verify ordering
|
||||
|
|
@ -367,11 +334,6 @@ class MeshConnectionManagerImplTest {
|
|||
// Power saving enabled with a short ls_secs so the sleep timeout fires quickly
|
||||
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1))
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
val observed = mutableListOf<ConnectionState>()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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 io.kotest.property.Arb
|
||||
import io.kotest.property.arbitrary.int
|
||||
|
|
@ -84,6 +85,8 @@ class PacketHandlerImplTest {
|
|||
val toRadio = ToRadio(packet = MeshPacket(id = 123))
|
||||
|
||||
handler.sendToRadio(toRadio)
|
||||
|
||||
verify { radioInterfaceService.sendToRadio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -93,6 +96,8 @@ class PacketHandlerImplTest {
|
|||
|
||||
handler.sendToRadio(packet)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
verify { radioInterfaceService.sendToRadio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
|
|||
/** Ability to mute notifications from specific nodes via admin messages. */
|
||||
val canMuteNode = atLeast(V2_7_18)
|
||||
|
||||
/** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */
|
||||
/** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */
|
||||
val canRequestNeighborInfo = atLeast(UNRELEASED)
|
||||
|
||||
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
|
||||
|
|
|
|||
|
|
@ -16,16 +16,16 @@
|
|||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
sealed class ConnectionState {
|
||||
sealed interface ConnectionState {
|
||||
/** We are disconnected from the device, and we should be trying to reconnect. */
|
||||
data object Disconnected : ConnectionState()
|
||||
data object Disconnected : ConnectionState
|
||||
|
||||
/** We are currently attempting to connect to the device. */
|
||||
data object Connecting : ConnectionState()
|
||||
data object Connecting : ConnectionState
|
||||
|
||||
/** We are connected to the device and communicating normally. */
|
||||
data object Connected : ConnectionState()
|
||||
data object Connected : ConnectionState
|
||||
|
||||
/** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */
|
||||
data object DeviceSleep : ConnectionState()
|
||||
data object DeviceSleep : ConnectionState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
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
|
||||
|
|
@ -25,21 +26,23 @@ 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 delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific
|
||||
* [InterfaceFactory].
|
||||
* 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 interfaceFactory: Lazy<InterfaceFactory>,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
private val usbRepository: UsbRepository,
|
||||
private val usbManager: UsbManager,
|
||||
scanner: BleScanner,
|
||||
bluetoothRepository: BluetoothRepository,
|
||||
connectionFactory: BleConnectionFactory,
|
||||
|
|
@ -48,13 +51,50 @@ class AndroidRadioTransportFactory(
|
|||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
|
||||
|
||||
override fun isMockInterface(): Boolean =
|
||||
override fun isMockTransport(): Boolean =
|
||||
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
|
||||
override fun isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address)
|
||||
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 {
|
||||
// Fallback to legacy factory for Serial, Mocks, and NOPs
|
||||
return interfaceFactory.value.createInterface(address, service)
|
||||
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,66 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
/**
|
||||
* Entry point for create radio backend instances given a specific address.
|
||||
*
|
||||
* This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest"
|
||||
* of the address (which varies per implementation).
|
||||
*/
|
||||
@Single
|
||||
class InterfaceFactory(
|
||||
private val nopInterfaceFactory: NopInterfaceFactory,
|
||||
private val mockSpec: Lazy<MockInterfaceSpec>,
|
||||
private val serialSpec: Lazy<SerialInterfaceSpec>,
|
||||
private val tcpSpec: Lazy<TCPInterfaceSpec>,
|
||||
) {
|
||||
internal val nopInterface by lazy { nopInterfaceFactory.create("") }
|
||||
|
||||
private val specMap: Map<InterfaceId, InterfaceSpec<*>> by lazy {
|
||||
mapOf(
|
||||
InterfaceId.MOCK to mockSpec.value,
|
||||
InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory),
|
||||
InterfaceId.SERIAL to serialSpec.value,
|
||||
InterfaceId.TCP to tcpSpec.value,
|
||||
)
|
||||
}
|
||||
|
||||
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
||||
fun createInterface(address: String, service: RadioInterfaceService): RadioTransport {
|
||||
val (spec, rest) = splitAddress(address)
|
||||
return spec?.createInterface(rest, service) ?: nopInterface
|
||||
}
|
||||
|
||||
fun addressValid(address: String?): Boolean = address?.let {
|
||||
val (spec, rest) = splitAddress(it)
|
||||
spec?.addressValid(rest)
|
||||
} ?: false
|
||||
|
||||
private fun splitAddress(address: String): Pair<InterfaceSpec<*>?, String> {
|
||||
if (address.isEmpty()) return Pair(null, "")
|
||||
val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] }
|
||||
val rest = address.substring(1)
|
||||
return Pair(c, rest)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `SerialInterface` instances. */
|
||||
@Single
|
||||
class SerialInterfaceFactory(private val usbRepository: UsbRepository) {
|
||||
fun create(rest: String, service: RadioInterfaceService): SerialInterface =
|
||||
SerialInterface(service, usbRepository, rest)
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.hardware.usb.UsbManager
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Serial/USB interface backend implementation. */
|
||||
@Single
|
||||
class SerialInterfaceSpec(
|
||||
private val factory: SerialInterfaceFactory,
|
||||
private val usbManager: UsbManager,
|
||||
private val usbRepository: UsbRepository,
|
||||
) : InterfaceSpec<SerialInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): SerialInterface =
|
||||
factory.create(rest, service)
|
||||
|
||||
override fun addressValid(rest: String): Boolean {
|
||||
val driver = findSerial(rest) ?: return false
|
||||
return usbManager.hasPermission(driver.device)
|
||||
}
|
||||
|
||||
internal fun findSerial(rest: String): UsbSerialDriver? {
|
||||
val deviceMap = usbRepository.serialDevices.value
|
||||
return deviceMap[rest] ?: deviceMap.values.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -17,24 +17,28 @@
|
|||
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.repository.RadioInterfaceService
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import org.meshtastic.core.network.transport.HeartbeatSender
|
||||
import org.meshtastic.core.repository.RadioTransportCallback
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** An interface that assumes we are talking to a meshtastic device via USB serial */
|
||||
class SerialInterface(
|
||||
service: RadioInterfaceService,
|
||||
/** An Android USB/serial [RadioTransport] implementation. */
|
||||
class SerialRadioTransport(
|
||||
callback: RadioTransportCallback,
|
||||
scope: CoroutineScope,
|
||||
private val usbRepository: UsbRepository,
|
||||
private val address: String,
|
||||
) : StreamInterface(service) {
|
||||
) : StreamTransport(callback, scope) {
|
||||
private var connRef = AtomicReference<SerialConnection?>()
|
||||
|
||||
init {
|
||||
private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]")
|
||||
|
||||
override fun start() {
|
||||
connect()
|
||||
}
|
||||
|
||||
|
|
@ -116,14 +120,9 @@ class SerialInterface(
|
|||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
// Send a ToRadio heartbeat so the firmware resets its idle timer and responds with
|
||||
// a FromRadio queueStatus — proving the serial link is alive. Without this, the
|
||||
// serial transport has no way to detect a silently dead device (battery depleted,
|
||||
// firmware crash without the `rebooted` flag). The queueStatus response also feeds
|
||||
// into MeshMessageProcessorImpl.refreshLocalNodeLastHeard() to keep the local
|
||||
// node's lastHeard timestamp current.
|
||||
Logger.d { "[$address] Serial keepAlive — sending heartbeat" }
|
||||
handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode())
|
||||
// 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) {
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `TCPInterface` instances. */
|
||||
@Single
|
||||
class TCPInterfaceFactory(private val dispatchers: CoroutineDispatchers) {
|
||||
fun create(rest: String, service: RadioInterfaceService): TCPInterface = TCPInterface(service, dispatchers, rest)
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** TCP interface backend implementation. */
|
||||
@Single
|
||||
class TCPInterfaceSpec(private val factory: TCPInterfaceFactory) : InterfaceSpec<TCPInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): TCPInterface =
|
||||
factory.create(rest, service)
|
||||
}
|
||||
|
|
@ -38,40 +38,41 @@ abstract class BaseRadioTransportFactory(
|
|||
|
||||
override fun isAddressValid(address: String?): Boolean {
|
||||
val spec = address?.firstOrNull() ?: return false
|
||||
return spec in
|
||||
listOf(InterfaceId.TCP.id, InterfaceId.SERIAL.id, InterfaceId.BLUETOOTH.id, InterfaceId.MOCK.id) ||
|
||||
spec == '!' ||
|
||||
isPlatformAddressValid(address)
|
||||
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 = when {
|
||||
address.startsWith(InterfaceId.BLUETOOTH.id) -> {
|
||||
BleRadioInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()),
|
||||
)
|
||||
}
|
||||
address.startsWith("!") -> {
|
||||
BleRadioInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address.removePrefix("!"),
|
||||
)
|
||||
}
|
||||
else -> createPlatformTransport(address, service)
|
||||
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 interfaces. */
|
||||
/** Delegate to platform for Mock, TCP, or Serial/USB transports. */
|
||||
protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
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.DelicateCoroutinesApi
|
||||
|
|
@ -32,7 +33,6 @@ import kotlinx.coroutines.flow.catch
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -47,54 +47,22 @@ 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.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.network.transport.HeartbeatSender
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import org.meshtastic.core.repository.RadioTransportCallback
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.time.Duration
|
||||
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
|
||||
private const val RECONNECT_FAILURE_THRESHOLD = 3
|
||||
private val RECONNECT_BASE_DELAY = 5.seconds
|
||||
private val RECONNECT_MAX_DELAY = 60.seconds
|
||||
private const val RECONNECT_MAX_FAILURES = 10
|
||||
|
||||
/** Settle delay before each connection attempt to let the Android BLE stack finish any pending disconnect cleanup. */
|
||||
private val SETTLE_DELAY = 1.seconds
|
||||
|
||||
/**
|
||||
* Minimum time a BLE connection must stay up before we consider it "stable" and reset
|
||||
* [BleRadioInterface.consecutiveFailures]. Without this, a device at the edge of BLE range can repeatedly connect for a
|
||||
* fraction of a second and drop — each brief connection resets the failure counter so [RECONNECT_FAILURE_THRESHOLD] is
|
||||
* never reached, and the app never signals [ConnectionState.DeviceSleep].
|
||||
*
|
||||
* The value (5 s) is long enough that only connections that survive past the initial GATT setup are treated as genuine,
|
||||
* but short enough that normal reconnects after light-sleep still reset the counter promptly.
|
||||
*/
|
||||
private val MIN_STABLE_CONNECTION = 5.seconds
|
||||
|
||||
/**
|
||||
* 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 RECONNECT_BASE_DELAY
|
||||
val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(4)
|
||||
return minOf(RECONNECT_BASE_DELAY * multiplier, RECONNECT_MAX_DELAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay after writing a heartbeat before re-polling FROMRADIO.
|
||||
|
|
@ -117,27 +85,27 @@ private val GATT_CLEANUP_TIMEOUT = 5.seconds
|
|||
* - Bonding and discovery.
|
||||
* - Automatic reconnection logic.
|
||||
* - MTU and connection parameter monitoring.
|
||||
* - Routing raw byte packets between the radio and [RadioInterfaceService].
|
||||
* - Routing raw byte packets between the radio and [RadioTransportCallback].
|
||||
*
|
||||
* @param serviceScope The coroutine scope to use for launching coroutines.
|
||||
* @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 service The [RadioInterfaceService] to use for handling radio events.
|
||||
* @param callback The [RadioTransportCallback] to use for handling radio events.
|
||||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
class BleRadioInterface(
|
||||
private val serviceScope: CoroutineScope,
|
||||
class BleRadioTransport(
|
||||
private val scope: CoroutineScope,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val service: RadioInterfaceService,
|
||||
private val callback: RadioTransportCallback,
|
||||
internal val address: String,
|
||||
) : RadioTransport {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
serviceScope.launch {
|
||||
scope.launch {
|
||||
try {
|
||||
bleConnection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -145,13 +113,11 @@ class BleRadioInterface(
|
|||
}
|
||||
}
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
callback.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(
|
||||
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
|
||||
)
|
||||
CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler)
|
||||
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
|
|
@ -167,12 +133,19 @@ class BleRadioInterface(
|
|||
|
||||
@Volatile private var isFullyConnected = false
|
||||
private var connectionJob: Job? = null
|
||||
private var consecutiveFailures = 0
|
||||
private val reconnectPolicy = BleReconnectPolicy()
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
private val heartbeatNonce = AtomicInt(0)
|
||||
private val heartbeatSender =
|
||||
HeartbeatSender(
|
||||
sendToRadio = ::handleSendToRadio,
|
||||
afterHeartbeat = {
|
||||
delay(HEARTBEAT_DRAIN_DELAY)
|
||||
radioService?.requestDrain()
|
||||
},
|
||||
logTag = address,
|
||||
)
|
||||
|
||||
init {
|
||||
override fun start() {
|
||||
connect()
|
||||
}
|
||||
|
||||
|
|
@ -209,134 +182,104 @@ class BleRadioInterface(
|
|||
throw RadioNotConnectedException("Device not found at address $address")
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
private fun connect() {
|
||||
connectionJob =
|
||||
connectionScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Settle delay: let the Android BLE stack finish any pending
|
||||
// disconnect cleanup before starting a new connection attempt.
|
||||
delay(SETTLE_DELAY)
|
||||
|
||||
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: Exception) {
|
||||
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
|
||||
}
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
|
||||
/**
|
||||
* 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" }
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
val device = findDevice()
|
||||
|
||||
// Only reset failures if connection was stable (see MIN_STABLE_CONNECTION).
|
||||
val gattConnectedAt = nowMillis
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
// 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: Exception) {
|
||||
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
|
||||
}
|
||||
}
|
||||
|
||||
// Scope the connectionState listener to this iteration so it's
|
||||
// cancelled automatically before the next reconnect cycle.
|
||||
var disconnectReason: DisconnectReason = DisconnectReason.Unknown
|
||||
coroutineScope {
|
||||
bleConnection.connectionState
|
||||
.onEach { s ->
|
||||
if (s is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
disconnectReason = s.reason
|
||||
onDisconnected()
|
||||
}
|
||||
}
|
||||
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } }
|
||||
.launchIn(this)
|
||||
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
|
||||
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
}
|
||||
val gattConnectedAt = nowMillis
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
|
||||
Logger.i {
|
||||
"[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect"
|
||||
}
|
||||
|
||||
// Skip failure counting for intentional disconnects.
|
||||
if (disconnectReason is DisconnectReason.LocalDisconnect) {
|
||||
consecutiveFailures = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// A connection that drops almost immediately (< MIN_STABLE_CONNECTION)
|
||||
// is treated as a failure — the BLE stack may have "connected" to a
|
||||
// cached GATT profile before realising the device is gone.
|
||||
val connectionUptime = (nowMillis - gattConnectedAt).milliseconds
|
||||
if (connectionUptime >= MIN_STABLE_CONNECTION) {
|
||||
consecutiveFailures = 0
|
||||
} else {
|
||||
consecutiveFailures++
|
||||
Logger.w {
|
||||
"[$address] Connection lasted only $connectionUptime " +
|
||||
"(< $MIN_STABLE_CONNECTION) — treating as failure " +
|
||||
"(consecutive failures: $consecutiveFailures)"
|
||||
}
|
||||
if (consecutiveFailures >= RECONNECT_MAX_FAILURES) {
|
||||
Logger.e { "[$address] Giving up after $consecutiveFailures unstable connections" }
|
||||
service.onDisconnect(
|
||||
isPermanent = true,
|
||||
errorMessage = "Device unreachable (unstable connection)",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) {
|
||||
service.onDisconnect(
|
||||
isPermanent = false,
|
||||
errorMessage = "Device unreachable (unstable connection)",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
Logger.d { "[$address] BLE connection coroutine cancelled" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = (nowMillis - connectionStartTime).milliseconds
|
||||
consecutiveFailures++
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to connect to device after $failureTime " +
|
||||
"(consecutive failures: $consecutiveFailures)"
|
||||
}
|
||||
|
||||
// Give up permanently to stop draining battery.
|
||||
if (consecutiveFailures >= RECONNECT_MAX_FAILURES) {
|
||||
Logger.e { "[$address] Giving up after $consecutiveFailures consecutive failures" }
|
||||
val (_, msg) = e.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent = true, errorMessage = msg)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Signal DeviceSleep so MeshConnectionManagerImpl starts its sleep timeout.
|
||||
if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) {
|
||||
handleFailure(e)
|
||||
}
|
||||
|
||||
val backoff = computeReconnectBackoff(consecutiveFailures)
|
||||
Logger.d { "[$address] Retrying in $backoff (failure #$consecutiveFailures)" }
|
||||
delay(backoff)
|
||||
// Scope the connectionState listener to this iteration so it's
|
||||
// cancelled automatically before the next reconnect cycle.
|
||||
var disconnectReason: DisconnectReason = DisconnectReason.Unknown
|
||||
coroutineScope {
|
||||
bleConnection.connectionState
|
||||
.onEach { s ->
|
||||
if (s is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
disconnectReason = s.reason
|
||||
onDisconnected()
|
||||
}
|
||||
}
|
||||
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } }
|
||||
.launchIn(this)
|
||||
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
@ -354,7 +297,7 @@ class BleRadioInterface(
|
|||
radioService = null
|
||||
Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" }
|
||||
// Signal immediately so the UI reflects the disconnect while reconnect continues.
|
||||
service.onDisconnect(isPermanent = false)
|
||||
callback.onDisconnect(isPermanent = false)
|
||||
}
|
||||
|
||||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
|
|
@ -384,7 +327,7 @@ class BleRadioInterface(
|
|||
}
|
||||
.launchIn(this)
|
||||
|
||||
this@BleRadioInterface.radioService = radioService
|
||||
this@BleRadioTransport.radioService = radioService
|
||||
|
||||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
|
||||
|
|
@ -395,7 +338,7 @@ class BleRadioInterface(
|
|||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
|
||||
|
||||
this@BleRadioInterface.service.onConnect()
|
||||
this@BleRadioTransport.callback.onConnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
|
||||
|
|
@ -409,7 +352,7 @@ class BleRadioInterface(
|
|||
}
|
||||
}
|
||||
|
||||
@Volatile private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
|
||||
@Volatile private var radioService: MeshtasticRadioProfile? = null
|
||||
|
||||
// --- RadioTransport Implementation ---
|
||||
|
||||
|
|
@ -445,36 +388,19 @@ class BleRadioInterface(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
override fun keepAlive() {
|
||||
// Send a ToRadio heartbeat so the firmware resets its power-saving idle timer.
|
||||
// The firmware only resets the timer on writes to the TORADIO characteristic; a
|
||||
// BLE-level GATT keepalive is invisible to it. Without this the device may enter
|
||||
// light-sleep and drop the BLE connection after ~60 s of application inactivity.
|
||||
//
|
||||
// Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the
|
||||
// firmware's per-connection duplicate-write filter from silently dropping it.
|
||||
val nonce = heartbeatNonce.fetchAndAdd(1)
|
||||
Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
|
||||
handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode())
|
||||
|
||||
// The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet
|
||||
// on the next getFromRadio() call, but it does NOT send a FROMNUM notification for
|
||||
// it. The immediate drain trigger in sendToRadio() fires before the ESP32's async
|
||||
// task queue has processed the heartbeat, so the response sits unread. Schedule a
|
||||
// delayed re-drain to pick it up.
|
||||
connectionScope.launch {
|
||||
delay(HEARTBEAT_DRAIN_DELAY)
|
||||
radioService?.requestDrain()
|
||||
}
|
||||
// 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 fun close() {
|
||||
Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" }
|
||||
connectionScope.cancel("close() called")
|
||||
// GATT cleanup must outlive serviceScope cancellation — GlobalScope is intentional.
|
||||
// SharedRadioInterfaceService cancels serviceScope immediately after close(), so a
|
||||
// GATT cleanup must outlive scope cancellation — GlobalScope is intentional.
|
||||
// SharedRadioInterfaceService cancels the scope immediately after close(), so a
|
||||
// coroutine launched there may never run, leaking BluetoothGatt (causes GATT 133).
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch {
|
||||
|
|
@ -493,12 +419,12 @@ class BleRadioInterface(
|
|||
"[$address] Dispatching packet #$packetsReceived " +
|
||||
"(${packet.size} bytes, total RX: $bytesReceived bytes)"
|
||||
}
|
||||
service.handleFromRadio(packet)
|
||||
callback.handleFromRadio(packet)
|
||||
}
|
||||
|
||||
private fun handleFailure(throwable: Throwable) {
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
callback.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
/** Formats a one-line session statistics summary for logging. */
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* 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, signal a transient disconnect (DeviceSleep), or
|
||||
* give up permanently.
|
||||
*
|
||||
* @param maxFailures maximum consecutive failures before giving up permanently
|
||||
* @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
|
||||
val DEFAULT_SETTLE_DELAY = 1.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,28 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
/** This interface defines the contract that all radio backend implementations must adhere to. */
|
||||
interface InterfaceSpec<T : RadioTransport> {
|
||||
fun createInterface(rest: String, service: RadioInterfaceService): T
|
||||
|
||||
/** Return true if this address is still acceptable. For BLE that means, still bonded */
|
||||
fun addressValid(rest: String): Boolean = true
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `MockInterface` instances. */
|
||||
@Single
|
||||
class MockInterfaceFactory {
|
||||
fun create(rest: String, service: RadioInterfaceService): MockInterface = MockInterface(service, rest)
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Mock interface backend implementation. */
|
||||
@Single
|
||||
class MockInterfaceSpec(private val factory: MockInterfaceFactory) : InterfaceSpec<MockInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): MockInterface =
|
||||
factory.create(rest, service)
|
||||
|
||||
/** Return true if this address is still acceptable. For BLE that means, still bonded */
|
||||
override fun addressValid(rest: String): Boolean = true
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
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
|
||||
|
|
@ -25,8 +26,8 @@ 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.RadioInterfaceService
|
||||
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
|
||||
|
|
@ -55,9 +56,13 @@ private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Co
|
|||
|
||||
private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY)
|
||||
|
||||
/** A simulated interface that is used for testing in the simulator */
|
||||
/** A simulated transport that is used for testing in the simulator. */
|
||||
@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber")
|
||||
class MockInterface(private val service: RadioInterfaceService, val address: String) : RadioTransport {
|
||||
class MockRadioTransport(
|
||||
private val callback: RadioTransportCallback,
|
||||
private val scope: CoroutineScope,
|
||||
val address: String,
|
||||
) : RadioTransport {
|
||||
|
||||
companion object {
|
||||
private const val MY_NODE = 0x42424242
|
||||
|
|
@ -68,13 +73,22 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
|
|||
// an infinite sequence of ints
|
||||
private val packetIdSequence = generateSequence { currentPacketId++ }.iterator()
|
||||
|
||||
init {
|
||||
Logger.i { "Starting the mock interface" }
|
||||
service.onConnect() // Tell clients they can use the API
|
||||
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)
|
||||
|
|
@ -83,11 +97,10 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
|
|||
val data = packet?.decoded
|
||||
|
||||
when {
|
||||
(pr.want_config_id ?: 0) != 0 -> sendConfigResponse(pr.want_config_id ?: 0)
|
||||
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 interface $pr" }
|
||||
else -> Logger.i { "Ignoring data sent to mock transport $pr" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,12 +140,12 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
|
|||
)
|
||||
}
|
||||
|
||||
else -> Logger.i { "Ignoring admin sent to mock interface $d" }
|
||||
else -> Logger.i { "Ignoring admin sent to mock transport $d" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
Logger.i { "Closing the mock interface" }
|
||||
Logger.i { "Closing the mock transport" }
|
||||
}
|
||||
|
||||
// / Generate a fake text message from a node
|
||||
|
|
@ -279,7 +292,7 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
|
|||
Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId),
|
||||
)
|
||||
|
||||
private fun sendQueueStatus(msgId: Int) = service.handleFromRadio(
|
||||
private fun sendQueueStatus(msgId: Int) = callback.handleFromRadio(
|
||||
FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(),
|
||||
)
|
||||
|
||||
|
|
@ -291,14 +304,14 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
|
|||
toIn,
|
||||
Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId),
|
||||
)
|
||||
service.handleFromRadio(p.encode())
|
||||
callback.handleFromRadio(p.encode())
|
||||
}
|
||||
|
||||
// / Send a fake ack packet back if the sender asked for want_ack
|
||||
private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch {
|
||||
private fun sendFakeAck(pr: ToRadio) = scope.handledLaunch {
|
||||
val packet = pr.packet ?: return@handledLaunch
|
||||
delay(2000)
|
||||
service.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode())
|
||||
callback.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode())
|
||||
}
|
||||
|
||||
private fun sendConfigResponse(configId: Int) {
|
||||
|
|
@ -353,6 +366,6 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
|
|||
makeNodeStatus(MY_NODE + 1),
|
||||
)
|
||||
|
||||
packets.forEach { p -> service.handleFromRadio(p.encode()) }
|
||||
packets.forEach { p -> callback.handleFromRadio(p.encode()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
|
||||
/** Factory for creating `NopInterface` instances. */
|
||||
@Single
|
||||
class NopInterfaceFactory {
|
||||
fun create(rest: String): NopInterface = NopInterface(rest)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** No-op interface backend implementation. */
|
||||
@Single
|
||||
class NopInterfaceSpec(private val factory: NopInterfaceFactory) : InterfaceSpec<NopInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): NopInterface = factory.create(rest)
|
||||
}
|
||||
|
|
@ -18,7 +18,14 @@ package org.meshtastic.core.network.radio
|
|||
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
|
||||
class NopInterface(val address: String) : 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
|
||||
}
|
||||
|
|
@ -17,10 +17,11 @@
|
|||
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.RadioInterfaceService
|
||||
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
|
||||
|
|
@ -28,9 +29,11 @@ import org.meshtastic.core.repository.RadioTransport
|
|||
*
|
||||
* Delegates framing logic to [StreamFrameCodec] from `core:network`.
|
||||
*/
|
||||
abstract class StreamInterface(protected val service: RadioInterfaceService) : RadioTransport {
|
||||
abstract class StreamTransport(protected val callback: RadioTransportCallback, protected val scope: CoroutineScope) :
|
||||
RadioTransport {
|
||||
|
||||
private val codec = StreamFrameCodec(onPacketReceived = { service.handleFromRadio(it) }, logTag = "StreamInterface")
|
||||
private val codec =
|
||||
StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport")
|
||||
|
||||
override fun close() {
|
||||
Logger.d { "Closing stream for good" }
|
||||
|
|
@ -38,33 +41,34 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : R
|
|||
}
|
||||
|
||||
/**
|
||||
* Tell MeshService our device has gone away, but wait for it to come back
|
||||
* Notify the transport callback that our device has gone away, but wait for it to come back.
|
||||
*
|
||||
* @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the
|
||||
* manager callbacks
|
||||
* @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside
|
||||
* transport callbacks
|
||||
* @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g.
|
||||
* TCP transient disconnect). Defaults to true for serial — subclasses like [TCPInterface] override with false.
|
||||
* TCP transient disconnect). Defaults to true for serial — subclasses may override with false.
|
||||
*/
|
||||
protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) {
|
||||
service.onDisconnect(isPermanent = isPermanent)
|
||||
callback.onDisconnect(isPermanent = isPermanent)
|
||||
}
|
||||
|
||||
protected open fun connect() {
|
||||
// Before telling mesh service, send a few START1s to wake a sleeping device
|
||||
// Before connecting, send a few START1s to wake a sleeping device
|
||||
sendBytes(StreamFrameCodec.WAKE_BYTES)
|
||||
|
||||
// Now tell clients they can (finally use the api)
|
||||
service.onConnect()
|
||||
callback.onConnect()
|
||||
}
|
||||
|
||||
/** Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). */
|
||||
abstract fun sendBytes(p: ByteArray)
|
||||
|
||||
// If subclasses need to flush at the end of a packet they can implement
|
||||
/** 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
|
||||
service.serviceScope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) }
|
||||
scope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) }
|
||||
}
|
||||
|
||||
/** Process a single incoming byte through the stream framing state machine. */
|
||||
|
|
@ -18,12 +18,15 @@ package org.meshtastic.core.network.repository
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.github.davidepianca98.MQTTClient
|
||||
import io.github.davidepianca98.mqtt.MQTTException
|
||||
import io.github.davidepianca98.mqtt.MQTTVersion
|
||||
import io.github.davidepianca98.mqtt.Subscription
|
||||
import io.github.davidepianca98.mqtt.packets.Qos
|
||||
import io.github.davidepianca98.mqtt.packets.mqttv5.ReasonCode
|
||||
import io.github.davidepianca98.mqtt.packets.mqttv5.SubscriptionOptions
|
||||
import io.github.davidepianca98.socket.IOException
|
||||
import io.github.davidepianca98.socket.tls.TLSClientSettings
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -36,9 +39,12 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonDecodingException
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.MqttJsonPayload
|
||||
import org.meshtastic.core.model.util.subscribeList
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
|
|
@ -50,7 +56,7 @@ import kotlin.concurrent.Volatile
|
|||
class MQTTRepositoryImpl(
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) : MQTTRepository {
|
||||
|
||||
companion object {
|
||||
|
|
@ -78,14 +84,15 @@ class MQTTRepositoryImpl(
|
|||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun disconnect() {
|
||||
Logger.i { "MQTT Disconnecting" }
|
||||
val c = client
|
||||
client = null // Null first to prevent re-entrant disconnect
|
||||
try {
|
||||
client?.disconnect(ReasonCode.SUCCESS)
|
||||
c?.disconnect(ReasonCode.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "MQTT clean disconnect failed" }
|
||||
}
|
||||
clientJob?.cancel()
|
||||
clientJob = null
|
||||
client = null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
|
|
@ -123,10 +130,10 @@ class MQTTRepositoryImpl(
|
|||
Logger.d { "MQTT parsed JSON payload successfully" }
|
||||
|
||||
trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = packet.retain))
|
||||
} catch (e: kotlinx.serialization.json.JsonDecodingException) {
|
||||
} catch (e: JsonDecodingException) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" }
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
} catch (e: SerializationException) {
|
||||
Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" }
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" }
|
||||
|
|
@ -180,11 +187,11 @@ class MQTTRepositoryImpl(
|
|||
// Reset backoff so the next reconnect starts with the minimum delay.
|
||||
reconnectDelay = INITIAL_RECONNECT_DELAY_MS
|
||||
Logger.w { "MQTT client loop ended normally, reconnecting in ${reconnectDelay}ms" }
|
||||
} catch (e: io.github.davidepianca98.mqtt.MQTTException) {
|
||||
} catch (e: MQTTException) {
|
||||
Logger.e(e) { "MQTT Client loop error (MQTT), reconnecting in ${reconnectDelay}ms" }
|
||||
} catch (e: io.github.davidepianca98.socket.IOException) {
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "MQTT Client loop error (IO), reconnecting in ${reconnectDelay}ms" }
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
Logger.i { "MQTT Client loop cancelled" }
|
||||
throw e
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -36,10 +36,9 @@ import org.meshtastic.core.testing.FakeBluetoothRepository
|
|||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleRadioInterfaceTest {
|
||||
class BleRadioTransportTest {
|
||||
|
||||
private val testScope = TestScope()
|
||||
private val scanner = FakeBleScanner()
|
||||
|
|
@ -56,66 +55,69 @@ class BleRadioInterfaceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `connect attempts to scan and connect via init`() = runTest {
|
||||
fun `connect attempts to scan and connect via start`() = runTest {
|
||||
val device = FakeBleDevice(address = address, name = "Test Device")
|
||||
scanner.emitDevice(device)
|
||||
|
||||
val bleInterface =
|
||||
BleRadioInterface(
|
||||
serviceScope = testScope,
|
||||
val bleTransport =
|
||||
BleRadioTransport(
|
||||
scope = testScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
callback = service,
|
||||
address = address,
|
||||
)
|
||||
bleTransport.start()
|
||||
|
||||
// init starts connect() which is async
|
||||
// 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, bleInterface.address)
|
||||
assertEquals(address, bleTransport.address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address returns correct value`() {
|
||||
val bleInterface =
|
||||
BleRadioInterface(
|
||||
serviceScope = testScope,
|
||||
val bleTransport =
|
||||
BleRadioTransport(
|
||||
scope = testScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
callback = service,
|
||||
address = address,
|
||||
)
|
||||
assertEquals(address, bleInterface.address)
|
||||
assertEquals(address, bleTransport.address)
|
||||
}
|
||||
|
||||
/**
|
||||
* After [RECONNECT_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]).
|
||||
* 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 (RECONNECT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses,
|
||||
* Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses,
|
||||
* connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay
|
||||
* elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3
|
||||
* settle delay elapses, connectAndAwait throws → onDisconnect called
|
||||
*/
|
||||
@Test
|
||||
fun `onDisconnect is called after RECONNECT_FAILURE_THRESHOLD consecutive failures`() = runTest {
|
||||
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 bleInterface =
|
||||
BleRadioInterface(
|
||||
serviceScope = this,
|
||||
val bleTransport =
|
||||
BleRadioTransport(
|
||||
scope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
callback = service,
|
||||
address = address,
|
||||
)
|
||||
bleTransport.start()
|
||||
|
||||
// Advance through exactly 3 failure iterations (≈18 001 ms virtual time).
|
||||
// The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended
|
||||
|
|
@ -125,12 +127,12 @@ class BleRadioInterfaceTest {
|
|||
verify { service.onDisconnect(any(), any()) }
|
||||
|
||||
// Cancel the reconnect loop so runTest can complete.
|
||||
bleInterface.close()
|
||||
bleTransport.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* After [RECONNECT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and signal a permanent
|
||||
* disconnect. This prevents infinite battery drain when the device is genuinely offline.
|
||||
* After [BleReconnectPolicy.DEFAULT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and
|
||||
* signal a permanent disconnect. This prevents infinite battery drain when the device is genuinely offline.
|
||||
*
|
||||
* Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw +
|
||||
* backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s
|
||||
|
|
@ -138,22 +140,23 @@ class BleRadioInterfaceTest {
|
|||
* variance.
|
||||
*/
|
||||
@Test
|
||||
fun `reconnect loop stops after RECONNECT_MAX_FAILURES with permanent disconnect`() = runTest {
|
||||
fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = 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 bleInterface =
|
||||
BleRadioInterface(
|
||||
serviceScope = this,
|
||||
val bleTransport =
|
||||
BleRadioTransport(
|
||||
scope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
callback = service,
|
||||
address = address,
|
||||
)
|
||||
bleTransport.start()
|
||||
|
||||
// Advance enough time for all 10 failures to occur.
|
||||
advanceTimeBy(400_001L)
|
||||
|
|
@ -161,18 +164,6 @@ class BleRadioInterfaceTest {
|
|||
// Should have been called with isPermanent=true at least once (the final call).
|
||||
verify { service.onDisconnect(isPermanent = true, errorMessage = any()) }
|
||||
|
||||
bleInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `computeReconnectBackoff returns correct backoff values`() {
|
||||
assertEquals(5.seconds, computeReconnectBackoff(0))
|
||||
assertEquals(5.seconds, computeReconnectBackoff(1))
|
||||
assertEquals(10.seconds, computeReconnectBackoff(2))
|
||||
assertEquals(20.seconds, computeReconnectBackoff(3))
|
||||
assertEquals(40.seconds, computeReconnectBackoff(4))
|
||||
assertEquals(60.seconds, computeReconnectBackoff(5))
|
||||
assertEquals(60.seconds, computeReconnectBackoff(10))
|
||||
assertEquals(60.seconds, computeReconnectBackoff(100))
|
||||
bleTransport.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ import kotlin.test.assertTrue
|
|||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Tests the exponential backoff schedule used by [BleRadioInterface] when consecutive connection attempts fail. The
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@
|
|||
package org.meshtastic.core.network.radio
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import io.kotest.property.Arb
|
||||
|
|
@ -29,17 +27,16 @@ 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.RadioInterfaceService
|
||||
import kotlin.test.BeforeTest
|
||||
import org.meshtastic.core.repository.RadioTransportCallback
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class StreamInterfaceTest {
|
||||
class StreamTransportTest {
|
||||
|
||||
private val radioService: RadioInterfaceService = mock(MockMode.autofill)
|
||||
private lateinit var fakeStream: FakeStreamInterface
|
||||
private val callback: RadioTransportCallback = mock(MockMode.autofill)
|
||||
private lateinit var fakeStream: FakeStreamTransport
|
||||
|
||||
class FakeStreamInterface(service: RadioInterfaceService) : StreamInterface(service) {
|
||||
class FakeStreamTransport(callback: RadioTransportCallback, scope: TestScope) : StreamTransport(callback, scope) {
|
||||
val sentBytes = mutableListOf<ByteArray>()
|
||||
|
||||
override fun sendBytes(p: ByteArray) {
|
||||
|
|
@ -59,21 +56,18 @@ class StreamInterfaceTest {
|
|||
public override fun connect() = super.connect()
|
||||
}
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { radioService.serviceScope } returns TestScope()
|
||||
}
|
||||
private val testScope = TestScope()
|
||||
|
||||
@Test
|
||||
fun `handleSendToRadio property test`() = runTest {
|
||||
fakeStream = FakeStreamInterface(radioService)
|
||||
fakeStream = FakeStreamTransport(callback, testScope)
|
||||
|
||||
checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readChar property test`() = runTest {
|
||||
fakeStream = FakeStreamInterface(radioService)
|
||||
fakeStream = FakeStreamTransport(callback, testScope)
|
||||
|
||||
checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data ->
|
||||
data.forEach { fakeStream.feed(it) }
|
||||
|
|
@ -83,11 +77,11 @@ class StreamInterfaceTest {
|
|||
|
||||
@Test
|
||||
fun `connect sends wake bytes`() {
|
||||
fakeStream = FakeStreamInterface(radioService)
|
||||
fakeStream = FakeStreamTransport(callback, testScope)
|
||||
fakeStream.connect()
|
||||
|
||||
assertTrue(fakeStream.sentBytes.isNotEmpty())
|
||||
assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES))
|
||||
verify { radioService.onConnect() }
|
||||
verify { callback.onConnect() }
|
||||
}
|
||||
}
|
||||
|
|
@ -17,76 +17,76 @@
|
|||
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.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportCallback
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Android TCP radio interface — thin adapter over the shared [TcpTransport] from `core:network`.
|
||||
* TCP radio transport — thin adapter over the shared [TcpTransport] from `core:network`.
|
||||
*
|
||||
* Manages the mapping between the Android-specific [StreamInterface]/[RadioTransport] contract and the shared transport
|
||||
* layer.
|
||||
* 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 TCPInterface(
|
||||
service: RadioInterfaceService,
|
||||
open class TcpRadioTransport(
|
||||
private val callback: RadioTransportCallback,
|
||||
private val scope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val address: String,
|
||||
) : StreamInterface(service) {
|
||||
) : 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 = service.serviceScope,
|
||||
scope = scope,
|
||||
listener =
|
||||
object : TcpTransport.Listener {
|
||||
override fun onConnected() {
|
||||
super@TCPInterface.connect()
|
||||
callback.onConnect()
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
// Transport already performed teardown; only propagate lifecycle to StreamInterface.
|
||||
if (closing) return // close() will fire the permanent disconnect itself
|
||||
// TCP disconnects are transient (not permanent) — the transport will auto-reconnect.
|
||||
super@TCPInterface.onDeviceDisconnect(false, isPermanent = false)
|
||||
callback.onDisconnect(isPermanent = false)
|
||||
}
|
||||
|
||||
override fun onPacketReceived(bytes: ByteArray) {
|
||||
service.handleFromRadio(bytes)
|
||||
callback.handleFromRadio(bytes)
|
||||
}
|
||||
},
|
||||
logTag = "TCPInterface[$address]",
|
||||
logTag = "TcpRadioTransport[$address]",
|
||||
)
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
override fun sendBytes(p: ByteArray) {
|
||||
// Direct byte sending is handled by the transport; this is used by StreamInterface for serial compat
|
||||
Logger.d { "[$address] TCPInterface.sendBytes delegated to transport" }
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) {
|
||||
transport.stop()
|
||||
super.onDeviceDisconnect(waitForStopped, isPermanent = false)
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
override fun start() {
|
||||
transport.start(address)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
Logger.d { "[$address] Closing TCP transport" }
|
||||
closing = true
|
||||
transport.stop()
|
||||
callback.onDisconnect(isPermanent = true)
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
Logger.d { "[$address] TCP keepAlive" }
|
||||
service.serviceScope.handledLaunch { transport.sendHeartbeat() }
|
||||
scope.handledLaunch { transport.sendHeartbeat() }
|
||||
}
|
||||
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
service.serviceScope.handledLaunch { transport.sendPacket(p) }
|
||||
scope.handledLaunch { transport.sendPacket(p) }
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,6 @@ 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.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
|
|
@ -34,13 +33,14 @@ 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. Heartbeat scheduling is owned by [SharedRadioInterfaceService]; this class
|
||||
* only exposes [sendHeartbeat] for external callers.
|
||||
* 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`.
|
||||
*/
|
||||
|
|
@ -109,6 +109,8 @@ class TcpTransport(
|
|||
|
||||
@Volatile private var timeoutEvents: Int = 0
|
||||
|
||||
private val heartbeatNonce = AtomicInteger(0)
|
||||
|
||||
/** Whether the transport is currently connected. */
|
||||
val isConnected: Boolean
|
||||
get() {
|
||||
|
|
@ -146,9 +148,10 @@ class TcpTransport(
|
|||
bytesSent += payload.size
|
||||
}
|
||||
|
||||
/** Send a heartbeat packet to keep the connection alive. */
|
||||
/** Send a heartbeat packet with a monotonically-increasing nonce to keep the connection alive. */
|
||||
suspend fun sendHeartbeat() {
|
||||
val heartbeat = ToRadio(heartbeat = Heartbeat())
|
||||
val nonce = heartbeatNonce.getAndIncrement()
|
||||
val heartbeat = ToRadio(heartbeat = org.meshtastic.proto.Heartbeat(nonce = nonce))
|
||||
sendPacket(heartbeat.encode())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,18 +19,19 @@ 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.StreamInterface
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
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 [StreamInterface] for START1/START2 packet
|
||||
* 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
|
||||
|
|
@ -40,12 +41,15 @@ class SerialTransport
|
|||
private constructor(
|
||||
private val portName: String,
|
||||
private val baudRate: Int = DEFAULT_BAUD_RATE,
|
||||
service: RadioInterfaceService,
|
||||
callback: RadioTransportCallback,
|
||||
scope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : StreamInterface(service) {
|
||||
) : 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 {
|
||||
|
|
@ -57,7 +61,7 @@ private constructor(
|
|||
port.setDTR()
|
||||
port.setRTS()
|
||||
Logger.i { "[$portName] Serial port opened (baud=$baudRate)" }
|
||||
super.connect() // Sends WAKE_BYTES and signals service.onConnect()
|
||||
super.connect() // Sends WAKE_BYTES and signals callback.onConnect()
|
||||
startReadLoop(port)
|
||||
true
|
||||
} else {
|
||||
|
|
@ -74,7 +78,7 @@ private constructor(
|
|||
private fun startReadLoop(port: SerialPort) {
|
||||
Logger.d { "[$portName] Starting serial read loop" }
|
||||
readJob =
|
||||
service.serviceScope.launch(dispatchers.io) {
|
||||
scope.launch(dispatchers.io) {
|
||||
val input = port.inputStream
|
||||
val buffer = ByteArray(READ_BUFFER_SIZE)
|
||||
try {
|
||||
|
|
@ -91,7 +95,7 @@ private constructor(
|
|||
}
|
||||
} catch (_: SerialPortTimeoutException) {
|
||||
// Expected timeout when no data is available
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
if (isActive) {
|
||||
|
|
@ -102,7 +106,7 @@ private constructor(
|
|||
reading = false
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
if (isActive) {
|
||||
|
|
@ -140,11 +144,9 @@ private constructor(
|
|||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
// Send a ToRadio heartbeat so the firmware resets its idle timer and responds with
|
||||
// a FromRadio queueStatus — proving the serial link is alive. Without this, the
|
||||
// serial transport has no way to detect a silently dead device.
|
||||
Logger.d { "[$portName] Serial keepAlive — sending heartbeat" }
|
||||
handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode())
|
||||
// Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the
|
||||
// serial link is alive.
|
||||
scope.launch { heartbeatSender.sendHeartbeat() }
|
||||
}
|
||||
|
||||
private fun closePortResources() {
|
||||
|
|
@ -168,19 +170,20 @@ private constructor(
|
|||
|
||||
/**
|
||||
* Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
|
||||
* disconnect to the [service] and returns the (non-connected) instance.
|
||||
* disconnect to the [callback] and returns the (non-connected) instance.
|
||||
*/
|
||||
fun open(
|
||||
portName: String,
|
||||
baudRate: Int = DEFAULT_BAUD_RATE,
|
||||
service: RadioInterfaceService,
|
||||
callback: RadioTransportCallback,
|
||||
scope: CoroutineScope,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
): SerialTransport {
|
||||
val transport = SerialTransport(portName, baudRate, service, dispatchers)
|
||||
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" }
|
||||
service.onDisconnect(isPermanent = true, errorMessage = errorMessage)
|
||||
callback.onDisconnect(isPermanent = true, errorMessage = errorMessage)
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
|
@ -28,7 +29,7 @@ interface MeshServiceNotifications {
|
|||
|
||||
fun initChannels()
|
||||
|
||||
fun updateServiceStateNotification(state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?)
|
||||
fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?)
|
||||
|
||||
suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import org.meshtastic.core.model.MeshActivity
|
|||
*
|
||||
* @see ServiceRepository.connectionState
|
||||
*/
|
||||
interface RadioInterfaceService {
|
||||
interface RadioInterfaceService : RadioTransportCallback {
|
||||
/** The device types supported by this platform's radio interface. */
|
||||
val supportedDeviceTypes: List<DeviceType>
|
||||
|
||||
|
|
@ -65,8 +65,8 @@ interface RadioInterfaceService {
|
|||
/** Flow of the current device address. */
|
||||
val currentDeviceAddressFlow: StateFlow<String?>
|
||||
|
||||
/** Whether we are currently using a mock interface. */
|
||||
fun isMockInterface(): Boolean
|
||||
/** Whether we are currently using a mock transport. */
|
||||
fun isMockTransport(): Boolean
|
||||
|
||||
/** Flow of raw data received from the radio. */
|
||||
val receivedData: SharedFlow<ByteArray>
|
||||
|
|
@ -89,15 +89,6 @@ interface RadioInterfaceService {
|
|||
/** Constructs a full radio address for the specific interface type. */
|
||||
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String
|
||||
|
||||
/** Called by an interface when it has successfully connected. */
|
||||
fun onConnect()
|
||||
|
||||
/** Called by an interface when it has disconnected. */
|
||||
fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null)
|
||||
|
||||
/** Called by an interface when it has received raw data from the radio. */
|
||||
fun handleFromRadio(bytes: ByteArray)
|
||||
|
||||
/** Flow of user-facing connection error messages (e.g. permission failures). */
|
||||
val connectionError: SharedFlow<String>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ interface RadioTransport : Closeable {
|
|||
/** 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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
|
|
@ -28,8 +28,8 @@ interface RadioTransportFactory {
|
|||
/** The device types supported by this factory. */
|
||||
val supportedDeviceTypes: List<DeviceType>
|
||||
|
||||
/** Whether we are currently forced into using a mock interface (e.g., Firebase Test Lab). */
|
||||
fun isMockInterface(): Boolean
|
||||
/** 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
|
||||
|
|
|
|||
|
|
@ -17,15 +17,22 @@
|
|||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/**
|
||||
* Android [RadioController] implementation that delegates to the bound [MeshService] via AIDL.
|
||||
|
|
@ -69,41 +76,37 @@ class AndroidRadioControllerImpl(
|
|||
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
|
||||
val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
|
||||
val contact =
|
||||
org.meshtastic.proto.SharedContact(
|
||||
node_num = nodeDef.num,
|
||||
user = nodeDef.user,
|
||||
manually_verified = nodeDef.manuallyVerified,
|
||||
)
|
||||
SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified)
|
||||
val action = ServiceAction.SendContact(contact)
|
||||
serviceRepository.onServiceAction(action)
|
||||
return action.result.await()
|
||||
}
|
||||
|
||||
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {
|
||||
override suspend fun setLocalConfig(config: Config) {
|
||||
serviceRepository.meshService?.setConfig(config.encode())
|
||||
}
|
||||
|
||||
override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {
|
||||
override suspend fun setLocalChannel(channel: Channel) {
|
||||
serviceRepository.meshService?.setChannel(channel.encode())
|
||||
}
|
||||
|
||||
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {
|
||||
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
|
||||
serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode())
|
||||
}
|
||||
|
||||
override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {
|
||||
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
|
||||
serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode())
|
||||
}
|
||||
|
||||
override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {
|
||||
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
|
||||
serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode())
|
||||
}
|
||||
|
||||
override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {
|
||||
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
|
||||
serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode())
|
||||
}
|
||||
|
||||
override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {
|
||||
override suspend fun setFixedPosition(destNum: Int, position: Position) {
|
||||
serviceRepository.meshService?.setFixedPosition(destNum, position)
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +174,7 @@ class AndroidRadioControllerImpl(
|
|||
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
|
||||
}
|
||||
|
||||
override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {
|
||||
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
|
||||
serviceRepository.meshService?.requestPosition(destNum, currentPosition)
|
||||
}
|
||||
|
||||
|
|
@ -214,10 +217,7 @@ class AndroidRadioControllerImpl(
|
|||
@Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder
|
||||
serviceRepository.meshService?.setDeviceAddress(address)
|
||||
// Ensure service is running/restarted to handle the new address
|
||||
val intent =
|
||||
android.content.Intent().apply {
|
||||
setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService")
|
||||
}
|
||||
val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") }
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ import org.meshtastic.core.repository.ServiceBroadcasts
|
|||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.PortNum
|
||||
|
||||
/**
|
||||
* Android foreground service that hosts the Meshtastic mesh radio connection.
|
||||
*
|
||||
* Acts as the lifecycle anchor for the [MeshServiceOrchestrator], which manages all manager initialization and
|
||||
* connection state. Exposes an AIDL binder for external client integration via [core:api].
|
||||
*/
|
||||
// IMeshService is deprecated but still required for AIDL binding
|
||||
@Suppress("TooManyFunctions", "LargeClass", "DEPRECATION")
|
||||
class MeshService : Service() {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import org.jetbrains.compose.resources.StringResource
|
|||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Message
|
||||
import org.meshtastic.core.model.Node
|
||||
|
|
@ -303,17 +304,14 @@ class MeshServiceNotificationsImpl(
|
|||
|
||||
// region Public Notification Methods
|
||||
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
|
||||
override fun updateServiceStateNotification(
|
||||
state: org.meshtastic.core.model.ConnectionState,
|
||||
telemetry: Telemetry?,
|
||||
) {
|
||||
override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {
|
||||
val summaryString =
|
||||
when (state) {
|
||||
is org.meshtastic.core.model.ConnectionState.Connected ->
|
||||
is ConnectionState.Connected ->
|
||||
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
|
||||
is org.meshtastic.core.model.ConnectionState.Disconnected -> getString(Res.string.disconnected)
|
||||
is org.meshtastic.core.model.ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
|
||||
is org.meshtastic.core.model.ConnectionState.Connecting -> getString(Res.string.connecting)
|
||||
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
|
||||
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
|
||||
is ConnectionState.Connecting -> getString(Res.string.connecting)
|
||||
}
|
||||
|
||||
// Update caches if telemetry is provided
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ 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.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -96,10 +99,7 @@ class SharedRadioInterfaceService(
|
|||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
|
|
@ -109,12 +109,12 @@ class SharedRadioInterfaceService(
|
|||
get() = _serviceScope
|
||||
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
private var radioIf: RadioTransport? = null
|
||||
private var runningInterfaceId: InterfaceId? = null
|
||||
private var radioTransport: RadioTransport? = null
|
||||
private var runningTransportId: InterfaceId? = null
|
||||
private var isStarted = false
|
||||
|
||||
private val listenersInitialized = kotlinx.atomicfu.atomic(false)
|
||||
private var heartbeatJob: kotlinx.coroutines.Job? = null
|
||||
private val listenersInitialized = atomic(false)
|
||||
private var heartbeatJob: Job? = null
|
||||
private var lastHeartbeatMillis = 0L
|
||||
|
||||
@Volatile private var lastDataReceivedMillis = 0L
|
||||
|
|
@ -130,7 +130,7 @@ class SharedRadioInterfaceService(
|
|||
}
|
||||
|
||||
private val initLock = Mutex()
|
||||
private val interfaceMutex = Mutex()
|
||||
private val transportMutex = Mutex()
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized.value) return
|
||||
|
|
@ -141,10 +141,10 @@ class SharedRadioInterfaceService(
|
|||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
interfaceMutex.withLock {
|
||||
transportMutex.withLock {
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterfaceLocked()
|
||||
startTransportLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,11 +152,11 @@ class SharedRadioInterfaceService(
|
|||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
interfaceMutex.withLock {
|
||||
transportMutex.withLock {
|
||||
if (state.enabled) {
|
||||
startInterfaceLocked()
|
||||
} else if (runningInterfaceId == InterfaceId.BLUETOOTH) {
|
||||
stopInterfaceLocked()
|
||||
startTransportLocked()
|
||||
} else if (runningTransportId == InterfaceId.BLUETOOTH) {
|
||||
stopTransportLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,11 +165,11 @@ class SharedRadioInterfaceService(
|
|||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
interfaceMutex.withLock {
|
||||
transportMutex.withLock {
|
||||
if (state) {
|
||||
startInterfaceLocked()
|
||||
} else if (runningInterfaceId == InterfaceId.TCP) {
|
||||
stopInterfaceLocked()
|
||||
startTransportLocked()
|
||||
} else if (runningTransportId == InterfaceId.TCP) {
|
||||
stopTransportLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,11 +180,11 @@ class SharedRadioInterfaceService(
|
|||
}
|
||||
|
||||
override fun connect() {
|
||||
processLifecycle.coroutineScope.launch { interfaceMutex.withLock { startInterfaceLocked() } }
|
||||
processLifecycle.coroutineScope.launch { transportMutex.withLock { startTransportLocked() } }
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
override fun isMockInterface(): Boolean = transportFactory.isMockInterface()
|
||||
override fun isMockTransport(): Boolean = transportFactory.isMockTransport()
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
transportFactory.toInterfaceAddress(interfaceId, rest)
|
||||
|
|
@ -215,17 +215,17 @@ class SharedRadioInterfaceService(
|
|||
_currentDeviceAddressFlow.value = sanitized
|
||||
|
||||
processLifecycle.coroutineScope.launch {
|
||||
interfaceMutex.withLock {
|
||||
ignoreException { stopInterfaceLocked() }
|
||||
startInterfaceLocked()
|
||||
transportMutex.withLock {
|
||||
ignoreException { stopTransportLocked() }
|
||||
startTransportLocked()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Must be called under [interfaceMutex]. */
|
||||
private fun startInterfaceLocked() {
|
||||
if (radioIf != null) return
|
||||
/** 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
|
||||
|
|
@ -237,26 +237,26 @@ class SharedRadioInterfaceService(
|
|||
return
|
||||
}
|
||||
|
||||
Logger.i { "Starting radio interface for ${address.anonymize}" }
|
||||
Logger.i { "Starting radio transport for ${address.anonymize}" }
|
||||
isStarted = true
|
||||
runningInterfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
|
||||
radioIf = transportFactory.createTransport(address, this)
|
||||
runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
|
||||
radioTransport = transportFactory.createTransport(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
/** Must be called under [interfaceMutex]. */
|
||||
private fun stopInterfaceLocked() {
|
||||
val currentIf = radioIf
|
||||
Logger.i { "Stopping interface $currentIf" }
|
||||
/** Must be called under [transportMutex]. */
|
||||
private fun stopTransportLocked() {
|
||||
val currentTransport = radioTransport
|
||||
Logger.i { "Stopping transport $currentTransport" }
|
||||
isStarted = false
|
||||
radioIf = null
|
||||
runningInterfaceId = null
|
||||
currentIf?.close()
|
||||
radioTransport = null
|
||||
runningTransportId = null
|
||||
currentTransport?.close()
|
||||
|
||||
_serviceScope.cancel("stopping interface")
|
||||
_serviceScope.cancel("stopping transport")
|
||||
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
if (currentIf != null) {
|
||||
if (currentTransport != null) {
|
||||
onDisconnect(isPermanent = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -295,23 +295,25 @@ class SharedRadioInterfaceService(
|
|||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
|
||||
radioIf?.keepAlive()
|
||||
radioTransport?.keepAlive()
|
||||
lastHeartbeatMillis = now
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
// Capture radioIf reference atomically to avoid racing with stopInterfaceLocked()
|
||||
// which sets radioIf = null and cancels _serviceScope. Without this snapshot,
|
||||
// we could read a non-null radioIf but launch into an already-cancelled scope.
|
||||
val currentIf =
|
||||
radioIf
|
||||
// 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 interface, dropping ${bytes.size} bytes" }
|
||||
Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" }
|
||||
return
|
||||
}
|
||||
_serviceScope.handledLaunch {
|
||||
currentIf.handleSendToRadio(bytes)
|
||||
currentTransport.handleSendToRadio(bytes)
|
||||
_meshActivity.tryEmit(MeshActivity.Send)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.testing
|
||||
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
|
|
@ -28,10 +29,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
|||
|
||||
override fun initChannels() {}
|
||||
|
||||
override fun updateServiceStateNotification(
|
||||
state: org.meshtastic.core.model.ConnectionState,
|
||||
telemetry: Telemetry?,
|
||||
) {}
|
||||
override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {}
|
||||
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,13 @@ package org.meshtastic.core.testing
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/**
|
||||
* A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests.
|
||||
|
|
@ -79,19 +84,19 @@ class FakeRadioController :
|
|||
return true
|
||||
}
|
||||
|
||||
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {}
|
||||
override suspend fun setLocalConfig(config: Config) {}
|
||||
|
||||
override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {}
|
||||
override suspend fun setLocalChannel(channel: Channel) {}
|
||||
|
||||
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {}
|
||||
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {}
|
||||
|
||||
override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {}
|
||||
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {}
|
||||
|
||||
override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {}
|
||||
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {}
|
||||
|
||||
override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {}
|
||||
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {}
|
||||
|
||||
override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {}
|
||||
override suspend fun setFixedPosition(destNum: Int, position: Position) {}
|
||||
|
||||
override suspend fun setRingtone(destNum: Int, ringtone: String) {}
|
||||
|
||||
|
|
@ -125,7 +130,7 @@ class FakeRadioController :
|
|||
|
||||
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
|
||||
|
||||
override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {}
|
||||
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {}
|
||||
|
||||
override suspend fun requestUserInfo(destNum: Int) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
|
|||
val sentToRadio = mutableListOf<ByteArray>()
|
||||
var connectCalled = false
|
||||
|
||||
override fun isMockInterface(): Boolean = true
|
||||
override fun isMockTransport(): Boolean = true
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
sentToRadio.add(bytes)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package org.meshtastic.core.ui.viewmodel
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -34,6 +35,7 @@ import kotlinx.coroutines.flow.onEach
|
|||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.database.entity.asDeviceVersion
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
|
|
@ -43,6 +45,7 @@ import org.meshtastic.core.model.TracerouteMapAvailability
|
|||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.model.service.TracerouteResponse
|
||||
import org.meshtastic.core.model.util.dispatchMeshtasticUri
|
||||
import org.meshtastic.core.navigation.DeepLinkRouter
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
|
|
@ -84,7 +87,7 @@ class UIViewModel(
|
|||
val snackbarManager: SnackbarManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigationDeepLink = MutableSharedFlow<List<androidx.navigation3.runtime.NavKey>>(replay = 1)
|
||||
private val _navigationDeepLink = MutableSharedFlow<List<NavKey>>(replay = 1)
|
||||
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
|
||||
|
||||
/**
|
||||
|
|
@ -97,10 +100,10 @@ class UIViewModel(
|
|||
* [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations.
|
||||
*/
|
||||
fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) {
|
||||
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
|
||||
val commonUri = CommonUri.parse(uri.uriString)
|
||||
|
||||
// Try navigation routing first
|
||||
val navKeys = org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)
|
||||
val navKeys = DeepLinkRouter.route(commonUri)
|
||||
if (navKeys != null) {
|
||||
_navigationDeepLink.tryEmit(navKeys)
|
||||
return
|
||||
|
|
@ -236,7 +239,7 @@ class UIViewModel(
|
|||
_sharedContactRequested.value = contact
|
||||
}
|
||||
|
||||
/** Called immediately after activity observes requestChannelUrl */
|
||||
/** Clears the pending shared contact request. */
|
||||
fun clearSharedContactRequested() {
|
||||
_sharedContactRequested.value = null
|
||||
}
|
||||
|
|
@ -255,7 +258,7 @@ class UIViewModel(
|
|||
|
||||
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
|
||||
|
||||
/** Called immediately after activity observes requestChannelUrl */
|
||||
/** Clears the pending channel set import request. */
|
||||
fun clearRequestChannelUrl() {
|
||||
_requestChannelSet.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,16 +34,25 @@ import org.meshtastic.core.model.NetworkFirmwareReleases
|
|||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.network.KermitHttpLogger
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
import org.meshtastic.core.network.service.ApiService
|
||||
import org.meshtastic.core.network.service.ApiServiceImpl
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.LocationRepository
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
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.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.service.DirectRadioControllerImpl
|
||||
import org.meshtastic.core.service.ServiceRepositoryImpl
|
||||
import org.meshtastic.desktop.DesktopBuildConfig
|
||||
import org.meshtastic.desktop.DesktopNotificationManager
|
||||
import org.meshtastic.desktop.notification.DesktopMeshServiceNotifications
|
||||
import org.meshtastic.desktop.radio.DesktopMessageQueue
|
||||
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
|
||||
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
|
||||
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
|
||||
|
|
@ -55,6 +64,9 @@ import org.meshtastic.desktop.stub.NoopMeshWorkerManager
|
|||
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
|
||||
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
|
||||
import org.meshtastic.desktop.stub.NoopServiceBroadcasts
|
||||
import org.meshtastic.feature.node.compass.CompassHeadingProvider
|
||||
import org.meshtastic.feature.node.compass.MagneticFieldProvider
|
||||
import org.meshtastic.feature.node.compass.PhoneLocationProvider
|
||||
import org.meshtastic.core.ble.di.module as coreBleModule
|
||||
import org.meshtastic.core.common.di.module as coreCommonModule
|
||||
import org.meshtastic.core.data.di.module as coreDataModule
|
||||
|
|
@ -124,7 +136,7 @@ fun desktopModule() = module {
|
|||
*/
|
||||
@Suppress("LongMethod")
|
||||
private fun desktopPlatformStubsModule() = module {
|
||||
single<ServiceRepository> { org.meshtastic.core.service.ServiceRepositoryImpl() }
|
||||
single<ServiceRepository> { ServiceRepositoryImpl() }
|
||||
single<RadioTransportFactory> {
|
||||
DesktopRadioTransportFactory(
|
||||
dispatchers = get(),
|
||||
|
|
@ -134,7 +146,7 @@ private fun desktopPlatformStubsModule() = module {
|
|||
)
|
||||
}
|
||||
single<RadioController> {
|
||||
org.meshtastic.core.service.DirectRadioControllerImpl(
|
||||
DirectRadioControllerImpl(
|
||||
serviceRepository = get(),
|
||||
nodeRepository = get(),
|
||||
commandSender = get(),
|
||||
|
|
@ -144,37 +156,29 @@ private fun desktopPlatformStubsModule() = module {
|
|||
locationManager = get(),
|
||||
)
|
||||
}
|
||||
single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) }
|
||||
single<org.meshtastic.core.repository.NotificationManager> {
|
||||
get<org.meshtastic.desktop.DesktopNotificationManager>()
|
||||
}
|
||||
single<MeshServiceNotifications> {
|
||||
org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get())
|
||||
}
|
||||
single { DesktopNotificationManager(prefs = get()) }
|
||||
single<NotificationManager> { get<DesktopNotificationManager>() }
|
||||
single<MeshServiceNotifications> { DesktopMeshServiceNotifications(notificationManager = get()) }
|
||||
single<PlatformAnalytics> { NoopPlatformAnalytics() }
|
||||
single<ServiceBroadcasts> { NoopServiceBroadcasts() }
|
||||
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
|
||||
single<MeshWorkerManager> { NoopMeshWorkerManager() }
|
||||
single<MessageQueue> {
|
||||
org.meshtastic.desktop.radio.DesktopMessageQueue(packetRepository = get(), radioController = get())
|
||||
}
|
||||
single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get()) }
|
||||
single<MeshLocationManager> { NoopMeshLocationManager() }
|
||||
single<LocationRepository> { NoopLocationRepository() }
|
||||
single<MQTTRepository> { NoopMQTTRepository() }
|
||||
single<org.meshtastic.feature.node.compass.CompassHeadingProvider> { NoopCompassHeadingProvider() }
|
||||
single<org.meshtastic.feature.node.compass.PhoneLocationProvider> { NoopPhoneLocationProvider() }
|
||||
single<org.meshtastic.feature.node.compass.MagneticFieldProvider> { NoopMagneticFieldProvider() }
|
||||
single<CompassHeadingProvider> { NoopCompassHeadingProvider() }
|
||||
single<PhoneLocationProvider> { NoopPhoneLocationProvider() }
|
||||
single<MagneticFieldProvider> { NoopMagneticFieldProvider() }
|
||||
|
||||
// Desktop uses the real ApiService implementation (no flavor stub needed)
|
||||
single<org.meshtastic.core.network.service.ApiService> {
|
||||
org.meshtastic.core.network.service.ApiServiceImpl(client = get())
|
||||
}
|
||||
single<ApiService> { ApiServiceImpl(client = get()) }
|
||||
|
||||
// Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android)
|
||||
single<HttpClient> {
|
||||
HttpClient(Java) {
|
||||
install(ContentNegotiation) { json(get<Json>()) }
|
||||
if (org.meshtastic.desktop.DesktopBuildConfig.IS_DEBUG) {
|
||||
if (DesktopBuildConfig.IS_DEBUG) {
|
||||
install(Logging) {
|
||||
logger = KermitHttpLogger
|
||||
level = LogLevel.HEADERS
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ 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.TCPInterface
|
||||
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
|
||||
|
|
@ -45,16 +45,22 @@ class DesktopRadioTransportFactory(
|
|||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
|
||||
|
||||
override fun isMockInterface(): Boolean = false
|
||||
override fun isMockTransport(): Boolean = false
|
||||
|
||||
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
|
||||
address.startsWith(InterfaceId.TCP.id) -> {
|
||||
TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString()))
|
||||
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()),
|
||||
service = service,
|
||||
callback = service,
|
||||
scope = service.serviceScope,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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
|
||||
|
|
@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
|
|
@ -37,14 +39,11 @@ import org.meshtastic.core.repository.DataPair
|
|||
import org.meshtastic.core.repository.Location
|
||||
import org.meshtastic.core.repository.LocationRepository
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.MqttClientProxyMessage
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
/**
|
||||
|
|
@ -66,12 +65,12 @@ private fun logWarn(message: String) {
|
|||
// region Transport / Radio Stubs (Android BLE/USB — no commonMain impl)
|
||||
|
||||
class NoopRadioInterfaceService : RadioInterfaceService {
|
||||
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> = emptyList()
|
||||
override val supportedDeviceTypes: List<DeviceType> = emptyList()
|
||||
|
||||
override val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val currentDeviceAddressFlow = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun isMockInterface(): Boolean = false
|
||||
override fun isMockTransport(): Boolean = false
|
||||
|
||||
override val receivedData = MutableSharedFlow<ByteArray>()
|
||||
override val meshActivity = MutableSharedFlow<MeshActivity>()
|
||||
|
|
@ -98,65 +97,13 @@ class NoopRadioInterfaceService : RadioInterfaceService {
|
|||
override fun handleFromRadio(bytes: ByteArray) {}
|
||||
|
||||
@Suppress("InjectDispatcher")
|
||||
override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Default)
|
||||
override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Notification / Platform Stubs (Android-only)
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class NoopMeshServiceNotifications : MeshServiceNotifications {
|
||||
override fun clearNotifications() {}
|
||||
|
||||
override fun initChannels() {}
|
||||
|
||||
override fun updateServiceStateNotification(
|
||||
state: org.meshtastic.core.model.ConnectionState,
|
||||
telemetry: Telemetry?,
|
||||
) {}
|
||||
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateWaypointNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
waypointId: Int,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
|
||||
|
||||
override fun showNewNodeSeenNotification(node: Node) {}
|
||||
|
||||
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
|
||||
|
||||
override fun showClientNotification(clientNotification: ClientNotification) {}
|
||||
|
||||
override fun cancelMessageNotification(contactKey: String) {}
|
||||
|
||||
override fun cancelLowBatteryNotification(node: Node) {}
|
||||
|
||||
override fun clearClientNotification(notification: ClientNotification) {}
|
||||
}
|
||||
|
||||
class NoopPlatformAnalytics : PlatformAnalytics {
|
||||
override fun track(event: String, vararg properties: DataPair) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ The core transport abstraction was previously locked in `app/repository/radio/`
|
|||
1. Defined `RadioTransport` interface in `core:repository/commonMain` (replacing `IRadioInterface`)
|
||||
2. Moved `StreamFrameCodec`-based framing to `core:network/commonMain`
|
||||
3. Moved TCP transport to `core:network/jvmAndroidMain`
|
||||
4. The remaining `app/repository/radio/` implementations (BLE, Serial, Mock) now implement `RadioTransport`.
|
||||
4. BLE, Serial, and Mock transports now reside in `core:network` and implement `RadioTransport`.
|
||||
|
||||
**Recommended next steps:**
|
||||
1. Move BLE transport to `core:ble/androidMain`
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
|
|||
| `core:database` | ✅ | ✅ | Room KMP |
|
||||
| `core:domain` | ✅ | ✅ | UseCases |
|
||||
| `core:prefs` | ✅ | ✅ | Preferences layer |
|
||||
| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface` |
|
||||
| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioTransport` |
|
||||
| `core:data` | ✅ | ✅ | Data orchestration |
|
||||
| `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain |
|
||||
| `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain |
|
||||
|
|
@ -116,7 +116,7 @@ Based on the latest codebase investigation, the following steps are proposed to
|
|||
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
|
||||
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. |
|
||||
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
|
||||
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioInterface`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |
|
||||
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |
|
||||
|
||||
## Navigation Parity Note
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ Extracted to shared `commonMain` (no longer app-only):
|
|||
Extracted to core KMP modules:
|
||||
- Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain`
|
||||
- USB/Serial radio connections → `core:network/androidMain`
|
||||
- TCP radio connections, BLE radio connections (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations)
|
||||
- TCP radio connections, BLE radio connections (`BleRadioTransport`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations)
|
||||
|
||||
Remaining to be extracted from `:app` or unified in `commonMain`:
|
||||
- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface. `MapViewProvider` interface simplified — track rendering and traceroute rendering extracted to dedicated provider contracts)
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ These items address structural gaps identified in the March 2026 architecture re
|
|||
| TCP | Desktop (JVM) | ✅ Done — shared `StreamFrameCodec` + `TcpTransport` in `core:network` |
|
||||
| Serial/USB | Desktop (JVM) | ✅ Done — jSerialComm |
|
||||
| MQTT | All (KMP) | ✅ Completed — KMQTT in commonMain |
|
||||
| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioInterface`) |
|
||||
| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioTransport`) |
|
||||
|
||||
### Desktop Feature Gaps
|
||||
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ open class ScannerViewModel(
|
|||
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
|
||||
private val bleScanner: org.meshtastic.core.ble.BleScanner? = null,
|
||||
) : ViewModel() {
|
||||
private val _showMockInterface = MutableStateFlow(false)
|
||||
val showMockInterface: StateFlow<Boolean> = _showMockInterface.asStateFlow()
|
||||
private val _showMockTransport = MutableStateFlow(false)
|
||||
val showMockTransport: StateFlow<Boolean> = _showMockTransport.asStateFlow()
|
||||
|
||||
private val _errorText = MutableStateFlow<String?>(null)
|
||||
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
||||
|
|
@ -68,7 +68,7 @@ open class ScannerViewModel(
|
|||
private var scanJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
_showMockInterface.value = radioInterfaceService.isMockInterface()
|
||||
_showMockTransport.value = radioInterfaceService.isMockTransport()
|
||||
}
|
||||
|
||||
fun startBleScan() {
|
||||
|
|
@ -77,25 +77,26 @@ open class ScannerViewModel(
|
|||
isBleScanningState.value = true
|
||||
scannedBleDevices.value = emptyMap()
|
||||
|
||||
scanJob = viewModelScope.launch {
|
||||
try {
|
||||
bleScanner
|
||||
.scan(
|
||||
timeout = kotlin.time.Duration.INFINITE,
|
||||
serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID,
|
||||
)
|
||||
.flowOn(dispatchers.io)
|
||||
.collect { device ->
|
||||
if (!scannedBleDevices.value.containsKey(device.address)) {
|
||||
scannedBleDevices.update { current -> current + (device.address to device) }
|
||||
scanJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
bleScanner
|
||||
.scan(
|
||||
timeout = kotlin.time.Duration.INFINITE,
|
||||
serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID,
|
||||
)
|
||||
.flowOn(dispatchers.io)
|
||||
.collect { device ->
|
||||
if (!scannedBleDevices.value.containsKey(device.address)) {
|
||||
scannedBleDevices.update { current -> current + (device.address to device) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
co.touchlab.kermit.Logger.w(e) { "BLE scan failed" }
|
||||
} finally {
|
||||
isBleScanningState.value = false
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
co.touchlab.kermit.Logger.w(e) { "BLE scan failed" }
|
||||
} finally {
|
||||
isBleScanningState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopBleScan() {
|
||||
|
|
@ -105,7 +106,7 @@ open class ScannerViewModel(
|
|||
}
|
||||
|
||||
private val discoveredDevicesFlow =
|
||||
showMockInterface
|
||||
showMockTransport
|
||||
.flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
|
|
|
|||
|
|
@ -167,17 +167,19 @@ fun ConnectionsScreen(
|
|||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val uiState =
|
||||
when {
|
||||
connectionState is ConnectionState.Connected && ourNode != null -> 2
|
||||
connectionState is ConnectionState.Connected && ourNode != null ->
|
||||
ConnectionUiState.CONNECTED_WITH_NODE
|
||||
|
||||
connectionState is ConnectionState.Connected ||
|
||||
connectionState == ConnectionState.Connecting ||
|
||||
selectedDevice != NO_DEVICE_SELECTED -> 1
|
||||
selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING
|
||||
|
||||
else -> 0
|
||||
else -> ConnectionUiState.NO_DEVICE
|
||||
}
|
||||
|
||||
Crossfade(targetState = uiState, label = "connection_state") { state ->
|
||||
when (state) {
|
||||
2 ->
|
||||
ConnectionUiState.CONNECTED_WITH_NODE ->
|
||||
ConnectedDeviceContent(
|
||||
ourNode = ourNode,
|
||||
regionUnset = regionUnset,
|
||||
|
|
@ -191,7 +193,7 @@ fun ConnectionsScreen(
|
|||
},
|
||||
)
|
||||
|
||||
1 ->
|
||||
ConnectionUiState.CONNECTING ->
|
||||
ConnectingDeviceContent(
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
|
|
@ -208,7 +210,9 @@ fun ConnectionsScreen(
|
|||
}
|
||||
|
||||
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
|
||||
LaunchedEffect(Unit) { DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } }
|
||||
LaunchedEffect(selectedDevice) {
|
||||
DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it }
|
||||
}
|
||||
|
||||
val supportedDeviceTypes = scanModel.supportedDeviceTypes
|
||||
|
||||
|
|
@ -369,3 +373,15 @@ private fun NoDeviceContent() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Visual state for the connection screen's [Crossfade] animation. */
|
||||
private enum class ConnectionUiState {
|
||||
/** No device is selected. */
|
||||
NO_DEVICE,
|
||||
|
||||
/** A device is selected or we are actively connecting. */
|
||||
CONNECTING,
|
||||
|
||||
/** Connected with node info available. */
|
||||
CONNECTED_WITH_NODE,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ import org.meshtastic.core.resources.connecting
|
|||
import org.meshtastic.core.resources.disconnect
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
||||
/**
|
||||
* Displays the currently connecting (or connected) device with its name, address, connection status, and a disconnect
|
||||
* button.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectingDeviceInfo(
|
||||
connectionState: ConnectionState,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class ScannerViewModelTest {
|
|||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { radioInterfaceService.isMockInterface() } returns false
|
||||
every { radioInterfaceService.isMockTransport() } returns false
|
||||
every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
|
||||
every { radioInterfaceService.supportedDeviceTypes } returns emptyList()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue