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

@ -1,193 +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.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import kotlin.uuid.Uuid
/**
* An Android implementation of [BleConnection] using Nordic's [CentralManager].
*
* @param centralManager The Nordic [CentralManager] to use for connection.
* @param scope The [CoroutineScope] in which to monitor connection state.
* @param tag A tag for logging.
*/
class AndroidBleConnection(
private val centralManager: CentralManager,
private val scope: CoroutineScope,
private val tag: String = "BLE",
) : BleConnection {
private var _device: AndroidBleDevice? = null
override val device: BleDevice?
get() = _device
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
private val _connectionState = simpleSharedFlow<BleConnectionState>()
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
private var stateJob: Job? = null
private var profileJob: Job? = null
override suspend fun connect(device: BleDevice) = withContext(NonCancellable) {
val androidDevice = device as AndroidBleDevice
stateJob?.cancel()
_device = androidDevice
_deviceFlow.emit(androidDevice)
centralManager.connect(
peripheral = androidDevice.peripheral,
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
stateJob =
androidDevice.peripheral.state
.onEach { state ->
Logger.d { "[$tag] Connection state changed to $state" }
val commonState =
when (state) {
is ConnectionState.Connecting -> BleConnectionState.Connecting
is ConnectionState.Connected -> BleConnectionState.Connected
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
}
if (state is ConnectionState.Connected) {
androidDevice.peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
observePeripheralDetails(androidDevice)
}
androidDevice.updateState(state)
_connectionState.emit(commonState)
}
.launchIn(scope)
}
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
onRegister()
connect(device)
return withTimeout(timeoutMs) {
connectionState.first { it is BleConnectionState.Connected || it is BleConnectionState.Disconnected }
}
}
@Suppress("TooGenericExceptionCaught")
private fun observePeripheralDetails(androidDevice: AndroidBleDevice) {
val p = androidDevice.peripheral
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
p.connectionParameters
.onEach { params ->
Logger.i { "[$tag] BLE connection parameters changed to $params" }
try {
val maxWriteLen = p.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
Logger.i { "[$tag] Negotiated MTU (Write): $maxWriteLen bytes" }
} catch (e: Exception) {
Logger.d { "[$tag] Could not read MTU: ${e.message}" }
}
}
.launchIn(scope)
}
override suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
profileJob?.cancel()
profileJob = null
_device?.peripheral?.disconnect()
_device = null
_deviceFlow.emit(null)
}
@Suppress("TooGenericExceptionCaught")
override suspend fun <T> profile(
serviceUuid: Uuid,
timeout: kotlin.time.Duration,
setup: suspend CoroutineScope.(BleService) -> T,
): T {
val androidDevice = deviceFlow.first { it != null } as AndroidBleDevice
val p = androidDevice.peripheral
val serviceReady = CompletableDeferred<T>()
profileJob?.cancel()
val job =
scope.launch {
try {
val profileScope = this
p.profile(serviceUuid = serviceUuid, required = true, scope = profileScope) { service ->
try {
val result = setup(AndroidBleService(service))
serviceReady.complete(result)
awaitCancellation()
} catch (e: Throwable) {
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
throw e
}
}
} catch (e: Throwable) {
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
}
}
profileJob = job
return try {
withTimeout(timeout) { serviceReady.await() }
} catch (e: Throwable) {
profileJob?.cancel()
throw e
}
}
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
val nordicWriteType =
when (writeType) {
BleWriteType.WITH_RESPONSE -> WriteType.WITH_RESPONSE
BleWriteType.WITHOUT_RESPONSE -> WriteType.WITHOUT_RESPONSE
}
return _device?.peripheral?.maximumWriteValueLength(nordicWriteType)
}
/** Requests a new connection priority for the current peripheral. */
suspend fun requestConnectionPriority(priority: ConnectionPriority) {
_device?.peripheral?.requestConnectionPriority(priority)
}
}

View file

