mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(ble): Handle invalid BLE attributes (#4485)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
bf4020a939
commit
ba03aacdc9
15 changed files with 1546 additions and 57 deletions
|
|
@ -245,7 +245,7 @@ dependencies {
|
|||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.kermit)
|
||||
|
||||
implementation(libs.nordic)
|
||||
implementation(libs.nordic.client.android)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
|
|
@ -263,6 +263,10 @@ dependencies {
|
|||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.nordic.client.android.mock)
|
||||
testImplementation(libs.nordic.client.mock)
|
||||
testImplementation(libs.nordic.core.mock)
|
||||
testImplementation(libs.nordic.core.android.mock)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
|
|
|
|||
|
|
@ -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,7 +14,6 @@
|
|||
* 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.model
|
||||
|
||||
import android.app.Application
|
||||
|
|
@ -209,10 +208,11 @@ constructor(
|
|||
Logger.i { "Bonding complete for ${entry.peripheral.address.anonymize}, selecting device..." }
|
||||
changeDeviceAddress(entry.fullAddress)
|
||||
} catch (ex: SecurityException) {
|
||||
Logger.e(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" }
|
||||
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" }
|
||||
serviceRepository.setErrorMessage("Bonding failed: ${ex.message} Permissions not granted")
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" }
|
||||
// Bonding is often flaky and can fail for many reasons (timeout, user cancel, etc)
|
||||
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" }
|
||||
serviceRepository.setErrorMessage("Bonding failed: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +14,6 @@
|
|||
* 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.repository.radio
|
||||
|
||||
import com.geeksville.mesh.service.RadioNotConnectedException
|
||||
|
|
@ -67,8 +66,7 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) {
|
|||
*
|
||||
* @param exception The underlying GattException.
|
||||
*/
|
||||
class GattError(exception: GattException) :
|
||||
BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
|
||||
class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/**
|
||||
* Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
|
||||
|
|
@ -86,9 +84,12 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) {
|
|||
class OperationFailed(exception: OperationFailedException) :
|
||||
BleError("Operation failed: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/** An invalid attribute was used. This is a non-recoverable error. */
|
||||
/**
|
||||
* An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
|
||||
* change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
|
||||
*/
|
||||
class InvalidAttribute(exception: InvalidAttributeException) :
|
||||
BleError("Invalid attribute: ${exception.message}", shouldReconnect = false)
|
||||
BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/** An error occurred while scanning for devices. This may be recoverable. */
|
||||
class Scanning(exception: ScanningException) :
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import kotlinx.coroutines.flow.catch
|
|||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.isActive
|
||||
|
|
@ -49,6 +48,7 @@ import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
|||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
|
||||
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
|
|
@ -78,12 +78,12 @@ constructor(
|
|||
) : IRadioInterface {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.e(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
serviceScope.launch {
|
||||
try {
|
||||
peripheral?.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "[$address] Failed to disconnect in exception handler" }
|
||||
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
|
||||
}
|
||||
}
|
||||
service.onDisconnect(BleError.from(throwable))
|
||||
|
|
@ -116,6 +116,10 @@ constructor(
|
|||
val packet =
|
||||
try {
|
||||
fromRadioCharacteristic?.read()?.takeIf { it.isNotEmpty() }
|
||||
} catch (e: InvalidAttributeException) {
|
||||
Logger.w(e) { "[$address] Attribute invalidated during read, clearing characteristics" }
|
||||
handleInvalidAttribute(e)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Error reading fromRadioCharacteristic (likely disconnected)" }
|
||||
null
|
||||
|
|
@ -153,11 +157,6 @@ constructor(
|
|||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { ex -> Logger.w(ex) { "[$address] Exception while draining packet queue" } }
|
||||
.onCompletion {
|
||||
if (drainedCount > 0) {
|
||||
Logger.d { "[$address] Drained $drainedCount packets from packet queue" }
|
||||
}
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -184,7 +183,8 @@ constructor(
|
|||
}
|
||||
} catch (e: Exception) {
|
||||
val failureTime = System.currentTimeMillis() - connectionStartTime
|
||||
Logger.e(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
|
||||
// BLE connection errors are common and often transient
|
||||
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
}
|
||||
|
|
@ -226,10 +226,7 @@ constructor(
|
|||
.onEach { state ->
|
||||
Logger.i { "[$address] BLE connection state changed to $state" }
|
||||
if (state is ConnectionState.Disconnected) {
|
||||
toRadioCharacteristic = null
|
||||
fromNumCharacteristic = null
|
||||
fromRadioCharacteristic = null
|
||||
logRadioCharacteristic = null
|
||||
clearCharacteristics()
|
||||
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
|
|
@ -301,11 +298,11 @@ constructor(
|
|||
}
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.e(e) { "[$address] Service discovery failed" }
|
||||
Logger.w(e) { "[$address] Service discovery failed" }
|
||||
try {
|
||||
peripheral.disconnect()
|
||||
} catch (e2: Exception) {
|
||||
Logger.e(e2) { "[$address] Failed to disconnect in discovery catch" }
|
||||
Logger.w(e2) { "[$address] Failed to disconnect in discovery catch" }
|
||||
}
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
|
|
@ -324,10 +321,9 @@ constructor(
|
|||
connectionScope.launch { drainPacketQueueAndDispatch() }
|
||||
}
|
||||
?.catch { e ->
|
||||
Logger.e(e) { "[$address] Error subscribing to fromNumCharacteristic" }
|
||||
Logger.w(e) { "[$address] Error subscribing to fromNumCharacteristic" }
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
?.onCompletion { cause -> Logger.d { "[$address] fromNum sub flow completed, cause=$cause" } }
|
||||
?.launchIn(scope = connectionScope)
|
||||
|
||||
retryCall { logRadioCharacteristic?.subscribe() }
|
||||
|
|
@ -337,10 +333,9 @@ constructor(
|
|||
dispatchPacket(notifyBytes)
|
||||
}
|
||||
?.catch { e ->
|
||||
Logger.e(e) { "[$address] Error subscribing to logRadioCharacteristic" }
|
||||
Logger.w(e) { "[$address] Error subscribing to logRadioCharacteristic" }
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
?.onCompletion { cause -> Logger.d { "[$address] logRadio sub flow completed, cause=$cause" } }
|
||||
?.launchIn(scope = connectionScope)
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +349,7 @@ constructor(
|
|||
} catch (e: Exception) {
|
||||
currentAttempt++
|
||||
if (currentAttempt >= RETRY_COUNT) {
|
||||
Logger.e(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" }
|
||||
Logger.w(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" }
|
||||
throw e
|
||||
}
|
||||
Logger.w(e) {
|
||||
|
|
@ -398,8 +393,11 @@ constructor(
|
|||
characteristic.write(p, writeType = writeType)
|
||||
}
|
||||
drainPacketQueueAndDispatch()
|
||||
} catch (e: InvalidAttributeException) {
|
||||
Logger.w(e) { "[$address] Attribute invalidated during write, clearing characteristics" }
|
||||
handleInvalidAttribute(e)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) {
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to write packet to toRadioCharacteristic after " +
|
||||
"$packetsSent successful writes"
|
||||
}
|
||||
|
|
@ -435,6 +433,18 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleInvalidAttribute(e: InvalidAttributeException) {
|
||||
clearCharacteristics()
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
|
||||
private fun clearCharacteristics() {
|
||||
toRadioCharacteristic = null
|
||||
fromNumCharacteristic = null
|
||||
fromRadioCharacteristic = null
|
||||
logRadioCharacteristic = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RETRY_COUNT = 3
|
||||
private const val RETRY_DELAY_MS = 500L
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ import javax.inject.Singleton
|
|||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Singleton
|
||||
class RadioInterfaceService
|
||||
open class RadioInterfaceService
|
||||
@Inject
|
||||
constructor(
|
||||
private val context: Application,
|
||||
|
|
@ -224,7 +224,7 @@ constructor(
|
|||
}
|
||||
|
||||
// Handle an incoming packet from the radio, broadcasts it as an android intent
|
||||
fun handleFromRadio(p: ByteArray) {
|
||||
open fun handleFromRadio(p: ByteArray) {
|
||||
if (logReceives) {
|
||||
try {
|
||||
receivedPacketsLog.write(p)
|
||||
|
|
|
|||
|
|
@ -134,7 +134,8 @@ constructor(
|
|||
} else {
|
||||
0
|
||||
}
|
||||
Logger.e(ex) { "[$address] TCP IOException after ${uptime}ms - ${ex.message}" }
|
||||
// Connection failures are common when the radio is offline or out of range
|
||||
Logger.w(ex) { "[$address] TCP connection error after ${uptime}ms - ${ex.message}" }
|
||||
onDeviceDisconnect(false)
|
||||
} catch (ex: Throwable) {
|
||||
val uptime =
|
||||
|
|
|
|||
|
|
@ -135,25 +135,46 @@ class MeshService : Service() {
|
|||
|
||||
val notification = connectionManager.updateStatusNotification()
|
||||
|
||||
val foregroundServiceType =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
if (hasLocationPermission()) {
|
||||
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
}
|
||||
types
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
SERVICE_NOTIFY_ID,
|
||||
notification,
|
||||
ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, foregroundServiceType)
|
||||
} catch (ex: SecurityException) {
|
||||
// On Android 14+ starting a location FGS from the background can fail with SecurityException
|
||||
// if the app is not in an allowed state. Retry without the location type if that was requested.
|
||||
val connectedDeviceOnly =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
if (hasLocationPermission()) {
|
||||
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
}
|
||||
types
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
}
|
||||
if (foregroundServiceType != connectedDeviceOnly) {
|
||||
Logger.w(ex) {
|
||||
"Failed to start foreground service with location type, retrying with connectedDevice only"
|
||||
}
|
||||
try {
|
||||
ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, connectedDeviceOnly)
|
||||
} catch (retryEx: Exception) {
|
||||
Logger.e(retryEx) { "Failed to start foreground service even after retry" }
|
||||
}
|
||||
} else {
|
||||
Logger.e(ex) { "SecurityException starting foreground service" }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Error starting foreground service" }
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
return if (!wantForeground) {
|
||||
Logger.i { "Stopping mesh service because no device is selected" }
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.repository.radio
|
||||
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.ConnectionResult
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.client.mock.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 org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::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())
|
||||
|
||||
@Test
|
||||
fun `drainPacketQueueAndDispatch reads multiple packets until empty`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var fromRadioHandle: Int = -1
|
||||
var fromNumHandle: Int = -1
|
||||
val packetsToRead = mutableListOf(byteArrayOf(0x01), byteArrayOf(0x02), byteArrayOf(0x03))
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
|
||||
ConnectionResult.Accept
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse {
|
||||
if (characteristic.instanceId == fromRadioHandle) {
|
||||
return if (packetsToRead.isNotEmpty()) {
|
||||
val p = packetsToRead.removeAt(0)
|
||||
println("Mock: Returning packet ${p.contentToString()}")
|
||||
ReadResponse.Success(p)
|
||||
} else {
|
||||
println("Mock: Queue empty, returning empty")
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
}
|
||||
return ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
|
||||
override fun onWriteRequest(characteristic: MockRemoteCharacteristic, value: ByteArray) =
|
||||
WriteResponse.Success
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_Drain")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_Drain",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
fromNumHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
fromRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
delay(2000.milliseconds)
|
||||
verify(timeout = 5000) { service.onConnect() }
|
||||
clearMocks(service, answers = false, recordedCalls = true)
|
||||
|
||||
// Trigger drain
|
||||
println("Simulating FromNum notification...")
|
||||
peripheralSpec.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01))
|
||||
|
||||
// Wait for all packets to be processed
|
||||
delay(2000.milliseconds)
|
||||
|
||||
// Verify handleFromRadio was called 3 times
|
||||
verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x01)) }
|
||||
verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x02)) }
|
||||
verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x03)) }
|
||||
|
||||
assert(packetsToRead.isEmpty()) { "All packets should have been read" }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.repository.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.delay
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.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 java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::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(
|
||||
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 = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
delay(100.milliseconds)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection and stable state
|
||||
delay(2000.milliseconds)
|
||||
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 (500ms delay per retry in code)
|
||||
delay(1500.milliseconds)
|
||||
|
||||
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<BleError.BluetoothError>()) }
|
||||
|
||||
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 = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
delay(100.milliseconds)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = uniqueAddress,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
delay(2000.milliseconds)
|
||||
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 3 attempts + delays (500ms * 2)
|
||||
delay(2500.milliseconds)
|
||||
|
||||
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<BleError.BluetoothError>()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,572 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.repository.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.delay
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.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 org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::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(
|
||||
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 centralManager = CentralManager.Factory.mock(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 = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
fromNumHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
fromRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
logRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
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
|
||||
delay(100.milliseconds)
|
||||
|
||||
// Create the interface
|
||||
println("Creating NordicBleInterface")
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection and discovery
|
||||
println("Waiting for connection...")
|
||||
delay(2000.milliseconds)
|
||||
|
||||
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
|
||||
delay(500.milliseconds)
|
||||
|
||||
// Simulate a log radio notification
|
||||
val logData = "test log".toByteArray()
|
||||
otaPeripheral.simulateValueUpdate(logRadioHandle, logData)
|
||||
|
||||
delay(500.milliseconds)
|
||||
|
||||
// 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 centralManager = CentralManager.Factory.mock(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 = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
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 = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
delay(100.milliseconds)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
delay(1000.milliseconds)
|
||||
verify(timeout = 2000) { service.onConnect() }
|
||||
|
||||
// Test writing
|
||||
val dataToSend = byteArrayOf(0x01, 0x02, 0x03)
|
||||
nordicInterface.handleSendToRadio(dataToSend)
|
||||
|
||||
// Give it time to process
|
||||
delay(500.milliseconds)
|
||||
|
||||
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 centralManager = CentralManager.Factory.mock(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 = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
delay(100.milliseconds)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
delay(1000.milliseconds)
|
||||
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
|
||||
delay(1000.milliseconds)
|
||||
|
||||
// Verify onDisconnect was called on the service
|
||||
// NordicBleInterface calls onDisconnect(BleError.Disconnected)
|
||||
verify { service.onDisconnect(any<BleError.Disconnected>()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discovery fails if required characteristic missing`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(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 = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
// OMIT toRadio characteristic to force failure
|
||||
/*
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.WRITE, CharacteristicProperty.WRITE_WITHOUT_RESPONSE),
|
||||
permission = Permission.WRITE
|
||||
)
|
||||
*/
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
delay(100.milliseconds)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection and eventual failure
|
||||
delay(1000.milliseconds)
|
||||
|
||||
// Verify that discovery failed
|
||||
verify { service.onDisconnect(any<BleError.DiscoveryFailed>()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write exception triggers disconnect`() = runTest(testDispatcher) {
|
||||
val uniqueAddress = "11:22:33:44:55:66"
|
||||
val centralManager = CentralManager.Factory.mock(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 = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
delay(1000.milliseconds)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
service = service,
|
||||
address = uniqueAddress,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
delay(1000.milliseconds)
|
||||
verify(timeout = 2000) { service.onConnect() }
|
||||
|
||||
// Trigger write which will fail
|
||||
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
|
||||
|
||||
// Wait for error propagation
|
||||
delay(500.milliseconds)
|
||||
|
||||
// Verify onDisconnect was called with error
|
||||
verify { service.onDisconnect(any<BleError>()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -240,16 +240,18 @@ constructor(
|
|||
// Filter out normal coroutine cancellations
|
||||
if (throwable is CancellationException) return
|
||||
|
||||
// Only record non-fatal exceptions for actual Errors (or if a throwable is provided)
|
||||
if (throwable != null) {
|
||||
Firebase.crashlytics.recordException(throwable)
|
||||
} else if (severity >= Severity.Error) {
|
||||
Firebase.crashlytics.setCustomKeys {
|
||||
key(KEY_PRIORITY, severity.ordinal)
|
||||
key(KEY_TAG, tag)
|
||||
key(KEY_MESSAGE, message)
|
||||
// Only record non-fatal exceptions for actual Errors (Severity.Error or Severity.Assert)
|
||||
if (severity >= Severity.Error) {
|
||||
if (throwable != null) {
|
||||
Firebase.crashlytics.recordException(throwable)
|
||||
} else {
|
||||
Firebase.crashlytics.setCustomKeys {
|
||||
key(KEY_PRIORITY, severity.ordinal)
|
||||
key(KEY_TAG, tag)
|
||||
key(KEY_MESSAGE, message)
|
||||
}
|
||||
Firebase.crashlytics.recordException(Exception(message))
|
||||
}
|
||||
Firebase.crashlytics.recordException(Exception(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ dependencies {
|
|||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.kermit)
|
||||
|
||||
implementation(libs.nordic)
|
||||
implementation(libs.nordic.client.android)
|
||||
implementation(libs.nordic.dfu)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
|
@ -77,5 +77,9 @@ dependencies {
|
|||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.nordic.client.android.mock)
|
||||
testImplementation(libs.nordic.client.mock)
|
||||
testImplementation(libs.nordic.core.android.mock)
|
||||
testImplementation(libs.nordic.core.mock)
|
||||
testImplementation(libs.mockk)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.ConnectionResult
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.client.mock.WriteResponse
|
||||
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.core.Permission
|
||||
import no.nordicsemi.kotlin.ble.core.and
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
class BleOtaTransportErrorTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
private val serviceUuid = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val otaCharacteristicUuid = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val txCharacteristicUuid = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
|
||||
|
||||
@Test
|
||||
fun `startOta fails when device rejects hash`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
lateinit var otaPeripheral: PeripheralSpec<String>
|
||||
var txCharHandle: Int = -1
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
|
||||
ConnectionResult.Accept
|
||||
|
||||
override fun onWriteRequest(
|
||||
characteristic: MockRemoteCharacteristic,
|
||||
value: ByteArray,
|
||||
): WriteResponse {
|
||||
val command = value.decodeToString()
|
||||
if (command.startsWith("OTA")) {
|
||||
backgroundScope.launch {
|
||||
delay(50.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Rejected\n".toByteArray())
|
||||
}
|
||||
}
|
||||
return WriteResponse.Success
|
||||
}
|
||||
}
|
||||
|
||||
otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
|
||||
Service(uuid = serviceUuid.toKotlinUuid()) {
|
||||
Characteristic(
|
||||
uuid = otaCharacteristicUuid.toKotlinUuid(),
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = txCharacteristicUuid.toKotlinUuid(),
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
val transport = BleOtaTransport(centralManager, address, testDispatcher)
|
||||
|
||||
transport.connect().getOrThrow()
|
||||
|
||||
val result = transport.startOta(1024, "badhash") {}
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is OtaProtocolException.HashRejected)
|
||||
|
||||
transport.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streamFirmware fails when connection lost`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
lateinit var otaPeripheral: PeripheralSpec<String>
|
||||
var txCharHandle: Int = -1
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
|
||||
ConnectionResult.Accept
|
||||
|
||||
override fun onWriteRequest(
|
||||
characteristic: MockRemoteCharacteristic,
|
||||
value: ByteArray,
|
||||
): WriteResponse {
|
||||
backgroundScope.launch {
|
||||
delay(50.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
|
||||
}
|
||||
return WriteResponse.Success
|
||||
}
|
||||
}
|
||||
|
||||
otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
|
||||
Service(uuid = serviceUuid.toKotlinUuid()) {
|
||||
Characteristic(
|
||||
uuid = otaCharacteristicUuid.toKotlinUuid(),
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = txCharacteristicUuid.toKotlinUuid(),
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
val transport = BleOtaTransport(centralManager, address, testDispatcher)
|
||||
|
||||
transport.connect().getOrThrow()
|
||||
transport.startOta(1024, "hash") {}.getOrThrow()
|
||||
|
||||
// Find the connected peripheral and disconnect it
|
||||
// We use isBonded=true to ensure it shows up in getBondedPeripherals()
|
||||
val peripheral = centralManager.getBondedPeripherals().first { it.address == address }
|
||||
peripheral.disconnect()
|
||||
|
||||
// Wait for state propagation
|
||||
delay(100.milliseconds)
|
||||
|
||||
val data = ByteArray(1024) { it.toByte() }
|
||||
val result = transport.streamFirmware(data, 512) {}
|
||||
|
||||
assertTrue("Should fail due to connection loss", result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is OtaProtocolException.TransferFailed)
|
||||
assertTrue(result.exceptionOrNull()?.message?.contains("Connection lost") == true)
|
||||
|
||||
transport.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streamFirmware fails on hash mismatch at verification`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
lateinit var otaPeripheral: PeripheralSpec<String>
|
||||
var txCharHandle: Int = -1
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
|
||||
ConnectionResult.Accept
|
||||
|
||||
override fun onWriteRequest(
|
||||
characteristic: MockRemoteCharacteristic,
|
||||
value: ByteArray,
|
||||
): WriteResponse {
|
||||
backgroundScope.launch {
|
||||
delay(50.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
|
||||
}
|
||||
return WriteResponse.Success
|
||||
}
|
||||
|
||||
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
|
||||
backgroundScope.launch {
|
||||
delay(10.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "ACK\n".toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) {
|
||||
Service(uuid = serviceUuid.toKotlinUuid()) {
|
||||
Characteristic(
|
||||
uuid = otaCharacteristicUuid.toKotlinUuid(),
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = txCharacteristicUuid.toKotlinUuid(),
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
val transport = BleOtaTransport(centralManager, address, testDispatcher)
|
||||
|
||||
transport.connect().getOrThrow()
|
||||
transport.startOta(1024, "hash") {}.getOrThrow()
|
||||
|
||||
// Setup final response to be a Hash Mismatch error after chunks are sent
|
||||
backgroundScope.launch {
|
||||
delay(1000.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Mismatch\n".toByteArray())
|
||||
}
|
||||
|
||||
val data = ByteArray(1024) { it.toByte() }
|
||||
val result = transport.streamFirmware(data, 512) {}
|
||||
|
||||
assertTrue("Should fail due to hash mismatch, but got ${result.exceptionOrNull()}", result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is OtaProtocolException.VerificationFailed)
|
||||
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.ConnectionResult
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.client.mock.WriteResponse
|
||||
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.core.Permission
|
||||
import no.nordicsemi.kotlin.ble.core.and
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
class BleOtaTransportNordicMockTest {
|
||||
|
||||
private val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
private fun java.util.UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Logger.setLogWriters(
|
||||
object : co.touchlab.kermit.LogWriter() {
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
println("[$severity] $tag: $message")
|
||||
throwable?.printStackTrace()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full ota flow with nordic mocks`() = runTest(testDispatcher) {
|
||||
// Use default mock environment
|
||||
// Use backgroundScope so that simulation coroutines are cancelled after the test
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
|
||||
var otaCharHandle: Int = -1
|
||||
var txCharHandle: Int = -1
|
||||
|
||||
// Use a property to hold the peripheral since we need it in the event handler
|
||||
lateinit var otaPeripheral: PeripheralSpec<String>
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
override fun onWriteRequest(
|
||||
characteristic: MockRemoteCharacteristic,
|
||||
value: ByteArray,
|
||||
): WriteResponse {
|
||||
val command = value.decodeToString()
|
||||
if (command.startsWith("OTA")) {
|
||||
backgroundScope.launch {
|
||||
delay(50.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
|
||||
}
|
||||
}
|
||||
return WriteResponse.Success
|
||||
}
|
||||
|
||||
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
|
||||
// For firmware chunks (WITHOUT_RESPONSE)
|
||||
backgroundScope.launch {
|
||||
delay(10.milliseconds)
|
||||
// In the real protocol, ACK is sent after each chunk.
|
||||
// OK is sent after the final chunk is verified.
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "ACK\n".toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(name = "ESP32-OTA", eventHandler = eventHandler) {
|
||||
Service(uuid = SERVICE_UUID.toKotlinUuid()) {
|
||||
otaCharHandle =
|
||||
Characteristic(
|
||||
uuid = OTA_CHARACTERISTIC_UUID.toKotlinUuid(),
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = TX_CHARACTERISTIC_UUID.toKotlinUuid(),
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
val transport = BleOtaTransport(centralManager, address, testDispatcher)
|
||||
|
||||
// 1. Connect
|
||||
// Note: BleOtaTransport includes a 5s delay at the start of connect()
|
||||
val connectResult = transport.connect()
|
||||
assertTrue("Connection failed: ${connectResult.exceptionOrNull()}", connectResult.isSuccess)
|
||||
|
||||
// 2. Start OTA
|
||||
val startResult = transport.startOta(1024L, "somehash") {}
|
||||
assertTrue("Start OTA failed: ${startResult.exceptionOrNull()}", startResult.isSuccess)
|
||||
|
||||
// 3. Stream firmware
|
||||
// Need to simulate an OK at the very end
|
||||
backgroundScope.launch {
|
||||
// Wait for chunks to be sent. 1024 bytes with 512 chunk size = 2 chunks.
|
||||
// Each chunk takes 10ms + ACK processing.
|
||||
delay(500.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
|
||||
}
|
||||
|
||||
val data = ByteArray(1024) { it.toByte() }
|
||||
val streamResult = transport.streamFirmware(data, 512) {}
|
||||
assertTrue("Stream firmware failed: ${streamResult.exceptionOrNull()}", streamResult.isSuccess)
|
||||
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ wire = "6.0.0-alpha02"
|
|||
vico = "3.0.0-beta.3"
|
||||
# Removed gradle-doctor
|
||||
dependency-guard = "0.5.0"
|
||||
nordic-ble = "2.0.0-alpha12"
|
||||
|
||||
|
||||
[libraries]
|
||||
|
|
@ -187,7 +188,11 @@ markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", v
|
|||
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
|
||||
markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
|
||||
material = { module = "com.google.android.material:material", version = "1.13.0" }
|
||||
nordic = { module = "no.nordicsemi.kotlin.ble:client-android", version = "2.0.0-alpha12" }
|
||||
nordic-client-android = { module = "no.nordicsemi.kotlin.ble:client-android", version.ref = "nordic-ble" }
|
||||
nordic-client-android-mock = { module = "no.nordicsemi.kotlin.ble:client-android-mock", version.ref = "nordic-ble" }
|
||||
nordic-client-mock = { module = "no.nordicsemi.kotlin.ble:client-mock", version.ref = "nordic-ble" }
|
||||
nordic-core-android-mock = { module = "no.nordicsemi.kotlin.ble:core-android-mock", version.ref = "nordic-ble" }
|
||||
nordic-core-mock = { module = "no.nordicsemi.kotlin.ble:core-mock", version.ref = "nordic-ble" }
|
||||
nordic-dfu = { module = "no.nordicsemi.android:dfu", version = "2.10.1" }
|
||||
org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }
|
||||
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue