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

@ -68,7 +68,7 @@ The app follows modern Android development practices, built on top of a shared K
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)
The BLE stack uses a hybrid interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, while the Android implementation utilizes **Nordic Semiconductor's Kotlin BLE Library**. This provides a robust, Coroutine-based architecture for reliable device communication while remaining KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
The BLE stack uses a multiplatform interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, utilizing the **Kable** multiplatform BLE library to handle device communication across all supported targets (Android, Desktop). This provides a robust, Coroutine-based architecture for reliable device communication while remaining fully KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
## Translations

View file

@ -273,13 +273,6 @@ dependencies {
implementation(libs.kermit)
implementation(libs.kotlinx.datetime)
implementation(libs.nordic.client.android)
implementation(libs.nordic.common.core)
implementation(libs.nordic.common.permissions.ble)
implementation(libs.nordic.common.permissions.notification)
implementation(libs.nordic.common.scanner.ble)
implementation(libs.nordic.common.ui)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)
@ -307,8 +300,6 @@ dependencies {
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.nordic.client.android.mock)
androidTestImplementation(libs.nordic.core.mock)
androidTestImplementation(libs.koin.test)
testImplementation(libs.androidx.work.testing)
@ -316,9 +307,6 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.nordic.client.android.mock)
testImplementation(libs.nordic.client.core.mock)
testImplementation(libs.nordic.core.mock)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -2,7 +2,31 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : RadioTransport</ID>
<ID>LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()</ID>
<ID>LongParameterList:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, dispatchers: CoroutineDispatchers, meshLogRepository: MeshLogRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, tracerouteSnapshotRepository: TracerouteSnapshotRepository, nodeRequestActions: NodeRequestActions, alertManager: AlertManager, getNodeDetailsUseCase: GetNodeDetailsUseCase, )</ID>
<ID>LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, )</ID>
<ID>LongParameterList:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, radioConfigRepository: RadioConfigRepository, packetRepository: PacketRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val locationRepository: LocationRepository, mapConsentPrefs: MapConsentPrefs, analyticsPrefs: AnalyticsPrefs, homoglyphEncodingPrefs: HomoglyphPrefs, toggleAnalyticsUseCase: ToggleAnalyticsUseCase, toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, importProfileUseCase: ImportProfileUseCase, exportProfileUseCase: ExportProfileUseCase, exportSecurityConfigUseCase: ExportSecurityConfigUseCase, installProfileUseCase: InstallProfileUseCase, radioConfigUseCase: RadioConfigUseCase, adminActionsUseCase: AdminActionsUseCase, processRadioResponseUseCase: ProcessRadioResponseUseCase, )</ID>
<ID>LongParameterList:AndroidSettingsViewModel.kt$AndroidSettingsViewModel$( private val app: Application, radioConfigRepository: RadioConfigRepository, radioController: RadioController, nodeRepository: NodeRepository, uiPrefs: UiPrefs, buildConfigProvider: BuildConfigProvider, databaseManager: DatabaseManager, meshLogPrefs: MeshLogPrefs, setThemeUseCase: SetThemeUseCase, setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, setProvideLocationUseCase: SetProvideLocationUseCase, setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase, setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase, meshLocationUseCase: MeshLocationUseCase, exportDataUseCase: ExportDataUseCase, isOtaCapableUseCase: IsOtaCapableUseCase, )</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$0xff</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$3</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$4</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception</ID>
<ID>TooGenericExceptionCaught:BleRadioInterface.kt$BleRadioInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooManyFunctions:BleRadioInterface.kt$BleRadioInterface : RadioTransport</ID>
>>>>>>> ba83c3564 (chore(conductor): Complete Phase 4 - Wire Kable and Remove Nordic)
</CurrentIssues>
</SmellBaseline>

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

@ -0,0 +1,380 @@
/*
* 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 android.annotation.SuppressLint
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.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.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 the common BLE abstractions (which are powered by Kable).
*
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
* - Bonding and discovery.
* - Automatic reconnection logic.
* - MTU and connection parameter monitoring.
* - Routing raw byte packets between the radio and [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.
*/
@SuppressLint("MissingPermission")
class BleRadioInterface(
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() + 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
@Volatile private var isFullyConnected = false
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(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)
}
}
throw RadioNotConnectedException("Device not found at address $address")
}
private fun connect() {
connectionScope.launch {
val device = findDevice()
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)
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)
}
}
}
}
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@BleRadioInterface.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@BleRadioInterface.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] Disconnecting. " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
connectionScope.launch {
bleConnection.disconnect()
service.onDisconnect(true)
connectionScope.cancel()
}
}
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

@ -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,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)
}
}
}

View file

@ -0,0 +1,5 @@
# Track android_kable_migration_20260314 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View file

@ -0,0 +1,8 @@
{
"track_id": "android_kable_migration_20260314",
"type": "feature",
"status": "new",
"created_at": "2026-03-14T17:15:00Z",
"updated_at": "2026-03-14T17:15:00Z",
"description": "Replace Nordic with Kable on Android"
}

View file

@ -0,0 +1,44 @@
# Implementation Plan: Replace Nordic with Kable on Android (Deduplication Pass)
## Phase 1: Deduplicate Kable Abstractions into `commonMain` [checkpoint: 709f6e3]
- [x] Task: Extract common Kable state mapping logic from jvmMain to commonMain 10cdd16
- [x] Create `commonMain` tests for `BleConnectionState` mapping using Kable `State`
- [x] Move `KableMeshtasticRadioProfile` and `KableBleConnection` logic that doesn't depend on platform specifics to `commonMain`
- [x] Task: Implement common Kable `Scanner` and `Peripheral` wrappers 2691d70
- [x] Extract generic connection lifecycle (connect, reconnect, close) to `commonMain` using Kable's `Peripheral` interface
- [x] Task: Conductor - User Manual Verification 'Phase 1: Deduplicate Kable Abstractions into commonMain' (Protocol in workflow.md) 709f6e3
## Phase 2: Implement Kable Backend for Android (`androidMain`) [checkpoint: 12217de]
- [x] Task: Add Kable dependency to Android source set in `core:ble/build.gradle.kts` 011d619
- [x] Task: Implement Android-specific `BleConnectionFactory` and `BleScanner` using the deduplicated `commonMain` logic 589ee93
- [x] Write failing integration tests for Android Kable scanner (using fakes/mocks)
- [x] Implement `KableBleScanner` for `androidMain`
- [x] Write failing integration tests for Android Kable connection (using fakes/mocks)
- [x] Implement `KableBleConnection` for `androidMain` (handling Android-specific MTU requests if necessary)
- [x] Task: Conductor - User Manual Verification 'Phase 2: Implement Kable Backend for Android' (Protocol in workflow.md) 12217de
## Phase 3: Migrate OTA Firmware Update Logic [checkpoint: 663c8e2]
- [x] Task: Deprecate `NordicDfuHandler` and replace with Kable-based DFU 06fe4f5
- [x] Write failing tests for Kable DFU integration
- [x] Implement new DFU handler in `feature:firmware` using `MeshtasticRadioProfile` / Kable abstraction
- [x] Task: Conductor - User Manual Verification 'Phase 3: Migrate OTA Firmware Update Logic' (Protocol in workflow.md) 663c8e2
## Phase 4: Wire Kable into Android App and Remove Nordic [checkpoint: ebe1617]
- [x] Task: Deprecate and remove `NordicBleInterface` and `AndroidBleConnection` ebe1617
- [x] Remove `NordicAndroidCommonLibraries` and `NordicDfuLibrary` from `gradle/libs.versions.toml` and build files
- [x] Delete `NordicBleInterface.kt` and associated Nordic-specific radio implementations
- [x] Task: Wire new `androidMain` Kable implementation into the Koin DI graph ebe1617
- [x] Update `AndroidRadioControllerImpl` or DI modules to provide the new Kable `BleConnectionFactory` and `BleScanner`
- [x] Task: Conductor - User Manual Verification 'Phase 4: Wire Kable into Android App and Remove Nordic' (Protocol in workflow.md) ebe1617
## Phase 5: Final Testing and Integration [checkpoint: 4778c0e]
- [x] Task: Update Android `app` UI tests and BLE unit tests to use Kable fakes 4778c0e
- [x] Fix any failing tests related to the Nordic removal
- [x] Task: Manual end-to-end verification 4778c0e
- [x] Build and run the Android app, verify BLE scanning, connecting, and messaging
- [x] Verify OTA updates work via BLE
- [x] Verify the Desktop app still functions correctly
- [x] Task: Conductor - User Manual Verification 'Phase 5: Final Testing and Integration' (Protocol in workflow.md) 4778c0e
## Phase: Review Fixes
- [x] Task: Apply review suggestions e5dffd9

View file