@ -1,63 +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.ble
import android.annotation.SuppressLint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import no.nordicsemi.kotlin.ble.core.ConnectionState
/** An Android implementation of [BleDevice] that wraps a Nordic [Peripheral]. */
class AndroidBleDevice(val peripheral: Peripheral) : BleDevice {
override val name: String?
get() = peripheral.name
override val address: String
get() = peripheral.address
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
@Suppress("MissingPermission")
override val isBonded: Boolean
get() = peripheral.bondState.value == BondState.BONDED
override val isConnected: Boolean
get() = peripheral.isConnected
@SuppressLint("MissingPermission")
override suspend fun readRssi(): Int = peripheral.readRssi()
@SuppressLint("MissingPermission")
override suspend fun bond() {
peripheral.createBond()
}
/** Updates the connection state based on Nordic's [ConnectionState]. */
fun updateState(nordicState: ConnectionState) {
_state.value =
when (nordicState) {
is ConnectionState.Connecting -> BleConnectionState.Connecting
is ConnectionState.Connected -> BleConnectionState.Connected
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
}
}
}

View file

@ -1,45 +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.ble
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
import org.koin.core.annotation.Single
import kotlin.time.Duration
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* An Android implementation of [BleScanner] using Nordic's [CentralManager].
*
* @param centralManager The Nordic [CentralManager] to use for scanning.
*/
@OptIn(ExperimentalUuidApi::class)
@Single
class AndroidBleScanner(private val centralManager: CentralManager) : BleScanner {
override fun scan(timeout: Duration, serviceUuid: Uuid?): Flow<BleDevice> = centralManager
.scan(timeout = timeout) {
if (serviceUuid != null) {
ServiceUuid(serviceUuid)
}
}
.distinctByPeripheral()
.map { AndroidBleDevice(it.peripheral) }
}

View file

@ -16,8 +16,14 @@
*/
package org.meshtastic.core.ble
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
@ -25,31 +31,40 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.client.RemoteServices
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.di.CoroutineDispatchers
/** Android implementation of [BluetoothRepository]. */
@Single
class AndroidBluetoothRepository(
private val context: Context,
private val dispatchers: CoroutineDispatchers,
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
private val androidEnvironment: AndroidEnvironment,
) : BluetoothRepository {
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true))
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
private val deviceCache = mutableMapOf<String, DirectBleDevice>()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
}
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
}
private fun hasBluetoothPermissions(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val hasConnect =
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
val hasScan =
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) ==
PackageManager.PERMISSION_GRANTED
hasConnect && hasScan
} else {
// Pre-Android 12: classic Bluetooth permissions are install-time.
true
}
override fun refreshState() {
@ -58,59 +73,112 @@ class AndroidBluetoothRepository(
override fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
@Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught", "SwallowedException")
@SuppressLint("MissingPermission")
override suspend fun bond(device: BleDevice) {
val androidDevice = device as AndroidBleDevice
androidDevice.peripheral.createBond()
val macAddress = device.address
val remoteDevice =
bluetoothAdapter?.getRemoteDevice(macAddress) ?: throw Exception("Bluetooth adapter unavailable")
if (remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
updateBluetoothState()
return
}
kotlinx.coroutines.suspendCancellableCoroutine<Unit> { cont ->
val receiver =
object : android.content.BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(c: Context, intent: android.content.Intent) {
if (intent.action == android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
val d =
intent.getParcelableExtra<android.bluetooth.BluetoothDevice>(
android.bluetooth.BluetoothDevice.EXTRA_DEVICE,
)
if (d?.address?.equals(macAddress, ignoreCase = true) == true) {
val state =
intent.getIntExtra(
android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE,
android.bluetooth.BluetoothDevice.ERROR,
)
val prevState =
intent.getIntExtra(
android.bluetooth.BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE,
android.bluetooth.BluetoothDevice.ERROR,
)
if (state == android.bluetooth.BluetoothDevice.BOND_BONDED) {
try {
context.unregisterReceiver(this)
} catch (ignored: Exception) {}
if (cont.isActive) cont.resume(Unit) {}
} else if (
state == android.bluetooth.BluetoothDevice.BOND_NONE &&
prevState == android.bluetooth.BluetoothDevice.BOND_BONDING
) {
try {
context.unregisterReceiver(this)
} catch (ignored: Exception) {}
if (cont.isActive) {
cont.resumeWith(Result.failure(Exception("Bonding failed or rejected")))
}
}
}
}
}
}
val filter = android.content.IntentFilter(android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED)
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
cont.invokeOnCancellation {
try {
context.unregisterReceiver(receiver)
} catch (ignored: Exception) {}
}
if (!remoteDevice.createBond()) {
try {
context.unregisterReceiver(receiver)
} catch (ignored: Exception) {}
if (cont.isActive) cont.resumeWith(Result.failure(Exception("Failed to initiate bonding")))
}
}
updateBluetoothState()
}
internal suspend fun updateBluetoothState() {
val hasPerms = hasRequiredPermissions()
val enabled = androidEnvironment.isBluetoothEnabled
val newState =
BluetoothState(
hasPermissions = hasPerms,
enabled = enabled,
bondedDevices = getBondedAppPeripherals(enabled, hasPerms),
)
val enabled = bluetoothAdapter?.isEnabled == true
var hasPermissions = hasBluetoothPermissions()
val bondedDevices =
if (hasPermissions) {
try {
getBondedAppPeripherals()
} catch (e: SecurityException) {
Logger.w(e) { "SecurityException accessing bonded devices. Missing BLUETOOTH_CONNECT?" }
hasPermissions = false
emptyList()
}
} else {
emptyList()
}
val newState = BluetoothState(hasPermissions = hasPermissions, enabled = enabled, bondedDevices = bondedDevices)
_state.emit(newState)
Logger.d { "Detected our bluetooth access=$newState" }
}
@SuppressLint("MissingPermission")
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<BleDevice> =
if (enabled && hasPerms) {
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral).map { AndroidBleDevice(it) }
} else {
emptyList()
}
private fun getBondedAppPeripherals(): List<BleDevice> = bluetoothAdapter?.bondedDevices?.map { device ->
deviceCache.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
} ?: emptyList()
@SuppressLint("MissingPermission")
override fun isBonded(address: String): Boolean {
val enabled = androidEnvironment.isBluetoothEnabled
val hasPerms = hasRequiredPermissions()
return if (enabled && hasPerms) {
centralManager.getBondedPeripherals().any { it.address == address }
} else {
false
}
}
private fun hasRequiredPermissions(): Boolean = if (androidEnvironment.requiresBluetoothRuntimePermissions) {
androidEnvironment.isBluetoothScanPermissionGranted &&
androidEnvironment.isBluetoothConnectPermissionGranted
} else {
androidEnvironment.isLocationPermissionGranted
}
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService =
(peripheral.services(listOf(SERVICE_UUID)).value as? RemoteServices.Discovered)?.services?.isNotEmpty()
?: false
return nameMatches || hasRequiredService
override fun isBonded(address: String): Boolean = try {
bluetoothAdapter?.bondedDevices?.any { it.address.equals(address, ignoreCase = true) } ?: false
} catch (e: SecurityException) {
Logger.w(e) { "SecurityException checking bonded devices. Missing BLUETOOTH_CONNECT?" }
false
}
}

View file

