mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Complete ViewModel extraction and update documentation (#4817)
This commit is contained in:
parent
80cae8e620
commit
6e81ceec91
65 changed files with 952 additions and 633 deletions
|
|
@ -0,0 +1,310 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,758 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import io.mockk.confirmVerified
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
class StreamInterfaceTest {
|
||||
|
||||
private val service: RadioInterfaceService = mockk(relaxed = true)
|
||||
|
||||
// Concrete implementation for testing
|
||||
private class TestStreamInterface(service: RadioInterfaceService) : StreamInterface(service) {
|
||||
override fun sendBytes(p: ByteArray) {}
|
||||
|
||||
fun testReadChar(c: Byte) = readChar(c)
|
||||
}
|
||||
|
||||
private val streamInterface = TestStreamInterface(service)
|
||||
|
||||
@Test
|
||||
fun `readChar delivers a 1-byte packet`() {
|
||||
// Header: START1, START2, LenMSB=0, LenLSB=1
|
||||
val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x42)
|
||||
|
||||
packet.forEach { streamInterface.testReadChar(it) }
|
||||
|
||||
verify { service.handleFromRadio(byteArrayOf(0x42)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readChar handles zero length packet`() {
|
||||
// Header: START1, START2, LenMSB=0, LenLSB=0
|
||||
val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x00)
|
||||
|
||||
packet.forEach { streamInterface.testReadChar(it) }
|
||||
|
||||
verify { service.handleFromRadio(byteArrayOf()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readChar loses sync on invalid START2`() {
|
||||
// START1, wrong START2, START1, START2, LenMSB=0, LenLSB=1, payload
|
||||
val data = byteArrayOf(0x94.toByte(), 0x00, 0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x55)
|
||||
|
||||
data.forEach { streamInterface.testReadChar(it) }
|
||||
|
||||
verify { service.handleFromRadio(byteArrayOf(0x55)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readChar handles multiple packets sequentially`() {
|
||||
val packet1 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x11)
|
||||
val packet2 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x22)
|
||||
|
||||
packet1.forEach { streamInterface.testReadChar(it) }
|
||||
packet2.forEach { streamInterface.testReadChar(it) }
|
||||
|
||||
verify { service.handleFromRadio(byteArrayOf(0x11)) }
|
||||
verify { service.handleFromRadio(byteArrayOf(0x22)) }
|
||||
confirmVerified(service)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readChar handles large packet up to MAX_TO_FROM_RADIO_SIZE`() {
|
||||
val size = 512
|
||||
val payload = ByteArray(size) { it.toByte() }
|
||||
val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), (size shr 8).toByte(), (size and 0xff).toByte())
|
||||
|
||||
header.forEach { streamInterface.testReadChar(it) }
|
||||
payload.forEach { streamInterface.testReadChar(it) }
|
||||
|
||||
verify { service.handleFromRadio(payload) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readChar loses sync on overly large packet length`() {
|
||||
// 513 bytes is > 512
|
||||
val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x02, 0x01)
|
||||
|
||||
header.forEach { streamInterface.testReadChar(it) }
|
||||
|
||||
// Should ignore and reset, not expecting handleFromRadio
|
||||
verify(exactly = 0) { service.handleFromRadio(any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.network.transport.StreamFrameCodec
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
class TCPInterfaceTest {
|
||||
|
||||
@Test
|
||||
fun testHeartbeatFraming() = runTest {
|
||||
val sentBytes = mutableListOf<ByteArray>()
|
||||
|
||||
val codec = StreamFrameCodec(onPacketReceived = {}, logTag = "Test")
|
||||
|
||||
val heartbeat = ToRadio(heartbeat = Heartbeat()).encode()
|
||||
codec.frameAndSend(heartbeat, { sentBytes.add(it) })
|
||||
|
||||
// First sent bytes are the 4-byte header, second is the payload
|
||||
assertEquals(2, sentBytes.size)
|
||||
val header = sentBytes[0]
|
||||
assertEquals(4, header.size)
|
||||
assertEquals(0x94.toByte(), header[0])
|
||||
assertEquals(0xc3.toByte(), header[1])
|
||||
|
||||
val payload = sentBytes[1]
|
||||
assertEquals(heartbeat.toList(), payload.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testServicePort() {
|
||||
assertEquals(4403, TCPInterface.SERVICE_PORT)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue