refactor: Replace Nordic, use Kable backend for Desktop and Android with BLE support (#4818)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-16 18:06:43 -05:00 committed by GitHub
parent 0e5f94579f
commit 0b2e89c46f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 1980 additions and 2965 deletions

View file

@ -54,6 +54,7 @@ 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.core.ble.di.module as coreBleModule
import org.meshtastic.core.common.di.module as coreCommonModule
import org.meshtastic.core.data.di.module as coreDataModule
import org.meshtastic.core.database.di.module as coreDatabaseModule
@ -94,6 +95,7 @@ fun desktopModule() = module {
org.meshtastic.core.domain.di.CoreDomainModule().coreDomainModule(),
org.meshtastic.core.repository.di.CoreRepositoryModule().coreRepositoryModule(),
org.meshtastic.core.network.di.CoreNetworkModule().coreNetworkModule(),
org.meshtastic.core.ble.di.CoreBleModule().coreBleModule(),
org.meshtastic.core.ui.di.CoreUiModule().coreUiModule(),
org.meshtastic.core.service.di.CoreServiceModule().coreServiceModule(),
org.meshtastic.feature.settings.di.FeatureSettingsModule().featureSettingsModule(),
@ -109,9 +111,18 @@ fun desktopModule() = module {
* Stubs for truly platform-specific interfaces that have no `commonMain` implementation. These require Android APIs
* (BLE/USB transport, notifications, WorkManager, location, broadcasts, widgets).
*/
@Suppress("LongMethod")
private fun desktopPlatformStubsModule() = module {
single<ServiceRepository> { org.meshtastic.core.service.ServiceRepositoryImpl() }
single<RadioInterfaceService> { DesktopRadioInterfaceService(dispatchers = get(), radioPrefs = get()) }
single<RadioInterfaceService> {
DesktopRadioInterfaceService(
dispatchers = get(),
radioPrefs = get(),
scanner = get(),
bluetoothRepository = get(),
connectionFactory = get(),
)
}
single<RadioController> {
org.meshtastic.core.service.DirectRadioControllerImpl(
serviceRepository = get(),

View file

@ -0,0 +1,348 @@
/*
* 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.desktop.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
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.repository.RadioTransport
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 1000L
private const val CONNECTION_TIMEOUT_MS = 15_000L
private val SCAN_TIMEOUT = 5.seconds
/**
* A [RadioTransport] implementation for BLE devices using Kable for desktop.
*
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
* - Bonding and discovery.
* - Automatic reconnection logic.
* - MTU and connection parameter monitoring.
* - Routing raw byte packets between the radio and [RadioInterfaceService].
*
* @param serviceScope 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 address The BLE address of the device to connect to.
*/
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@Suppress("TooManyFunctions", "TooGenericExceptionCaught", "SwallowedException")
class DesktopBleInterface(
private val serviceScope: CoroutineScope,
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
private val service: RadioInterfaceService,
val address: String,
) : RadioTransport {
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
serviceScope.launch {
try {
bleConnection.disconnect()
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
}
val (isPermanent, msg) = throwable.toDisconnectReason()
service.onDisconnect(isPermanent, errorMessage = msg)
}
private val connectionScope: CoroutineScope =
CoroutineScope(
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
private var connectionStartTime: Long = 0
private var packetsReceived: Int = 0
private var packetsSent: Int = 0
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
init {
connect()
}
// --- Connection & Discovery Logic ---
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findDevice(): BleDevice {
bluetoothRepository.state.value.bondedDevices
.firstOrNull { it.address == address }
?.let {
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
repeat(SCAN_RETRY_COUNT) { attempt ->
try {
val d =
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
scanner.scan(SCAN_TIMEOUT).first { it.address == address }
}
if (d != null) return d
} catch (e: Exception) {
// Ignore timeout exceptions
}
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
}
}
throw RadioNotConnectedException("Device not found at address $address")
}
private fun connect() {
connectionScope.launch {
try {
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
bleConnection.connectionState
.onEach { state ->
if (state is BleConnectionState.Disconnected) {
onDisconnected(state)
}
}
.catch { e ->
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
handleFailure(e)
}
.launchIn(connectionScope)
val device = findDevice()
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
onConnected()
discoverServicesAndSetupCharacteristics()
} catch (e: kotlinx.coroutines.CancellationException) {
Logger.d { "[$address] BLE connection coroutine cancelled" }
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
handleFailure(e)
}
}
}
private suspend fun onConnected() {
try {
bleConnection.deviceFlow.first()?.let { device ->
val rssi = retryBleOperation(tag = address) { device.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
}
}
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
radioService = null
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] BLE disconnected, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
// Note: Disconnected state in commonMain doesn't currently carry a reason.
// We might want to add that later if needed.
service.onDisconnect(false, errorMessage = "Disconnected")
}
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
val radioService = service.toMeshtasticRadioProfile()
// Wire up notifications
radioService.fromRadio
.onEach { packet ->
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
Logger.w(e) { "[$address] Error in fromRadio flow" }
handleFailure(e)
}
.launchIn(this)
radioService.logRadio
.onEach { packet ->
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
Logger.w(e) { "[$address] Error in logRadio flow" }
handleFailure(e)
}
.launchIn(this)
// Store reference for handleSendToRadio
this@DesktopBleInterface.radioService = radioService
Logger.i { "[$address] Profile service active and characteristics subscribed" }
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
this@DesktopBleInterface.service.onConnect()
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
bleConnection.disconnect()
handleFailure(e)
}
}
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
// --- RadioTransport Implementation ---
/**
* Sends a packet to the radio with retry support.
*
* @param p The packet to send.
*/
override fun handleSendToRadio(p: ByteArray) {
val currentService = radioService
if (currentService != null) {
connectionScope.launch {
writeMutex.withLock {
try {
retryBleOperation(tag = address) { currentService.sendToRadio(p) }
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Successfully wrote packet #$packetsSent " +
"to toRadioCharacteristic - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
} catch (e: Exception) {
Logger.w(e) {
"[$address] Failed to write packet to toRadioCharacteristic after " +
"$packetsSent successful writes"
}
handleFailure(e)
}
}
}
} else {
Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
}
}
override fun keepAlive() {
Logger.d { "[$address] BLE keepAlive" }
}
/** Closes the connection to the device. */
override fun close() {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.i {
"[$address] BLE close() called - " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
serviceScope.launch {
connectionScope.cancel()
bleConnection.disconnect()
service.onDisconnect(true)
}
}
private fun dispatchPacket(packet: ByteArray) {
packetsReceived++
bytesReceived += packet.size
Logger.d {
"[$address] Dispatching packet to service.handleFromRadio() - " +
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
}
service.handleFromRadio(packet)
}
private fun handleFailure(throwable: Throwable) {
val (isPermanent, msg) = throwable.toDisconnectReason()
service.onDisconnect(isPermanent, errorMessage = msg)
}
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
val isPermanent =
this::class.simpleName == "BluetoothUnavailableException" ||
this::class.simpleName == "ManagerClosedException"
val msg =
when {
this is RadioNotConnectedException -> this.message ?: "Device not found"
this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
else -> this.message ?: this::class.simpleName ?: "Unknown"
}
return Pair(isPermanent, msg)
}
}

View file

@ -44,14 +44,19 @@ import org.meshtastic.core.repository.RadioPrefs
* Desktop implementation of [RadioInterfaceService] with real TCP transport.
*
* Delegates all TCP socket management, stream framing, reconnect logic, and heartbeat to the shared [TcpTransport] from
* `core:network`. Desktop only supports TCP connections (no BLE/USB/Serial).
* `core:network`. Desktop supports TCP and BLE connections.
*/
@Suppress("TooManyFunctions")
class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers, private val radioPrefs: RadioPrefs) :
RadioInterfaceService {
class DesktopRadioInterfaceService(
private val dispatchers: CoroutineDispatchers,
private val radioPrefs: RadioPrefs,
private val scanner: org.meshtastic.core.ble.BleScanner,
private val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository,
private val connectionFactory: org.meshtastic.core.ble.BleConnectionFactory,
) : RadioInterfaceService {
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
listOf(org.meshtastic.core.model.DeviceType.TCP)
listOf(org.meshtastic.core.model.DeviceType.TCP, org.meshtastic.core.model.DeviceType.BLE)
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@ -70,6 +75,7 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
private set
private var transport: TcpTransport? = null
private var bleTransport: DesktopBleInterface? = null
init {
// Observe radioPrefs to handle asynchronous loads from DataStore
@ -78,10 +84,10 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
}
// Auto-connect if we have a valid TCP address and are disconnected
if (addr != null && addr.startsWith("t") && _connectionState.value == ConnectionState.Disconnected) {
// Auto-connect if we have a valid address and are disconnected
if (addr != null && _connectionState.value == ConnectionState.Disconnected) {
Logger.i { "DesktopRadio: Auto-connecting to saved address ${addr.anonymize}" }
startTcpConnection(addr.removePrefix("t"))
startConnection(addr)
}
}
.launchIn(serviceScope)
@ -95,11 +101,11 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
override fun connect() {
val address = getDeviceAddress()
if (address == null || !address.startsWith("t")) {
Logger.w { "DesktopRadio: No TCP address configured, skipping connect" }
if (address.isNullOrBlank() || address == "n") {
Logger.w { "DesktopRadio: No address configured, skipping connect" }
return
}
startTcpConnection(address.removePrefix("t"))
startConnection(address)
}
override fun setDeviceAddress(deviceAddr: String?): Boolean {
@ -119,15 +125,18 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
radioPrefs.setDevAddr(sanitized)
_currentDeviceAddressFlow.value = sanitized
// Start connection if we have a TCP address
if (sanitized != null && sanitized.startsWith("t")) {
startTcpConnection(sanitized.removePrefix("t"))
// Start connection if we have a valid address
if (sanitized != null && sanitized != "n") {
startConnection(sanitized)
}
return true
}
override fun sendToRadio(bytes: ByteArray) {
serviceScope.handledLaunch { transport?.sendPacket(bytes) }
serviceScope.handledLaunch {
transport?.sendPacket(bytes)
bleTransport?.handleSendToRadio(bytes)
}
}
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
@ -156,7 +165,34 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
// endregion
// region TCP Connection Management
// region Connection Management
private fun startConnection(address: String) {
if (address.startsWith("t")) {
startTcpConnection(address.removePrefix("t"))
} else if (address.startsWith("x")) {
startBleConnection(address.removePrefix("x"))
} else {
// Assume BLE if no prefix, or prefix is not supported
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
startBleConnection(stripped)
}
}
private fun startBleConnection(address: String) {
transport?.stop()
bleTransport?.close()
bleTransport =
DesktopBleInterface(
serviceScope = serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = this,
address = address,
)
}
private fun startTcpConnection(address: String) {
transport?.stop()
@ -189,6 +225,9 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
transport?.stop()
transport = null
bleTransport?.close()
bleTransport = null
// Recreate the service scope
serviceScope.cancel("stopping interface")
serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())