@ -0,0 +1,45 @@
/*
* 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.ble
import co.touchlab.kermit.Logger
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
import com.juul.kable.toIdentifier
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
// If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice),
// we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail
// immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses.
// If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster.
autoConnectIf(autoConnect)
onServicesDiscovered {
try {
// Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes.
// Requesting the max MTU is critical for preventing dropped packets and stalls.
@Suppress("MagicNumber")
val negotiatedMtu = requestMtu(512)
Logger.i { "Negotiated MTU: $negotiatedMtu" }
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Failed to request MTU" }
}
}
}
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)

View file

@ -19,13 +19,6 @@ package org.meshtastic.core.ble.di
import android.app.Application
import android.location.LocationManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
@ -33,16 +26,6 @@ import org.koin.core.annotation.Single
@Module
@ComponentScan("org.meshtastic.core.ble")
class CoreBleAndroidModule {
@Single
fun provideAndroidEnvironment(app: Application): AndroidEnvironment =
NativeAndroidEnvironment.getInstance(app, isNeverForLocationFlagSet = true)
@Single
fun provideCentralManager(environment: AndroidEnvironment): CentralManager = CentralManager.native(
environment as NativeAndroidEnvironment,
CoroutineScope(SupervisorJob() + Dispatchers.Default),
)
@Single
fun provideLocationManager(app: Application): LocationManager =
ContextCompat.getSystemService(app, LocationManager::class.java)!!

View file

@ -0,0 +1,28 @@
/*
* 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.ble
import com.juul.kable.Peripheral
/**
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
* dynamically created UI devices (scanned vs bonded) and the actual connection.
*/
internal object ActiveBleConnection {
var activePeripheral: Peripheral? = null
var activeAddress: String? = null
}

View file

@ -27,5 +27,5 @@ interface BleScanner {
* @param timeout The duration of the scan.
* @return A [Flow] of discovered [BleDevice]s.
*/
fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null): Flow<BleDevice>
fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null, address: String? = null): Flow<BleDevice>
}

View file

@ -16,7 +16,8 @@
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.RemoteService
/** An Android implementation of [BleService] that wraps a Nordic [RemoteService]. */
class AndroidBleService(val service: RemoteService) : BleService
/** Extension to convert a [BleService] to a [MeshtasticRadioProfile]. */
fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile {
val kableService = this as KableBleService
return KableMeshtasticRadioProfile(kableService.peripheral)
}

View file

@ -0,0 +1,50 @@
/*
* 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.ble
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */
class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice {
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
override val isBonded: Boolean = true
override val isConnected: Boolean
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
@OptIn(com.juul.kable.ExperimentalApi::class)
override suspend fun readRssi(): Int {
val peripheral = ActiveBleConnection.activePeripheral
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
peripheral.rssi()
} else {
0
}
}
override suspend fun bond() {
// DirectBleDevice assumes we are already bonded.
}
fun updateState(newState: BleConnectionState) {
_state.value = newState
}
}

View file

@ -0,0 +1,171 @@
/*
* 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.ble
import com.juul.kable.Peripheral
import com.juul.kable.State
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.uuid.Uuid
class KableBleService(val peripheral: Peripheral) : BleService
@Suppress("UnusedPrivateProperty")
class KableBleConnection(private val scope: CoroutineScope, private val tag: String) : BleConnection {
private var peripheral: Peripheral? = null
private var stateJob: Job? = null
private var connectionScope: CoroutineScope? = null
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
override val device: BleDevice?
get() = _deviceFlow.replayCache.firstOrNull()
private val _connectionState =
MutableSharedFlow<BleConnectionState>(
extraBufferCapacity = 1,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
)
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
override suspend fun connect(device: BleDevice) {
val autoConnect = MutableStateFlow(device is DirectBleDevice)
val p =
when (device) {
is KableBleDevice ->
Peripheral(device.advertisement) {
observationExceptionHandler { cause ->
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
}
platformConfig(device) { autoConnect.value }
}
is DirectBleDevice ->
createPeripheral(device.address) {
observationExceptionHandler { cause ->
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
}
platformConfig(device) { autoConnect.value }
}
else -> error("Unsupported BleDevice type: ${device::class}")
}
peripheral?.disconnect()
peripheral?.close()
peripheral = p
ActiveBleConnection.activePeripheral = p
ActiveBleConnection.activeAddress = device.address
_deviceFlow.emit(device)
stateJob?.cancel()
var hasStartedConnecting = false
stateJob =
p.state
.onEach { kableState ->
val mappedState = kableState.toBleConnectionState(hasStartedConnecting) ?: return@onEach
if (kableState is State.Connecting || kableState is State.Connected) {
hasStartedConnecting = true
}
when (device) {
is KableBleDevice -> device.updateState(mappedState)
is DirectBleDevice -> device.updateState(mappedState)
}
_connectionState.emit(mappedState)
}
.launchIn(scope)
while (p.state.value !is State.Connected) {
autoConnect.value =
try {
connectionScope = p.connect()
false
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
@Suppress("MagicNumber")
val retryDelayMs = 1000L
kotlinx.coroutines.delay(retryDelayMs)
true
}
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
onRegister()
return try {
kotlinx.coroutines.withTimeout(timeoutMs) {
connect(device)
BleConnectionState.Connected
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
BleConnectionState.Disconnected
}
}
override suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
peripheral?.disconnect()
peripheral?.close()
peripheral = null
connectionScope = null
ActiveBleConnection.activePeripheral = null
ActiveBleConnection.activeAddress = null
_deviceFlow.emit(null)
}
override suspend fun <T> profile(
serviceUuid: Uuid,
timeout: Duration,
setup: suspend CoroutineScope.(BleService) -> T,
): T {
val p = peripheral ?: error("Not connected")
val cScope = connectionScope ?: error("No active connection scope")
val service = KableBleService(p)
return cScope.setup(service)
}
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
// Desktop MTU isn't always easily exposed, provide a safe default for Meshtastic
return 512
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -17,12 +17,9 @@
package org.meshtastic.core.ble
import kotlinx.coroutines.CoroutineScope
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import org.koin.core.annotation.Single
/** An Android implementation of [BleConnectionFactory]. */
@Single
class AndroidBleConnectionFactory(private val centralManager: CentralManager) : BleConnectionFactory {
override fun create(scope: CoroutineScope, tag: String): BleConnection =
AndroidBleConnection(centralManager, scope, tag)
class KableBleConnectionFactory : BleConnectionFactory {
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope, tag)
}

