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

@ -43,8 +43,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.koin.android.ext.android.inject
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
@ -83,8 +81,6 @@ class MainActivity : ComponentActivity() {
*/
internal val meshServiceClient: MeshServiceClient by inject { parametersOf(this) }
internal val androidEnvironment: AndroidEnvironment by inject()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@ -124,9 +120,7 @@ class MainActivity : ComponentActivity() {
)
}
@Suppress("SpreadOperator")
CompositionLocalProvider(
*(LocalEnvironmentOwner provides androidEnvironment),
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },

View file

@ -33,7 +33,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.workmanager.koin.workManagerFactory
@ -119,7 +118,6 @@ open class MeshUtilApplication :
override fun onTerminate() {
// Shutdown managers (useful for Robolectric tests)
get<DatabaseManager>().close()
get<AndroidEnvironment>().close()
applicationScope.cancel()
super.onTerminate()
org.koin.core.context.stopKoin()

View file

@ -37,7 +37,6 @@ import org.meshtastic.core.database.di.CoreDatabaseAndroidModule
import org.meshtastic.core.database.di.CoreDatabaseModule
import org.meshtastic.core.datastore.di.CoreDatastoreAndroidModule
import org.meshtastic.core.datastore.di.CoreDatastoreModule
import org.meshtastic.core.di.di.CoreDiModule
import org.meshtastic.core.network.di.CoreNetworkModule
import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
import org.meshtastic.core.prefs.di.CorePrefsModule
@ -57,7 +56,6 @@ import org.meshtastic.feature.settings.di.FeatureSettingsModule
includes =
[
org.meshtastic.app.MainKoinModule::class,
CoreDiModule::class,
CoreCommonModule::class,
CoreBleModule::class,
CoreBleAndroidModule::class,
@ -91,6 +89,14 @@ class AppKoinModule {
@Named("ProcessLifecycle")
fun provideProcessLifecycle(): Lifecycle = ProcessLifecycleOwner.get().lifecycle
@Single
fun provideCoroutineDispatchers(): org.meshtastic.core.di.CoroutineDispatchers =
org.meshtastic.core.di.CoroutineDispatchers(
io = kotlinx.coroutines.Dispatchers.IO,
main = kotlinx.coroutines.Dispatchers.Main,
default = kotlinx.coroutines.Dispatchers.Default,
)
@Single
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
override val isDebug: Boolean = org.meshtastic.app.BuildConfig.DEBUG

View file

@ -142,7 +142,7 @@ class AndroidRadioInterfaceService(
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (radioIf is NordicBleInterface) {
} else if (radioIf is BleRadioInterface) {
stopInterface()
}
}

View file

@ -25,14 +25,12 @@ 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.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.ble.AndroidBleDevice
import org.meshtastic.core.ble.AndroidBleService
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
@ -42,6 +40,7 @@ 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
@ -54,8 +53,7 @@ private const val CONNECTION_TIMEOUT_MS = 15_000L
private val SCAN_TIMEOUT = 5.seconds
/**
* A [RadioTransport] implementation for BLE devices using Nordic Kotlin BLE Library.
* https://github.com/NordicSemiconductor/Kotlin-BLE-Library.
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
*
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
* - Bonding and discovery.
@ -71,7 +69,7 @@ private val SCAN_TIMEOUT = 5.seconds
* @param address The BLE address of the device to connect to.
*/
@SuppressLint("MissingPermission")
class NordicBleInterface(
class BleRadioInterface(
private val serviceScope: CoroutineScope,
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
@ -104,6 +102,8 @@ class NordicBleInterface(
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
@Volatile private var isFullyConnected = false
init {
connect()
}
@ -121,8 +121,17 @@ class NordicBleInterface(
Logger.i { "[$address] Device not found in bonded list, scanning..." }
repeat(SCAN_RETRY_COUNT) { attempt ->
val d = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
if (d != null) return d
try {
val d =
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
it.address == address
}
}
if (d != null) return d
} catch (e: Exception) {
Logger.v(e) { "Scan attempt failed or timed out" }
}
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
@ -134,34 +143,68 @@ class NordicBleInterface(
private fun connect() {
connectionScope.launch {
try {
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
val device = findDevice()
bleConnection.connectionState
.onEach { state ->
if (state is BleConnectionState.Disconnected) {
onDisconnected(state)
}
bleConnection.connectionState
.onEach { state ->
if (state is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
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")
}
.catch { e ->
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
handleFailure(e)
}
.launchIn(connectionScope)
onConnected()
discoverServicesAndSetupCharacteristics()
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
handleFailure(e)
while (isActive) {
try {
// Add a delay to allow any pending background disconnects (from a previous close() call)
// to complete and the Android BLE stack to settle before we attempt a new connection.
@Suppress("MagicNumber")
val connectDelayMs = 1000L
kotlinx.coroutines.delay(connectDelayMs)
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
// Kable on Android occasionally fails the first connection attempt with NotConnectedException
// if the previous peripheral wasn't fully cleaned up by the OS. A quick retry resolves it.
Logger.w { "[$address] First connection attempt failed, retrying in 1.5s..." }
@Suppress("MagicNumber")
val retryDelayMs = 1500L
kotlinx.coroutines.delay(retryDelayMs)
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
}
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
isFullyConnected = true
onConnected()
discoverServicesAndSetupCharacteristics()
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
} 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)
// Wait before retrying to prevent hot loops
@Suppress("MagicNumber")
kotlinx.coroutines.delay(5000L)
}
}
}
}
@ -169,8 +212,7 @@ class NordicBleInterface(
private suspend fun onConnected() {
try {
bleConnection.deviceFlow.first()?.let { device ->
val androidDevice = device as AndroidBleDevice
val rssi = retryBleOperation(tag = address) { androidDevice.peripheral.readRssi() }
val rssi = retryBleOperation(tag = address) { device.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
} catch (e: Exception) {
@ -202,8 +244,7 @@ class NordicBleInterface(
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
val androidService = (service as AndroidBleService).service
val radioService = MeshtasticRadioServiceImpl(androidService)
val radioService = service.toMeshtasticRadioProfile()
// Wire up notifications
radioService.fromRadio
@ -229,7 +270,7 @@ class NordicBleInterface(
.launchIn(this)
// Store reference for handleSendToRadio
this@NordicBleInterface.radioService = radioService
this@BleRadioInterface.radioService = radioService
Logger.i { "[$address] Profile service active and characteristics subscribed" }
@ -237,7 +278,7 @@ class NordicBleInterface(
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
this@NordicBleInterface.service.onConnect()
this@BleRadioInterface.service.onConnect()
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
@ -246,7 +287,7 @@ class NordicBleInterface(
}
}
private var radioService: MeshtasticRadioProfile.State? = null
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
// --- RadioTransport Implementation ---
@ -296,15 +337,15 @@ class NordicBleInterface(
0
}
Logger.i {
"[$address] BLE close() called - " +
"[$address] Disconnecting. " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
serviceScope.launch {
connectionScope.cancel()
connectionScope.launch {
bleConnection.disconnect()
service.onDisconnect(true)
connectionScope.cancel()
}
}
@ -325,16 +366,14 @@ class NordicBleInterface(
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
val isPermanent =
this is no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException ||
this is no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
this::class.simpleName == "BluetoothUnavailableException" ||
this::class.simpleName == "ManagerClosedException"
val msg =
when (this) {
is RadioNotConnectedException -> this.message ?: "Device not found"
is NoSuchElementException,
is IllegalArgumentException,
-> "Required characteristic missing"
is no.nordicsemi.kotlin.ble.core.exception.GattException -> "GATT Error: ${this.message}"
else -> this.message ?: this.javaClass.simpleName
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

@ -22,14 +22,14 @@ import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.repository.RadioInterfaceService
/** Factory for creating `NordicBleInterface` instances. */
/** Factory for creating `BleRadioInterface` instances. */
@Single
class NordicBleInterfaceFactory(
class BleRadioInterfaceFactory(
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
) {
fun create(rest: String, service: RadioInterfaceService): NordicBleInterface = NordicBleInterface(
fun create(rest: String, service: RadioInterfaceService): BleRadioInterface = BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,

View file

@ -16,26 +16,19 @@
*/
package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.RadioInterfaceService
/** Bluetooth backend implementation. */
@Single
class NordicBleInterfaceSpec(
private val factory: NordicBleInterfaceFactory,
private val bluetoothRepository: BluetoothRepository,
) : InterfaceSpec<NordicBleInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): NordicBleInterface =
class BleRadioInterfaceSpec(private val factory: BleRadioInterfaceFactory) : InterfaceSpec<BleRadioInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): BleRadioInterface =
factory.create(rest, service)
/** Return true if this address is still acceptable. For BLE that means, still bonded */
override fun addressValid(rest: String): Boolean = if (!bluetoothRepository.isBonded(rest)) {
Logger.w { "Ignoring stale bond to ${rest.anonymize}" }
false
} else {
true
/** Return true if this address is still acceptable. For Kable we don't strictly require prior bonding. */
override fun addressValid(rest: String): Boolean {
// We no longer strictly require the device to be in the bonded list before attempting connection,
// as Kable and Android will handle bonding seamlessly during connection/characteristic access if needed.
return rest.isNotBlank()
}
}

View file

@ -30,7 +30,7 @@ import org.meshtastic.core.repository.RadioTransport
@Single
class InterfaceFactory(
private val nopInterfaceFactory: NopInterfaceFactory,
private val bluetoothSpec: Lazy<NordicBleInterfaceSpec>,
private val bluetoothSpec: Lazy<BleRadioInterfaceSpec>,
private val mockSpec: Lazy<MockInterfaceSpec>,
private val serialSpec: Lazy<SerialInterfaceSpec>,
private val tcpSpec: Lazy<TCPInterfaceSpec>,

View file

@ -1,33 +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.app.repository.radio
import kotlinx.coroutines.flow.Flow
/** A definition of the Meshtastic BLE Service profile. */
interface MeshtasticRadioProfile {
interface State {
/** The flow of incoming packets from the radio. */
val fromRadio: Flow<ByteArray>
/** The flow of incoming log packets from the radio. */
val logRadio: Flow<ByteArray>
/** Sends a packet to the radio. */
suspend fun sendToRadio(packet: ByteArray)
}
}

View file

@ -1,94 +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.app.repository.radio
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.RemoteService
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
class MeshtasticRadioServiceImpl(private val remoteService: RemoteService) : MeshtasticRadioProfile.State {
private val toRadioCharacteristic: RemoteCharacteristic =
remoteService.characteristics.first { it.uuid == TORADIO_CHARACTERISTIC }
private val fromRadioCharacteristic: RemoteCharacteristic =
remoteService.characteristics.first { it.uuid == FROMRADIO_CHARACTERISTIC }
private val fromRadioSyncCharacteristic: RemoteCharacteristic? =
remoteService.characteristics.firstOrNull { it.uuid == FROMRADIOSYNC_CHARACTERISTIC }
private val fromNumCharacteristic: RemoteCharacteristic? =
if (fromRadioSyncCharacteristic == null) {
remoteService.characteristics.first { it.uuid == FROMNUM_CHARACTERISTIC }
} else {
null
}
private val logRadioCharacteristic: RemoteCharacteristic =
remoteService.characteristics.first { it.uuid == LOGRADIO_CHARACTERISTIC }
private val triggerDrain = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
init {
require(toRadioCharacteristic.isWritable()) { "TORADIO must be writable" }
require(fromRadioCharacteristic.isReadable()) { "FROMRADIO must be readable" }
fromRadioSyncCharacteristic?.let { require(it.isSubscribable()) { "FROMRADIOSYNC must be subscribable" } }
fromNumCharacteristic?.let { require(it.isSubscribable()) { "FROMNUM must be subscribable" } }
require(logRadioCharacteristic.isSubscribable()) { "LOGRADIO must be subscribable" }
}
override val fromRadio: Flow<ByteArray> =
if (fromRadioSyncCharacteristic != null) {
fromRadioSyncCharacteristic.subscribe()
} else {
// Legacy path: drain fromRadio characteristic when notified or after write
channelFlow {
launch { fromNumCharacteristic!!.subscribe().collect { triggerDrain.tryEmit(Unit) } }
triggerDrain.collect {
var keepReading = true
while (keepReading) {
try {
val packet = fromRadioCharacteristic.read()
if (packet.isEmpty()) {
keepReading = false
} else {
send(packet)
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
co.touchlab.kermit.Logger.e(e) { "BLE: Failed to read from FROMRADIO" }
keepReading = false
}
}
}
}
}
override val logRadio: Flow<ByteArray> = logRadioCharacteristic.subscribe()
override suspend fun sendToRadio(packet: ByteArray) {
toRadioCharacteristic.write(packet, WriteType.WITHOUT_RESPONSE)
if (fromRadioSyncCharacteristic == null) {
triggerDrain.tryEmit(Unit)
}
}
}