@ -0,0 +1,28 @@
# Specification: Replace Nordic with Kable on Android (Deduplication Pass)
## Overview
This track executes a full migration of the Android application's BLE transport layer from the legacy Nordic Android Common Libraries to the multiplatform Kable library. Building upon the successful `MeshtasticRadioProfile` abstraction introduced for the Desktop target, this track aims to unify the BLE transport layer across all platforms (Android, Desktop, iOS) under a single KMP technology stack. Crucially, this pass focuses on **maximal code deduplication**, moving as much BLE logic as possible into `commonMain` to share it across all targets, including OTA firmware update logic.
## Functional Requirements
- **Kable Integration:** Implement the `MeshtasticRadioProfile` using Kable for the `androidMain` source set, replacing the existing Nordic implementation.
- **Maximal Deduplication:** Refactor the existing Kable `jvmMain` implementation and the new `androidMain` implementation to extract common connection management, scanning logic, and characteristic observation into `core:ble/commonMain`.
- **OTA Firmware Updates:** Migrate the Android OTA firmware update logic (currently handled by `NordicDfuHandler`) to use the new Kable/KMP abstraction.
- **Full Migration:** The Android app must exclusively use the new Kable backend for all BLE operations (scanning, connecting, data transfer, firmware updates).
- **Deprecation/Removal:** Remove all dependencies on the Nordic Android Common Libraries and Nordic DFU libraries from the project configuration (`build.gradle.kts`, version catalogs).
- **Feature Parity:** The new Kable implementation on Android must maintain full feature parity with the previous Nordic implementation, including connection stability, MTU negotiation, and data throughput.
## Non-Functional Requirements
- **Expanded Testing:** Adapt existing Android BLE tests to use Kable fakes and write new `commonMain` tests to expand test coverage for the shared KMP BLE abstraction.
- **Architecture:** Maintain strict adherence to the MVI/UDF patterns and the pure KMP DI architecture (Koin annotations).
## Acceptance Criteria
- [ ] Kable backend is fully implemented for Android (`androidMain`).
- [ ] Nordic Android Common Libraries and DFU dependencies are completely removed from the project.
- [ ] Android application successfully scans, connects, and transfers data via BLE using Kable.
- [ ] BLE logic (connection state, profile mapping, retry logic) is heavily deduplicated into `core:ble/commonMain`.
- [ ] OTA firmware update logic is successfully migrated to use the Kable backend.
- [ ] Existing BLE tests are updated or replaced, and all test suites pass.
- [ ] New KMP BLE tests are added, improving overall test coverage.
## Out of Scope
- Migrating USB or TCP network transports.

View file

@ -0,0 +1,5 @@
# Track desktop_ble_kable_20260314 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View file

@ -0,0 +1,8 @@
{
"track_id": "desktop_ble_kable_20260314",
"type": "feature",
"status": "new",
"created_at": "2026-03-14T12:00:00Z",
"updated_at": "2026-03-14T12:00:00Z",
"description": "Kable swap Keep Nordic on Android short-term. Add Kable backend only for jvmMain in core:ble first (desktop BLE enablement). Introduce a MeshtasticRadioProfile abstraction in core:ble/commonMain so NordicBleInterface no longer depends on Android/Nordic classes. Once that seam is clean, decide whether Android should stay Nordic or move to Kable."
}

View file

@ -0,0 +1,37 @@
# Implementation Plan: Desktop BLE Enablement via Kable
## Phase 1: Define `MeshtasticRadioProfile` Abstraction [checkpoint: 1206e87]
- [x] Task: Define `MeshtasticRadioProfile` interface in `core:ble/commonMain` eaa623a
- [ ] Write tests for expected profile behavior (e.g., state flow emission) using a simple fake
- [ ] Implement `MeshtasticRadioProfile` interface, data classes for states, and configuration
- [x] Task: Conductor - User Manual Verification 'Phase 1: Define `MeshtasticRadioProfile` Abstraction' (Protocol in workflow.md) 1206e87
## Phase 2: Refactor Nordic Implementation to use Abstraction [checkpoint: dc700a5]
- [x] Task: Implement `MeshtasticRadioProfile` in the existing Nordic implementation (`androidMain`) 83a8a9b
- [ ] Write/adapt existing Android tests to verify `MeshtasticRadioProfile` adherence
- [ ] Implement wrapper/adapter for Nordic classes to fulfill `MeshtasticRadioProfile`
- [x] Task: Decouple app-level BLE transport from Nordic types 2dfedde
- [ ] Write tests to ensure BLE transport only relies on `MeshtasticRadioProfile`
- [ ] Refactor transport layer (e.g., `NordicBleInterface` usages) to use the new profile interface
- [x] Task: Conductor - User Manual Verification 'Phase 2: Refactor Nordic Implementation to use Abstraction' (Protocol in workflow.md) dc700a5
## Phase 3: Implement Kable Backend for Desktop [checkpoint: ed2a459]
- [x] Task: Setup Kable dependencies for `jvmMain` in `core:ble` b152eff
- [ ] Update `build.gradle.kts` to include Kable dependency for Desktop
- [x] Task: Implement Kable `MeshtasticRadioProfile` backend (`jvmMain`) fa5cc82
- [ ] Write `commonMain` unit tests with Kable fakes to verify scanning, connection, and read/write operations
- [ ] Implement Kable scanning logic
- [ ] Implement Kable connection and characteristic management
- [ ] Implement Kable read/write data transfer logic
- [x] Task: Conductor - User Manual Verification 'Phase 3: Implement Kable Backend for Desktop' (Protocol in workflow.md) ed2a459
## Phase 4: Integration and Final Testing [checkpoint: af6d3b3]
- [x] Task: Integrate Kable backend into Desktop app DI graph 28afcad
- [ ] Wire up the Kable implementation in `desktop` module DI
- [x] Task: End-to-end verification 84aae75
- [ ] Verify Android app still compiles and connects using Nordic
- [ ] Verify Desktop app compiles and connects using Kable
- [x] Task: Conductor - User Manual Verification 'Phase 4: Integration and Final Testing' (Protocol in workflow.md) af6d3b3
## Phase: Review Fixes
- [x] Task: Apply review suggestions b36da82

View file

@ -0,0 +1,31 @@
# Specification: Desktop BLE Enablement via Kable
## Overview
This track introduces a Kable BLE backend specifically for the `jvmMain` (Desktop) target within `core:ble`. To facilitate this without breaking the existing Android implementation, we will introduce a `MeshtasticRadioProfile` abstraction in `core:ble/commonMain`. This abstraction will ensure that the app-level BLE transport path no longer depends on Android-specific or Nordic-specific classes. Initially, Android will continue to use the Nordic BLE implementation, while Desktop will use Kable. Once this seam is proven, a future decision will determine whether Android should fully migrate to Kable. This approach lays the groundwork for seamless integration of future targets (e.g., iOS) under the same KMP abstraction.
## Functional Requirements
- **MeshtasticRadioProfile Abstraction:** Introduce a multiplatform interface (`MeshtasticRadioProfile`) in `core:ble/commonMain` to abstract all BLE operations.
- **Remove Nordic Dependencies:** Ensure that the app-level BLE transport path is entirely decoupled from Nordic types, relying solely on the new abstraction.
- **Kable Backend (jvmMain):** Implement the Kable backend for the Desktop target. This backend must support all core BLE operations:
- Scanning for nearby Meshtastic devices.
- Establishing and managing BLE connections.
- Reading from and writing to characteristics (sending/receiving protobuf payloads).
- **Nordic Backend Preservation (androidMain):** Update the existing Android Nordic implementation to implement the new `MeshtasticRadioProfile` interface without changing its core behavior.
- **Future-Proofing:** Design the abstraction in a way that is generic enough to support adding an iOS or other future target's BLE implementation with minimal refactoring.
## Non-Functional Requirements
- **Testing:** New `commonMain` unit tests must be written utilizing fakes for the Kable implementation. This is crucial as we cannot rely on Nordic's ready-made mocks in a multiplatform context or if a full migration to Kable occurs.
- **Architecture:** The abstraction must adhere to the project's KMP goals, keeping `core:ble/commonMain` completely free of platform-specific imports (e.g., `java.*`, `android.*`).
- **Compatibility:** The Android build and BLE functionality must remain fully functional using the existing Nordic library.
## Acceptance Criteria
- [ ] `MeshtasticRadioProfile` is defined in `core:ble/commonMain`.
- [ ] No Nordic-specific or Android-specific types are present in the app-level BLE transport path.
- [ ] Desktop application can successfully scan, connect, and perform read/write operations with a Meshtastic device using Kable.
- [ ] Android application continues to function normally using the Nordic library.
- [ ] New unit tests using Kable fakes are added to `commonMain` and pass successfully.
- [ ] The abstraction architecture provides a clear path for future platform support (like iOS).
## Out of Scope
- Migrating the Android application to use the Kable backend (this will be evaluated after this track is complete).
- Modifying non-BLE network transports (e.g., USB, TCP).

View file

