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

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