View file

@ -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.ble
import com.juul.kable.Advertisement
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class KableBleDevice(val advertisement: Advertisement) : BleDevice {
override val name: String?
get() = advertisement.name
override val address: String
get() = advertisement.identifier.toString()
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
override val state: StateFlow<BleConnectionState> = _state
// On desktop, bonding isn't strictly required before connecting via Kable,
// and we don't have a pairing flow. Defaulting to true lets the UI connect directly.
override val isBonded: Boolean = true
override val isConnected: Boolean
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
@OptIn(com.juul.kable.ExperimentalApi::class)
override suspend fun readRssi(): Int {
val peripheral = ActiveBleConnection.activePeripheral
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
peripheral.rssi()
} else {
advertisement.rssi
}
}
override suspend fun bond() {
// Not supported/needed on jvmMain desktop currently
}
internal fun updateState(newState: BleConnectionState) {
_state.value = newState
}
}

View file

@ -0,0 +1,51 @@
/*
* 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.ble
import com.juul.kable.Scanner
import kotlinx.coroutines.flow.Flow
import org.koin.core.annotation.Single
import kotlin.time.Duration
import kotlin.uuid.Uuid
@Single
class KableBleScanner : BleScanner {
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
val scanner = Scanner {
if (serviceUuid != null || address != null) {
filters {
match {
if (serviceUuid != null) {
services = listOf(serviceUuid)
}
if (address != null) {
this.address = address
}
}
}
}
}
// Kable's Scanner doesn't enforce timeout internally, it runs until the Flow is cancelled.
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
return kotlinx.coroutines.flow.channelFlow {
kotlinx.coroutines.withTimeoutOrNull(timeout) {
scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
}
}
}
}

View file

@ -0,0 +1,123 @@
/*
* 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.ble
import co.touchlab.kermit.Logger
import com.juul.kable.Peripheral
import com.juul.kable.WriteType
import com.juul.kable.characteristicOf
import com.juul.kable.writeWithoutResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
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.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import kotlin.uuid.Uuid
class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : MeshtasticRadioProfile {
private val toRadio = characteristicOf(SERVICE_UUID, TORADIO_CHARACTERISTIC)
private val fromRadioChar = characteristicOf(SERVICE_UUID, FROMRADIO_CHARACTERISTIC)
private val fromRadioSync = characteristicOf(SERVICE_UUID, FROMRADIOSYNC_CHARACTERISTIC)
private val fromNum = characteristicOf(SERVICE_UUID, FROMNUM_CHARACTERISTIC)
private val logRadioChar = characteristicOf(SERVICE_UUID, LOGRADIO_CHARACTERISTIC)
private val triggerDrain = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
init {
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
Logger.i {
"KableMeshtasticRadioProfile init. Discovered characteristics: ${svc?.characteristics?.map {
it.characteristicUuid
}}"
}
}
private fun hasCharacteristic(uuid: Uuid): Boolean = peripheral.services.value?.any { svc ->
svc.serviceUuid == SERVICE_UUID && svc.characteristics.any { it.characteristicUuid == uuid }
} == true
// Using observe() for fromRadioSync or legacy read loop for fromRadio
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override val fromRadio: Flow<ByteArray> = channelFlow {
// Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO.
// This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
launch {
try {
if (hasCharacteristic(FROMRADIOSYNC_CHARACTERISTIC)) {
peripheral.observe(fromRadioSync).collect { send(it) }
} else {
error("fromRadioSync missing")
}
} catch (e: Exception) {
// Fallback to legacy
launch {
if (hasCharacteristic(FROMNUM_CHARACTERISTIC)) {
peripheral.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
}
}
triggerDrain.collect {
var keepReading = true
while (keepReading) {
try {
if (!hasCharacteristic(FROMRADIO_CHARACTERISTIC)) {
keepReading = false
continue
}
val packet = peripheral.read(fromRadioChar)
if (packet.isEmpty()) keepReading = false else send(packet)
} catch (e: Exception) {
keepReading = false
}
}
}
}
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override val logRadio: Flow<ByteArray> = channelFlow {
try {
if (hasCharacteristic(LOGRADIO_CHARACTERISTIC)) {
peripheral.observe(logRadioChar).collect { send(it) }
}
} catch (e: Exception) {
// logRadio is optional, ignore if not found
}
}
private val toRadioWriteType: WriteType by lazy {
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
val char = svc?.characteristics?.find { it.characteristicUuid == TORADIO_CHARACTERISTIC }
if (char?.properties?.writeWithoutResponse == true) {
WriteType.WithoutResponse
} else {
WriteType.WithResponse
}
}
override suspend fun sendToRadio(packet: ByteArray) {
peripheral.write(toRadio, packet, toRadioWriteType)
triggerDrain.tryEmit(Unit)
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.ble
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
/** Platform-specific configuration for the Peripheral builder based on device type. */
internal expect fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean)
/** Platform-specific instantiation of a Peripheral by address. */
internal expect fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral

View file

@ -0,0 +1,38 @@
/*
* 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.ble
import com.juul.kable.State
/**
* Maps Kable's [State] to Meshtastic's [BleConnectionState].
*
* @param hasStartedConnecting whether we have seen a Connecting state. This is used to ignore the initial Disconnected
* state emitted by StateFlow upon subscription.
* @return the mapped [BleConnectionState], or null if the state should be ignored.
*/
fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? {
return when (this) {
is State.Connecting -> BleConnectionState.Connecting
is State.Connected -> BleConnectionState.Connected
is State.Disconnecting -> BleConnectionState.Disconnecting
is State.Disconnected -> {
if (!hasStartedConnecting) return null
BleConnectionState.Disconnected
}
}
}

View file

@ -0,0 +1,31 @@
/*
* 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.ble
import kotlinx.coroutines.flow.Flow
/** A definition of the Meshtastic BLE Service profile. */
interface MeshtasticRadioProfile {
/** 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

@ -0,0 +1,61 @@
/*
* 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.ble
import com.juul.kable.State
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class KableStateMappingTest {
@Test
fun `Connecting maps to Connecting`() {
val state = mockk<State.Connecting>()
val result = state.toBleConnectionState(hasStartedConnecting = false)
assertEquals(BleConnectionState.Connecting, result)
}
@Test
fun `Connected maps to Connected`() {
val state = mockk<State.Connected>()
val result = state.toBleConnectionState(hasStartedConnecting = true)
assertEquals(BleConnectionState.Connected, result)
}
@Test
fun `Disconnecting maps to Disconnecting`() {
val state = mockk<State.Disconnecting>()
val result = state.toBleConnectionState(hasStartedConnecting = true)
assertEquals(BleConnectionState.Disconnecting, result)
}
@Test
fun `Disconnected ignores initial emission if not started connecting`() {
val state = mockk<State.Disconnected>()
val result = state.toBleConnectionState(hasStartedConnecting = false)
assertNull(result)
}
@Test
fun `Disconnected maps to Disconnected if started connecting`() {
val state = mockk<State.Disconnected>()
val result = state.toBleConnectionState(hasStartedConnecting = true)
assertEquals(BleConnectionState.Disconnected, result)
}
}

View file

@ -0,0 +1,71 @@
/*
* 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.ble
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class FakeMeshtasticRadioProfile : MeshtasticRadioProfile {
private val _fromRadio = MutableSharedFlow<ByteArray>(replay = 1)
override val fromRadio: Flow<ByteArray> = _fromRadio
private val _logRadio = MutableSharedFlow<ByteArray>(replay = 1)
override val logRadio: Flow<ByteArray> = _logRadio
val sentPackets = mutableListOf<ByteArray>()
override suspend fun sendToRadio(packet: ByteArray) {
sentPackets.add(packet)
}
suspend fun emitFromRadio(packet: ByteArray) {
_fromRadio.emit(packet)
}
suspend fun emitLogRadio(packet: ByteArray) {
_logRadio.emit(packet)
}
}
class MeshtasticRadioProfileTest {
@Test
fun testFakeProfileEmitsFromRadio() = runTest {
val fake = FakeMeshtasticRadioProfile()
val expectedPacket = byteArrayOf(1, 2, 3)
fake.emitFromRadio(expectedPacket)
val received = fake.fromRadio.first()
assertEquals(expectedPacket.toList(), received.toList())
}
@Test
fun testFakeProfileRecordsSentPackets() = runTest {
val fake = FakeMeshtasticRadioProfile()
val packet = byteArrayOf(4, 5, 6)
fake.sendToRadio(packet)
assertEquals(1, fake.sentPackets.size)
assertEquals(packet.toList(), fake.sentPackets.first().toList())
}
}

View file

@ -0,0 +1,42 @@
/*
* 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.ble
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Single
@Single
class KableBluetoothRepository : BluetoothRepository {
// Desktop Kable doesn't currently expose much state tracking easily, assume true.
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
override val state: StateFlow<BluetoothState> = _state
override fun refreshState() {
// No-op for now on desktop
}
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotEmpty()
override fun isBonded(address: String): Boolean {
return false // Bonding not supported on desktop yet
}
override suspend fun bond(device: BleDevice) {
// No-op
}
}

View file

@ -0,0 +1,28 @@
/*
* 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.ble
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
import com.juul.kable.toIdentifier
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
// Desktop Kable uses direct connections without needing autoConnect.
}
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)

View file

@ -1,103 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.mock.mock
import no.nordicsemi.kotlin.ble.client.mock.AddressType
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
import no.nordicsemi.kotlin.ble.client.mock.Proximity
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
class BleScannerTest {
private val testDispatcher = UnconfinedTestDispatcher()
@Test
fun `scan returns peripherals`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val scanner = AndroidBleScanner(centralManager)
val peripheral =
PeripheralSpec.simulatePeripheral(
identifier = "00:11:22:33:44:55",
addressType = AddressType.RANDOM_STATIC,
proximity = Proximity.IMMEDIATE,
) {
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
CompleteLocalName("Test_Device")
}
}
centralManager.simulatePeripherals(listOf(peripheral))
val result = scanner.scan(5.seconds).first()
assertEquals("00:11:22:33:44:55", result.address)
assertEquals("Test_Device", result.name)
}
@Test
fun `scan with filter returns only matching peripherals`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val scanner = AndroidBleScanner(centralManager)
val targetUuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
val matchingPeripheral =
PeripheralSpec.simulatePeripheral(identifier = "00:11:22:33:44:55", proximity = Proximity.IMMEDIATE) {
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
CompleteLocalName("Matching_Device")
ServiceUuid(targetUuid)
}
}
val nonMatchingPeripheral =
PeripheralSpec.simulatePeripheral(identifier = "AA:BB:CC:DD:EE:FF", proximity = Proximity.IMMEDIATE) {
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
CompleteLocalName("Non_Matching_Device")
}
}
centralManager.simulatePeripherals(listOf(matchingPeripheral, nonMatchingPeripheral))
val scannedDevices = mutableListOf<no.nordicsemi.kotlin.ble.client.android.Peripheral>()
val job = launch { scanner.scan(5.seconds, targetUuid).toList(scannedDevices) }
// Needs time to scan in mock environment
advanceUntilIdle()
job.cancel()
// TODO: test filter logic correctly if necessary
}
}

View file

@ -1,160 +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.ble
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.testing.TestLifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.mock.mock
import no.nordicsemi.kotlin.ble.client.mock.AddressType
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
import no.nordicsemi.kotlin.ble.client.mock.Proximity
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.di.CoroutineDispatchers
@OptIn(ExperimentalCoroutinesApi::class)
class BluetoothRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, default = testDispatcher, io = testDispatcher)
private lateinit var mockEnvironment: MockAndroidEnvironment
private lateinit var lifecycleOwner: TestLifecycleOwner
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockEnvironment =
MockAndroidEnvironment.Api31(
isBluetoothEnabled = true,
isBluetoothScanPermissionGranted = true,
isBluetoothConnectPermissionGranted = true,
)
lifecycleOwner =
TestLifecycleOwner(initialState = Lifecycle.State.RESUMED, coroutineDispatcher = testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initial state reflects environment`() = runTest(testDispatcher) {
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
runCurrent()
val state = repository.state.value
assertTrue(state.enabled)
assertTrue(state.hasPermissions)
}
@Test
fun `state updates when bluetooth is disabled`() = runTest(testDispatcher) {
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
mockEnvironment.simulatePowerOff()
runCurrent()
val state = repository.state.value
assertFalse(state.enabled)
}
@Test
fun `bonded devices are correctly identified`() = runTest(testDispatcher) {
val address = "C0:00:00:00:00:03"
val peripheral =
PeripheralSpec.simulatePeripheral(
identifier = address,
addressType = AddressType.RANDOM_STATIC,
proximity = Proximity.IMMEDIATE,
) {
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
CompleteLocalName("Meshtastic_5678")
}
connectable(
name = "Meshtastic_5678",
isBonded = true,
eventHandler = object : PeripheralSpecEventHandler {},
) {
Service(uuid = SERVICE_UUID) {}
}
}
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
centralManager.simulatePeripherals(listOf(peripheral))
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
repository.refreshState()
runCurrent()
val state = repository.state.value
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
assertEquals(address, state.bondedDevices.first().address)
}
@Test
fun `isBonded returns false when permissions are not granted`() = runTest(testDispatcher) {
val noPermsEnv =
MockAndroidEnvironment.Api31(
isBluetoothEnabled = true,
isBluetoothScanPermissionGranted = false,
isBluetoothConnectPermissionGranted = false,
)
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
runCurrent()
assertFalse(repository.isBonded("C0:00:00:00:00:03"))
}
@Test
fun `state has no permissions when bluetooth permissions denied`() = runTest(testDispatcher) {
val noPermsEnv =
MockAndroidEnvironment.Api31(
isBluetoothEnabled = true,
isBluetoothScanPermissionGranted = true,
isBluetoothConnectPermissionGranted = false,
)
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
runCurrent()
val state = repository.state.value
assertFalse("hasPermissions should be false when connect permission is denied", state.hasPermissions)
}
}