@ -19,6 +19,6 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facil
- Device configuration and firmware updates
## Key Architecture Goals
- Provide a robust, shared KMP core (`core:model`, `core:repository`, `core:domain`, `core:data`, `core:network`) to support multiple platforms (Android, Desktop, iOS)
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`) to support multiple platforms (Android, Desktop, iOS)
- Ensure offline-first functionality and resilient data persistence (Room KMP)
- Decouple UI logic into shared components (`core:ui`, `feature:*`) using Compose Multiplatform

View file

@ -20,4 +20,5 @@
## Networking & Transport
- **Ktor:** Multiplatform HTTP client for web services and TCP streaming.
- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS).
- **Coroutines & Flows:** For asynchronous programming and state management.

View file

@ -2,3 +2,4 @@
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
---

View file

@ -23,38 +23,38 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
## Overview
The `:core:ble` module contains the foundation for Bluetooth Low Energy (BLE) communication in the Meshtastic Android app. It has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**.
The `:core:ble` module contains the foundation for Bluetooth Low Energy (BLE) communication in the Meshtastic Android app. It uses the **Kable** multiplatform BLE library to provide a unified, Coroutine-based architecture across all supported targets (Android, Desktop, and future iOS).
This modernization replaces legacy callback-based implementations with robust, Coroutine-based architecture, ensuring better stability, maintainability, and standard compliance.
This module abstracts platform-specific BLE operations behind common Kotlin interfaces (`BleDevice`, `BleScanner`, `BleConnection`, `BleConnectionFactory`), ensuring that business logic in `commonMain` remains platform-agnostic and testable.
## Key Components
### 1. `BleConnection`
A robust wrapper around Nordic's `Peripheral` and `CentralManager` that simplifies the connection lifecycle and service discovery using modern Coroutine APIs.
A robust wrapper around Kable's `Peripheral` that simplifies the connection lifecycle and service discovery using modern Coroutine APIs.
- **Features:**
- **Connection & Await:** Provides suspend functions to connect and wait for a terminal state (Connected or Disconnected).
- **Unified Profile Helper:** A `profile` function that manages service discovery, characteristic setup, and lifecycle in a single block, with automatic timeout and error handling.
- **Observability:** Exposes `peripheralFlow` and `connectionState` as Flows for reactive UI and service updates.
- **Connection Management:** Handles PHY updates, MTU logging, and connection priority requests automatically.
- **Observability:** Exposes `connectionState` as a Flow for reactive UI and service updates.
- **Platform Setup:** Seamlessly handles platform-specific configuration (like MTU negotiation on Android or direct connections on Desktop) via `platformConfig()` extensions.
### 2. `BluetoothRepository`
A Singleton repository responsible for the global state of Bluetooth on the Android device.
A Singleton repository responsible for the global state of Bluetooth on the device.
- **Features:**
- **State Management:** Exposes a `StateFlow<BluetoothState>` reflecting whether Bluetooth is enabled, permissions are granted, and which devices are bonded.
- **Permission Handling:** Centralizes logic for checking Bluetooth and Location permissions across different Android versions.
- **Bonding:** Simplifies the process of creating bonds with peripherals.
- **Permission Handling:** Centralizes logic for checking Bluetooth and Location permissions across different platforms.
- **Bonding:** Simplifies the process of creating and validating bonds with peripherals.
### 3. `BleScanner`
A wrapper around Nordic's `CentralManager` scanning capabilities to provide a consistent and easy-to-use API for BLE scanning with built-in peripheral deduplication.
A wrapper around Kable's `Scanner` to provide a consistent and easy-to-use API for BLE scanning with built-in peripheral mapping.
### 4. `BleRetry`
A utility for executing BLE operations with retry logic, essential for handling the inherent unreliability of wireless communication.
## Integration in `app`
The `:core:ble` module is used by `NordicBleInterface` in the main application module to implement the `RadioTransport` interface for Bluetooth devices.
The `:core:ble` module is used by `BleRadioInterface` in the main application module to implement the `RadioTransport` interface for Bluetooth devices.
## Usage
@ -62,17 +62,15 @@ Dependencies are managed via the version catalog (`libs.versions.toml`).
```toml
[versions]
nordic-ble = "2.0.0-alpha15"
nordic-common = "2.8.2"
kable = "0.42.0"
[libraries]
nordic-client-android = { module = "no.nordicsemi.kotlin.ble:client-android", version.ref = "nordic-ble" }
# ... other nordic dependencies
kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" }
```
## Architecture
The module follows a clean architecture approach:
The module follows a clean multiplatform architecture approach:
- **Repository Pattern:** `BluetoothRepository` mediates data access.
- **Coroutines & Flow:** All asynchronous operations use Kotlin Coroutines and Flows.
@ -80,4 +78,4 @@ The module follows a clean architecture approach:
## Testing
The module includes unit tests for key components, mocking the underlying Nordic libraries to ensure logic correctness without requiring a physical device.
The module includes unit tests for key components, utilizing Kable's architecture and standard coroutine testing tools to ensure logic correctness.

View file

@ -27,6 +27,7 @@ kotlin {
android {
namespace = "org.meshtastic.core.ble"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
@ -37,31 +38,27 @@ kotlin {
implementation(libs.kermit)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kable.core)
}
androidMain.dependencies {
api(libs.nordic.client.android)
api(libs.nordic.ble.env.android)
api(libs.nordic.ble.env.android.compose)
api(libs.nordic.common.scanner.ble)
api(libs.nordic.common.core)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
}
jvmMain.dependencies {}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.mockk)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.nordic.client.android.mock)
implementation(libs.nordic.client.core.mock)
implementation(libs.nordic.core.mock)
implementation(libs.androidx.lifecycle.testing)
val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.androidx.lifecycle.testing)
}
}
}
}

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

@ -14,20 +14,18 @@
* 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
package org.meshtastic.core.ble
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 packets from the radio. */
val fromRadio: Flow<ByteArray>
/** The flow of incoming log packets from the radio. */
val logRadio: 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)
}
/** 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)
}
}

View file

@ -42,10 +42,7 @@ kotlin {
api(libs.okio)
implementation(libs.kermit)
}
androidMain.dependencies {
api(libs.androidx.core.ktx)
api(libs.nordic.common.core)
}
androidMain.dependencies { api(libs.androidx.core.ktx) }
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
}
}

View file

@ -0,0 +1,101 @@
/*
* 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 io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
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.BluetoothRepository
import org.meshtastic.core.ble.BluetoothState
import org.meshtastic.core.repository.RadioInterfaceService
@OptIn(ExperimentalCoroutinesApi::class)
class BleRadioInterfaceTest {
private val testScope = TestScope()
private val scanner: BleScanner = mockk()
private val bluetoothRepository: BluetoothRepository = mockk()
private val connectionFactory: BleConnectionFactory = mockk()
private val connection: BleConnection = mockk()
private val service: RadioInterfaceService = mockk(relaxed = true)
private val address = "00:11:22:33:44:55"
private val connectionStateFlow = MutableSharedFlow<BleConnectionState>(replay = 1)
private val bluetoothStateFlow = MutableStateFlow(BluetoothState())
@Before
fun setUp() {
every { connectionFactory.create(any(), any()) } returns connection
every { connection.connectionState } returns connectionStateFlow
every { bluetoothRepository.state } returns bluetoothStateFlow.asStateFlow()
bluetoothStateFlow.value = BluetoothState(enabled = true, hasPermissions = true)
}
@Test
fun `connect attempts to scan and connect via init`() = runTest {
val device: BleDevice = mockk()
every { device.address } returns address
every { device.name } returns "Test Device"
every { scanner.scan(any(), any()) } returns flowOf(device)
coEvery { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Connected
val bleInterface =
BleRadioInterface(
serviceScope = testScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// init starts connect() which is async
// We can wait for the coEvery to be triggered if needed,
// but for a basic test this confirms it doesn't crash on init.
}
@Test
fun `address returns correct value`() {
val bleInterface =
BleRadioInterface(
serviceScope = testScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
assertEquals(address, bleInterface.address)
}
}

View file

@ -1,310 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import io.mockk.clearMocks
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.ConnectionResult
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.client.mock.ReadResponse
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_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 org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceRetryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val address = "00:11:22:33:44:55"
@Before
fun setup() {
Logger.setLogWriters(
object : co.touchlab.kermit.LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
println("[$severity] $tag: $message")
throwable?.printStackTrace()
}
},
)
}
@Test
fun `write succeeds after one retry`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var toRadioHandle: Int = -1
var writeAttempts = 0
var writtenValue: ByteArray? = null
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
if (characteristic.instanceId == toRadioHandle) {
writeAttempts++
if (writeAttempts == 1) {
println("Simulating first write failure")
throw RuntimeException("Temporary failure")
}
println("Second write attempt succeeding")
writtenValue = value
}
}
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
ReadResponse.Success(byteArrayOf())
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_Retry")
}
connectable(
name = "Meshtastic_Retry",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
),
permission = Permission.WRITE,
)
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// Wait for connection and stable state
advanceUntilIdle()
verify(timeout = 5000) { service.onConnect() }
// Clear initial discovery errors if any (sometimes mock emits empty list initially)
clearMocks(service, answers = false, recordedCalls = true)
// Test writing
val dataToSend = byteArrayOf(0x01, 0x02, 0x03)
nordicInterface.handleSendToRadio(dataToSend)
// Give it time to process retries
advanceUntilIdle()
assert(writeAttempts == 2) { "Should have attempted write twice, but was $writeAttempts" }
assert(writtenValue != null) { "Value should have been eventually written" }
assert(writtenValue!!.contentEquals(dataToSend))
// Verify we didn't disconnect due to the retryable error
verify(exactly = 0) { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
@Test
fun `write fails after max retries`() = runTest(testDispatcher) {
val uniqueAddress = "11:22:33:44:55:66"
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var toRadioHandle: Int = -1
var writeAttempts = 0
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
if (characteristic.instanceId == toRadioHandle) {
writeAttempts++
println("Simulating write failure #$writeAttempts")
throw RuntimeException("Persistent failure")
}
}
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
ReadResponse.Success(byteArrayOf())
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = uniqueAddress, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_Fail")
}
connectable(
name = "Meshtastic_Fail",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
),
permission = Permission.WRITE,
)
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = uniqueAddress,
)
// Wait for connection
advanceUntilIdle()
verify(timeout = 5000) { service.onConnect() }
// Clear initial discovery errors
clearMocks(service, answers = false, recordedCalls = true)
// Trigger write which will fail repeatedly
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
// Wait for all attempts
advanceUntilIdle()
assert(writeAttempts == 3) {
"Should have attempted write 3 times (initial + 2 retries), but was $writeAttempts"
}
// Verify onDisconnect was called after retries exhausted
// Nordic BLE wraps RuntimeException in BluetoothException
verify { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
}

View file

@ -1,758 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.ConnectionResult
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.client.mock.ReadResponse
import no.nordicsemi.kotlin.ble.client.mock.WriteResponse
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.core.and
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Before
import org.junit.Test
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 org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val address = "00:11:22:33:44:55"
@Before
fun setup() {
Logger.setLogWriters(
object : co.touchlab.kermit.LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
println("[$severity] $tag: $message")
throwable?.printStackTrace()
}
},
)
}
@Test
fun `full connection and notification flow`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var fromNumHandle: Int = -1
var logRadioHandle: Int = -1
var fromRadioHandle: Int = -1
var fromRadioValue: ByteArray = byteArrayOf()
lateinit var otaPeripheral: PeripheralSpec<String>
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onWriteRequest(
characteristic: MockRemoteCharacteristic,
value: ByteArray,
): WriteResponse = WriteResponse.Success
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse {
if (characteristic.instanceId == fromRadioHandle) {
return ReadResponse.Success(fromRadioValue)
}
return ReadResponse.Success(byteArrayOf())
}
}
otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_1234")
}
connectable(
name = "Meshtastic_1234",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
fromNumHandle =
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
fromRadioHandle =
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
logRadioHandle =
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
println("Bonded peripherals: ${centralManager.getBondedPeripherals().size}")
centralManager.getBondedPeripherals().forEach { println("Found bonded peripheral: ${it.address}") }
// Give it a moment to stabilize
advanceUntilIdle()
// Create the interface
println("Creating NordicBleInterface")
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// Wait for connection and discovery
println("Waiting for connection...")
advanceUntilIdle()
println("Verifying onConnect...")
verify(timeout = 5000) { service.onConnect() }
println("onConnect verified.")
// Set data available on fromRadio BEFORE notifying fromNum
fromRadioValue = byteArrayOf(0xCA.toByte(), 0xFE.toByte())
// Simulate a notification from fromNum (indicates there are packets to read)
otaPeripheral.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01))
// Wait for drain to start
advanceUntilIdle()
// Simulate a log radio notification
val logData = "test log".toByteArray()
otaPeripheral.simulateValueUpdate(logRadioHandle, logData)
advanceUntilIdle()
// Explicitly stub handleFromRadio just in case relaxed mock fails
io.mockk.every { service.handleFromRadio(any()) } returns Unit
// Verify that handleFromRadio was called (any arguments) with timeout
verify(timeout = 2000) { service.handleFromRadio(any()) }
nordicInterface.close()
}
@Test
fun `handleSendToRadio writes to toRadioCharacteristic`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var toRadioHandle: Int = -1
var writtenValue: ByteArray? = null
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onWriteRequest(
characteristic: MockRemoteCharacteristic,
value: ByteArray,
): WriteResponse {
// Keep this for WITH_RESPONSE
println("onWriteRequest: charId=${characteristic.instanceId}, toRadioHandle=$toRadioHandle")
if (characteristic.instanceId == toRadioHandle) {
writtenValue = value
}
return WriteResponse.Success
}
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
// This is for WITHOUT_RESPONSE
println("onWriteCommand: charId=${characteristic.instanceId}, toRadioHandle=$toRadioHandle")
if (characteristic.instanceId == toRadioHandle) {
println("onWriteCommand matched! value=${value.toHexString()}")
writtenValue = value
} else {
println("onWriteCommand mismatch.")
}
}
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
ReadResponse.Success(byteArrayOf())
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_1234")
}
connectable(
name = "Meshtastic_1234",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
),
permission = Permission.WRITE,
)
.also {
println("Captured toRadioHandle: $it")
// toRadioHandle is assigned by the expression itself
}
// Add other required chars to avoid discovery failure
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// Wait for connection
advanceUntilIdle()
verify(timeout = 2000) { service.onConnect() }
// Test writing
val dataToSend = byteArrayOf(0x01, 0x02, 0x03)
nordicInterface.handleSendToRadio(dataToSend)
// Give it time to process
advanceUntilIdle()
assert(writtenValue != null) { "Value should have been written" }
assert(writtenValue!!.contentEquals(dataToSend)) {
"Written value ${writtenValue?.contentToString()} does not match expected ${dataToSend.contentToString()}"
}
nordicInterface.close()
}
@Test
fun `disconnection triggers onDisconnect`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
// Explicitly stub handleFromRadio just in case
io.mockk.every { service.handleFromRadio(any()) } returns Unit
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
// Minimal implementation for connection test
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
ReadResponse.Success(byteArrayOf())
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_1234")
}
connectable(
name = "Meshtastic_1234",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
),
permission = Permission.WRITE,
)
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// Wait for connection
advanceUntilIdle()
verify(timeout = 2000) { service.onConnect() }
// Find the connected peripheral from CentralManager to trigger disconnect
val connectedPeripheral = centralManager.getBondedPeripherals().first { it.address == address }
println("Simulating disconnect via peripheral.disconnect()")
connectedPeripheral.disconnect()
// Wait for disconnect event propagation
advanceUntilIdle()
// Verify onDisconnect was called on the service
verify { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
@Test
fun `discovery fails if required characteristic missing`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
io.mockk.every { service.handleFromRadio(any()) } returns Unit
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
ReadResponse.Success(byteArrayOf())
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_1234")
}
connectable(
name = "Meshtastic_1234",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
// OMIT toRadio characteristic to force failure
/*
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.WRITE, CharacteristicProperty.WRITE_WITHOUT_RESPONSE),
permission = Permission.WRITE
)
*/
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// Wait for connection and eventual failure
advanceUntilIdle()
// Verify that discovery failed
verify { service.onDisconnect(false, "Required characteristic missing") }
nordicInterface.close()
}
@Test
fun `write exception triggers disconnect`() = runTest(testDispatcher) {
val uniqueAddress = "11:22:33:44:55:66"
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
io.mockk.every { service.handleFromRadio(any()) } returns Unit
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
ReadResponse.Success(byteArrayOf())
// Throw exception on write
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray): Unit =
throw RuntimeException("Simulated write failure")
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = uniqueAddress, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_1234")
}
connectable(
name = "Meshtastic_1234",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
),
permission = Permission.WRITE,
)
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = uniqueAddress,
)
// Wait for connection
advanceUntilIdle()
verify(timeout = 2000) { service.onConnect() }
// Trigger write which will fail
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
// Wait for error propagation (retries take time!)
// 3 attempts with 500ms delay between them = ~1000ms+
advanceUntilIdle()
// Verify onDisconnect was called with error
verify { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
@Test
fun `fromRadioSync flow prefers Indicate characteristic`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var syncCharHandle: Int = -1
val payload = byteArrayOf(0xDE.toByte(), 0xAD.toByte())
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
ConnectionResult.Accept
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
ReadResponse.Success(byteArrayOf())
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_Sync")
}
connectable(
name = "Meshtastic_Sync",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.WRITE),
permission = Permission.WRITE,
)
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
// NEW: Provide the Sync characteristic
syncCharHandle =
Characteristic(
uuid = FROMRADIOSYNC_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.INDICATE),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// Wait for connection and discovery
advanceUntilIdle()
verify(timeout = 2000) { service.onConnect() }
// Simulate an indication from FROMRADIOSYNC
peripheralSpec.simulateValueUpdate(syncCharHandle, payload)
advanceUntilIdle()
// Verify handleFromRadio was called directly with the payload
verify(timeout = 2000) { service.handleFromRadio(payload) }
nordicInterface.close()
}
}

View file

@ -59,7 +59,6 @@ kotlin {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.zxing.core)
implementation(libs.nordic.common.core)
}
commonTest.dependencies {

View file

@ -16,20 +16,40 @@
*/
package org.meshtastic.core.ui.component
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import no.nordicsemi.android.common.core.registerReceiver
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun rememberTimeTickWithLifecycle(): Long {
val context = LocalContext.current
var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
registerReceiver(IntentFilter(Intent.ACTION_TIME_TICK)) { value = System.currentTimeMillis() }
DisposableEffect(context) {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
value = System.currentTimeMillis()
}
}
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(Intent.ACTION_TIME_TICK),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { context.unregisterReceiver(receiver) }
}
return value
}

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

@ -14,9 +14,8 @@
* 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
package org.meshtastic.desktop.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@ -28,11 +27,10 @@ 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.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 Kable for desktop.
*
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
* - Bonding and discovery.
@ -70,8 +68,9 @@ private val SCAN_TIMEOUT = 5.seconds
* @param service The [RadioInterfaceService] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
@SuppressLint("MissingPermission")
class NordicBleInterface(
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@Suppress("TooManyFunctions", "TooGenericExceptionCaught", "SwallowedException")
class DesktopBleInterface(
private val serviceScope: CoroutineScope,
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
@ -94,7 +93,9 @@ class NordicBleInterface(
}
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
CoroutineScope(
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
@ -121,8 +122,15 @@ 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(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)
@ -158,6 +166,9 @@ class NordicBleInterface(
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" }
@ -169,8 +180,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 +212,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 +238,7 @@ class NordicBleInterface(
.launchIn(this)
// Store reference for handleSendToRadio
this@NordicBleInterface.radioService = radioService
this@DesktopBleInterface.radioService = radioService
Logger.i { "[$address] Profile service active and characteristics subscribed" }
@ -237,7 +246,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@DesktopBleInterface.service.onConnect()
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
@ -246,7 +255,7 @@ class NordicBleInterface(
}
}
private var radioService: MeshtasticRadioProfile.State? = null
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
// --- RadioTransport Implementation ---
@ -325,16 +334,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

@ -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())

View file

@ -1,30 +1,31 @@
# Decision: BLE KMP Strategy
> Date: 2026-03-10 | Status: **Decided — Phase 1 complete**
> Date: 2026-03-16 | Status: **Decided — Fully Migrated to Kable**
## Context
`core:ble` needed to support non-Android targets. Nordic's KMM-BLE-Library is Android/iOS only (no Desktop/Web). KABLE supports all KMP targets but lacks mock modules.
`core:ble` needed to support non-Android targets. Nordic's Kotlin-BLE-Library, while mature on Android and actively tested in the app, was primarily Android/iOS focused and lacked support for Desktop (JVM) targets. Kable natively supports all Kotlin Multiplatform targets (Android, Apple, Desktop/JVM, Web).
Initially, we implemented an **Interface-Driven "Nordic Hybrid" Abstraction** (keeping Nordic on Android behind `commonMain` interfaces) to wait and see if Nordic expanded their KMP support.
However, as Desktop integration advanced, we found the need for a unified BLE transport.
## Decision
**Interface-Driven "Nordic Hybrid" Abstraction:**
**Migrate entirely to Kable:**
- `commonMain`: Pure Kotlin interfaces (`BleConnection`, `BleScanner`, `BleDevice`, `BleConnectionFactory`, etc.) — zero platform imports
- `androidMain`: Nordic KMM-BLE-Library implementations behind those interfaces
- `jvm()` target added — interfaces compile fine; no JVM BLE implementation needed yet
- Future: KABLE or alternative can implement the same interfaces for Desktop/iOS without touching core logic
**BLE library decision: Stay on Nordic, wait.** Our abstraction layer is clean — switching backends later is a bounded, mechanical task (~6 files, ~400 lines). Nordic is actively developing. We don't currently need real BLE on JVM/iOS. If Nordic hasn't shipped KMP by the time we need iOS, revisit KABLE.
- We migrated all BLE transport logic across Android and Desktop to use Kable.
- The `commonMain` interfaces (`BleConnection`, `BleScanner`, `BleDevice`, `BluetoothRepository`, etc.) remain, but their core implementations (`KableBleConnection`, `KableBleScanner`) are now entirely shared in `commonMain`.
- The Android-specific Nordic dependencies (`no.nordicsemi.kotlin.ble:*`) and the Nordic DFU library were completely excised from the project.
- OTA Firmware updates on Android were successfully refactored to use the Kable-based `BleOtaTransport`.
## Consequences
- `core:ble` compiles on JVM and is included in CI smoke compile
- No Nordic types leak into `commonMain`
- Desktop simply doesn't inject BLE bindings
- Migration cost to KABLE is predictable and bounded
- **Maximal Code Deduplication:** The BLE implementation is completely shared across Android and Desktop in `core:ble/commonMain`.
- **Future-Proofing:** Adding an `iosMain` target in the future will be trivial, as it can leverage the same shared Kable abstractions.
- **Lost Nordic Mocks:** Kable lacks the comprehensive mock infrastructure of the Nordic library. Consequently, several complex BLE OTA unit tests had to be deprecated. Re-establishing this test coverage using custom Kable fakes is an ongoing technical debt item.
## Archive
Full analysis: [`archive/ble-kmp-strategy.md`](../archive/ble-kmp-strategy.md)
- Original Hybrid Analysis: [`archive/ble-kmp-strategy.md`](../archive/ble-kmp-strategy.md)
- Original Abstraction Plan: [`archive/ble-kmp-abstraction-plan.md`](../archive/ble-kmp-abstraction-plan.md)

View file

@ -29,7 +29,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
| `core:prefs` | ✅ | ✅ | Preferences layer |
| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport` |
| `core:data` | ✅ | ✅ | Data orchestration |
| `core:ble` | ✅ | ✅ | BLE abstractions in commonMain; Nordic in androidMain |
| `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain |
| `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain |
| `core:service` | ✅ | ✅ | Service layer; Android bindings in androidMain |
| `core:ui` | ✅ | ✅ | Shared Compose UI, `jvmAndroidMain` + `jvmMain` actuals |
@ -103,7 +103,7 @@ Based on the latest codebase investigation, the following steps are proposed to
|---|---|---|
| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared enum + parity tests. See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) |
| Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) |
| BLE abstraction (Nordic Hybrid) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) |
| BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) |
| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-alpha04` |
| JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime |
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
@ -141,7 +141,7 @@ Extracted to shared `commonMain` (no longer app-only):
| Koin | `4.2.0-RC2` | Nav3 + K2 compiler plugin support |
| JetBrains Lifecycle | `2.10.0-beta01` | Multiplatform ViewModel/lifecycle |
| JetBrains Navigation 3 | `1.1.0-alpha04` | Multiplatform navigation |
| Nordic BLE | `2.0.0-alpha16` | Behind abstraction boundary |
| Kable BLE | `0.42.0` | Provides fully multiplatform BLE support |
**Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features.

View file

@ -75,12 +75,11 @@ fun CurrentlyConnectedInfo(
while (bleDevice.device.isConnected) {
try {
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
delay(RSSI_DELAY.seconds)
} catch (e: Exception) {
// RSSI reading failures are common when disconnecting; log as warning to avoid Crashlytics noise
Logger.w(e) { "Failed to read RSSI ${e.message}" }
break
// RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise
Logger.d(e) { "Failed to read RSSI ${e.message}" }
}
delay(RSSI_DELAY.seconds)
}
}
}

View file

@ -30,7 +30,7 @@ The `:feature:firmware` module provides a unified interface for updating Meshtas
Meshtastic-Android supports three primary firmware update flows:
#### 1. ESP32 Unified OTA (WiFi & BLE)
Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Nordic Semiconductor Kotlin-BLE-Library** for architectural consistency and modern coroutine support.
Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Kable** multiplatform library for architectural consistency and modern coroutine support.
**Key Features:**
- **Pre-shared Hash Verification**: The app sends the firmware SHA256 hash in an initial `AdminMessage` trigger. The device stores this in NVS and verifies the incoming stream against it.
@ -102,5 +102,5 @@ sequenceDiagram
- `UpdateHandler.kt`: Entry point for choosing the correct handler.
- `Esp32OtaUpdateHandler.kt`: Orchestrates the Unified OTA flow.
- `WifiOtaTransport.kt`: Implements the TCP/UDP transport logic for ESP32.
- `BleOtaTransport.kt`: Implements the BLE transport logic for ESP32 using the Nordic BLE library.
- `BleOtaTransport.kt`: Implements the BLE transport logic for ESP32 using the Kable BLE library.
- `FirmwareRetriever.kt`: Handles downloading and extracting firmware assets (ZIP/BIN/UF2).

View file

@ -48,6 +48,7 @@ kotlin {
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(libs.kable.core)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kermit)
@ -64,31 +65,26 @@ kotlin {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.navigation.common)
implementation(libs.nordic.dfu)
implementation(libs.coil)
implementation(libs.coil.network.okhttp)
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
// DFU / Nordic specific dependencies
implementation(libs.nordic.client.android)
implementation(libs.nordic.dfu)
}
commonTest.dependencies { implementation(projects.core.testing) }
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.mockk)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.test.ext.junit)
implementation(libs.nordic.client.android.mock)
implementation(libs.nordic.client.core.mock)
implementation(libs.nordic.core.mock)
val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.mockk)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.test.ext.junit)
}
}
}
}

View file

@ -24,7 +24,6 @@ import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import java.io.File
class FirmwareRetrieverTest {
@ -41,7 +40,7 @@ class FirmwareRetrieverTest {
architecture = "esp32-s3",
hasMui = false,
)
val expectedFile = File("firmware-heltec-v3-2.5.0.bin")
val expectedFile = "firmware-heltec-v3-2.5.0.bin"
// Generic fast OTA check fails
coEvery { fileHandler.checkUrlExists(match { it.contains("mt-esp32s3-ota.bin") }) } returns false
@ -51,7 +50,7 @@ class FirmwareRetrieverTest {
// Board-specific check succeeds
coEvery { fileHandler.checkUrlExists(match { it.contains("firmware-heltec-v3") }) } returns true
coEvery { fileHandler.downloadFile(any(), "firmware-heltec-v3-2.5.0.bin", any()) } returns expectedFile
coEvery { fileHandler.extractFirmware(any<File>(), any(), any(), any()) } returns null
coEvery { fileHandler.extractFirmwareFromZip(any<String>(), any(), any(), any()) } returns null
val result = retriever.retrieveEsp32Firmware(release, hardware) {}
@ -70,7 +69,7 @@ class FirmwareRetrieverTest {
fun `retrieveEsp32Firmware uses Unified OTA path for ESP32`() = runTest {
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip")
val hardware = DeviceHardware(hwModelSlug = "TLORA_V2", platformioTarget = "tlora-v2", architecture = "esp32")
val expectedFile = File("mt-esp32-ota.bin")
val expectedFile = "mt-esp32-ota.bin"
coEvery { fileHandler.checkUrlExists(any()) } returns true
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
@ -89,7 +88,7 @@ class FirmwareRetrieverTest {
fun `retrieveOtaFirmware uses correct zip extension for NRF52`() = runTest {
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip")
val hardware = DeviceHardware(hwModelSlug = "RAK4631", platformioTarget = "rak4631", architecture = "nrf52840")
val expectedFile = File("firmware-rak4631-2.5.0-ota.zip")
val expectedFile = "firmware-rak4631-2.5.0-ota.zip"
coEvery { fileHandler.checkUrlExists(any()) } returns true
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
@ -113,7 +112,7 @@ class FirmwareRetrieverTest {
platformioTarget = "rak4631_nomadstar_meteor_pro",
architecture = "nrf52840",
)
val expectedFile = File("firmware-rak4631_nomadstar_meteor_pro-2.5.0-ota.zip")
val expectedFile = "firmware-rak4631_nomadstar_meteor_pro-2.5.0-ota.zip"
coEvery { fileHandler.checkUrlExists(any()) } returns true
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
@ -133,7 +132,7 @@ class FirmwareRetrieverTest {
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/stm32.zip")
val hardware =
DeviceHardware(hwModelSlug = "ST_GENERIC", platformioTarget = "stm32-generic", architecture = "stm32")
val expectedFile = File("firmware-stm32-generic-2.5.0-ota.zip")
val expectedFile = "firmware-stm32-generic-2.5.0-ota.zip"
coEvery { fileHandler.checkUrlExists(any()) } returns true
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
@ -152,7 +151,7 @@ class FirmwareRetrieverTest {
fun `retrieveUsbFirmware uses correct uf2 extension for RP2040`() = runTest {
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/rp2040.zip")
val hardware = DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040")
val expectedFile = File("firmware-pico-2.5.0.uf2")
val expectedFile = "firmware-pico-2.5.0.uf2"
coEvery { fileHandler.checkUrlExists(any()) } returns true
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
@ -172,7 +171,7 @@ class FirmwareRetrieverTest {
fun `retrieveUsbFirmware uses correct uf2 extension for NRF52`() = runTest {
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip")
val hardware = DeviceHardware(hwModelSlug = "T_ECHO", platformioTarget = "t-echo", architecture = "nrf52840")
val expectedFile = File("firmware-t-echo-2.5.0.uf2")
val expectedFile = "firmware-t-echo-2.5.0.uf2"
coEvery { fileHandler.checkUrlExists(any()) } returns true
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile

View file

@ -0,0 +1,86 @@
/*
* 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.feature.firmware.ota
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
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
@OptIn(ExperimentalCoroutinesApi::class)
class BleOtaTransportTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val scanner: BleScanner = mockk()
private val connectionFactory: BleConnectionFactory = mockk()
private val connection: BleConnection = mockk()
private val address = "00:11:22:33:44:55"
private lateinit var transport: BleOtaTransport
@Before
fun setup() {
every { connectionFactory.create(any(), any()) } returns connection
every { connection.connectionState } returns MutableSharedFlow(replay = 1)
transport =
BleOtaTransport(
scanner = scanner,
connectionFactory = connectionFactory,
address = address,
dispatcher = testDispatcher,
)
}
@Test
fun `connect throws when device not found`() = runTest(testDispatcher) {
every { scanner.scan(any(), any()) } returns flowOf()
val result = transport.connect()
assertTrue("Expected failure", result.isFailure)
assertTrue(result.exceptionOrNull() is OtaProtocolException.ConnectionFailed)
}
@Test
fun `connect fails when connection state is disconnected`() = runTest(testDispatcher) {
val device: BleDevice = mockk()
every { device.address } returns address
every { device.name } returns "Test Device"
every { scanner.scan(any(), any()) } returns flowOf(device)
coEvery { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Disconnected
val result = transport.connect()
assertTrue("Expected failure", result.isFailure)
assertTrue(result.exceptionOrNull() is OtaProtocolException.ConnectionFailed)
}
}

View file

@ -30,6 +30,8 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.RadioController
@ -84,22 +86,23 @@ class Esp32OtaUpdateHandlerTest {
val release = FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = "")
val hardware = DeviceHardware(hwModelSlug = "V3", architecture = "esp32")
val target = "00:11:22:33:44:55"
val uri: Uri = mockk()
val platformUri: Uri = mockk()
val commonUri: CommonUri = mockk()
mockkStatic("org.meshtastic.core.common.util.CommonUri_androidKt")
every { commonUri.toPlatformUri() } returns platformUri
every { context.contentResolver } returns contentResolver
every { contentResolver.openInputStream(uri) } throws IOException("Read error")
every { contentResolver.openInputStream(platformUri) } throws IOException("Read error")
val states = mutableListOf<FirmwareUpdateState>()
handler.startUpdate(release, hardware, target, { states.add(it) }, uri)
// Before fix, this would be FirmwareUpdateState.Error("Could not retrieve firmware file.")
// After fix, it should ideally contain "Read error" or be the original exception if we don't catch it too
// early.
// Esp32OtaUpdateHandler.performUpdate catches Exception and uses e.message.
handler.startUpdate(release, hardware, target, { states.add(it) }, commonUri)
val lastState = states.last()
assert(lastState is FirmwareUpdateState.Error)
assertEquals("OTA update failed: Read error", (lastState as FirmwareUpdateState.Error).error)
unmockkStatic("org.meshtastic.core.common.util.CommonUri_androidKt")
}
}

View file

@ -47,6 +47,7 @@ private const val PERCENT_MAX = 100
private const val PREPARE_DATA_DELAY = 400L
/** Handles Over-the-Air (OTA) firmware updates for nRF52-based devices using the Nordic DFU library. */
@Deprecated("Use KableNordicDfuHandler instead")
@Single
class NordicDfuHandler(
private val firmwareRetriever: FirmwareRetriever,

View file

@ -17,6 +17,7 @@
package org.meshtastic.feature.firmware.ota
import co.touchlab.kermit.Logger
import com.juul.kable.characteristicOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@ -30,25 +31,18 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import org.meshtastic.core.ble.AndroidBleService
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.KableBleService
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
import kotlin.time.Duration.Companion.seconds
/**
* BLE transport implementation for ESP32 Unified OTA protocol.
*
* Service UUID: 4FAFC201-1FB5-459E-8FCC-C5C9C331914B
* - OTA Characteristic (Write): 62ec0272-3ec5-11eb-b378-0242ac130005
* - TX Characteristic (Notify): 62ec0272-3ec5-11eb-b378-0242ac130003
*/
/** BLE transport implementation for ESP32 Unified OTA protocol using Kable. */
class BleOtaTransport(
private val scanner: BleScanner,
connectionFactory: BleConnectionFactory,
@ -58,15 +52,16 @@ class BleOtaTransport(
private val transportScope = CoroutineScope(SupervisorJob() + dispatcher)
private val bleConnection = connectionFactory.create(transportScope, "BLE OTA")
private var otaCharacteristic: RemoteCharacteristic? = null
private val otaChar = characteristicOf(OTA_SERVICE_UUID, OTA_WRITE_CHARACTERISTIC)
private val txChar = characteristicOf(OTA_SERVICE_UUID, OTA_NOTIFY_CHARACTERISTIC)
private val responseChannel = Channel<String>(Channel.UNLIMITED)
private var isConnected = false
/** Scan for the device by MAC address with retries. After reboot, the device needs time to come up in OTA mode. */
/** Scan for the device by MAC address with retries. */
private suspend fun scanForOtaDevice(): BleDevice? {
// ESP32 OTA bootloader may use MAC address with last byte incremented by 1
val otaAddress = calculateOtaAddress(macAddress = address)
val targetAddresses = setOf(address, otaAddress)
Logger.i { "BLE OTA: Will match addresses: $targetAddresses" }
@ -77,7 +72,7 @@ class BleOtaTransport(
val foundDevices = mutableSetOf<String>()
val device =
scanner
.scan(SCAN_TIMEOUT)
.scan(timeout = SCAN_TIMEOUT, serviceUuid = OTA_SERVICE_UUID)
.onEach { d ->
if (foundDevices.add(d.address)) {
Logger.d { "BLE OTA: Scan found device: ${d.address} (name=${d.name})" }
@ -100,11 +95,7 @@ class BleOtaTransport(
return null
}
/**
* Calculate the potential OTA MAC address by incrementing the last byte. Some ESP32 bootloaders use MAC+1 for OTA
* mode to distinguish from normal operation.
*/
@Suppress("MagicNumber", "ReturnCount")
@Suppress("ReturnCount", "MagicNumber")
private fun calculateOtaAddress(macAddress: String): String {
val parts = macAddress.split(":")
if (parts.size != 6) return macAddress
@ -114,13 +105,12 @@ class BleOtaTransport(
return parts.take(5).joinToString(":") + ":" + incrementedByte
}
/** Connect to the device and discover OTA service. */
@Suppress("LongMethod")
@Suppress("MagicNumber")
override suspend fun connect(): Result<Unit> = runCatching {
Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." }
delay(REBOOT_DELAY_MS)
Logger.i { "BLE OTA: Connecting to $address using Nordic BLE Library..." }
Logger.i { "BLE OTA: Connecting to $address using Kable..." }
val device =
scanForOtaDevice()
@ -149,19 +139,9 @@ class BleOtaTransport(
Logger.i { "BLE OTA: Connected to ${device.address}, discovering services..." }
// Discover services using our unified profile helper
bleConnection.profile(OTA_SERVICE_UUID) { service ->
val androidService = (service as AndroidBleService).service
val ota =
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_WRITE_CHARACTERISTIC }) {
"OTA characteristic not found"
}
val txChar =
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_NOTIFY_CHARACTERISTIC }) {
"TX characteristic not found"
}
otaCharacteristic = ota
val kableService = service as KableBleService
val peripheral = kableService.peripheral
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
@ -169,13 +149,14 @@ class BleOtaTransport(
// Enable notifications and collect responses
val subscribed = CompletableDeferred<Unit>()
txChar
.subscribe {
Logger.d { "BLE OTA: TX characteristic subscribed" }
subscribed.complete(Unit)
}
peripheral
.observe(txChar)
.onEach { notifyBytes ->
try {
if (!subscribed.isCompleted) {
Logger.d { "BLE OTA: TX characteristic subscribed" }
subscribed.complete(Unit)
}
val response = notifyBytes.decodeToString()
Logger.d { "BLE OTA: Received response: $response" }
responseChannel.trySend(response)
@ -189,12 +170,17 @@ class BleOtaTransport(
}
.launchIn(this)
// Kable's observe doesn't provide a way to know when subscription is finished,
// but usually first value or just waiting a bit works.
// For Meshtastic, it might not emit immediately.
delay(500)
if (!subscribed.isCompleted) subscribed.complete(Unit)
subscribed.await()
Logger.i { "BLE OTA: Service discovered and ready" }
}
}
/** Initiates the OTA update by sending the size and hash. */
override suspend fun startOta(
sizeBytes: Long,
sha256Hash: String,
@ -214,19 +200,16 @@ class BleOtaTransport(
handshakeComplete = true
}
}
is OtaResponse.Erasing -> {
Logger.i { "BLE OTA: Device erasing flash..." }
onHandshakeStatus(OtaHandshakeStatus.Erasing)
}
is OtaResponse.Error -> {
if (parsed.message.contains("Hash Rejected", ignoreCase = true)) {
throw OtaProtocolException.HashRejected(sha256Hash)
}
throw OtaProtocolException.CommandFailed(command, parsed)
}
else -> {
Logger.w { "BLE OTA: Unexpected handshake response: $response" }
}
@ -234,7 +217,7 @@ class BleOtaTransport(
}
}
/** Streams the firmware data in chunks. */
@Suppress("MagicNumber")
override suspend fun streamFirmware(
data: ByteArray,
chunkSize: Int,
@ -252,20 +235,15 @@ class BleOtaTransport(
val currentChunkSize = minOf(chunkSize, remainingBytes)
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
// Write chunk
val packetsSentForChunk = writeData(chunk, BleWriteType.WITHOUT_RESPONSE)
// Wait for responses
val nextSentBytes = sentBytes + currentChunkSize
repeat(packetsSentForChunk) { i ->
val response = waitForResponse(ACK_TIMEOUT_MS)
val isLastPacketOfChunk = i == packetsSentForChunk - 1
when (val parsed = OtaResponse.parse(response)) {
is OtaResponse.Ack -> {
// Normal packet success
}
is OtaResponse.Ack -> {}
is OtaResponse.Ok -> {
if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
sentBytes = nextSentBytes
@ -273,14 +251,12 @@ class BleOtaTransport(
return@runCatching Unit
}
}
is OtaResponse.Error -> {
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
}
throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
}
else -> throw OtaProtocolException.TransferFailed("Unexpected response: $response")
}
}
@ -298,7 +274,6 @@ class BleOtaTransport(
}
throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}")
}
else -> throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $parsed")
}
}
@ -315,9 +290,6 @@ class BleOtaTransport(
}
private suspend fun writeData(data: ByteArray, writeType: BleWriteType): Int {
val characteristic =
otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available")
val maxLen = bleConnection.maximumWriteValueLength(writeType) ?: data.size
var offset = 0
var packetsSent = 0
@ -327,13 +299,17 @@ class BleOtaTransport(
val chunkSize = minOf(data.size - offset, maxLen)
val packet = data.copyOfRange(offset, offset + chunkSize)
val nordicWriteType =
val kableWriteType =
when (writeType) {
BleWriteType.WITH_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITH_RESPONSE
BleWriteType.WITHOUT_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITHOUT_RESPONSE
BleWriteType.WITH_RESPONSE -> com.juul.kable.WriteType.WithResponse
BleWriteType.WITHOUT_RESPONSE -> com.juul.kable.WriteType.WithoutResponse
}
characteristic.write(packet, writeType = nordicWriteType)
bleConnection.profile(OTA_SERVICE_UUID) { service ->
val peripheral = (service as KableBleService).peripheral
peripheral.write(otaChar, packet, kableWriteType)
}
offset += chunkSize
packetsSent++
}
@ -350,17 +326,14 @@ class BleOtaTransport(
}
companion object {
// Timeouts and retries
private val SCAN_TIMEOUT = 10.seconds
private const val CONNECTION_TIMEOUT_MS = 15_000L
private const val ERASING_TIMEOUT_MS = 60_000L
private const val ACK_TIMEOUT_MS = 10_000L
private const val VERIFICATION_TIMEOUT_MS = 10_000L
private const val REBOOT_DELAY_MS = 5_000L
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 2_000L
const val RECOMMENDED_CHUNK_SIZE = 512
}
}

View file

@ -1,277 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.ota
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
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.ConnectionResult
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.client.mock.WriteResponse
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.core.and
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.Uuid
private val serviceUuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
private val otaCharacteristicUuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
private val txCharacteristicUuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
@OptIn(ExperimentalCoroutinesApi::class)
class BleOtaTransportErrorTest {
private val testDispatcher = StandardTestDispatcher()
private val address = "00:11:22:33:44:55"
@Test
fun `startOta fails when device rejects hash`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
lateinit var otaPeripheral: PeripheralSpec<String>
var txCharHandle: Int = -1
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
ConnectionResult.Accept
override fun onWriteRequest(
characteristic: MockRemoteCharacteristic,
value: ByteArray,
): WriteResponse {
val command = value.decodeToString()
if (command.startsWith("OTA")) {
backgroundScope.launch {
delay(50.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Rejected\n".toByteArray())
}
}
return WriteResponse.Success
}
}
otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
Service(uuid = serviceUuid) {
Characteristic(
uuid = otaCharacteristicUuid,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
txCharHandle =
Characteristic(
uuid = txCharacteristicUuid,
property = CharacteristicProperty.NOTIFY,
permission = Permission.READ,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
try {
transport.connect().getOrThrow()
val result = transport.startOta(1024, "badhash") {}
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is OtaProtocolException.HashRejected)
} finally {
transport.close()
}
}
@Test
fun `streamFirmware fails when connection lost`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
lateinit var otaPeripheral: PeripheralSpec<String>
var txCharHandle: Int = -1
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
ConnectionResult.Accept
override fun onWriteRequest(
characteristic: MockRemoteCharacteristic,
value: ByteArray,
): WriteResponse {
backgroundScope.launch {
delay(50.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
}
return WriteResponse.Success
}
}
otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
Service(uuid = serviceUuid) {
Characteristic(
uuid = otaCharacteristicUuid,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
txCharHandle =
Characteristic(
uuid = txCharacteristicUuid,
property = CharacteristicProperty.NOTIFY,
permission = Permission.READ,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
try {
transport.connect().getOrThrow()
transport.startOta(1024, "hash") {}.getOrThrow()
// Find the connected peripheral and disconnect it
// We use isBonded=true to ensure it shows up in getBondedPeripherals()
val peripheral = centralManager.getBondedPeripherals().first { it.address == address }
peripheral.disconnect()
// Wait for state propagation
delay(100.milliseconds)
val data = ByteArray(1024) { it.toByte() }
val result = transport.streamFirmware(data, 512) {}
assertTrue("Should fail due to connection loss", result.isFailure)
assertTrue(result.exceptionOrNull() is OtaProtocolException.TransferFailed)
assertTrue(result.exceptionOrNull()?.message?.contains("Connection lost") == true)
} finally {
transport.close()
}
}
@Test
fun `streamFirmware fails on hash mismatch at verification`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
lateinit var otaPeripheral: PeripheralSpec<String>
var txCharHandle: Int = -1
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
ConnectionResult.Accept
override fun onWriteRequest(
characteristic: MockRemoteCharacteristic,
value: ByteArray,
): WriteResponse {
backgroundScope.launch {
delay(50.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
}
return WriteResponse.Success
}
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
backgroundScope.launch {
delay(10.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "ACK\n".toByteArray())
}
}
}
otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
Service(uuid = serviceUuid) {
Characteristic(
uuid = otaCharacteristicUuid,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
txCharHandle =
Characteristic(
uuid = txCharacteristicUuid,
property = CharacteristicProperty.NOTIFY,
permission = Permission.READ,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
try {
transport.connect().getOrThrow()
transport.startOta(1024, "hash") {}.getOrThrow()
// Setup final response to be a Hash Mismatch error after chunks are sent
backgroundScope.launch {
delay(1000.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Mismatch\n".toByteArray())
}
val data = ByteArray(1024) { it.toByte() }
val result = transport.streamFirmware(data, 512) {}
val exception = result.exceptionOrNull()
assertTrue("Expected failure, but succeeded", result.isFailure)
assertTrue(
"Expected OtaProtocolException.VerificationFailed but got $exception",
exception is OtaProtocolException.VerificationFailed,
)
} finally {
transport.close()
}
}
}

View file

@ -1,97 +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.feature.firmware.ota
import io.mockk.coVerify
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.PeripheralSpec
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
import no.nordicsemi.kotlin.ble.client.mock.Proximity
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.core.and
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.Uuid
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
@OptIn(ExperimentalCoroutinesApi::class)
class BleOtaTransportMtuTest {
private val address = "00:11:22:33:44:55"
private val testDispatcher = UnconfinedTestDispatcher()
@Test
fun `connect requests MTU`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = spyk(CentralManager.mock(mockEnvironment, backgroundScope))
val otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(
name = "ESP32-OTA",
eventHandler = object : PeripheralSpecEventHandler {},
isBonded = true,
) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = OTA_CHARACTERISTIC_UUID,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
Characteristic(
uuid = TX_CHARACTERISTIC_UUID,
property = CharacteristicProperty.NOTIFY,
permission = Permission.READ,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
transport.connect().getOrThrow()
// Verify connect was called with automaticallyRequestHighestValueLength = true
coVerify {
centralManager.connect(
any(),
CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
}
}
}

View file

@ -1,166 +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.feature.firmware.ota
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.ConnectionResult
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.client.mock.WriteResponse
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.core.and
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.Uuid
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
@OptIn(ExperimentalCoroutinesApi::class)
class BleOtaTransportNordicMockTest {
private val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher()
private val address = "00:11:22:33:44:55"
@Before
fun setup() {
Logger.setLogWriters(
object : co.touchlab.kermit.LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
println("[$severity] $tag: $message")
throwable?.printStackTrace()
}
},
)
}
@Test
fun `full ota flow with nordic mocks`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
var txCharHandle: Int = -1
val totalExpectedBytes = AtomicLong(64) // Smaller data for faster test
val bytesReceived = AtomicLong(0)
lateinit var otaPeripheral: PeripheralSpec<String>
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onWriteRequest(
characteristic: MockRemoteCharacteristic,
value: ByteArray,
): WriteResponse {
val command = value.decodeToString()
if (command.startsWith("OTA")) {
println("Mock: Received Start OTA command: ${command.trim()}")
val parts = command.trim().split(" ")
if (parts.size >= 2) {
totalExpectedBytes.set(parts[1].toLongOrNull() ?: 64L)
}
backgroundScope.launch(testDispatcher) {
delay(50.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
}
}
return WriteResponse.Success
}
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
val currentTotal = bytesReceived.addAndGet(value.size.toLong())
val expected = totalExpectedBytes.get()
println("Mock: Received chunk size=${value.size}, total=$currentTotal/$expected")
backgroundScope.launch(testDispatcher) {
delay(5.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "ACK\n".toByteArray())
if (currentTotal >= expected && expected > 0) {
delay(10.milliseconds)
println("Mock: Sending final OK")
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
}
}
}
}
otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = OTA_CHARACTERISTIC_UUID,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
txCharHandle =
Characteristic(
uuid = TX_CHARACTERISTIC_UUID,
property = CharacteristicProperty.NOTIFY,
permission = Permission.READ,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
// 1. Connect
val connectResult = transport.connect()
assertTrue("Connection failed: ${connectResult.exceptionOrNull()}", connectResult.isSuccess)
// 2. Start OTA
val startResult = transport.startOta(totalExpectedBytes.get(), "somehash") {}
assertTrue("Start OTA failed: ${startResult.exceptionOrNull()}", startResult.isSuccess)
// 3. Stream firmware
val data = ByteArray(totalExpectedBytes.get().toInt()) { it.toByte() }
val streamResult = transport.streamFirmware(data, 20) {}
assertTrue("Stream firmware failed: ${streamResult.exceptionOrNull()}", streamResult.isSuccess)
transport.close()
}
}

View file

@ -1,217 +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.feature.firmware.ota
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
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.ConnectionResult
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.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.core.and
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.Uuid
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
/**
* Tests for BleOtaTransport service discovery via Nordic's Peripheral.profile() API. These validate the refactored
* connect() path that replaced discoverCharacteristics().
*/
@OptIn(ExperimentalCoroutinesApi::class)
class BleOtaTransportServiceDiscoveryTest {
private val testDispatcher = StandardTestDispatcher()
private val address = "00:11:22:33:44:55"
@Before
fun setup() {
Logger.setLogWriters(
object : co.touchlab.kermit.LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
println("[$severity] $tag: $message")
throwable?.printStackTrace()
}
},
)
}
@Test
fun `connect fails when OTA service not found on device`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Create a peripheral with a DIFFERENT service UUID (not the OTA service)
val wrongServiceUuid = Uuid.parse("0000180A-0000-1000-8000-00805F9B34FB") // Device Info
val otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(
name = "ESP32-OTA",
eventHandler = object : PeripheralSpecEventHandler {},
isBonded = true,
) {
Service(uuid = wrongServiceUuid) {
Characteristic(
uuid = OTA_CHARACTERISTIC_UUID,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should fail when OTA service is missing", result.isFailure)
transport.close()
}
@Test
fun `connect fails when TX characteristic is missing`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Create a peripheral with the OTA service but only the OTA characteristic (no TX)
val otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(
name = "ESP32-OTA",
eventHandler = object : PeripheralSpecEventHandler {},
isBonded = true,
) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = OTA_CHARACTERISTIC_UUID,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
// TX_CHARACTERISTIC intentionally omitted
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should fail when TX characteristic is missing", result.isFailure)
transport.close()
}
@Test
fun `connect fails when device is not found during scan`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Don't simulate any peripherals — scan will find nothing
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should fail when device is not found", result.isFailure)
val exception = result.exceptionOrNull()
assertTrue(
"Should be ConnectionFailed, got: $exception",
exception is OtaProtocolException.ConnectionFailed,
)
transport.close()
}
@Test
fun `connect succeeds with valid OTA service and characteristics`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(
name = "ESP32-OTA",
eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
},
isBonded = true,
) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = OTA_CHARACTERISTIC_UUID,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
Characteristic(
uuid = TX_CHARACTERISTIC_UUID,
property = CharacteristicProperty.NOTIFY,
permission = Permission.READ,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
val result = transport.connect()
assertTrue("Connect should succeed: ${result.exceptionOrNull()}", result.isSuccess)
transport.close()
}
}

View file

@ -1,119 +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.feature.firmware.ota
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
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.ConnectionResult
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.client.mock.WriteResponse
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.core.and
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.Uuid
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
@OptIn(ExperimentalCoroutinesApi::class)
class BleOtaTransportTest {
private val address = "00:11:22:33:44:55"
private val testDispatcher = StandardTestDispatcher()
@Test
fun `race condition check - response before waitForResponse`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
var txCharHandle: Int = -1
lateinit var otaPeripheral: PeripheralSpec<String>
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
): ConnectionResult = ConnectionResult.Accept
override fun onWriteRequest(
characteristic: MockRemoteCharacteristic,
value: ByteArray,
): WriteResponse {
// When receiving an OTA command, immediately simulate a response
backgroundScope.launch(testDispatcher) {
// Use a very small delay to simulate high speed
delay(1.milliseconds)
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
}
return WriteResponse.Success
}
}
otaPeripheral =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("ESP32-OTA")
}
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = OTA_CHARACTERISTIC_UUID,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
txCharHandle =
Characteristic(
uuid = TX_CHARACTERISTIC_UUID,
property = CharacteristicProperty.NOTIFY,
permission = Permission.READ,
)
}
}
}
centralManager.simulatePeripherals(listOf(otaPeripheral))
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
// 1. Connect
transport.connect().getOrThrow()
// 2. Start OTA - should succeed even if response is very fast
val result = transport.startOta(100L, "hash") {}
assert(result.isSuccess)
transport.close()
}
}

View file

@ -85,8 +85,6 @@ kotlin {
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
implementation(libs.nordic.common.core)
implementation(libs.nordic.common.permissions.ble)
}
commonTest.dependencies { implementation(projects.core.testing) }

View file

@ -73,8 +73,6 @@ kotlin {
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
implementation(libs.nordic.common.core)
implementation(libs.nordic.common.permissions.ble)
}
commonTest.dependencies { implementation(projects.core.testing) }

View file

@ -16,6 +16,8 @@
*/
package org.meshtastic.feature.settings.radio.component
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.compose.foundation.clickable
@ -38,6 +40,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -47,6 +50,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
@ -56,7 +60,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.common.core.registerReceiver
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.toPosixString
@ -252,10 +255,23 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
}
item {
TitledCard(title = stringResource(Res.string.time_zone)) {
val context = LocalContext.current
var appTzPosixString by remember { mutableStateOf(ZoneId.systemDefault().toPosixString()) }
registerReceiver(IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)) {
appTzPosixString = ZoneId.systemDefault().toPosixString()
DisposableEffect(context) {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
appTzPosixString = ZoneId.systemDefault().toPosixString()
}
}
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(Intent.ACTION_TIMEZONE_CHANGED),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { context.unregisterReceiver(receiver) }
}
EditTextPreference(

View file

@ -36,7 +36,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.core.location.LocationCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.permissions.ble.RequireLocation
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Position
import org.meshtastic.core.resources.Res
@ -251,16 +250,16 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
onValueChanged = { alt: Int -> locationInput = locationInput.copy(altitude = alt) },
)
HorizontalDivider()
RequireLocation { isLocationRequiredAndDisabled: Boolean ->
TextButton(
enabled = state.connected && !isLocationRequiredAndDisabled,
onClick = {
@SuppressLint("MissingPermission")
coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() }
},
) {
Text(text = stringResource(Res.string.position_config_set_fixed_from_phone))
}
// RequireLocation wrapper removed to complete Nordic removal.
// Should be replaced with a generic solution later.
TextButton(
enabled = state.connected,
onClick = {
@SuppressLint("MissingPermission")
coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() as? Location }
},
) {
Text(text = stringResource(Res.string.position_config_set_fixed_from_phone))
}
} else {
HorizontalDivider()

View file

@ -60,8 +60,8 @@ spotless = "8.3.0"
wire = "6.0.0"
vico = "3.0.3"
dependency-guard = "0.5.0"
nordic-ble = "2.0.0-alpha16"
nordic-common = "2.9.2"
kable = "0.42.0"
nordic-dfu = "2.11.0"
[libraries]
@ -213,19 +213,9 @@ markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", v
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
material = { module = "com.google.android.material:material", version = "1.13.0" }
nordic-client-android = { module = "no.nordicsemi.kotlin.ble:client-android", version.ref = "nordic-ble" }
nordic-client-android-mock = { module = "no.nordicsemi.kotlin.ble:client-android-mock", version.ref = "nordic-ble" }
nordic-client-core-mock = { module = "no.nordicsemi.kotlin.ble:client-core-mock", version.ref = "nordic-ble" }
nordic-core-mock = { module = "no.nordicsemi.kotlin.ble:core-mock", version.ref = "nordic-ble" }
nordic-dfu = { module = "no.nordicsemi.android:dfu", version = "2.11.0" }
nordic-ble-env-android = { module = "no.nordicsemi.kotlin.ble:environment-android", version.ref = "nordic-ble" }
nordic-ble-env-android-compose = { module = "no.nordicsemi.kotlin.ble:environment-android-compose", version.ref = "nordic-ble" }
nordic-dfu = { module = "no.nordicsemi.android:dfu", version.ref = "nordic-dfu" }
nordic-common-core = { module = "no.nordicsemi.android.common:core", version.ref = "nordic-common" }
nordic-common-permissions-ble = { module = "no.nordicsemi.android.common:permissions-ble", version.ref = "nordic-common" }
nordic-common-permissions-notification = { module = "no.nordicsemi.android.common:permissions-notification", version.ref = "nordic-common" }
nordic-common-scanner-ble = { module = "no.nordicsemi.android.common:scanner-ble", version.ref = "nordic-common" }
nordic-common-ui = { module = "no.nordicsemi.android.common:ui", version.ref = "nordic-common" }
kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" }
org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }