mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(ble): Add support for FromRadioSync characteristic (#4609)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
5d2336c092
commit
96d4027f74
10 changed files with 396 additions and 108 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue