feat(ble): Add support for FromRadioSync characteristic (#4609)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-20 18:02:00 -06:00 committed by GitHub
parent 5d2336c092
commit 96d4027f74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 396 additions and 108 deletions

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
@ -71,6 +72,7 @@ open class MeshUtilApplication :
// Shutdown managers (useful for Robolectric tests)
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
entryPoint.databaseManager().close()
entryPoint.androidEnvironment().close()
applicationScope.cancel()
super.onTerminate()
}
@ -99,6 +101,8 @@ interface AppEntryPoint {
fun meshPrefs(): MeshPrefs
fun meshLogPrefs(): MeshLogPrefs
fun androidEnvironment(): AndroidEnvironment
}
fun logAssert(executeReliableWrite: Boolean) {

View file

@ -51,6 +51,7 @@ import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BleScanner
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
@ -113,6 +114,7 @@ constructor(
private var fromNumCharacteristic: RemoteCharacteristic? = null
private var fromRadioCharacteristic: RemoteCharacteristic? = null
private var logRadioCharacteristic: RemoteCharacteristic? = null
private var fromRadioSyncCharacteristic: RemoteCharacteristic? = null
init {
connect()
@ -257,13 +259,15 @@ constructor(
try {
val chars =
bleConnection.discoverCharacteristics(
SERVICE_UUID,
serviceUuid = SERVICE_UUID,
requiredUuids =
listOf(
TORADIO_CHARACTERISTIC,
FROMNUM_CHARACTERISTIC,
FROMRADIO_CHARACTERISTIC,
LOGRADIO_CHARACTERISTIC,
),
optionalUuids = listOf(FROMRADIOSYNC_CHARACTERISTIC),
)
if (chars != null) {
@ -271,6 +275,7 @@ constructor(
fromNumCharacteristic = chars[FROMNUM_CHARACTERISTIC]
fromRadioCharacteristic = chars[FROMRADIO_CHARACTERISTIC]
logRadioCharacteristic = chars[LOGRADIO_CHARACTERISTIC]
fromRadioSyncCharacteristic = chars[FROMRADIOSYNC_CHARACTERISTIC]
Logger.d { "[$address] Characteristics discovered successfully" }
setupNotifications()
@ -288,25 +293,48 @@ constructor(
// --- Notification Setup ---
@Suppress("LongMethod")
private suspend fun setupNotifications() {
val fromNumReady = CompletableDeferred<Unit>()
val fromRadioReady = CompletableDeferred<Unit>()
val logRadioReady = CompletableDeferred<Unit>()
fromNumCharacteristic
?.subscribe {
Logger.d { "[$address] FromNum subscription active" }
fromNumReady.complete(Unit)
}
?.onEach { notifyBytes ->
Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" }
connectionScope.launch { drainPacketQueueAndDispatch() }
}
?.catch { e ->
if (!fromNumReady.isCompleted) fromNumReady.completeExceptionally(e)
Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" }
service.onDisconnect(BleError.from(e))
}
?.launchIn(connectionScope) ?: fromNumReady.complete(Unit)
// 1. Prefer FromRadioSync (Indicate) if available
if (fromRadioSyncCharacteristic != null) {
Logger.i { "[$address] Using FromRadioSync for packet reception" }
fromRadioSyncCharacteristic
?.subscribe {
Logger.d { "[$address] FromRadioSync subscription active" }
fromRadioReady.complete(Unit)
}
?.onEach { payload ->
Logger.d { "[$address] FromRadioSync Indication (${payload.size} bytes)" }
dispatchPacket(payload)
}
?.catch { e ->
if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e)
Logger.w(e) { "[$address] Error in fromRadioSyncCharacteristic subscription" }
service.onDisconnect(BleError.from(e))
}
?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit)
} else {
// 2. Fallback to legacy FromNum (Notify) + FromRadio (Read)
Logger.i { "[$address] Using legacy FromNum/FromRadio for packet reception" }
fromNumCharacteristic
?.subscribe {
Logger.d { "[$address] FromNum subscription active" }
fromRadioReady.complete(Unit)
}
?.onEach { notifyBytes ->
Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" }
connectionScope.launch { drainPacketQueueAndDispatch() }
}
?.catch { e ->
if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e)
Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" }
service.onDisconnect(BleError.from(e))
}
?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit)
}
logRadioCharacteristic
?.subscribe {
@ -326,7 +354,7 @@ constructor(
try {
withTimeout(CONNECTION_TIMEOUT_MS) {
fromNumReady.await()
fromRadioReady.await()
logRadioReady.await()
}
Logger.d { "[$address] All notifications successfully subscribed" }
@ -364,7 +392,11 @@ constructor(
"to toRadioCharacteristic with $writeType - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
drainPacketQueueAndDispatch()
// Only manually drain if we are using the legacy FromNum/FromRadio flow
if (fromRadioSyncCharacteristic == null) {
drainPacketQueueAndDispatch()
}
} catch (e: InvalidAttributeException) {
Logger.w(e) { "[$address] Attribute invalidated during write, clearing characteristics" }
handleInvalidAttribute(e)
@ -415,5 +447,6 @@ constructor(
fromNumCharacteristic = null
fromRadioCharacteristic = null
logRadioCharacteristic = null
fromRadioSyncCharacteristic = null
}
}

View file

@ -22,8 +22,8 @@ import io.mockk.clearMocks
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
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
@ -139,7 +139,7 @@ class NordicBleInterfaceRetryTest {
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
delay(100.milliseconds)
advanceUntilIdle()
val nordicInterface =
NordicBleInterface(
@ -150,7 +150,7 @@ class NordicBleInterfaceRetryTest {
)
// Wait for connection and stable state
delay(2000.milliseconds)
advanceUntilIdle()
verify(timeout = 5000) { service.onConnect() }
// Clear initial discovery errors if any (sometimes mock emits empty list initially)
@ -160,8 +160,8 @@ class NordicBleInterfaceRetryTest {
val dataToSend = byteArrayOf(0x01, 0x02, 0x03)
nordicInterface.handleSendToRadio(dataToSend)
// Give it time to process retries (500ms delay per retry in code)
delay(1500.milliseconds)
// 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" }
@ -244,7 +244,7 @@ class NordicBleInterfaceRetryTest {
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
delay(100.milliseconds)
advanceUntilIdle()
val nordicInterface =
NordicBleInterface(
@ -255,7 +255,7 @@ class NordicBleInterfaceRetryTest {
)
// Wait for connection
delay(2000.milliseconds)
advanceUntilIdle()
verify(timeout = 5000) { service.onConnect() }
// Clear initial discovery errors
@ -264,8 +264,8 @@ class NordicBleInterfaceRetryTest {
// Trigger write which will fail repeatedly
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
// Wait for all 3 attempts + delays (500ms * 2)
delay(2500.milliseconds)
// Wait for all attempts
advanceUntilIdle()
assert(writeAttempts == 3) {
"Should have attempted write 3 times (initial + 2 retries), but was $writeAttempts"

View file

@ -21,8 +21,8 @@ import co.touchlab.kermit.Severity
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
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
@ -42,6 +42,7 @@ import org.junit.Before
import org.junit.Test
import org.meshtastic.core.ble.BleError
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
@ -146,7 +147,7 @@ class NordicBleInterfaceTest {
centralManager.getBondedPeripherals().forEach { println("Found bonded peripheral: ${it.address}") }
// Give it a moment to stabilize
delay(100.milliseconds)
advanceUntilIdle()
// Create the interface
println("Creating NordicBleInterface")
@ -160,7 +161,7 @@ class NordicBleInterfaceTest {
// Wait for connection and discovery
println("Waiting for connection...")
delay(2000.milliseconds)
advanceUntilIdle()
println("Verifying onConnect...")
verify(timeout = 5000) { service.onConnect() }
@ -173,13 +174,13 @@ class NordicBleInterfaceTest {
otaPeripheral.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01))
// Wait for drain to start
delay(500.milliseconds)
advanceUntilIdle()
// Simulate a log radio notification
val logData = "test log".toByteArray()
otaPeripheral.simulateValueUpdate(logRadioHandle, logData)
delay(500.milliseconds)
advanceUntilIdle()
// Explicitly stub handleFromRadio just in case relaxed mock fails
io.mockk.every { service.handleFromRadio(any()) } returns Unit
@ -281,7 +282,7 @@ class NordicBleInterfaceTest {
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
delay(100.milliseconds)
advanceUntilIdle()
val nordicInterface =
NordicBleInterface(
@ -292,7 +293,7 @@ class NordicBleInterfaceTest {
)
// Wait for connection
delay(1000.milliseconds)
advanceUntilIdle()
verify(timeout = 2000) { service.onConnect() }
// Test writing
@ -300,7 +301,7 @@ class NordicBleInterfaceTest {
nordicInterface.handleSendToRadio(dataToSend)
// Give it time to process
delay(500.milliseconds)
advanceUntilIdle()
assert(writtenValue != null) { "Value should have been written" }
assert(writtenValue!!.contentEquals(dataToSend)) {
@ -374,7 +375,7 @@ class NordicBleInterfaceTest {
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
delay(100.milliseconds)
advanceUntilIdle()
val nordicInterface =
NordicBleInterface(
@ -385,7 +386,7 @@ class NordicBleInterfaceTest {
)
// Wait for connection
delay(1000.milliseconds)
advanceUntilIdle()
verify(timeout = 2000) { service.onConnect() }
// Find the connected peripheral from CentralManager to trigger disconnect
@ -395,7 +396,7 @@ class NordicBleInterfaceTest {
connectedPeripheral.disconnect()
// Wait for disconnect event propagation
delay(1000.milliseconds)
advanceUntilIdle()
// Verify onDisconnect was called on the service
// NordicBleInterface calls onDisconnect(BleError.Disconnected)
@ -465,7 +466,7 @@ class NordicBleInterfaceTest {
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
delay(100.milliseconds)
advanceUntilIdle()
val nordicInterface =
NordicBleInterface(
@ -476,7 +477,7 @@ class NordicBleInterfaceTest {
)
// Wait for connection and eventual failure
delay(1000.milliseconds)
advanceUntilIdle()
// Verify that discovery failed
verify { service.onDisconnect(any<BleError.DiscoveryFailed>()) }
@ -551,7 +552,7 @@ class NordicBleInterfaceTest {
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
delay(1000.milliseconds)
advanceUntilIdle()
val nordicInterface =
NordicBleInterface(
@ -562,7 +563,7 @@ class NordicBleInterfaceTest {
)
// Wait for connection
delay(1000.milliseconds)
advanceUntilIdle()
verify(timeout = 2000) { service.onConnect() }
// Trigger write which will fail
@ -570,11 +571,99 @@ class NordicBleInterfaceTest {
// Wait for error propagation (retries take time!)
// 3 attempts with 500ms delay between them = ~1000ms+
delay(2500.milliseconds)
advanceUntilIdle()
// Verify onDisconnect was called with error
verify { service.onDisconnect(any<BleError>()) }
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 nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
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(p = payload) }
nordicInterface.close()
}
}

View file

@ -20,14 +20,20 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.RemoteService
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
@ -105,25 +111,40 @@ class BleConnection(
}
}
/** Discovers characteristics for a specific service with retries. */
@Suppress("ReturnCount")
/** A flow of discovered services. Useful for reacting to "Service Changed" indications. */
val services: SharedFlow<List<RemoteService>> =
_connectionState
.asSharedFlow()
.filter { it is ConnectionState.Connected }
.flatMapLatest { peripheral?.services() ?: flowOf(emptyList()) }
.filterNotNull()
.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
/** Discovers characteristics for a specific service. */
suspend fun discoverCharacteristics(
serviceUuid: Uuid,
characteristicUuids: List<Uuid>,
): Map<Uuid, RemoteCharacteristic>? = retryBleOperation(tag = tag) {
val p = peripheral ?: return@retryBleOperation null
val services =
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
val service = services.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
requiredUuids: List<Uuid>,
optionalUuids: List<Uuid> = emptyList(),
): Map<Uuid, RemoteCharacteristic>? {
val p = peripheral ?: return null
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
for (uuid in characteristicUuids) {
val char = service.characteristics.find { it.uuid == uuid }
if (char != null) {
result[uuid] = char
return retryBleOperation(tag = tag) {
val allRequested = requiredUuids + optionalUuids
val serviceList =
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
val service = serviceList.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
for (uuid in allRequested) {
val char = service.characteristics.find { it.uuid == uuid }
if (char != null) {
result[uuid] = char
}
}
val hasAllRequired = requiredUuids.all { result.containsKey(it) }
if (hasAllRequired) result else null
}
return@retryBleOperation if (result.size == characteristicUuids.size) result else null
}
private fun observePeripheralDetails(p: Peripheral) {

View file

@ -1,48 +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.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import dagger.Lazy
import javax.inject.Inject
/** BroadcastReceiver to handle Bluetooth adapter and device state changes. */
class BluetoothBroadcastReceiver @Inject constructor(private val bluetoothRepository: Lazy<BluetoothRepository>) :
BroadcastReceiver() {
val intentFilter: IntentFilter
get() =
IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
BluetoothAdapter.ACTION_STATE_CHANGED,
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
-> {
bluetoothRepository.get().refreshState()
}
}
}
}

View file

@ -18,7 +18,6 @@ package org.meshtastic.core.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.os.Build
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
@ -86,7 +85,7 @@ constructor(
internal suspend fun updateBluetoothState() {
val hasPerms =
if (androidEnvironment.androidSdkVersion >= Build.VERSION_CODES.S) {
if (androidEnvironment.requiresBluetoothRuntimePermissions) {
androidEnvironment.isBluetoothScanPermissionGranted &&
androidEnvironment.isBluetoothConnectPermissionGranted
} else {

View file

@ -37,4 +37,6 @@ object MeshtasticBleConstants {
/** Characteristic for receiving log notifications from the radio. */
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d")
}

View file

@ -0,0 +1,85 @@
/*
* 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.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class BleRetryTest {
@Test
fun `retryBleOperation returns immediately on success`() = runTest {
var attempts = 0
val result =
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
"success"
}
assertEquals("success", result)
assertEquals(1, attempts)
}
@Test
fun `retryBleOperation retries on exception and succeeds`() = runTest {
var attempts = 0
val result =
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
if (attempts < 2) {
throw RuntimeException("Temporary error")
}
"success"
}
assertEquals("success", result)
assertEquals(2, attempts)
}
@Test
fun `retryBleOperation throws exception after max attempts`() = runTest {
var attempts = 0
var caughtException: Exception? = null
try {
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
throw RuntimeException("Persistent error")
}
} catch (e: Exception) {
caughtException = e
}
assertTrue(caughtException is RuntimeException)
assertEquals("Persistent error", caughtException?.message)
assertEquals(3, attempts)
}
@Test(expected = CancellationException::class)
fun `retryBleOperation does not retry CancellationException`() = runTest {
var attempts = 0
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
throw CancellationException("Cancelled")
}
// Test fails if it catches and doesn't rethrow, or if it retries.
// It shouldn't reach the assertion below because the exception should be thrown immediately.
assertEquals(1, attempts)
}
}

View file

@ -0,0 +1,103 @@
/*
* 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 = BleScanner(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 = BleScanner(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) { ServiceUuid(targetUuid) }.toList(scannedDevices) }
// Needs time to scan in mock environment
advanceUntilIdle()
job.cancel()
// TODO: test filter logic correctly if necessary
}
}