refactor(ble): Centralize BLE logic into a core module (#4550)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-20 06:41:52 -06:00 committed by GitHub
parent 7a68802bc2
commit 6bfa5b5f70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 3471 additions and 2405 deletions

View file

@ -1,81 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.concurrent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Test
@ExperimentalCoroutinesApi
class SequentialJobTest {
private val sequentialJob = SequentialJob()
@Test
fun `launch cancels previous job`() = runTest {
var job1Active = false
var job1Cancelled = false
// Launch first job
sequentialJob.launch(this) {
try {
job1Active = true
delay(1000)
} finally {
job1Cancelled = true
}
}
advanceTimeBy(100)
assertTrue("Job 1 should be active", job1Active)
// Launch second job
sequentialJob.launch(this) {
// Do nothing
}
advanceTimeBy(100)
assertTrue("Job 1 should be cancelled", job1Cancelled)
}
@Test
fun `cancel stops the job`() = runTest {
var jobActive = false
var jobCancelled = false
sequentialJob.launch(this) {
try {
jobActive = true
delay(1000)
} finally {
jobCancelled = true
}
}
advanceTimeBy(100)
assertTrue("Job should be active", jobActive)
sequentialJob.cancel()
advanceTimeBy(100)
assertTrue("Job should be cancelled", jobCancelled)
}
}

View file

@ -35,25 +35,25 @@ 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.Ignore
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Test
import java.util.UUID
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 kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceDrainTest {
private val testDispatcher = StandardTestDispatcher()
private val address = "00:11:22:33:44:55"
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
@Ignore("Flaky: relies on timing in the Nordic BLE mock library which causes intermittent CI failures")
@Test
fun `drainPacketQueueAndDispatch reads multiple packets until empty`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var fromRadioHandle: Int = -1
@ -95,9 +95,9 @@ class NordicBleInterfaceDrainTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -107,18 +107,18 @@ class NordicBleInterfaceDrainTest {
)
fromNumHandle =
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
fromRadioHandle =
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)

View file

@ -38,19 +38,20 @@ import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import org.junit.Before
import org.junit.Test
import java.util.UUID
import org.meshtastic.core.ble.BleError
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 kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceRetryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val address = "00:11:22:33:44:55"
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
@Before
fun setup() {
Logger.setLogWriters(
@ -106,10 +107,10 @@ class NordicBleInterfaceRetryTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -118,17 +119,17 @@ class NordicBleInterfaceRetryTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -211,10 +212,10 @@ class NordicBleInterfaceRetryTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -223,17 +224,17 @@ class NordicBleInterfaceRetryTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)

View file

@ -37,21 +37,23 @@ 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 java.util.UUID
import org.meshtastic.core.ble.BleError
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 kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val address = "00:11:22:33:44:55"
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
@Before
fun setup() {
Logger.setLogWriters(
@ -66,7 +68,8 @@ class NordicBleInterfaceTest {
@Test
fun `full connection and notification flow`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var fromNumHandle: Int = -1
@ -107,28 +110,28 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
fromNumHandle =
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
fromRadioHandle =
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
logRadioHandle =
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -189,7 +192,8 @@ class NordicBleInterfaceTest {
@Test
fun `handleSendToRadio writes to toRadioCharacteristic`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var toRadioHandle: Int = -1
@ -240,10 +244,10 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -257,17 +261,17 @@ class NordicBleInterfaceTest {
}
// Add other required chars to avoid discovery failure
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -308,7 +312,8 @@ class NordicBleInterfaceTest {
@Test
fun `disconnection triggers onDisconnect`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
@ -338,9 +343,9 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -349,17 +354,17 @@ class NordicBleInterfaceTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -401,7 +406,8 @@ class NordicBleInterfaceTest {
@Test
fun `discovery fails if required characteristic missing`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
@ -429,27 +435,27 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
// OMIT toRadio characteristic to force failure
/*
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.WRITE, CharacteristicProperty.WRITE_WITHOUT_RESPONSE),
permission = Permission.WRITE
)
*/
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -481,7 +487,8 @@ class NordicBleInterfaceTest {
@Test
fun `write exception triggers disconnect`() = runTest(testDispatcher) {
val uniqueAddress = "11:22:33:44:55:66"
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
@ -513,9 +520,9 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -524,17 +531,17 @@ class NordicBleInterfaceTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -561,8 +568,9 @@ class NordicBleInterfaceTest {
// Trigger write which will fail
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
// Wait for error propagation
delay(500.milliseconds)
// Wait for error propagation (retries take time!)
// 3 attempts with 500ms delay between them = ~1000ms+
delay(2500.milliseconds)
// Verify onDisconnect was called with error
verify { service.onDisconnect(any<BleError>()) }

View file

@ -28,8 +28,10 @@ import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MeshServiceBroadcastsTest {
private lateinit var context: Context

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,12 +14,11 @@
* 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 com.geeksville.mesh.ui
import com.geeksville.mesh.model.getInitials
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.model.util.getInitials
class UIUnitTest {
@Test