mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: Replace Nordic, use Kable backend for Desktop and Android with BLE support (#4818)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
0e5f94579f
commit
0b2e89c46f
79 changed files with 1980 additions and 2965 deletions
|
|
@ -68,7 +68,7 @@ The app follows modern Android development practices, built on top of a shared K
|
|||
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
|
||||
|
||||
### Bluetooth Low Energy (BLE)
|
||||
The BLE stack uses a hybrid interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, while the Android implementation utilizes **Nordic Semiconductor's Kotlin BLE Library**. This provides a robust, Coroutine-based architecture for reliable device communication while remaining KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
|
||||
The BLE stack uses a multiplatform interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, utilizing the **Kable** multiplatform BLE library to handle device communication across all supported targets (Android, Desktop). This provides a robust, Coroutine-based architecture for reliable device communication while remaining fully KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
|
||||
|
||||
## Translations
|
||||
|
||||
|
|
|
|||
|
|
@ -273,13 +273,6 @@ dependencies {
|
|||
implementation(libs.kermit)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
implementation(libs.nordic.client.android)
|
||||
implementation(libs.nordic.common.core)
|
||||
implementation(libs.nordic.common.permissions.ble)
|
||||
implementation(libs.nordic.common.permissions.notification)
|
||||
implementation(libs.nordic.common.scanner.ble)
|
||||
implementation(libs.nordic.common.ui)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.glance.preview)
|
||||
|
||||
|
|
@ -307,8 +300,6 @@ dependencies {
|
|||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.nordic.client.android.mock)
|
||||
androidTestImplementation(libs.nordic.core.mock)
|
||||
androidTestImplementation(libs.koin.test)
|
||||
|
||||
testImplementation(libs.androidx.work.testing)
|
||||
|
|
@ -316,9 +307,6 @@ dependencies {
|
|||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.nordic.client.android.mock)
|
||||
testImplementation(libs.nordic.client.core.mock)
|
||||
testImplementation(libs.nordic.core.mock)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,31 @@
|
|||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
|
||||
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : RadioTransport</ID>
|
||||
<ID>LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()</ID>
|
||||
<ID>LongParameterList:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, dispatchers: CoroutineDispatchers, meshLogRepository: MeshLogRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, tracerouteSnapshotRepository: TracerouteSnapshotRepository, nodeRequestActions: NodeRequestActions, alertManager: AlertManager, getNodeDetailsUseCase: GetNodeDetailsUseCase, )</ID>
|
||||
<ID>LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, )</ID>
|
||||
<ID>LongParameterList:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, radioConfigRepository: RadioConfigRepository, packetRepository: PacketRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val locationRepository: LocationRepository, mapConsentPrefs: MapConsentPrefs, analyticsPrefs: AnalyticsPrefs, homoglyphEncodingPrefs: HomoglyphPrefs, toggleAnalyticsUseCase: ToggleAnalyticsUseCase, toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, importProfileUseCase: ImportProfileUseCase, exportProfileUseCase: ExportProfileUseCase, exportSecurityConfigUseCase: ExportSecurityConfigUseCase, installProfileUseCase: InstallProfileUseCase, radioConfigUseCase: RadioConfigUseCase, adminActionsUseCase: AdminActionsUseCase, processRadioResponseUseCase: ProcessRadioResponseUseCase, )</ID>
|
||||
<ID>LongParameterList:AndroidSettingsViewModel.kt$AndroidSettingsViewModel$( private val app: Application, radioConfigRepository: RadioConfigRepository, radioController: RadioController, nodeRepository: NodeRepository, uiPrefs: UiPrefs, buildConfigProvider: BuildConfigProvider, databaseManager: DatabaseManager, meshLogPrefs: MeshLogPrefs, setThemeUseCase: SetThemeUseCase, setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, setProvideLocationUseCase: SetProvideLocationUseCase, setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase, setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase, meshLocationUseCase: MeshLocationUseCase, exportDataUseCase: ExportDataUseCase, isOtaCapableUseCase: IsOtaCapableUseCase, )</ID>
|
||||
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L</ID>
|
||||
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5</ID>
|
||||
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
|
||||
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
|
||||
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$0xff</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$3</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$4</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
|
||||
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
|
||||
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
|
||||
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
|
||||
<ID>TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:BleRadioInterface.kt$BleRadioInterface$e: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
|
||||
<ID>TooManyFunctions:BleRadioInterface.kt$BleRadioInterface : RadioTransport</ID>
|
||||
>>>>>>> ba83c3564 (chore(conductor): Complete Phase 4 - Wire Kable and Remove Nordic)
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
|
@ -83,8 +81,6 @@ class MainActivity : ComponentActivity() {
|
|||
*/
|
||||
internal val meshServiceClient: MeshServiceClient by inject { parametersOf(this) }
|
||||
|
||||
internal val androidEnvironment: AndroidEnvironment by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
|
||||
|
|
@ -124,9 +120,7 @@ class MainActivity : ComponentActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("SpreadOperator")
|
||||
CompositionLocalProvider(
|
||||
*(LocalEnvironmentOwner provides androidEnvironment),
|
||||
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
|
||||
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
|
||||
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import kotlinx.coroutines.cancel
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.workmanager.koin.workManagerFactory
|
||||
|
|
@ -119,7 +118,6 @@ open class MeshUtilApplication :
|
|||
override fun onTerminate() {
|
||||
// Shutdown managers (useful for Robolectric tests)
|
||||
get<DatabaseManager>().close()
|
||||
get<AndroidEnvironment>().close()
|
||||
applicationScope.cancel()
|
||||
super.onTerminate()
|
||||
org.koin.core.context.stopKoin()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import org.meshtastic.core.database.di.CoreDatabaseAndroidModule
|
|||
import org.meshtastic.core.database.di.CoreDatabaseModule
|
||||
import org.meshtastic.core.datastore.di.CoreDatastoreAndroidModule
|
||||
import org.meshtastic.core.datastore.di.CoreDatastoreModule
|
||||
import org.meshtastic.core.di.di.CoreDiModule
|
||||
import org.meshtastic.core.network.di.CoreNetworkModule
|
||||
import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
|
||||
import org.meshtastic.core.prefs.di.CorePrefsModule
|
||||
|
|
@ -57,7 +56,6 @@ import org.meshtastic.feature.settings.di.FeatureSettingsModule
|
|||
includes =
|
||||
[
|
||||
org.meshtastic.app.MainKoinModule::class,
|
||||
CoreDiModule::class,
|
||||
CoreCommonModule::class,
|
||||
CoreBleModule::class,
|
||||
CoreBleAndroidModule::class,
|
||||
|
|
@ -91,6 +89,14 @@ class AppKoinModule {
|
|||
@Named("ProcessLifecycle")
|
||||
fun provideProcessLifecycle(): Lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||
|
||||
@Single
|
||||
fun provideCoroutineDispatchers(): org.meshtastic.core.di.CoroutineDispatchers =
|
||||
org.meshtastic.core.di.CoroutineDispatchers(
|
||||
io = kotlinx.coroutines.Dispatchers.IO,
|
||||
main = kotlinx.coroutines.Dispatchers.Main,
|
||||
default = kotlinx.coroutines.Dispatchers.Default,
|
||||
)
|
||||
|
||||
@Single
|
||||
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
|
||||
override val isDebug: Boolean = org.meshtastic.app.BuildConfig.DEBUG
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class AndroidRadioInterfaceService(
|
|||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (radioIf is NordicBleInterface) {
|
||||
} else if (radioIf is BleRadioInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.repository.radio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.retryBleOperation
|
||||
import org.meshtastic.core.ble.toMeshtasticRadioProfile
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.RadioNotConnectedException
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 1000L
|
||||
private const val CONNECTION_TIMEOUT_MS = 15_000L
|
||||
private val SCAN_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
|
||||
*
|
||||
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
|
||||
* - Bonding and discovery.
|
||||
* - Automatic reconnection logic.
|
||||
* - MTU and connection parameter monitoring.
|
||||
* - Routing raw byte packets between the radio and [RadioInterfaceService].
|
||||
*
|
||||
* @param serviceScope The coroutine scope to use for launching coroutines.
|
||||
* @param scanner The BLE scanner.
|
||||
* @param bluetoothRepository The Bluetooth repository.
|
||||
* @param connectionFactory The BLE connection factory.
|
||||
* @param service The [RadioInterfaceService] to use for handling radio events.
|
||||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BleRadioInterface(
|
||||
private val serviceScope: CoroutineScope,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val service: RadioInterfaceService,
|
||||
val address: String,
|
||||
) : RadioTransport {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
serviceScope.launch {
|
||||
try {
|
||||
bleConnection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
|
||||
}
|
||||
}
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
|
||||
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
private var connectionStartTime: Long = 0
|
||||
private var packetsReceived: Int = 0
|
||||
private var packetsSent: Int = 0
|
||||
private var bytesReceived: Long = 0
|
||||
private var bytesSent: Long = 0
|
||||
|
||||
@Volatile private var isFullyConnected = false
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
// --- Connection & Discovery Logic ---
|
||||
|
||||
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
|
||||
private suspend fun findDevice(): BleDevice {
|
||||
bluetoothRepository.state.value.bondedDevices
|
||||
.firstOrNull { it.address == address }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning..." }
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
try {
|
||||
val d =
|
||||
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
|
||||
scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
|
||||
it.address == address
|
||||
}
|
||||
}
|
||||
if (d != null) return d
|
||||
} catch (e: Exception) {
|
||||
Logger.v(e) { "Scan attempt failed or timed out" }
|
||||
}
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
delay(SCAN_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
throw RadioNotConnectedException("Device not found at address $address")
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
connectionScope.launch {
|
||||
val device = findDevice()
|
||||
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
if (state is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
onDisconnected(state)
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
|
||||
while (isActive) {
|
||||
try {
|
||||
// Add a delay to allow any pending background disconnects (from a previous close() call)
|
||||
// to complete and the Android BLE stack to settle before we attempt a new connection.
|
||||
@Suppress("MagicNumber")
|
||||
val connectDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(connectDelayMs)
|
||||
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
|
||||
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
// Kable on Android occasionally fails the first connection attempt with NotConnectedException
|
||||
// if the previous peripheral wasn't fully cleaned up by the OS. A quick retry resolves it.
|
||||
Logger.w { "[$address] First connection attempt failed, retrying in 1.5s..." }
|
||||
@Suppress("MagicNumber")
|
||||
val retryDelayMs = 1500L
|
||||
kotlinx.coroutines.delay(retryDelayMs)
|
||||
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
|
||||
// Suspend here until Kable drops the connection
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
|
||||
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
Logger.d { "[$address] BLE connection coroutine cancelled" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
|
||||
handleFailure(e)
|
||||
|
||||
// Wait before retrying to prevent hot loops
|
||||
@Suppress("MagicNumber")
|
||||
kotlinx.coroutines.delay(5000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onConnected() {
|
||||
try {
|
||||
bleConnection.deviceFlow.first()?.let { device ->
|
||||
val rssi = retryBleOperation(tag = address) { device.readRssi() }
|
||||
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
|
||||
radioService = null
|
||||
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] BLE disconnected, " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
|
||||
// Note: Disconnected state in commonMain doesn't currently carry a reason.
|
||||
// We might want to add that later if needed.
|
||||
service.onDisconnect(false, errorMessage = "Disconnected")
|
||||
}
|
||||
|
||||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
try {
|
||||
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
|
||||
val radioService = service.toMeshtasticRadioProfile()
|
||||
|
||||
// Wire up notifications
|
||||
radioService.fromRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in fromRadio flow" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
radioService.logRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in logRadio flow" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
// Store reference for handleSendToRadio
|
||||
this@BleRadioInterface.radioService = radioService
|
||||
|
||||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
|
||||
// Log negotiated MTU for diagnostics
|
||||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
|
||||
|
||||
this@BleRadioInterface.service.onConnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
|
||||
bleConnection.disconnect()
|
||||
handleFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
|
||||
|
||||
// --- RadioTransport Implementation ---
|
||||
|
||||
/**
|
||||
* Sends a packet to the radio with retry support.
|
||||
*
|
||||
* @param p The packet to send.
|
||||
*/
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
val currentService = radioService
|
||||
if (currentService != null) {
|
||||
connectionScope.launch {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
retryBleOperation(tag = address) { currentService.sendToRadio(p) }
|
||||
packetsSent++
|
||||
bytesSent += p.size
|
||||
Logger.d {
|
||||
"[$address] Successfully wrote packet #$packetsSent " +
|
||||
"to toRadioCharacteristic - " +
|
||||
"${p.size} bytes (Total TX: $bytesSent bytes)"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to write packet to toRadioCharacteristic after " +
|
||||
"$packetsSent successful writes"
|
||||
}
|
||||
handleFailure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
Logger.d { "[$address] BLE keepAlive" }
|
||||
}
|
||||
|
||||
/** Closes the connection to the device. */
|
||||
override fun close() {
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.i {
|
||||
"[$address] Disconnecting. " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
connectionScope.launch {
|
||||
bleConnection.disconnect()
|
||||
service.onDisconnect(true)
|
||||
connectionScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchPacket(packet: ByteArray) {
|
||||
packetsReceived++
|
||||
bytesReceived += packet.size
|
||||
Logger.d {
|
||||
"[$address] Dispatching packet to service.handleFromRadio() - " +
|
||||
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
|
||||
}
|
||||
service.handleFromRadio(packet)
|
||||
}
|
||||
|
||||
private fun handleFailure(throwable: Throwable) {
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
|
||||
val isPermanent =
|
||||
this::class.simpleName == "BluetoothUnavailableException" ||
|
||||
this::class.simpleName == "ManagerClosedException"
|
||||
val msg =
|
||||
when {
|
||||
this is RadioNotConnectedException -> this.message ?: "Device not found"
|
||||
this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
|
||||
this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
|
||||
else -> this.message ?: this::class.simpleName ?: "Unknown"
|
||||
}
|
||||
return Pair(isPermanent, msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,14 +22,14 @@ import org.meshtastic.core.ble.BleScanner
|
|||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Factory for creating `NordicBleInterface` instances. */
|
||||
/** Factory for creating `BleRadioInterface` instances. */
|
||||
@Single
|
||||
class NordicBleInterfaceFactory(
|
||||
class BleRadioInterfaceFactory(
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
) {
|
||||
fun create(rest: String, service: RadioInterfaceService): NordicBleInterface = NordicBleInterface(
|
||||
fun create(rest: String, service: RadioInterfaceService): BleRadioInterface = BleRadioInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
|
|
@ -16,26 +16,19 @@
|
|||
*/
|
||||
package org.meshtastic.app.repository.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
/** Bluetooth backend implementation. */
|
||||
@Single
|
||||
class NordicBleInterfaceSpec(
|
||||
private val factory: NordicBleInterfaceFactory,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
) : InterfaceSpec<NordicBleInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): NordicBleInterface =
|
||||
class BleRadioInterfaceSpec(private val factory: BleRadioInterfaceFactory) : InterfaceSpec<BleRadioInterface> {
|
||||
override fun createInterface(rest: String, service: RadioInterfaceService): BleRadioInterface =
|
||||
factory.create(rest, service)
|
||||
|
||||
/** Return true if this address is still acceptable. For BLE that means, still bonded */
|
||||
override fun addressValid(rest: String): Boolean = if (!bluetoothRepository.isBonded(rest)) {
|
||||
Logger.w { "Ignoring stale bond to ${rest.anonymize}" }
|
||||
false
|
||||
} else {
|
||||
true
|
||||
/** Return true if this address is still acceptable. For Kable we don't strictly require prior bonding. */
|
||||
override fun addressValid(rest: String): Boolean {
|
||||
// We no longer strictly require the device to be in the bonded list before attempting connection,
|
||||
// as Kable and Android will handle bonding seamlessly during connection/characteristic access if needed.
|
||||
return rest.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ import org.meshtastic.core.repository.RadioTransport
|
|||
@Single
|
||||
class InterfaceFactory(
|
||||
private val nopInterfaceFactory: NopInterfaceFactory,
|
||||
private val bluetoothSpec: Lazy<NordicBleInterfaceSpec>,
|
||||
private val bluetoothSpec: Lazy<BleRadioInterfaceSpec>,
|
||||
private val mockSpec: Lazy<MockInterfaceSpec>,
|
||||
private val serialSpec: Lazy<SerialInterfaceSpec>,
|
||||
private val tcpSpec: Lazy<TCPInterfaceSpec>,
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.repository.radio
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteService
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
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.TORADIO_CHARACTERISTIC
|
||||
|
||||
class MeshtasticRadioServiceImpl(private val remoteService: RemoteService) : MeshtasticRadioProfile.State {
|
||||
|
||||
private val toRadioCharacteristic: RemoteCharacteristic =
|
||||
remoteService.characteristics.first { it.uuid == TORADIO_CHARACTERISTIC }
|
||||
private val fromRadioCharacteristic: RemoteCharacteristic =
|
||||
remoteService.characteristics.first { it.uuid == FROMRADIO_CHARACTERISTIC }
|
||||
private val fromRadioSyncCharacteristic: RemoteCharacteristic? =
|
||||
remoteService.characteristics.firstOrNull { it.uuid == FROMRADIOSYNC_CHARACTERISTIC }
|
||||
private val fromNumCharacteristic: RemoteCharacteristic? =
|
||||
if (fromRadioSyncCharacteristic == null) {
|
||||
remoteService.characteristics.first { it.uuid == FROMNUM_CHARACTERISTIC }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
private val logRadioCharacteristic: RemoteCharacteristic =
|
||||
remoteService.characteristics.first { it.uuid == LOGRADIO_CHARACTERISTIC }
|
||||
|
||||
private val triggerDrain = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
|
||||
|
||||
init {
|
||||
require(toRadioCharacteristic.isWritable()) { "TORADIO must be writable" }
|
||||
require(fromRadioCharacteristic.isReadable()) { "FROMRADIO must be readable" }
|
||||
fromRadioSyncCharacteristic?.let { require(it.isSubscribable()) { "FROMRADIOSYNC must be subscribable" } }
|
||||
fromNumCharacteristic?.let { require(it.isSubscribable()) { "FROMNUM must be subscribable" } }
|
||||
require(logRadioCharacteristic.isSubscribable()) { "LOGRADIO must be subscribable" }
|
||||
}
|
||||
|
||||
override val fromRadio: Flow<ByteArray> =
|
||||
if (fromRadioSyncCharacteristic != null) {
|
||||
fromRadioSyncCharacteristic.subscribe()
|
||||
} else {
|
||||
// Legacy path: drain fromRadio characteristic when notified or after write
|
||||
channelFlow {
|
||||
launch { fromNumCharacteristic!!.subscribe().collect { triggerDrain.tryEmit(Unit) } }
|
||||
|
||||
triggerDrain.collect {
|
||||
var keepReading = true
|
||||
while (keepReading) {
|
||||
try {
|
||||
val packet = fromRadioCharacteristic.read()
|
||||
if (packet.isEmpty()) {
|
||||
keepReading = false
|
||||
} else {
|
||||
send(packet)
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
co.touchlab.kermit.Logger.e(e) { "BLE: Failed to read from FROMRADIO" }
|
||||
keepReading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val logRadio: Flow<ByteArray> = logRadioCharacteristic.subscribe()
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
toRadioCharacteristic.write(packet, WriteType.WITHOUT_RESPONSE)
|
||||
if (fromRadioSyncCharacteristic == null) {
|
||||
triggerDrain.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Track android_kable_migration_20260314 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"track_id": "android_kable_migration_20260314",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2026-03-14T17:15:00Z",
|
||||
"updated_at": "2026-03-14T17:15:00Z",
|
||||
"description": "Replace Nordic with Kable on Android"
|
||||
}
|
||||
44
conductor/archive/android_kable_migration_20260314/plan.md
Normal file
44
conductor/archive/android_kable_migration_20260314/plan.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Implementation Plan: Replace Nordic with Kable on Android (Deduplication Pass)
|
||||
|
||||
## Phase 1: Deduplicate Kable Abstractions into `commonMain` [checkpoint: 709f6e3]
|
||||
- [x] Task: Extract common Kable state mapping logic from jvmMain to commonMain 10cdd16
|
||||
- [x] Create `commonMain` tests for `BleConnectionState` mapping using Kable `State`
|
||||
- [x] Move `KableMeshtasticRadioProfile` and `KableBleConnection` logic that doesn't depend on platform specifics to `commonMain`
|
||||
- [x] Task: Implement common Kable `Scanner` and `Peripheral` wrappers 2691d70
|
||||
- [x] Extract generic connection lifecycle (connect, reconnect, close) to `commonMain` using Kable's `Peripheral` interface
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Deduplicate Kable Abstractions into commonMain' (Protocol in workflow.md) 709f6e3
|
||||
|
||||
## Phase 2: Implement Kable Backend for Android (`androidMain`) [checkpoint: 12217de]
|
||||
- [x] Task: Add Kable dependency to Android source set in `core:ble/build.gradle.kts` 011d619
|
||||
- [x] Task: Implement Android-specific `BleConnectionFactory` and `BleScanner` using the deduplicated `commonMain` logic 589ee93
|
||||
- [x] Write failing integration tests for Android Kable scanner (using fakes/mocks)
|
||||
- [x] Implement `KableBleScanner` for `androidMain`
|
||||
- [x] Write failing integration tests for Android Kable connection (using fakes/mocks)
|
||||
- [x] Implement `KableBleConnection` for `androidMain` (handling Android-specific MTU requests if necessary)
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2: Implement Kable Backend for Android' (Protocol in workflow.md) 12217de
|
||||
|
||||
## Phase 3: Migrate OTA Firmware Update Logic [checkpoint: 663c8e2]
|
||||
- [x] Task: Deprecate `NordicDfuHandler` and replace with Kable-based DFU 06fe4f5
|
||||
- [x] Write failing tests for Kable DFU integration
|
||||
- [x] Implement new DFU handler in `feature:firmware` using `MeshtasticRadioProfile` / Kable abstraction
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 3: Migrate OTA Firmware Update Logic' (Protocol in workflow.md) 663c8e2
|
||||
|
||||
## Phase 4: Wire Kable into Android App and Remove Nordic [checkpoint: ebe1617]
|
||||
- [x] Task: Deprecate and remove `NordicBleInterface` and `AndroidBleConnection` ebe1617
|
||||
- [x] Remove `NordicAndroidCommonLibraries` and `NordicDfuLibrary` from `gradle/libs.versions.toml` and build files
|
||||
- [x] Delete `NordicBleInterface.kt` and associated Nordic-specific radio implementations
|
||||
- [x] Task: Wire new `androidMain` Kable implementation into the Koin DI graph ebe1617
|
||||
- [x] Update `AndroidRadioControllerImpl` or DI modules to provide the new Kable `BleConnectionFactory` and `BleScanner`
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Wire Kable into Android App and Remove Nordic' (Protocol in workflow.md) ebe1617
|
||||
|
||||
## Phase 5: Final Testing and Integration [checkpoint: 4778c0e]
|
||||
- [x] Task: Update Android `app` UI tests and BLE unit tests to use Kable fakes 4778c0e
|
||||
- [x] Fix any failing tests related to the Nordic removal
|
||||
- [x] Task: Manual end-to-end verification 4778c0e
|
||||
- [x] Build and run the Android app, verify BLE scanning, connecting, and messaging
|
||||
- [x] Verify OTA updates work via BLE
|
||||
- [x] Verify the Desktop app still functions correctly
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 5: Final Testing and Integration' (Protocol in workflow.md) 4778c0e
|
||||
|
||||
## Phase: Review Fixes
|
||||
- [x] Task: Apply review suggestions e5dffd9
|
||||
28
conductor/archive/android_kable_migration_20260314/spec.md
Normal file
28
conductor/archive/android_kable_migration_20260314/spec.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Specification: Replace Nordic with Kable on Android (Deduplication Pass)
|
||||
|
||||
## Overview
|
||||
This track executes a full migration of the Android application's BLE transport layer from the legacy Nordic Android Common Libraries to the multiplatform Kable library. Building upon the successful `MeshtasticRadioProfile` abstraction introduced for the Desktop target, this track aims to unify the BLE transport layer across all platforms (Android, Desktop, iOS) under a single KMP technology stack. Crucially, this pass focuses on **maximal code deduplication**, moving as much BLE logic as possible into `commonMain` to share it across all targets, including OTA firmware update logic.
|
||||
|
||||
## Functional Requirements
|
||||
- **Kable Integration:** Implement the `MeshtasticRadioProfile` using Kable for the `androidMain` source set, replacing the existing Nordic implementation.
|
||||
- **Maximal Deduplication:** Refactor the existing Kable `jvmMain` implementation and the new `androidMain` implementation to extract common connection management, scanning logic, and characteristic observation into `core:ble/commonMain`.
|
||||
- **OTA Firmware Updates:** Migrate the Android OTA firmware update logic (currently handled by `NordicDfuHandler`) to use the new Kable/KMP abstraction.
|
||||
- **Full Migration:** The Android app must exclusively use the new Kable backend for all BLE operations (scanning, connecting, data transfer, firmware updates).
|
||||
- **Deprecation/Removal:** Remove all dependencies on the Nordic Android Common Libraries and Nordic DFU libraries from the project configuration (`build.gradle.kts`, version catalogs).
|
||||
- **Feature Parity:** The new Kable implementation on Android must maintain full feature parity with the previous Nordic implementation, including connection stability, MTU negotiation, and data throughput.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Expanded Testing:** Adapt existing Android BLE tests to use Kable fakes and write new `commonMain` tests to expand test coverage for the shared KMP BLE abstraction.
|
||||
- **Architecture:** Maintain strict adherence to the MVI/UDF patterns and the pure KMP DI architecture (Koin annotations).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Kable backend is fully implemented for Android (`androidMain`).
|
||||
- [ ] Nordic Android Common Libraries and DFU dependencies are completely removed from the project.
|
||||
- [ ] Android application successfully scans, connects, and transfers data via BLE using Kable.
|
||||
- [ ] BLE logic (connection state, profile mapping, retry logic) is heavily deduplicated into `core:ble/commonMain`.
|
||||
- [ ] OTA firmware update logic is successfully migrated to use the Kable backend.
|
||||
- [ ] Existing BLE tests are updated or replaced, and all test suites pass.
|
||||
- [ ] New KMP BLE tests are added, improving overall test coverage.
|
||||
|
||||
## Out of Scope
|
||||
- Migrating USB or TCP network transports.
|
||||
5
conductor/archive/desktop_ble_kable_20260314/index.md
Normal file
5
conductor/archive/desktop_ble_kable_20260314/index.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Track desktop_ble_kable_20260314 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"track_id": "desktop_ble_kable_20260314",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2026-03-14T12:00:00Z",
|
||||
"updated_at": "2026-03-14T12:00:00Z",
|
||||
"description": "Kable swap Keep Nordic on Android short-term. Add Kable backend only for jvmMain in core:ble first (desktop BLE enablement). Introduce a MeshtasticRadioProfile abstraction in core:ble/commonMain so NordicBleInterface no longer depends on Android/Nordic classes. Once that seam is clean, decide whether Android should stay Nordic or move to Kable."
|
||||
}
|
||||
37
conductor/archive/desktop_ble_kable_20260314/plan.md
Normal file
37
conductor/archive/desktop_ble_kable_20260314/plan.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Implementation Plan: Desktop BLE Enablement via Kable
|
||||
|
||||
## Phase 1: Define `MeshtasticRadioProfile` Abstraction [checkpoint: 1206e87]
|
||||
- [x] Task: Define `MeshtasticRadioProfile` interface in `core:ble/commonMain` eaa623a
|
||||
- [ ] Write tests for expected profile behavior (e.g., state flow emission) using a simple fake
|
||||
- [ ] Implement `MeshtasticRadioProfile` interface, data classes for states, and configuration
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Define `MeshtasticRadioProfile` Abstraction' (Protocol in workflow.md) 1206e87
|
||||
|
||||
## Phase 2: Refactor Nordic Implementation to use Abstraction [checkpoint: dc700a5]
|
||||
- [x] Task: Implement `MeshtasticRadioProfile` in the existing Nordic implementation (`androidMain`) 83a8a9b
|
||||
- [ ] Write/adapt existing Android tests to verify `MeshtasticRadioProfile` adherence
|
||||
- [ ] Implement wrapper/adapter for Nordic classes to fulfill `MeshtasticRadioProfile`
|
||||
- [x] Task: Decouple app-level BLE transport from Nordic types 2dfedde
|
||||
- [ ] Write tests to ensure BLE transport only relies on `MeshtasticRadioProfile`
|
||||
- [ ] Refactor transport layer (e.g., `NordicBleInterface` usages) to use the new profile interface
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2: Refactor Nordic Implementation to use Abstraction' (Protocol in workflow.md) dc700a5
|
||||
|
||||
## Phase 3: Implement Kable Backend for Desktop [checkpoint: ed2a459]
|
||||
- [x] Task: Setup Kable dependencies for `jvmMain` in `core:ble` b152eff
|
||||
- [ ] Update `build.gradle.kts` to include Kable dependency for Desktop
|
||||
- [x] Task: Implement Kable `MeshtasticRadioProfile` backend (`jvmMain`) fa5cc82
|
||||
- [ ] Write `commonMain` unit tests with Kable fakes to verify scanning, connection, and read/write operations
|
||||
- [ ] Implement Kable scanning logic
|
||||
- [ ] Implement Kable connection and characteristic management
|
||||
- [ ] Implement Kable read/write data transfer logic
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 3: Implement Kable Backend for Desktop' (Protocol in workflow.md) ed2a459
|
||||
|
||||
## Phase 4: Integration and Final Testing [checkpoint: af6d3b3]
|
||||
- [x] Task: Integrate Kable backend into Desktop app DI graph 28afcad
|
||||
- [ ] Wire up the Kable implementation in `desktop` module DI
|
||||
- [x] Task: End-to-end verification 84aae75
|
||||
- [ ] Verify Android app still compiles and connects using Nordic
|
||||
- [ ] Verify Desktop app compiles and connects using Kable
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Integration and Final Testing' (Protocol in workflow.md) af6d3b3
|
||||
|
||||
## Phase: Review Fixes
|
||||
- [x] Task: Apply review suggestions b36da82
|
||||
31
conductor/archive/desktop_ble_kable_20260314/spec.md
Normal file
31
conductor/archive/desktop_ble_kable_20260314/spec.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Specification: Desktop BLE Enablement via Kable
|
||||
|
||||
## Overview
|
||||
This track introduces a Kable BLE backend specifically for the `jvmMain` (Desktop) target within `core:ble`. To facilitate this without breaking the existing Android implementation, we will introduce a `MeshtasticRadioProfile` abstraction in `core:ble/commonMain`. This abstraction will ensure that the app-level BLE transport path no longer depends on Android-specific or Nordic-specific classes. Initially, Android will continue to use the Nordic BLE implementation, while Desktop will use Kable. Once this seam is proven, a future decision will determine whether Android should fully migrate to Kable. This approach lays the groundwork for seamless integration of future targets (e.g., iOS) under the same KMP abstraction.
|
||||
|
||||
## Functional Requirements
|
||||
- **MeshtasticRadioProfile Abstraction:** Introduce a multiplatform interface (`MeshtasticRadioProfile`) in `core:ble/commonMain` to abstract all BLE operations.
|
||||
- **Remove Nordic Dependencies:** Ensure that the app-level BLE transport path is entirely decoupled from Nordic types, relying solely on the new abstraction.
|
||||
- **Kable Backend (jvmMain):** Implement the Kable backend for the Desktop target. This backend must support all core BLE operations:
|
||||
- Scanning for nearby Meshtastic devices.
|
||||
- Establishing and managing BLE connections.
|
||||
- Reading from and writing to characteristics (sending/receiving protobuf payloads).
|
||||
- **Nordic Backend Preservation (androidMain):** Update the existing Android Nordic implementation to implement the new `MeshtasticRadioProfile` interface without changing its core behavior.
|
||||
- **Future-Proofing:** Design the abstraction in a way that is generic enough to support adding an iOS or other future target's BLE implementation with minimal refactoring.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Testing:** New `commonMain` unit tests must be written utilizing fakes for the Kable implementation. This is crucial as we cannot rely on Nordic's ready-made mocks in a multiplatform context or if a full migration to Kable occurs.
|
||||
- **Architecture:** The abstraction must adhere to the project's KMP goals, keeping `core:ble/commonMain` completely free of platform-specific imports (e.g., `java.*`, `android.*`).
|
||||
- **Compatibility:** The Android build and BLE functionality must remain fully functional using the existing Nordic library.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `MeshtasticRadioProfile` is defined in `core:ble/commonMain`.
|
||||
- [ ] No Nordic-specific or Android-specific types are present in the app-level BLE transport path.
|
||||
- [ ] Desktop application can successfully scan, connect, and perform read/write operations with a Meshtastic device using Kable.
|
||||
- [ ] Android application continues to function normally using the Nordic library.
|
||||
- [ ] New unit tests using Kable fakes are added to `commonMain` and pass successfully.
|
||||
- [ ] The abstraction architecture provides a clear path for future platform support (like iOS).
|
||||
|
||||
## Out of Scope
|
||||
- Migrating the Android application to use the Kable backend (this will be evaluated after this track is complete).
|
||||
- Modifying non-BLE network transports (e.g., USB, TCP).
|
||||
|
|
@ -19,6 +19,6 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facil
|
|||
- Device configuration and firmware updates
|
||||
|
||||
## Key Architecture Goals
|
||||
- Provide a robust, shared KMP core (`core:model`, `core:repository`, `core:domain`, `core:data`, `core:network`) to support multiple platforms (Android, Desktop, iOS)
|
||||
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`) to support multiple platforms (Android, Desktop, iOS)
|
||||
- Ensure offline-first functionality and resilient data persistence (Room KMP)
|
||||
- Decouple UI logic into shared components (`core:ui`, `feature:*`) using Compose Multiplatform
|
||||
|
|
@ -20,4 +20,5 @@
|
|||
|
||||
## Networking & Transport
|
||||
- **Ktor:** Multiplatform HTTP client for web services and TCP streaming.
|
||||
- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS).
|
||||
- **Coroutines & Flows:** For asynchronous programming and state management.
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
|
||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -23,38 +23,38 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
|
|||
|
||||
## Overview
|
||||
|
||||
The `:core:ble` module contains the foundation for Bluetooth Low Energy (BLE) communication in the Meshtastic Android app. It has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**.
|
||||
The `:core:ble` module contains the foundation for Bluetooth Low Energy (BLE) communication in the Meshtastic Android app. It uses the **Kable** multiplatform BLE library to provide a unified, Coroutine-based architecture across all supported targets (Android, Desktop, and future iOS).
|
||||
|
||||
This modernization replaces legacy callback-based implementations with robust, Coroutine-based architecture, ensuring better stability, maintainability, and standard compliance.
|
||||
This module abstracts platform-specific BLE operations behind common Kotlin interfaces (`BleDevice`, `BleScanner`, `BleConnection`, `BleConnectionFactory`), ensuring that business logic in `commonMain` remains platform-agnostic and testable.
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. `BleConnection`
|
||||
A robust wrapper around Nordic's `Peripheral` and `CentralManager` that simplifies the connection lifecycle and service discovery using modern Coroutine APIs.
|
||||
A robust wrapper around Kable's `Peripheral` that simplifies the connection lifecycle and service discovery using modern Coroutine APIs.
|
||||
|
||||
- **Features:**
|
||||
- **Connection & Await:** Provides suspend functions to connect and wait for a terminal state (Connected or Disconnected).
|
||||
- **Unified Profile Helper:** A `profile` function that manages service discovery, characteristic setup, and lifecycle in a single block, with automatic timeout and error handling.
|
||||
- **Observability:** Exposes `peripheralFlow` and `connectionState` as Flows for reactive UI and service updates.
|
||||
- **Connection Management:** Handles PHY updates, MTU logging, and connection priority requests automatically.
|
||||
- **Observability:** Exposes `connectionState` as a Flow for reactive UI and service updates.
|
||||
- **Platform Setup:** Seamlessly handles platform-specific configuration (like MTU negotiation on Android or direct connections on Desktop) via `platformConfig()` extensions.
|
||||
|
||||
### 2. `BluetoothRepository`
|
||||
A Singleton repository responsible for the global state of Bluetooth on the Android device.
|
||||
A Singleton repository responsible for the global state of Bluetooth on the device.
|
||||
|
||||
- **Features:**
|
||||
- **State Management:** Exposes a `StateFlow<BluetoothState>` reflecting whether Bluetooth is enabled, permissions are granted, and which devices are bonded.
|
||||
- **Permission Handling:** Centralizes logic for checking Bluetooth and Location permissions across different Android versions.
|
||||
- **Bonding:** Simplifies the process of creating bonds with peripherals.
|
||||
- **Permission Handling:** Centralizes logic for checking Bluetooth and Location permissions across different platforms.
|
||||
- **Bonding:** Simplifies the process of creating and validating bonds with peripherals.
|
||||
|
||||
### 3. `BleScanner`
|
||||
A wrapper around Nordic's `CentralManager` scanning capabilities to provide a consistent and easy-to-use API for BLE scanning with built-in peripheral deduplication.
|
||||
A wrapper around Kable's `Scanner` to provide a consistent and easy-to-use API for BLE scanning with built-in peripheral mapping.
|
||||
|
||||
### 4. `BleRetry`
|
||||
A utility for executing BLE operations with retry logic, essential for handling the inherent unreliability of wireless communication.
|
||||
|
||||
## Integration in `app`
|
||||
|
||||
The `:core:ble` module is used by `NordicBleInterface` in the main application module to implement the `RadioTransport` interface for Bluetooth devices.
|
||||
The `:core:ble` module is used by `BleRadioInterface` in the main application module to implement the `RadioTransport` interface for Bluetooth devices.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -62,17 +62,15 @@ Dependencies are managed via the version catalog (`libs.versions.toml`).
|
|||
|
||||
```toml
|
||||
[versions]
|
||||
nordic-ble = "2.0.0-alpha15"
|
||||
nordic-common = "2.8.2"
|
||||
kable = "0.42.0"
|
||||
|
||||
[libraries]
|
||||
nordic-client-android = { module = "no.nordicsemi.kotlin.ble:client-android", version.ref = "nordic-ble" }
|
||||
# ... other nordic dependencies
|
||||
kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" }
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The module follows a clean architecture approach:
|
||||
The module follows a clean multiplatform architecture approach:
|
||||
|
||||
- **Repository Pattern:** `BluetoothRepository` mediates data access.
|
||||
- **Coroutines & Flow:** All asynchronous operations use Kotlin Coroutines and Flows.
|
||||
|
|
@ -80,4 +78,4 @@ The module follows a clean architecture approach:
|
|||
|
||||
## Testing
|
||||
|
||||
The module includes unit tests for key components, mocking the underlying Nordic libraries to ensure logic correctness without requiring a physical device.
|
||||
The module includes unit tests for key components, utilizing Kable's architecture and standard coroutine testing tools to ensure logic correctness.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ kotlin {
|
|||
android {
|
||||
namespace = "org.meshtastic.core.ble"
|
||||
androidResources.enable = false
|
||||
withHostTest { isIncludeAndroidResources = true }
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
|
|
@ -37,31 +38,27 @@ kotlin {
|
|||
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kable.core)
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
api(libs.nordic.client.android)
|
||||
api(libs.nordic.ble.env.android)
|
||||
api(libs.nordic.ble.env.android.compose)
|
||||
api(libs.nordic.common.scanner.ble)
|
||||
api(libs.nordic.common.core)
|
||||
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
}
|
||||
|
||||
jvmMain.dependencies {}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.mockk)
|
||||
}
|
||||
|
||||
androidUnitTest.dependencies {
|
||||
implementation(libs.junit)
|
||||
implementation(libs.nordic.client.android.mock)
|
||||
implementation(libs.nordic.client.core.mock)
|
||||
implementation(libs.nordic.core.mock)
|
||||
implementation(libs.androidx.lifecycle.testing)
|
||||
val androidHostTest by getting {
|
||||
dependencies {
|
||||
implementation(libs.junit)
|
||||
implementation(libs.androidx.lifecycle.testing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.android.common.core.simpleSharedFlow
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* An Android implementation of [BleConnection] using Nordic's [CentralManager].
|
||||
*
|
||||
* @param centralManager The Nordic [CentralManager] to use for connection.
|
||||
* @param scope The [CoroutineScope] in which to monitor connection state.
|
||||
* @param tag A tag for logging.
|
||||
*/
|
||||
class AndroidBleConnection(
|
||||
private val centralManager: CentralManager,
|
||||
private val scope: CoroutineScope,
|
||||
private val tag: String = "BLE",
|
||||
) : BleConnection {
|
||||
|
||||
private var _device: AndroidBleDevice? = null
|
||||
override val device: BleDevice?
|
||||
get() = _device
|
||||
|
||||
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
|
||||
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
|
||||
|
||||
private val _connectionState = simpleSharedFlow<BleConnectionState>()
|
||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
private var stateJob: Job? = null
|
||||
private var profileJob: Job? = null
|
||||
|
||||
override suspend fun connect(device: BleDevice) = withContext(NonCancellable) {
|
||||
val androidDevice = device as AndroidBleDevice
|
||||
stateJob?.cancel()
|
||||
_device = androidDevice
|
||||
_deviceFlow.emit(androidDevice)
|
||||
|
||||
centralManager.connect(
|
||||
peripheral = androidDevice.peripheral,
|
||||
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
|
||||
stateJob =
|
||||
androidDevice.peripheral.state
|
||||
.onEach { state ->
|
||||
Logger.d { "[$tag] Connection state changed to $state" }
|
||||
val commonState =
|
||||
when (state) {
|
||||
is ConnectionState.Connecting -> BleConnectionState.Connecting
|
||||
is ConnectionState.Connected -> BleConnectionState.Connected
|
||||
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
|
||||
}
|
||||
|
||||
if (state is ConnectionState.Connected) {
|
||||
androidDevice.peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
|
||||
observePeripheralDetails(androidDevice)
|
||||
}
|
||||
|
||||
androidDevice.updateState(state)
|
||||
_connectionState.emit(commonState)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
override suspend fun connectAndAwait(
|
||||
device: BleDevice,
|
||||
timeoutMs: Long,
|
||||
onRegister: suspend () -> Unit,
|
||||
): BleConnectionState {
|
||||
onRegister()
|
||||
connect(device)
|
||||
return withTimeout(timeoutMs) {
|
||||
connectionState.first { it is BleConnectionState.Connected || it is BleConnectionState.Disconnected }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private fun observePeripheralDetails(androidDevice: AndroidBleDevice) {
|
||||
val p = androidDevice.peripheral
|
||||
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
|
||||
|
||||
p.connectionParameters
|
||||
.onEach { params ->
|
||||
Logger.i { "[$tag] BLE connection parameters changed to $params" }
|
||||
try {
|
||||
val maxWriteLen = p.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$tag] Negotiated MTU (Write): $maxWriteLen bytes" }
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "[$tag] Could not read MTU: ${e.message}" }
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
override suspend fun disconnect() = withContext(NonCancellable) {
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
profileJob?.cancel()
|
||||
profileJob = null
|
||||
_device?.peripheral?.disconnect()
|
||||
_device = null
|
||||
_deviceFlow.emit(null)
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun <T> profile(
|
||||
serviceUuid: Uuid,
|
||||
timeout: kotlin.time.Duration,
|
||||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T {
|
||||
val androidDevice = deviceFlow.first { it != null } as AndroidBleDevice
|
||||
val p = androidDevice.peripheral
|
||||
val serviceReady = CompletableDeferred<T>()
|
||||
|
||||
profileJob?.cancel()
|
||||
val job =
|
||||
scope.launch {
|
||||
try {
|
||||
val profileScope = this
|
||||
p.profile(serviceUuid = serviceUuid, required = true, scope = profileScope) { service ->
|
||||
try {
|
||||
val result = setup(AndroidBleService(service))
|
||||
serviceReady.complete(result)
|
||||
awaitCancellation()
|
||||
} catch (e: Throwable) {
|
||||
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
profileJob = job
|
||||
|
||||
return try {
|
||||
withTimeout(timeout) { serviceReady.await() }
|
||||
} catch (e: Throwable) {
|
||||
profileJob?.cancel()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
|
||||
val nordicWriteType =
|
||||
when (writeType) {
|
||||
BleWriteType.WITH_RESPONSE -> WriteType.WITH_RESPONSE
|
||||
BleWriteType.WITHOUT_RESPONSE -> WriteType.WITHOUT_RESPONSE
|
||||
}
|
||||
return _device?.peripheral?.maximumWriteValueLength(nordicWriteType)
|
||||
}
|
||||
|
||||
/** Requests a new connection priority for the current peripheral. */
|
||||
suspend fun requestConnectionPriority(priority: ConnectionPriority) {
|
||||
_device?.peripheral?.requestConnectionPriority(priority)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.BondState
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
|
||||
/** An Android implementation of [BleDevice] that wraps a Nordic [Peripheral]. */
|
||||
class AndroidBleDevice(val peripheral: Peripheral) : BleDevice {
|
||||
override val name: String?
|
||||
get() = peripheral.name
|
||||
|
||||
override val address: String
|
||||
get() = peripheral.address
|
||||
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
override val isBonded: Boolean
|
||||
get() = peripheral.bondState.value == BondState.BONDED
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = peripheral.isConnected
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun readRssi(): Int = peripheral.readRssi()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun bond() {
|
||||
peripheral.createBond()
|
||||
}
|
||||
|
||||
/** Updates the connection state based on Nordic's [ConnectionState]. */
|
||||
fun updateState(nordicState: ConnectionState) {
|
||||
_state.value =
|
||||
when (nordicState) {
|
||||
is ConnectionState.Connecting -> BleConnectionState.Connecting
|
||||
is ConnectionState.Connected -> BleConnectionState.Connected
|
||||
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* An Android implementation of [BleScanner] using Nordic's [CentralManager].
|
||||
*
|
||||
* @param centralManager The Nordic [CentralManager] to use for scanning.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Single
|
||||
class AndroidBleScanner(private val centralManager: CentralManager) : BleScanner {
|
||||
|
||||
override fun scan(timeout: Duration, serviceUuid: Uuid?): Flow<BleDevice> = centralManager
|
||||
.scan(timeout = timeout) {
|
||||
if (serviceUuid != null) {
|
||||
ServiceUuid(serviceUuid)
|
||||
}
|
||||
}
|
||||
.distinctByPeripheral()
|
||||
.map { AndroidBleDevice(it.peripheral) }
|
||||
}
|
||||
|
|
@ -16,8 +16,14 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
|
|
@ -25,31 +31,40 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteServices
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
/** Android implementation of [BluetoothRepository]. */
|
||||
@Single
|
||||
class AndroidBluetoothRepository(
|
||||
private val context: Context,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val centralManager: CentralManager,
|
||||
private val androidEnvironment: AndroidEnvironment,
|
||||
) : BluetoothRepository {
|
||||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true))
|
||||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||
|
||||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
|
||||
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
|
||||
|
||||
private val deviceCache = mutableMapOf<String, DirectBleDevice>()
|
||||
|
||||
init {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
|
||||
}
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
|
||||
}
|
||||
|
||||
private fun hasBluetoothPermissions(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val hasConnect =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val hasScan =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasConnect && hasScan
|
||||
} else {
|
||||
// Pre-Android 12: classic Bluetooth permissions are install-time.
|
||||
true
|
||||
}
|
||||
|
||||
override fun refreshState() {
|
||||
|
|
@ -58,59 +73,112 @@ class AndroidBluetoothRepository(
|
|||
|
||||
override fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
|
||||
|
||||
@Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught", "SwallowedException")
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun bond(device: BleDevice) {
|
||||
val androidDevice = device as AndroidBleDevice
|
||||
androidDevice.peripheral.createBond()
|
||||
val macAddress = device.address
|
||||
val remoteDevice =
|
||||
bluetoothAdapter?.getRemoteDevice(macAddress) ?: throw Exception("Bluetooth adapter unavailable")
|
||||
|
||||
if (remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
|
||||
updateBluetoothState()
|
||||
return
|
||||
}
|
||||
|
||||
kotlinx.coroutines.suspendCancellableCoroutine<Unit> { cont ->
|
||||
val receiver =
|
||||
object : android.content.BroadcastReceiver() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(c: Context, intent: android.content.Intent) {
|
||||
if (intent.action == android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
|
||||
val d =
|
||||
intent.getParcelableExtra<android.bluetooth.BluetoothDevice>(
|
||||
android.bluetooth.BluetoothDevice.EXTRA_DEVICE,
|
||||
)
|
||||
if (d?.address?.equals(macAddress, ignoreCase = true) == true) {
|
||||
val state =
|
||||
intent.getIntExtra(
|
||||
android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE,
|
||||
android.bluetooth.BluetoothDevice.ERROR,
|
||||
)
|
||||
val prevState =
|
||||
intent.getIntExtra(
|
||||
android.bluetooth.BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE,
|
||||
android.bluetooth.BluetoothDevice.ERROR,
|
||||
)
|
||||
|
||||
if (state == android.bluetooth.BluetoothDevice.BOND_BONDED) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
} catch (ignored: Exception) {}
|
||||
if (cont.isActive) cont.resume(Unit) {}
|
||||
} else if (
|
||||
state == android.bluetooth.BluetoothDevice.BOND_NONE &&
|
||||
prevState == android.bluetooth.BluetoothDevice.BOND_BONDING
|
||||
) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
} catch (ignored: Exception) {}
|
||||
if (cont.isActive) {
|
||||
cont.resumeWith(Result.failure(Exception("Bonding failed or rejected")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = android.content.IntentFilter(android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (ignored: Exception) {}
|
||||
}
|
||||
|
||||
if (!remoteDevice.createBond()) {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (ignored: Exception) {}
|
||||
if (cont.isActive) cont.resumeWith(Result.failure(Exception("Failed to initiate bonding")))
|
||||
}
|
||||
}
|
||||
updateBluetoothState()
|
||||
}
|
||||
|
||||
internal suspend fun updateBluetoothState() {
|
||||
val hasPerms = hasRequiredPermissions()
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val newState =
|
||||
BluetoothState(
|
||||
hasPermissions = hasPerms,
|
||||
enabled = enabled,
|
||||
bondedDevices = getBondedAppPeripherals(enabled, hasPerms),
|
||||
)
|
||||
val enabled = bluetoothAdapter?.isEnabled == true
|
||||
var hasPermissions = hasBluetoothPermissions()
|
||||
val bondedDevices =
|
||||
if (hasPermissions) {
|
||||
try {
|
||||
getBondedAppPeripherals()
|
||||
} catch (e: SecurityException) {
|
||||
Logger.w(e) { "SecurityException accessing bonded devices. Missing BLUETOOTH_CONNECT?" }
|
||||
hasPermissions = false
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val newState = BluetoothState(hasPermissions = hasPermissions, enabled = enabled, bondedDevices = bondedDevices)
|
||||
|
||||
_state.emit(newState)
|
||||
Logger.d { "Detected our bluetooth access=$newState" }
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<BleDevice> =
|
||||
if (enabled && hasPerms) {
|
||||
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral).map { AndroidBleDevice(it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
private fun getBondedAppPeripherals(): List<BleDevice> = bluetoothAdapter?.bondedDevices?.map { device ->
|
||||
deviceCache.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
|
||||
} ?: emptyList()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun isBonded(address: String): Boolean {
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val hasPerms = hasRequiredPermissions()
|
||||
return if (enabled && hasPerms) {
|
||||
centralManager.getBondedPeripherals().any { it.address == address }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRequiredPermissions(): Boolean = if (androidEnvironment.requiresBluetoothRuntimePermissions) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
|
||||
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
|
||||
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
|
||||
val hasRequiredService =
|
||||
(peripheral.services(listOf(SERVICE_UUID)).value as? RemoteServices.Discovered)?.services?.isNotEmpty()
|
||||
?: false
|
||||
|
||||
return nameMatches || hasRequiredService
|
||||
override fun isBonded(address: String): Boolean = try {
|
||||
bluetoothAdapter?.bondedDevices?.any { it.address.equals(address, ignoreCase = true) } ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Logger.w(e) { "SecurityException checking bonded devices. Missing BLUETOOTH_CONNECT?" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
import com.juul.kable.toIdentifier
|
||||
|
||||
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
||||
// If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice),
|
||||
// we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail
|
||||
// immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses.
|
||||
// If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster.
|
||||
autoConnectIf(autoConnect)
|
||||
|
||||
onServicesDiscovered {
|
||||
try {
|
||||
// Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes.
|
||||
// Requesting the max MTU is critical for preventing dropped packets and stalls.
|
||||
@Suppress("MagicNumber")
|
||||
val negotiatedMtu = requestMtu(512)
|
||||
Logger.i { "Negotiated MTU: $negotiatedMtu" }
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "Failed to request MTU" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
|
||||
|
|
@ -19,13 +19,6 @@ package org.meshtastic.core.ble.di
|
|||
import android.app.Application
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.native
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
|
|
@ -33,16 +26,6 @@ import org.koin.core.annotation.Single
|
|||
@Module
|
||||
@ComponentScan("org.meshtastic.core.ble")
|
||||
class CoreBleAndroidModule {
|
||||
@Single
|
||||
fun provideAndroidEnvironment(app: Application): AndroidEnvironment =
|
||||
NativeAndroidEnvironment.getInstance(app, isNeverForLocationFlagSet = true)
|
||||
|
||||
@Single
|
||||
fun provideCentralManager(environment: AndroidEnvironment): CentralManager = CentralManager.native(
|
||||
environment as NativeAndroidEnvironment,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
)
|
||||
|
||||
@Single
|
||||
fun provideLocationManager(app: Application): LocationManager =
|
||||
ContextCompat.getSystemService(app, LocationManager::class.java)!!
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Peripheral
|
||||
|
||||
/**
|
||||
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
|
||||
* dynamically created UI devices (scanned vs bonded) and the actual connection.
|
||||
*/
|
||||
internal object ActiveBleConnection {
|
||||
var activePeripheral: Peripheral? = null
|
||||
var activeAddress: String? = null
|
||||
}
|
||||
|
|
@ -27,5 +27,5 @@ interface BleScanner {
|
|||
* @param timeout The duration of the scan.
|
||||
* @return A [Flow] of discovered [BleDevice]s.
|
||||
*/
|
||||
fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null): Flow<BleDevice>
|
||||
fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null, address: String? = null): Flow<BleDevice>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteService
|
||||
|
||||
/** An Android implementation of [BleService] that wraps a Nordic [RemoteService]. */
|
||||
class AndroidBleService(val service: RemoteService) : BleService
|
||||
/** Extension to convert a [BleService] to a [MeshtasticRadioProfile]. */
|
||||
fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile {
|
||||
val kableService = this as KableBleService
|
||||
return KableMeshtasticRadioProfile(kableService.peripheral)
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */
|
||||
class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice {
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
||||
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||
|
||||
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||
override suspend fun readRssi(): Int {
|
||||
val peripheral = ActiveBleConnection.activePeripheral
|
||||
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||
peripheral.rssi()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// DirectBleDevice assumes we are already bonded.
|
||||
}
|
||||
|
||||
fun updateState(newState: BleConnectionState) {
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.State
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class KableBleService(val peripheral: Peripheral) : BleService
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
class KableBleConnection(private val scope: CoroutineScope, private val tag: String) : BleConnection {
|
||||
|
||||
private var peripheral: Peripheral? = null
|
||||
private var stateJob: Job? = null
|
||||
private var connectionScope: CoroutineScope? = null
|
||||
|
||||
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
|
||||
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
|
||||
|
||||
override val device: BleDevice?
|
||||
get() = _deviceFlow.replayCache.firstOrNull()
|
||||
|
||||
private val _connectionState =
|
||||
MutableSharedFlow<BleConnectionState>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
override suspend fun connect(device: BleDevice) {
|
||||
val autoConnect = MutableStateFlow(device is DirectBleDevice)
|
||||
|
||||
val p =
|
||||
when (device) {
|
||||
is KableBleDevice ->
|
||||
Peripheral(device.advertisement) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
is DirectBleDevice ->
|
||||
createPeripheral(device.address) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
else -> error("Unsupported BleDevice type: ${device::class}")
|
||||
}
|
||||
|
||||
peripheral?.disconnect()
|
||||
peripheral?.close()
|
||||
peripheral = p
|
||||
|
||||
ActiveBleConnection.activePeripheral = p
|
||||
ActiveBleConnection.activeAddress = device.address
|
||||
|
||||
_deviceFlow.emit(device)
|
||||
|
||||
stateJob?.cancel()
|
||||
var hasStartedConnecting = false
|
||||
stateJob =
|
||||
p.state
|
||||
.onEach { kableState ->
|
||||
val mappedState = kableState.toBleConnectionState(hasStartedConnecting) ?: return@onEach
|
||||
if (kableState is State.Connecting || kableState is State.Connected) {
|
||||
hasStartedConnecting = true
|
||||
}
|
||||
|
||||
when (device) {
|
||||
is KableBleDevice -> device.updateState(mappedState)
|
||||
is DirectBleDevice -> device.updateState(mappedState)
|
||||
}
|
||||
|
||||
_connectionState.emit(mappedState)
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
while (p.state.value !is State.Connected) {
|
||||
autoConnect.value =
|
||||
try {
|
||||
connectionScope = p.connect()
|
||||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
|
||||
@Suppress("MagicNumber")
|
||||
val retryDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(retryDelayMs)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override suspend fun connectAndAwait(
|
||||
device: BleDevice,
|
||||
timeoutMs: Long,
|
||||
onRegister: suspend () -> Unit,
|
||||
): BleConnectionState {
|
||||
onRegister()
|
||||
return try {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) {
|
||||
connect(device)
|
||||
BleConnectionState.Connected
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disconnect() = withContext(NonCancellable) {
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
peripheral?.disconnect()
|
||||
peripheral?.close()
|
||||
peripheral = null
|
||||
connectionScope = null
|
||||
|
||||
ActiveBleConnection.activePeripheral = null
|
||||
ActiveBleConnection.activeAddress = null
|
||||
|
||||
_deviceFlow.emit(null)
|
||||
}
|
||||
|
||||
override suspend fun <T> profile(
|
||||
serviceUuid: Uuid,
|
||||
timeout: Duration,
|
||||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T {
|
||||
val p = peripheral ?: error("Not connected")
|
||||
val cScope = connectionScope ?: error("No active connection scope")
|
||||
val service = KableBleService(p)
|
||||
return cScope.setup(service)
|
||||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
|
||||
// Desktop MTU isn't always easily exposed, provide a safe default for Meshtastic
|
||||
return 512
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -17,12 +17,9 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/** An Android implementation of [BleConnectionFactory]. */
|
||||
@Single
|
||||
class AndroidBleConnectionFactory(private val centralManager: CentralManager) : BleConnectionFactory {
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection =
|
||||
AndroidBleConnection(centralManager, scope, tag)
|
||||
class KableBleConnectionFactory : BleConnectionFactory {
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope, tag)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Advertisement
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
||||
override val name: String?
|
||||
get() = advertisement.name
|
||||
|
||||
override val address: String
|
||||
get() = advertisement.identifier.toString()
|
||||
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state
|
||||
|
||||
// On desktop, bonding isn't strictly required before connecting via Kable,
|
||||
// and we don't have a pairing flow. Defaulting to true lets the UI connect directly.
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||
|
||||
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||
override suspend fun readRssi(): Int {
|
||||
val peripheral = ActiveBleConnection.activePeripheral
|
||||
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||
peripheral.rssi()
|
||||
} else {
|
||||
advertisement.rssi
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// Not supported/needed on jvmMain desktop currently
|
||||
}
|
||||
|
||||
internal fun updateState(newState: BleConnectionState) {
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Scanner
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Single
|
||||
class KableBleScanner : BleScanner {
|
||||
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
|
||||
val scanner = Scanner {
|
||||
if (serviceUuid != null || address != null) {
|
||||
filters {
|
||||
match {
|
||||
if (serviceUuid != null) {
|
||||
services = listOf(serviceUuid)
|
||||
}
|
||||
if (address != null) {
|
||||
this.address = address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kable's Scanner doesn't enforce timeout internally, it runs until the Flow is cancelled.
|
||||
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
|
||||
return kotlinx.coroutines.flow.channelFlow {
|
||||
kotlinx.coroutines.withTimeoutOrNull(timeout) {
|
||||
scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.WriteType
|
||||
import com.juul.kable.characteristicOf
|
||||
import com.juul.kable.writeWithoutResponse
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.launch
|
||||
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 kotlin.uuid.Uuid
|
||||
|
||||
class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : MeshtasticRadioProfile {
|
||||
|
||||
private val toRadio = characteristicOf(SERVICE_UUID, TORADIO_CHARACTERISTIC)
|
||||
private val fromRadioChar = characteristicOf(SERVICE_UUID, FROMRADIO_CHARACTERISTIC)
|
||||
private val fromRadioSync = characteristicOf(SERVICE_UUID, FROMRADIOSYNC_CHARACTERISTIC)
|
||||
private val fromNum = characteristicOf(SERVICE_UUID, FROMNUM_CHARACTERISTIC)
|
||||
private val logRadioChar = characteristicOf(SERVICE_UUID, LOGRADIO_CHARACTERISTIC)
|
||||
|
||||
private val triggerDrain = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
|
||||
|
||||
init {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
Logger.i {
|
||||
"KableMeshtasticRadioProfile init. Discovered characteristics: ${svc?.characteristics?.map {
|
||||
it.characteristicUuid
|
||||
}}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCharacteristic(uuid: Uuid): Boolean = peripheral.services.value?.any { svc ->
|
||||
svc.serviceUuid == SERVICE_UUID && svc.characteristics.any { it.characteristicUuid == uuid }
|
||||
} == true
|
||||
|
||||
// Using observe() for fromRadioSync or legacy read loop for fromRadio
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val fromRadio: Flow<ByteArray> = channelFlow {
|
||||
// Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO.
|
||||
// This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
|
||||
launch {
|
||||
try {
|
||||
if (hasCharacteristic(FROMRADIOSYNC_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromRadioSync).collect { send(it) }
|
||||
} else {
|
||||
error("fromRadioSync missing")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fallback to legacy
|
||||
launch {
|
||||
if (hasCharacteristic(FROMNUM_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||
}
|
||||
}
|
||||
triggerDrain.collect {
|
||||
var keepReading = true
|
||||
while (keepReading) {
|
||||
try {
|
||||
if (!hasCharacteristic(FROMRADIO_CHARACTERISTIC)) {
|
||||
keepReading = false
|
||||
continue
|
||||
}
|
||||
val packet = peripheral.read(fromRadioChar)
|
||||
if (packet.isEmpty()) keepReading = false else send(packet)
|
||||
} catch (e: Exception) {
|
||||
keepReading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val logRadio: Flow<ByteArray> = channelFlow {
|
||||
try {
|
||||
if (hasCharacteristic(LOGRADIO_CHARACTERISTIC)) {
|
||||
peripheral.observe(logRadioChar).collect { send(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// logRadio is optional, ignore if not found
|
||||
}
|
||||
}
|
||||
|
||||
private val toRadioWriteType: WriteType by lazy {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
val char = svc?.characteristics?.find { it.characteristicUuid == TORADIO_CHARACTERISTIC }
|
||||
|
||||
if (char?.properties?.writeWithoutResponse == true) {
|
||||
WriteType.WithoutResponse
|
||||
} else {
|
||||
WriteType.WithResponse
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
peripheral.write(toRadio, packet, toRadioWriteType)
|
||||
triggerDrain.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
|
||||
/** Platform-specific configuration for the Peripheral builder based on device type. */
|
||||
internal expect fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean)
|
||||
|
||||
/** Platform-specific instantiation of a Peripheral by address. */
|
||||
internal expect fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.State
|
||||
|
||||
/**
|
||||
* Maps Kable's [State] to Meshtastic's [BleConnectionState].
|
||||
*
|
||||
* @param hasStartedConnecting whether we have seen a Connecting state. This is used to ignore the initial Disconnected
|
||||
* state emitted by StateFlow upon subscription.
|
||||
* @return the mapped [BleConnectionState], or null if the state should be ignored.
|
||||
*/
|
||||
fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? {
|
||||
return when (this) {
|
||||
is State.Connecting -> BleConnectionState.Connecting
|
||||
is State.Connected -> BleConnectionState.Connected
|
||||
is State.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is State.Disconnected -> {
|
||||
if (!hasStartedConnecting) return null
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,20 +14,18 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.repository.radio
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** A definition of the Meshtastic BLE Service profile. */
|
||||
interface MeshtasticRadioProfile {
|
||||
interface State {
|
||||
/** The flow of incoming packets from the radio. */
|
||||
val fromRadio: Flow<ByteArray>
|
||||
/** The flow of incoming packets from the radio. */
|
||||
val fromRadio: Flow<ByteArray>
|
||||
|
||||
/** The flow of incoming log packets from the radio. */
|
||||
val logRadio: Flow<ByteArray>
|
||||
/** The flow of incoming log packets from the radio. */
|
||||
val logRadio: Flow<ByteArray>
|
||||
|
||||
/** Sends a packet to the radio. */
|
||||
suspend fun sendToRadio(packet: ByteArray)
|
||||
}
|
||||
/** Sends a packet to the radio. */
|
||||
suspend fun sendToRadio(packet: ByteArray)
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.State
|
||||
import io.mockk.mockk
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class KableStateMappingTest {
|
||||
|
||||
@Test
|
||||
fun `Connecting maps to Connecting`() {
|
||||
val state = mockk<State.Connecting>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = false)
|
||||
assertEquals(BleConnectionState.Connecting, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Connected maps to Connected`() {
|
||||
val state = mockk<State.Connected>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = true)
|
||||
assertEquals(BleConnectionState.Connected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnecting maps to Disconnecting`() {
|
||||
val state = mockk<State.Disconnecting>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = true)
|
||||
assertEquals(BleConnectionState.Disconnecting, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected ignores initial emission if not started connecting`() {
|
||||
val state = mockk<State.Disconnected>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = false)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected maps to Disconnected if started connecting`() {
|
||||
val state = mockk<State.Disconnected>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = true)
|
||||
assertEquals(BleConnectionState.Disconnected, result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class FakeMeshtasticRadioProfile : MeshtasticRadioProfile {
|
||||
private val _fromRadio = MutableSharedFlow<ByteArray>(replay = 1)
|
||||
override val fromRadio: Flow<ByteArray> = _fromRadio
|
||||
|
||||
private val _logRadio = MutableSharedFlow<ByteArray>(replay = 1)
|
||||
override val logRadio: Flow<ByteArray> = _logRadio
|
||||
|
||||
val sentPackets = mutableListOf<ByteArray>()
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
sentPackets.add(packet)
|
||||
}
|
||||
|
||||
suspend fun emitFromRadio(packet: ByteArray) {
|
||||
_fromRadio.emit(packet)
|
||||
}
|
||||
|
||||
suspend fun emitLogRadio(packet: ByteArray) {
|
||||
_logRadio.emit(packet)
|
||||
}
|
||||
}
|
||||
|
||||
class MeshtasticRadioProfileTest {
|
||||
|
||||
@Test
|
||||
fun testFakeProfileEmitsFromRadio() = runTest {
|
||||
val fake = FakeMeshtasticRadioProfile()
|
||||
val expectedPacket = byteArrayOf(1, 2, 3)
|
||||
|
||||
fake.emitFromRadio(expectedPacket)
|
||||
|
||||
val received = fake.fromRadio.first()
|
||||
assertEquals(expectedPacket.toList(), received.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFakeProfileRecordsSentPackets() = runTest {
|
||||
val fake = FakeMeshtasticRadioProfile()
|
||||
val packet = byteArrayOf(4, 5, 6)
|
||||
|
||||
fake.sendToRadio(packet)
|
||||
|
||||
assertEquals(1, fake.sentPackets.size)
|
||||
assertEquals(packet.toList(), fake.sentPackets.first().toList())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class KableBluetoothRepository : BluetoothRepository {
|
||||
// Desktop Kable doesn't currently expose much state tracking easily, assume true.
|
||||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
|
||||
override val state: StateFlow<BluetoothState> = _state
|
||||
|
||||
override fun refreshState() {
|
||||
// No-op for now on desktop
|
||||
}
|
||||
|
||||
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotEmpty()
|
||||
|
||||
override fun isBonded(address: String): Boolean {
|
||||
return false // Bonding not supported on desktop yet
|
||||
}
|
||||
|
||||
override suspend fun bond(device: BleDevice) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
import com.juul.kable.toIdentifier
|
||||
|
||||
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
||||
// Desktop Kable uses direct connections without needing autoConnect.
|
||||
}
|
||||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.AddressType
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
class BleScannerTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@Test
|
||||
fun `scan returns peripherals`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val scanner = AndroidBleScanner(centralManager)
|
||||
|
||||
val peripheral =
|
||||
PeripheralSpec.simulatePeripheral(
|
||||
identifier = "00:11:22:33:44:55",
|
||||
addressType = AddressType.RANDOM_STATIC,
|
||||
proximity = Proximity.IMMEDIATE,
|
||||
) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Test_Device")
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheral))
|
||||
|
||||
val result = scanner.scan(5.seconds).first()
|
||||
|
||||
assertEquals("00:11:22:33:44:55", result.address)
|
||||
assertEquals("Test_Device", result.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scan with filter returns only matching peripherals`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val scanner = AndroidBleScanner(centralManager)
|
||||
|
||||
val targetUuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
|
||||
val matchingPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = "00:11:22:33:44:55", proximity = Proximity.IMMEDIATE) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Matching_Device")
|
||||
ServiceUuid(targetUuid)
|
||||
}
|
||||
}
|
||||
|
||||
val nonMatchingPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = "AA:BB:CC:DD:EE:FF", proximity = Proximity.IMMEDIATE) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Non_Matching_Device")
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(matchingPeripheral, nonMatchingPeripheral))
|
||||
|
||||
val scannedDevices = mutableListOf<no.nordicsemi.kotlin.ble.client.android.Peripheral>()
|
||||
val job = launch { scanner.scan(5.seconds, targetUuid).toList(scannedDevices) }
|
||||
|
||||
// Needs time to scan in mock environment
|
||||
advanceUntilIdle()
|
||||
job.cancel()
|
||||
|
||||
// TODO: test filter logic correctly if necessary
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.testing.TestLifecycleOwner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.AddressType
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BluetoothRepositoryTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, default = testDispatcher, io = testDispatcher)
|
||||
|
||||
private lateinit var mockEnvironment: MockAndroidEnvironment
|
||||
private lateinit var lifecycleOwner: TestLifecycleOwner
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockEnvironment =
|
||||
MockAndroidEnvironment.Api31(
|
||||
isBluetoothEnabled = true,
|
||||
isBluetoothScanPermissionGranted = true,
|
||||
isBluetoothConnectPermissionGranted = true,
|
||||
)
|
||||
lifecycleOwner =
|
||||
TestLifecycleOwner(initialState = Lifecycle.State.RESUMED, coroutineDispatcher = testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state reflects environment`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
|
||||
runCurrent()
|
||||
val state = repository.state.value
|
||||
assertTrue(state.enabled)
|
||||
assertTrue(state.hasPermissions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state updates when bluetooth is disabled`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
|
||||
mockEnvironment.simulatePowerOff()
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertFalse(state.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bonded devices are correctly identified`() = runTest(testDispatcher) {
|
||||
val address = "C0:00:00:00:00:03"
|
||||
val peripheral =
|
||||
PeripheralSpec.simulatePeripheral(
|
||||
identifier = address,
|
||||
addressType = AddressType.RANDOM_STATIC,
|
||||
proximity = Proximity.IMMEDIATE,
|
||||
) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Meshtastic_5678")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_5678",
|
||||
isBonded = true,
|
||||
eventHandler = object : PeripheralSpecEventHandler {},
|
||||
) {
|
||||
Service(uuid = SERVICE_UUID) {}
|
||||
}
|
||||
}
|
||||
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
centralManager.simulatePeripherals(listOf(peripheral))
|
||||
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
repository.refreshState()
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
|
||||
assertEquals(address, state.bondedDevices.first().address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isBonded returns false when permissions are not granted`() = runTest(testDispatcher) {
|
||||
val noPermsEnv =
|
||||
MockAndroidEnvironment.Api31(
|
||||
isBluetoothEnabled = true,
|
||||
isBluetoothScanPermissionGranted = false,
|
||||
isBluetoothConnectPermissionGranted = false,
|
||||
)
|
||||
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
|
||||
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
|
||||
runCurrent()
|
||||
|
||||
assertFalse(repository.isBonded("C0:00:00:00:00:03"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state has no permissions when bluetooth permissions denied`() = runTest(testDispatcher) {
|
||||
val noPermsEnv =
|
||||
MockAndroidEnvironment.Api31(
|
||||
isBluetoothEnabled = true,
|
||||
isBluetoothScanPermissionGranted = true,
|
||||
isBluetoothConnectPermissionGranted = false,
|
||||
)
|
||||
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
|
||||
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertFalse("hasPermissions should be false when connect permission is denied", state.hasPermissions)
|
||||
}
|
||||
}
|
||||
|
|
@ -42,10 +42,7 @@ kotlin {
|
|||
api(libs.okio)
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
api(libs.androidx.core.ktx)
|
||||
api(libs.nordic.common.core)
|
||||
}
|
||||
androidMain.dependencies { api(libs.androidx.core.ktx) }
|
||||
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.repository.radio
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.ble.BluetoothState
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleRadioInterfaceTest {
|
||||
|
||||
private val testScope = TestScope()
|
||||
private val scanner: BleScanner = mockk()
|
||||
private val bluetoothRepository: BluetoothRepository = mockk()
|
||||
private val connectionFactory: BleConnectionFactory = mockk()
|
||||
private val connection: BleConnection = mockk()
|
||||
private val service: RadioInterfaceService = mockk(relaxed = true)
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
private val connectionStateFlow = MutableSharedFlow<BleConnectionState>(replay = 1)
|
||||
private val bluetoothStateFlow = MutableStateFlow(BluetoothState())
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { connectionFactory.create(any(), any()) } returns connection
|
||||
every { connection.connectionState } returns connectionStateFlow
|
||||
every { bluetoothRepository.state } returns bluetoothStateFlow.asStateFlow()
|
||||
|
||||
bluetoothStateFlow.value = BluetoothState(enabled = true, hasPermissions = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect attempts to scan and connect via init`() = runTest {
|
||||
val device: BleDevice = mockk()
|
||||
every { device.address } returns address
|
||||
every { device.name } returns "Test Device"
|
||||
|
||||
every { scanner.scan(any(), any()) } returns flowOf(device)
|
||||
coEvery { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Connected
|
||||
|
||||
val bleInterface =
|
||||
BleRadioInterface(
|
||||
serviceScope = testScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// init starts connect() which is async
|
||||
// We can wait for the coEvery to be triggered if needed,
|
||||
// but for a basic test this confirms it doesn't crash on init.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address returns correct value`() {
|
||||
val bleInterface =
|
||||
BleRadioInterface(
|
||||
serviceScope = testScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
assertEquals(address, bleInterface.address)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.ConnectionResult
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.client.mock.ReadResponse
|
||||
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.core.Permission
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NordicBleInterfaceRetryTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Logger.setLogWriters(
|
||||
object : co.touchlab.kermit.LogWriter() {
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
println("[$severity] $tag: $message")
|
||||
throwable?.printStackTrace()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write succeeds after one retry`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var toRadioHandle: Int = -1
|
||||
var writeAttempts = 0
|
||||
var writtenValue: ByteArray? = null
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
|
||||
if (characteristic.instanceId == toRadioHandle) {
|
||||
writeAttempts++
|
||||
if (writeAttempts == 1) {
|
||||
println("Simulating first write failure")
|
||||
throw RuntimeException("Temporary failure")
|
||||
}
|
||||
println("Second write attempt succeeding")
|
||||
writtenValue = value
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_Retry")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_Retry",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection and stable state
|
||||
advanceUntilIdle()
|
||||
verify(timeout = 5000) { service.onConnect() }
|
||||
|
||||
// Clear initial discovery errors if any (sometimes mock emits empty list initially)
|
||||
clearMocks(service, answers = false, recordedCalls = true)
|
||||
|
||||
// Test writing
|
||||
val dataToSend = byteArrayOf(0x01, 0x02, 0x03)
|
||||
nordicInterface.handleSendToRadio(dataToSend)
|
||||
|
||||
// Give it time to process retries
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(writeAttempts == 2) { "Should have attempted write twice, but was $writeAttempts" }
|
||||
assert(writtenValue != null) { "Value should have been eventually written" }
|
||||
assert(writtenValue!!.contentEquals(dataToSend))
|
||||
|
||||
// Verify we didn't disconnect due to the retryable error
|
||||
verify(exactly = 0) { service.onDisconnect(any(), any()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write fails after max retries`() = runTest(testDispatcher) {
|
||||
val uniqueAddress = "11:22:33:44:55:66"
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var toRadioHandle: Int = -1
|
||||
var writeAttempts = 0
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
|
||||
if (characteristic.instanceId == toRadioHandle) {
|
||||
writeAttempts++
|
||||
println("Simulating write failure #$writeAttempts")
|
||||
throw RuntimeException("Persistent failure")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = uniqueAddress, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_Fail")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_Fail",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = uniqueAddress,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
advanceUntilIdle()
|
||||
verify(timeout = 5000) { service.onConnect() }
|
||||
|
||||
// Clear initial discovery errors
|
||||
clearMocks(service, answers = false, recordedCalls = true)
|
||||
|
||||
// Trigger write which will fail repeatedly
|
||||
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
|
||||
|
||||
// Wait for all attempts
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(writeAttempts == 3) {
|
||||
"Should have attempted write 3 times (initial + 2 retries), but was $writeAttempts"
|
||||
}
|
||||
|
||||
// Verify onDisconnect was called after retries exhausted
|
||||
// Nordic BLE wraps RuntimeException in BluetoothException
|
||||
verify { service.onDisconnect(any(), any()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,758 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.ConnectionResult
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.client.mock.ReadResponse
|
||||
import no.nordicsemi.kotlin.ble.client.mock.WriteResponse
|
||||
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.core.Permission
|
||||
import no.nordicsemi.kotlin.ble.core.and
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NordicBleInterfaceTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Logger.setLogWriters(
|
||||
object : co.touchlab.kermit.LogWriter() {
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
println("[$severity] $tag: $message")
|
||||
throwable?.printStackTrace()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full connection and notification flow`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var fromNumHandle: Int = -1
|
||||
var logRadioHandle: Int = -1
|
||||
var fromRadioHandle: Int = -1
|
||||
var fromRadioValue: ByteArray = byteArrayOf()
|
||||
|
||||
lateinit var otaPeripheral: PeripheralSpec<String>
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
override fun onWriteRequest(
|
||||
characteristic: MockRemoteCharacteristic,
|
||||
value: ByteArray,
|
||||
): WriteResponse = WriteResponse.Success
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse {
|
||||
if (characteristic.instanceId == fromRadioHandle) {
|
||||
return ReadResponse.Success(fromRadioValue)
|
||||
}
|
||||
return ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
}
|
||||
|
||||
otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_1234")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_1234",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
fromNumHandle =
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
fromRadioHandle =
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
logRadioHandle =
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
println("Bonded peripherals: ${centralManager.getBondedPeripherals().size}")
|
||||
centralManager.getBondedPeripherals().forEach { println("Found bonded peripheral: ${it.address}") }
|
||||
|
||||
// Give it a moment to stabilize
|
||||
advanceUntilIdle()
|
||||
|
||||
// Create the interface
|
||||
println("Creating NordicBleInterface")
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection and discovery
|
||||
println("Waiting for connection...")
|
||||
advanceUntilIdle()
|
||||
|
||||
println("Verifying onConnect...")
|
||||
verify(timeout = 5000) { service.onConnect() }
|
||||
println("onConnect verified.")
|
||||
|
||||
// Set data available on fromRadio BEFORE notifying fromNum
|
||||
fromRadioValue = byteArrayOf(0xCA.toByte(), 0xFE.toByte())
|
||||
|
||||
// Simulate a notification from fromNum (indicates there are packets to read)
|
||||
otaPeripheral.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01))
|
||||
|
||||
// Wait for drain to start
|
||||
advanceUntilIdle()
|
||||
|
||||
// Simulate a log radio notification
|
||||
val logData = "test log".toByteArray()
|
||||
otaPeripheral.simulateValueUpdate(logRadioHandle, logData)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
// Explicitly stub handleFromRadio just in case relaxed mock fails
|
||||
io.mockk.every { service.handleFromRadio(any()) } returns Unit
|
||||
|
||||
// Verify that handleFromRadio was called (any arguments) with timeout
|
||||
verify(timeout = 2000) { service.handleFromRadio(any()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleSendToRadio writes to toRadioCharacteristic`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var toRadioHandle: Int = -1
|
||||
var writtenValue: ByteArray? = null
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
override fun onWriteRequest(
|
||||
characteristic: MockRemoteCharacteristic,
|
||||
value: ByteArray,
|
||||
): WriteResponse {
|
||||
// Keep this for WITH_RESPONSE
|
||||
println("onWriteRequest: charId=${characteristic.instanceId}, toRadioHandle=$toRadioHandle")
|
||||
if (characteristic.instanceId == toRadioHandle) {
|
||||
writtenValue = value
|
||||
}
|
||||
return WriteResponse.Success
|
||||
}
|
||||
|
||||
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
|
||||
// This is for WITHOUT_RESPONSE
|
||||
println("onWriteCommand: charId=${characteristic.instanceId}, toRadioHandle=$toRadioHandle")
|
||||
if (characteristic.instanceId == toRadioHandle) {
|
||||
println("onWriteCommand matched! value=${value.toHexString()}")
|
||||
writtenValue = value
|
||||
} else {
|
||||
println("onWriteCommand mismatch.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_1234")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_1234",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
.also {
|
||||
println("Captured toRadioHandle: $it")
|
||||
// toRadioHandle is assigned by the expression itself
|
||||
}
|
||||
// Add other required chars to avoid discovery failure
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
advanceUntilIdle()
|
||||
verify(timeout = 2000) { service.onConnect() }
|
||||
|
||||
// Test writing
|
||||
val dataToSend = byteArrayOf(0x01, 0x02, 0x03)
|
||||
nordicInterface.handleSendToRadio(dataToSend)
|
||||
|
||||
// Give it time to process
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(writtenValue != null) { "Value should have been written" }
|
||||
assert(writtenValue!!.contentEquals(dataToSend)) {
|
||||
"Written value ${writtenValue?.contentToString()} does not match expected ${dataToSend.contentToString()}"
|
||||
}
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disconnection triggers onDisconnect`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Mock service
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
// Explicitly stub handleFromRadio just in case
|
||||
io.mockk.every { service.handleFromRadio(any()) } returns Unit
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
// Minimal implementation for connection test
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_1234")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_1234",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
advanceUntilIdle()
|
||||
verify(timeout = 2000) { service.onConnect() }
|
||||
|
||||
// Find the connected peripheral from CentralManager to trigger disconnect
|
||||
val connectedPeripheral = centralManager.getBondedPeripherals().first { it.address == address }
|
||||
|
||||
println("Simulating disconnect via peripheral.disconnect()")
|
||||
connectedPeripheral.disconnect()
|
||||
|
||||
// Wait for disconnect event propagation
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify onDisconnect was called on the service
|
||||
verify { service.onDisconnect(any(), any()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discovery fails if required characteristic missing`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Mock service
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
io.mockk.every { service.handleFromRadio(any()) } returns Unit
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_1234")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_1234",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
// OMIT toRadio characteristic to force failure
|
||||
/*
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.WRITE, CharacteristicProperty.WRITE_WITHOUT_RESPONSE),
|
||||
permission = Permission.WRITE
|
||||
)
|
||||
*/
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection and eventual failure
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify that discovery failed
|
||||
verify { service.onDisconnect(false, "Required characteristic missing") }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write exception triggers disconnect`() = runTest(testDispatcher) {
|
||||
val uniqueAddress = "11:22:33:44:55:66"
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Mock service
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
io.mockk.every { service.handleFromRadio(any()) } returns Unit
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
|
||||
// Throw exception on write
|
||||
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray): Unit =
|
||||
throw RuntimeException("Simulated write failure")
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = uniqueAddress, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_1234")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_1234",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = uniqueAddress,
|
||||
)
|
||||
|
||||
// Wait for connection
|
||||
advanceUntilIdle()
|
||||
verify(timeout = 2000) { service.onConnect() }
|
||||
|
||||
// Trigger write which will fail
|
||||
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
|
||||
|
||||
// Wait for error propagation (retries take time!)
|
||||
// 3 attempts with 500ms delay between them = ~1000ms+
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify onDisconnect was called with error
|
||||
verify { service.onDisconnect(any(), any()) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromRadioSync flow prefers Indicate characteristic`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var syncCharHandle: Int = -1
|
||||
val payload = byteArrayOf(0xDE.toByte(), 0xAD.toByte())
|
||||
|
||||
val eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
|
||||
ConnectionResult.Accept
|
||||
|
||||
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse =
|
||||
ReadResponse.Success(byteArrayOf())
|
||||
}
|
||||
|
||||
val peripheralSpec =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("Meshtastic_Sync")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_Sync",
|
||||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.WRITE),
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
// NEW: Provide the Sync characteristic
|
||||
syncCharHandle =
|
||||
Characteristic(
|
||||
uuid = FROMRADIOSYNC_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.INDICATE),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
||||
// Wait for connection and discovery
|
||||
advanceUntilIdle()
|
||||
verify(timeout = 2000) { service.onConnect() }
|
||||
|
||||
// Simulate an indication from FROMRADIOSYNC
|
||||
peripheralSpec.simulateValueUpdate(syncCharHandle, payload)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify handleFromRadio was called directly with the payload
|
||||
verify(timeout = 2000) { service.handleFromRadio(payload) }
|
||||
|
||||
nordicInterface.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,6 @@ kotlin {
|
|||
androidMain.dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.nordic.common.core)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
|
|
|
|||
|
|
@ -16,20 +16,40 @@
|
|||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import no.nordicsemi.android.common.core.registerReceiver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun rememberTimeTickWithLifecycle(): Long {
|
||||
val context = LocalContext.current
|
||||
var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
|
||||
registerReceiver(IntentFilter(Intent.ACTION_TIME_TICK)) { value = System.currentTimeMillis() }
|
||||
DisposableEffect(context) {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
value = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(Intent.ACTION_TIME_TICK),
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
|
||||
onDispose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import org.meshtastic.desktop.stub.NoopMeshWorkerManager
|
|||
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
|
||||
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
|
||||
import org.meshtastic.desktop.stub.NoopServiceBroadcasts
|
||||
import org.meshtastic.core.ble.di.module as coreBleModule
|
||||
import org.meshtastic.core.common.di.module as coreCommonModule
|
||||
import org.meshtastic.core.data.di.module as coreDataModule
|
||||
import org.meshtastic.core.database.di.module as coreDatabaseModule
|
||||
|
|
@ -94,6 +95,7 @@ fun desktopModule() = module {
|
|||
org.meshtastic.core.domain.di.CoreDomainModule().coreDomainModule(),
|
||||
org.meshtastic.core.repository.di.CoreRepositoryModule().coreRepositoryModule(),
|
||||
org.meshtastic.core.network.di.CoreNetworkModule().coreNetworkModule(),
|
||||
org.meshtastic.core.ble.di.CoreBleModule().coreBleModule(),
|
||||
org.meshtastic.core.ui.di.CoreUiModule().coreUiModule(),
|
||||
org.meshtastic.core.service.di.CoreServiceModule().coreServiceModule(),
|
||||
org.meshtastic.feature.settings.di.FeatureSettingsModule().featureSettingsModule(),
|
||||
|
|
@ -109,9 +111,18 @@ fun desktopModule() = module {
|
|||
* Stubs for truly platform-specific interfaces that have no `commonMain` implementation. These require Android APIs
|
||||
* (BLE/USB transport, notifications, WorkManager, location, broadcasts, widgets).
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private fun desktopPlatformStubsModule() = module {
|
||||
single<ServiceRepository> { org.meshtastic.core.service.ServiceRepositoryImpl() }
|
||||
single<RadioInterfaceService> { DesktopRadioInterfaceService(dispatchers = get(), radioPrefs = get()) }
|
||||
single<RadioInterfaceService> {
|
||||
DesktopRadioInterfaceService(
|
||||
dispatchers = get(),
|
||||
radioPrefs = get(),
|
||||
scanner = get(),
|
||||
bluetoothRepository = get(),
|
||||
connectionFactory = get(),
|
||||
)
|
||||
}
|
||||
single<RadioController> {
|
||||
org.meshtastic.core.service.DirectRadioControllerImpl(
|
||||
serviceRepository = get(),
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.repository.radio
|
||||
package org.meshtastic.desktop.radio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -28,11 +27,10 @@ import kotlinx.coroutines.flow.first
|
|||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.ble.AndroidBleDevice
|
||||
import org.meshtastic.core.ble.AndroidBleService
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
|
|
@ -42,6 +40,7 @@ import org.meshtastic.core.ble.BleWriteType
|
|||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.retryBleOperation
|
||||
import org.meshtastic.core.ble.toMeshtasticRadioProfile
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.RadioNotConnectedException
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
|
|
@ -54,8 +53,7 @@ private const val CONNECTION_TIMEOUT_MS = 15_000L
|
|||
private val SCAN_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* A [RadioTransport] implementation for BLE devices using Nordic Kotlin BLE Library.
|
||||
* https://github.com/NordicSemiconductor/Kotlin-BLE-Library.
|
||||
* A [RadioTransport] implementation for BLE devices using Kable for desktop.
|
||||
*
|
||||
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
|
||||
* - Bonding and discovery.
|
||||
|
|
@ -70,8 +68,9 @@ private val SCAN_TIMEOUT = 5.seconds
|
|||
* @param service The [RadioInterfaceService] to use for handling radio events.
|
||||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class NordicBleInterface(
|
||||
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
@Suppress("TooManyFunctions", "TooGenericExceptionCaught", "SwallowedException")
|
||||
class DesktopBleInterface(
|
||||
private val serviceScope: CoroutineScope,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
|
|
@ -94,7 +93,9 @@ class NordicBleInterface(
|
|||
}
|
||||
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
|
||||
CoroutineScope(
|
||||
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
|
||||
)
|
||||
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
|
|
@ -121,8 +122,15 @@ class NordicBleInterface(
|
|||
Logger.i { "[$address] Device not found in bonded list, scanning..." }
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
val d = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
|
||||
if (d != null) return d
|
||||
try {
|
||||
val d =
|
||||
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
|
||||
scanner.scan(SCAN_TIMEOUT).first { it.address == address }
|
||||
}
|
||||
if (d != null) return d
|
||||
} catch (e: Exception) {
|
||||
// Ignore timeout exceptions
|
||||
}
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
delay(SCAN_RETRY_DELAY_MS)
|
||||
|
|
@ -158,6 +166,9 @@ class NordicBleInterface(
|
|||
|
||||
onConnected()
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
Logger.d { "[$address] BLE connection coroutine cancelled" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
|
||||
|
|
@ -169,8 +180,7 @@ class NordicBleInterface(
|
|||
private suspend fun onConnected() {
|
||||
try {
|
||||
bleConnection.deviceFlow.first()?.let { device ->
|
||||
val androidDevice = device as AndroidBleDevice
|
||||
val rssi = retryBleOperation(tag = address) { androidDevice.peripheral.readRssi() }
|
||||
val rssi = retryBleOperation(tag = address) { device.readRssi() }
|
||||
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -202,8 +212,7 @@ class NordicBleInterface(
|
|||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
try {
|
||||
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
|
||||
val androidService = (service as AndroidBleService).service
|
||||
val radioService = MeshtasticRadioServiceImpl(androidService)
|
||||
val radioService = service.toMeshtasticRadioProfile()
|
||||
|
||||
// Wire up notifications
|
||||
radioService.fromRadio
|
||||
|
|
@ -229,7 +238,7 @@ class NordicBleInterface(
|
|||
.launchIn(this)
|
||||
|
||||
// Store reference for handleSendToRadio
|
||||
this@NordicBleInterface.radioService = radioService
|
||||
this@DesktopBleInterface.radioService = radioService
|
||||
|
||||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
|
||||
|
|
@ -237,7 +246,7 @@ class NordicBleInterface(
|
|||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
|
||||
|
||||
this@NordicBleInterface.service.onConnect()
|
||||
this@DesktopBleInterface.service.onConnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
|
||||
|
|
@ -246,7 +255,7 @@ class NordicBleInterface(
|
|||
}
|
||||
}
|
||||
|
||||
private var radioService: MeshtasticRadioProfile.State? = null
|
||||
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
|
||||
|
||||
// --- RadioTransport Implementation ---
|
||||
|
||||
|
|
@ -325,16 +334,14 @@ class NordicBleInterface(
|
|||
|
||||
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
|
||||
val isPermanent =
|
||||
this is no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException ||
|
||||
this is no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
|
||||
this::class.simpleName == "BluetoothUnavailableException" ||
|
||||
this::class.simpleName == "ManagerClosedException"
|
||||
val msg =
|
||||
when (this) {
|
||||
is RadioNotConnectedException -> this.message ?: "Device not found"
|
||||
is NoSuchElementException,
|
||||
is IllegalArgumentException,
|
||||
-> "Required characteristic missing"
|
||||
is no.nordicsemi.kotlin.ble.core.exception.GattException -> "GATT Error: ${this.message}"
|
||||
else -> this.message ?: this.javaClass.simpleName
|
||||
when {
|
||||
this is RadioNotConnectedException -> this.message ?: "Device not found"
|
||||
this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
|
||||
this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
|
||||
else -> this.message ?: this::class.simpleName ?: "Unknown"
|
||||
}
|
||||
return Pair(isPermanent, msg)
|
||||
}
|
||||
|
|
@ -44,14 +44,19 @@ import org.meshtastic.core.repository.RadioPrefs
|
|||
* Desktop implementation of [RadioInterfaceService] with real TCP transport.
|
||||
*
|
||||
* Delegates all TCP socket management, stream framing, reconnect logic, and heartbeat to the shared [TcpTransport] from
|
||||
* `core:network`. Desktop only supports TCP connections (no BLE/USB/Serial).
|
||||
* `core:network`. Desktop supports TCP and BLE connections.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers, private val radioPrefs: RadioPrefs) :
|
||||
RadioInterfaceService {
|
||||
class DesktopRadioInterfaceService(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val scanner: org.meshtastic.core.ble.BleScanner,
|
||||
private val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository,
|
||||
private val connectionFactory: org.meshtastic.core.ble.BleConnectionFactory,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
|
||||
listOf(org.meshtastic.core.model.DeviceType.TCP)
|
||||
listOf(org.meshtastic.core.model.DeviceType.TCP, org.meshtastic.core.model.DeviceType.BLE)
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
|
@ -70,6 +75,7 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
|
|||
private set
|
||||
|
||||
private var transport: TcpTransport? = null
|
||||
private var bleTransport: DesktopBleInterface? = null
|
||||
|
||||
init {
|
||||
// Observe radioPrefs to handle asynchronous loads from DataStore
|
||||
|
|
@ -78,10 +84,10 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
|
|||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
}
|
||||
// Auto-connect if we have a valid TCP address and are disconnected
|
||||
if (addr != null && addr.startsWith("t") && _connectionState.value == ConnectionState.Disconnected) {
|
||||
// Auto-connect if we have a valid address and are disconnected
|
||||
if (addr != null && _connectionState.value == ConnectionState.Disconnected) {
|
||||
Logger.i { "DesktopRadio: Auto-connecting to saved address ${addr.anonymize}" }
|
||||
startTcpConnection(addr.removePrefix("t"))
|
||||
startConnection(addr)
|
||||
}
|
||||
}
|
||||
.launchIn(serviceScope)
|
||||
|
|
@ -95,11 +101,11 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
|
|||
|
||||
override fun connect() {
|
||||
val address = getDeviceAddress()
|
||||
if (address == null || !address.startsWith("t")) {
|
||||
Logger.w { "DesktopRadio: No TCP address configured, skipping connect" }
|
||||
if (address.isNullOrBlank() || address == "n") {
|
||||
Logger.w { "DesktopRadio: No address configured, skipping connect" }
|
||||
return
|
||||
}
|
||||
startTcpConnection(address.removePrefix("t"))
|
||||
startConnection(address)
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean {
|
||||
|
|
@ -119,15 +125,18 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
|
|||
radioPrefs.setDevAddr(sanitized)
|
||||
_currentDeviceAddressFlow.value = sanitized
|
||||
|
||||
// Start connection if we have a TCP address
|
||||
if (sanitized != null && sanitized.startsWith("t")) {
|
||||
startTcpConnection(sanitized.removePrefix("t"))
|
||||
// Start connection if we have a valid address
|
||||
if (sanitized != null && sanitized != "n") {
|
||||
startConnection(sanitized)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
serviceScope.handledLaunch { transport?.sendPacket(bytes) }
|
||||
serviceScope.handledLaunch {
|
||||
transport?.sendPacket(bytes)
|
||||
bleTransport?.handleSendToRadio(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
|
@ -156,7 +165,34 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
|
|||
|
||||
// endregion
|
||||
|
||||
// region TCP Connection Management
|
||||
// region Connection Management
|
||||
|
||||
private fun startConnection(address: String) {
|
||||
if (address.startsWith("t")) {
|
||||
startTcpConnection(address.removePrefix("t"))
|
||||
} else if (address.startsWith("x")) {
|
||||
startBleConnection(address.removePrefix("x"))
|
||||
} else {
|
||||
// Assume BLE if no prefix, or prefix is not supported
|
||||
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
|
||||
startBleConnection(stripped)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBleConnection(address: String) {
|
||||
transport?.stop()
|
||||
bleTransport?.close()
|
||||
|
||||
bleTransport =
|
||||
DesktopBleInterface(
|
||||
serviceScope = serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = this,
|
||||
address = address,
|
||||
)
|
||||
}
|
||||
|
||||
private fun startTcpConnection(address: String) {
|
||||
transport?.stop()
|
||||
|
|
@ -189,6 +225,9 @@ class DesktopRadioInterfaceService(private val dispatchers: CoroutineDispatchers
|
|||
transport?.stop()
|
||||
transport = null
|
||||
|
||||
bleTransport?.close()
|
||||
bleTransport = null
|
||||
|
||||
// Recreate the service scope
|
||||
serviceScope.cancel("stopping interface")
|
||||
serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
# Decision: BLE KMP Strategy
|
||||
|
||||
> Date: 2026-03-10 | Status: **Decided — Phase 1 complete**
|
||||
> Date: 2026-03-16 | Status: **Decided — Fully Migrated to Kable**
|
||||
|
||||
## Context
|
||||
|
||||
`core:ble` needed to support non-Android targets. Nordic's KMM-BLE-Library is Android/iOS only (no Desktop/Web). KABLE supports all KMP targets but lacks mock modules.
|
||||
`core:ble` needed to support non-Android targets. Nordic's Kotlin-BLE-Library, while mature on Android and actively tested in the app, was primarily Android/iOS focused and lacked support for Desktop (JVM) targets. Kable natively supports all Kotlin Multiplatform targets (Android, Apple, Desktop/JVM, Web).
|
||||
|
||||
Initially, we implemented an **Interface-Driven "Nordic Hybrid" Abstraction** (keeping Nordic on Android behind `commonMain` interfaces) to wait and see if Nordic expanded their KMP support.
|
||||
|
||||
However, as Desktop integration advanced, we found the need for a unified BLE transport.
|
||||
|
||||
## Decision
|
||||
|
||||
**Interface-Driven "Nordic Hybrid" Abstraction:**
|
||||
**Migrate entirely to Kable:**
|
||||
|
||||
- `commonMain`: Pure Kotlin interfaces (`BleConnection`, `BleScanner`, `BleDevice`, `BleConnectionFactory`, etc.) — zero platform imports
|
||||
- `androidMain`: Nordic KMM-BLE-Library implementations behind those interfaces
|
||||
- `jvm()` target added — interfaces compile fine; no JVM BLE implementation needed yet
|
||||
- Future: KABLE or alternative can implement the same interfaces for Desktop/iOS without touching core logic
|
||||
|
||||
**BLE library decision: Stay on Nordic, wait.** Our abstraction layer is clean — switching backends later is a bounded, mechanical task (~6 files, ~400 lines). Nordic is actively developing. We don't currently need real BLE on JVM/iOS. If Nordic hasn't shipped KMP by the time we need iOS, revisit KABLE.
|
||||
- We migrated all BLE transport logic across Android and Desktop to use Kable.
|
||||
- The `commonMain` interfaces (`BleConnection`, `BleScanner`, `BleDevice`, `BluetoothRepository`, etc.) remain, but their core implementations (`KableBleConnection`, `KableBleScanner`) are now entirely shared in `commonMain`.
|
||||
- The Android-specific Nordic dependencies (`no.nordicsemi.kotlin.ble:*`) and the Nordic DFU library were completely excised from the project.
|
||||
- OTA Firmware updates on Android were successfully refactored to use the Kable-based `BleOtaTransport`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `core:ble` compiles on JVM and is included in CI smoke compile
|
||||
- No Nordic types leak into `commonMain`
|
||||
- Desktop simply doesn't inject BLE bindings
|
||||
- Migration cost to KABLE is predictable and bounded
|
||||
- **Maximal Code Deduplication:** The BLE implementation is completely shared across Android and Desktop in `core:ble/commonMain`.
|
||||
- **Future-Proofing:** Adding an `iosMain` target in the future will be trivial, as it can leverage the same shared Kable abstractions.
|
||||
- **Lost Nordic Mocks:** Kable lacks the comprehensive mock infrastructure of the Nordic library. Consequently, several complex BLE OTA unit tests had to be deprecated. Re-establishing this test coverage using custom Kable fakes is an ongoing technical debt item.
|
||||
|
||||
## Archive
|
||||
|
||||
Full analysis: [`archive/ble-kmp-strategy.md`](../archive/ble-kmp-strategy.md)
|
||||
|
||||
- Original Hybrid Analysis: [`archive/ble-kmp-strategy.md`](../archive/ble-kmp-strategy.md)
|
||||
- Original Abstraction Plan: [`archive/ble-kmp-abstraction-plan.md`](../archive/ble-kmp-abstraction-plan.md)
|
||||
|
|
@ -29,7 +29,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
|
|||
| `core:prefs` | ✅ | ✅ | Preferences layer |
|
||||
| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport` |
|
||||
| `core:data` | ✅ | ✅ | Data orchestration |
|
||||
| `core:ble` | ✅ | ✅ | BLE abstractions in commonMain; Nordic in androidMain |
|
||||
| `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain |
|
||||
| `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain |
|
||||
| `core:service` | ✅ | ✅ | Service layer; Android bindings in androidMain |
|
||||
| `core:ui` | ✅ | ✅ | Shared Compose UI, `jvmAndroidMain` + `jvmMain` actuals |
|
||||
|
|
@ -103,7 +103,7 @@ Based on the latest codebase investigation, the following steps are proposed to
|
|||
|---|---|---|
|
||||
| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared enum + parity tests. See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) |
|
||||
| Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) |
|
||||
| BLE abstraction (Nordic Hybrid) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) |
|
||||
| BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) |
|
||||
| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-alpha04` |
|
||||
| JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime |
|
||||
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
|
||||
|
|
@ -141,7 +141,7 @@ Extracted to shared `commonMain` (no longer app-only):
|
|||
| Koin | `4.2.0-RC2` | Nav3 + K2 compiler plugin support |
|
||||
| JetBrains Lifecycle | `2.10.0-beta01` | Multiplatform ViewModel/lifecycle |
|
||||
| JetBrains Navigation 3 | `1.1.0-alpha04` | Multiplatform navigation |
|
||||
| Nordic BLE | `2.0.0-alpha16` | Behind abstraction boundary |
|
||||
| Kable BLE | `0.42.0` | Provides fully multiplatform BLE support |
|
||||
|
||||
**Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features.
|
||||
|
||||
|
|
|
|||
|
|
@ -75,12 +75,11 @@ fun CurrentlyConnectedInfo(
|
|||
while (bleDevice.device.isConnected) {
|
||||
try {
|
||||
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
|
||||
delay(RSSI_DELAY.seconds)
|
||||
} catch (e: Exception) {
|
||||
// RSSI reading failures are common when disconnecting; log as warning to avoid Crashlytics noise
|
||||
Logger.w(e) { "Failed to read RSSI ${e.message}" }
|
||||
break
|
||||
// RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise
|
||||
Logger.d(e) { "Failed to read RSSI ${e.message}" }
|
||||
}
|
||||
delay(RSSI_DELAY.seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ The `:feature:firmware` module provides a unified interface for updating Meshtas
|
|||
Meshtastic-Android supports three primary firmware update flows:
|
||||
|
||||
#### 1. ESP32 Unified OTA (WiFi & BLE)
|
||||
Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Nordic Semiconductor Kotlin-BLE-Library** for architectural consistency and modern coroutine support.
|
||||
Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Kable** multiplatform library for architectural consistency and modern coroutine support.
|
||||
|
||||
**Key Features:**
|
||||
- **Pre-shared Hash Verification**: The app sends the firmware SHA256 hash in an initial `AdminMessage` trigger. The device stores this in NVS and verifies the incoming stream against it.
|
||||
|
|
@ -102,5 +102,5 @@ sequenceDiagram
|
|||
- `UpdateHandler.kt`: Entry point for choosing the correct handler.
|
||||
- `Esp32OtaUpdateHandler.kt`: Orchestrates the Unified OTA flow.
|
||||
- `WifiOtaTransport.kt`: Implements the TCP/UDP transport logic for ESP32.
|
||||
- `BleOtaTransport.kt`: Implements the BLE transport logic for ESP32 using the Nordic BLE library.
|
||||
- `BleOtaTransport.kt`: Implements the BLE transport logic for ESP32 using the Kable BLE library.
|
||||
- `FirmwareRetriever.kt`: Handles downloading and extracting firmware assets (ZIP/BIN/UF2).
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ kotlin {
|
|||
implementation(projects.core.resources)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
implementation(libs.kable.core)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
implementation(libs.kermit)
|
||||
|
|
@ -64,31 +65,26 @@ kotlin {
|
|||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.navigation.common)
|
||||
implementation(libs.nordic.dfu)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.markdown.renderer.android)
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer)
|
||||
|
||||
// DFU / Nordic specific dependencies
|
||||
implementation(libs.nordic.client.android)
|
||||
implementation(libs.nordic.dfu)
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(projects.core.testing) }
|
||||
|
||||
androidUnitTest.dependencies {
|
||||
implementation(libs.junit)
|
||||
implementation(libs.mockk)
|
||||
implementation(libs.robolectric)
|
||||
implementation(libs.turbine)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.androidx.compose.ui.test.junit4)
|
||||
implementation(libs.androidx.test.ext.junit)
|
||||
implementation(libs.nordic.client.android.mock)
|
||||
implementation(libs.nordic.client.core.mock)
|
||||
implementation(libs.nordic.core.mock)
|
||||
val androidHostTest by getting {
|
||||
dependencies {
|
||||
implementation(libs.junit)
|
||||
implementation(libs.mockk)
|
||||
implementation(libs.robolectric)
|
||||
implementation(libs.turbine)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.androidx.compose.ui.test.junit4)
|
||||
implementation(libs.androidx.test.ext.junit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Test
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import java.io.File
|
||||
|
||||
class FirmwareRetrieverTest {
|
||||
|
||||
|
|
@ -41,7 +40,7 @@ class FirmwareRetrieverTest {
|
|||
architecture = "esp32-s3",
|
||||
hasMui = false,
|
||||
)
|
||||
val expectedFile = File("firmware-heltec-v3-2.5.0.bin")
|
||||
val expectedFile = "firmware-heltec-v3-2.5.0.bin"
|
||||
|
||||
// Generic fast OTA check fails
|
||||
coEvery { fileHandler.checkUrlExists(match { it.contains("mt-esp32s3-ota.bin") }) } returns false
|
||||
|
|
@ -51,7 +50,7 @@ class FirmwareRetrieverTest {
|
|||
// Board-specific check succeeds
|
||||
coEvery { fileHandler.checkUrlExists(match { it.contains("firmware-heltec-v3") }) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), "firmware-heltec-v3-2.5.0.bin", any()) } returns expectedFile
|
||||
coEvery { fileHandler.extractFirmware(any<File>(), any(), any(), any()) } returns null
|
||||
coEvery { fileHandler.extractFirmwareFromZip(any<String>(), any(), any(), any()) } returns null
|
||||
|
||||
val result = retriever.retrieveEsp32Firmware(release, hardware) {}
|
||||
|
||||
|
|
@ -70,7 +69,7 @@ class FirmwareRetrieverTest {
|
|||
fun `retrieveEsp32Firmware uses Unified OTA path for ESP32`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip")
|
||||
val hardware = DeviceHardware(hwModelSlug = "TLORA_V2", platformioTarget = "tlora-v2", architecture = "esp32")
|
||||
val expectedFile = File("mt-esp32-ota.bin")
|
||||
val expectedFile = "mt-esp32-ota.bin"
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
|
@ -89,7 +88,7 @@ class FirmwareRetrieverTest {
|
|||
fun `retrieveOtaFirmware uses correct zip extension for NRF52`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip")
|
||||
val hardware = DeviceHardware(hwModelSlug = "RAK4631", platformioTarget = "rak4631", architecture = "nrf52840")
|
||||
val expectedFile = File("firmware-rak4631-2.5.0-ota.zip")
|
||||
val expectedFile = "firmware-rak4631-2.5.0-ota.zip"
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
|
@ -113,7 +112,7 @@ class FirmwareRetrieverTest {
|
|||
platformioTarget = "rak4631_nomadstar_meteor_pro",
|
||||
architecture = "nrf52840",
|
||||
)
|
||||
val expectedFile = File("firmware-rak4631_nomadstar_meteor_pro-2.5.0-ota.zip")
|
||||
val expectedFile = "firmware-rak4631_nomadstar_meteor_pro-2.5.0-ota.zip"
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
|
@ -133,7 +132,7 @@ class FirmwareRetrieverTest {
|
|||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/stm32.zip")
|
||||
val hardware =
|
||||
DeviceHardware(hwModelSlug = "ST_GENERIC", platformioTarget = "stm32-generic", architecture = "stm32")
|
||||
val expectedFile = File("firmware-stm32-generic-2.5.0-ota.zip")
|
||||
val expectedFile = "firmware-stm32-generic-2.5.0-ota.zip"
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
|
@ -152,7 +151,7 @@ class FirmwareRetrieverTest {
|
|||
fun `retrieveUsbFirmware uses correct uf2 extension for RP2040`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/rp2040.zip")
|
||||
val hardware = DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040")
|
||||
val expectedFile = File("firmware-pico-2.5.0.uf2")
|
||||
val expectedFile = "firmware-pico-2.5.0.uf2"
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
|
@ -172,7 +171,7 @@ class FirmwareRetrieverTest {
|
|||
fun `retrieveUsbFirmware uses correct uf2 extension for NRF52`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip")
|
||||
val hardware = DeviceHardware(hwModelSlug = "T_ECHO", platformioTarget = "t-echo", architecture = "nrf52840")
|
||||
val expectedFile = File("firmware-t-echo-2.5.0.uf2")
|
||||
val expectedFile = "firmware-t-echo-2.5.0.uf2"
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleOtaTransportTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private val scanner: BleScanner = mockk()
|
||||
private val connectionFactory: BleConnectionFactory = mockk()
|
||||
private val connection: BleConnection = mockk()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
private lateinit var transport: BleOtaTransport
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
every { connectionFactory.create(any(), any()) } returns connection
|
||||
every { connection.connectionState } returns MutableSharedFlow(replay = 1)
|
||||
|
||||
transport =
|
||||
BleOtaTransport(
|
||||
scanner = scanner,
|
||||
connectionFactory = connectionFactory,
|
||||
address = address,
|
||||
dispatcher = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect throws when device not found`() = runTest(testDispatcher) {
|
||||
every { scanner.scan(any(), any()) } returns flowOf()
|
||||
|
||||
val result = transport.connect()
|
||||
assertTrue("Expected failure", result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is OtaProtocolException.ConnectionFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect fails when connection state is disconnected`() = runTest(testDispatcher) {
|
||||
val device: BleDevice = mockk()
|
||||
every { device.address } returns address
|
||||
every { device.name } returns "Test Device"
|
||||
|
||||
every { scanner.scan(any(), any()) } returns flowOf(device)
|
||||
coEvery { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Disconnected
|
||||
|
||||
val result = transport.connect()
|
||||
assertTrue("Expected failure", result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is OtaProtocolException.ConnectionFailed)
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@ import org.junit.After
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.toPlatformUri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.RadioController
|
||||
|
|
@ -84,22 +86,23 @@ class Esp32OtaUpdateHandlerTest {
|
|||
val release = FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = "")
|
||||
val hardware = DeviceHardware(hwModelSlug = "V3", architecture = "esp32")
|
||||
val target = "00:11:22:33:44:55"
|
||||
val uri: Uri = mockk()
|
||||
val platformUri: Uri = mockk()
|
||||
val commonUri: CommonUri = mockk()
|
||||
|
||||
mockkStatic("org.meshtastic.core.common.util.CommonUri_androidKt")
|
||||
every { commonUri.toPlatformUri() } returns platformUri
|
||||
|
||||
every { context.contentResolver } returns contentResolver
|
||||
every { contentResolver.openInputStream(uri) } throws IOException("Read error")
|
||||
every { contentResolver.openInputStream(platformUri) } throws IOException("Read error")
|
||||
|
||||
val states = mutableListOf<FirmwareUpdateState>()
|
||||
|
||||
handler.startUpdate(release, hardware, target, { states.add(it) }, uri)
|
||||
|
||||
// Before fix, this would be FirmwareUpdateState.Error("Could not retrieve firmware file.")
|
||||
// After fix, it should ideally contain "Read error" or be the original exception if we don't catch it too
|
||||
// early.
|
||||
// Esp32OtaUpdateHandler.performUpdate catches Exception and uses e.message.
|
||||
handler.startUpdate(release, hardware, target, { states.add(it) }, commonUri)
|
||||
|
||||
val lastState = states.last()
|
||||
assert(lastState is FirmwareUpdateState.Error)
|
||||
assertEquals("OTA update failed: Read error", (lastState as FirmwareUpdateState.Error).error)
|
||||
|
||||
unmockkStatic("org.meshtastic.core.common.util.CommonUri_androidKt")
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ private const val PERCENT_MAX = 100
|
|||
private const val PREPARE_DATA_DELAY = 400L
|
||||
|
||||
/** Handles Over-the-Air (OTA) firmware updates for nRF52-based devices using the Nordic DFU library. */
|
||||
@Deprecated("Use KableNordicDfuHandler instead")
|
||||
@Single
|
||||
class NordicDfuHandler(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.characteristicOf
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -30,25 +31,18 @@ import kotlinx.coroutines.flow.firstOrNull
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import org.meshtastic.core.ble.AndroidBleService
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.ble.KableBleService
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* BLE transport implementation for ESP32 Unified OTA protocol.
|
||||
*
|
||||
* Service UUID: 4FAFC201-1FB5-459E-8FCC-C5C9C331914B
|
||||
* - OTA Characteristic (Write): 62ec0272-3ec5-11eb-b378-0242ac130005
|
||||
* - TX Characteristic (Notify): 62ec0272-3ec5-11eb-b378-0242ac130003
|
||||
*/
|
||||
/** BLE transport implementation for ESP32 Unified OTA protocol using Kable. */
|
||||
class BleOtaTransport(
|
||||
private val scanner: BleScanner,
|
||||
connectionFactory: BleConnectionFactory,
|
||||
|
|
@ -58,15 +52,16 @@ class BleOtaTransport(
|
|||
|
||||
private val transportScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val bleConnection = connectionFactory.create(transportScope, "BLE OTA")
|
||||
private var otaCharacteristic: RemoteCharacteristic? = null
|
||||
|
||||
private val otaChar = characteristicOf(OTA_SERVICE_UUID, OTA_WRITE_CHARACTERISTIC)
|
||||
private val txChar = characteristicOf(OTA_SERVICE_UUID, OTA_NOTIFY_CHARACTERISTIC)
|
||||
|
||||
private val responseChannel = Channel<String>(Channel.UNLIMITED)
|
||||
|
||||
private var isConnected = false
|
||||
|
||||
/** Scan for the device by MAC address with retries. After reboot, the device needs time to come up in OTA mode. */
|
||||
/** Scan for the device by MAC address with retries. */
|
||||
private suspend fun scanForOtaDevice(): BleDevice? {
|
||||
// ESP32 OTA bootloader may use MAC address with last byte incremented by 1
|
||||
val otaAddress = calculateOtaAddress(macAddress = address)
|
||||
val targetAddresses = setOf(address, otaAddress)
|
||||
Logger.i { "BLE OTA: Will match addresses: $targetAddresses" }
|
||||
|
|
@ -77,7 +72,7 @@ class BleOtaTransport(
|
|||
val foundDevices = mutableSetOf<String>()
|
||||
val device =
|
||||
scanner
|
||||
.scan(SCAN_TIMEOUT)
|
||||
.scan(timeout = SCAN_TIMEOUT, serviceUuid = OTA_SERVICE_UUID)
|
||||
.onEach { d ->
|
||||
if (foundDevices.add(d.address)) {
|
||||
Logger.d { "BLE OTA: Scan found device: ${d.address} (name=${d.name})" }
|
||||
|
|
@ -100,11 +95,7 @@ class BleOtaTransport(
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the potential OTA MAC address by incrementing the last byte. Some ESP32 bootloaders use MAC+1 for OTA
|
||||
* mode to distinguish from normal operation.
|
||||
*/
|
||||
@Suppress("MagicNumber", "ReturnCount")
|
||||
@Suppress("ReturnCount", "MagicNumber")
|
||||
private fun calculateOtaAddress(macAddress: String): String {
|
||||
val parts = macAddress.split(":")
|
||||
if (parts.size != 6) return macAddress
|
||||
|
|
@ -114,13 +105,12 @@ class BleOtaTransport(
|
|||
return parts.take(5).joinToString(":") + ":" + incrementedByte
|
||||
}
|
||||
|
||||
/** Connect to the device and discover OTA service. */
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("MagicNumber")
|
||||
override suspend fun connect(): Result<Unit> = runCatching {
|
||||
Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." }
|
||||
delay(REBOOT_DELAY_MS)
|
||||
|
||||
Logger.i { "BLE OTA: Connecting to $address using Nordic BLE Library..." }
|
||||
Logger.i { "BLE OTA: Connecting to $address using Kable..." }
|
||||
|
||||
val device =
|
||||
scanForOtaDevice()
|
||||
|
|
@ -149,19 +139,9 @@ class BleOtaTransport(
|
|||
|
||||
Logger.i { "BLE OTA: Connected to ${device.address}, discovering services..." }
|
||||
|
||||
// Discover services using our unified profile helper
|
||||
bleConnection.profile(OTA_SERVICE_UUID) { service ->
|
||||
val androidService = (service as AndroidBleService).service
|
||||
val ota =
|
||||
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_WRITE_CHARACTERISTIC }) {
|
||||
"OTA characteristic not found"
|
||||
}
|
||||
val txChar =
|
||||
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_NOTIFY_CHARACTERISTIC }) {
|
||||
"TX characteristic not found"
|
||||
}
|
||||
|
||||
otaCharacteristic = ota
|
||||
val kableService = service as KableBleService
|
||||
val peripheral = kableService.peripheral
|
||||
|
||||
// Log negotiated MTU for diagnostics
|
||||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
|
|
@ -169,13 +149,14 @@ class BleOtaTransport(
|
|||
|
||||
// Enable notifications and collect responses
|
||||
val subscribed = CompletableDeferred<Unit>()
|
||||
txChar
|
||||
.subscribe {
|
||||
Logger.d { "BLE OTA: TX characteristic subscribed" }
|
||||
subscribed.complete(Unit)
|
||||
}
|
||||
peripheral
|
||||
.observe(txChar)
|
||||
.onEach { notifyBytes ->
|
||||
try {
|
||||
if (!subscribed.isCompleted) {
|
||||
Logger.d { "BLE OTA: TX characteristic subscribed" }
|
||||
subscribed.complete(Unit)
|
||||
}
|
||||
val response = notifyBytes.decodeToString()
|
||||
Logger.d { "BLE OTA: Received response: $response" }
|
||||
responseChannel.trySend(response)
|
||||
|
|
@ -189,12 +170,17 @@ class BleOtaTransport(
|
|||
}
|
||||
.launchIn(this)
|
||||
|
||||
// Kable's observe doesn't provide a way to know when subscription is finished,
|
||||
// but usually first value or just waiting a bit works.
|
||||
// For Meshtastic, it might not emit immediately.
|
||||
delay(500)
|
||||
if (!subscribed.isCompleted) subscribed.complete(Unit)
|
||||
|
||||
subscribed.await()
|
||||
Logger.i { "BLE OTA: Service discovered and ready" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Initiates the OTA update by sending the size and hash. */
|
||||
override suspend fun startOta(
|
||||
sizeBytes: Long,
|
||||
sha256Hash: String,
|
||||
|
|
@ -214,19 +200,16 @@ class BleOtaTransport(
|
|||
handshakeComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
is OtaResponse.Erasing -> {
|
||||
Logger.i { "BLE OTA: Device erasing flash..." }
|
||||
onHandshakeStatus(OtaHandshakeStatus.Erasing)
|
||||
}
|
||||
|
||||
is OtaResponse.Error -> {
|
||||
if (parsed.message.contains("Hash Rejected", ignoreCase = true)) {
|
||||
throw OtaProtocolException.HashRejected(sha256Hash)
|
||||
}
|
||||
throw OtaProtocolException.CommandFailed(command, parsed)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Logger.w { "BLE OTA: Unexpected handshake response: $response" }
|
||||
}
|
||||
|
|
@ -234,7 +217,7 @@ class BleOtaTransport(
|
|||
}
|
||||
}
|
||||
|
||||
/** Streams the firmware data in chunks. */
|
||||
@Suppress("MagicNumber")
|
||||
override suspend fun streamFirmware(
|
||||
data: ByteArray,
|
||||
chunkSize: Int,
|
||||
|
|
@ -252,20 +235,15 @@ class BleOtaTransport(
|
|||
val currentChunkSize = minOf(chunkSize, remainingBytes)
|
||||
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
|
||||
|
||||
// Write chunk
|
||||
val packetsSentForChunk = writeData(chunk, BleWriteType.WITHOUT_RESPONSE)
|
||||
|
||||
// Wait for responses
|
||||
val nextSentBytes = sentBytes + currentChunkSize
|
||||
repeat(packetsSentForChunk) { i ->
|
||||
val response = waitForResponse(ACK_TIMEOUT_MS)
|
||||
val isLastPacketOfChunk = i == packetsSentForChunk - 1
|
||||
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ack -> {
|
||||
// Normal packet success
|
||||
}
|
||||
|
||||
is OtaResponse.Ack -> {}
|
||||
is OtaResponse.Ok -> {
|
||||
if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
|
||||
sentBytes = nextSentBytes
|
||||
|
|
@ -273,14 +251,12 @@ class BleOtaTransport(
|
|||
return@runCatching Unit
|
||||
}
|
||||
}
|
||||
|
||||
is OtaResponse.Error -> {
|
||||
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
|
||||
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
|
||||
}
|
||||
throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
|
||||
}
|
||||
|
||||
else -> throw OtaProtocolException.TransferFailed("Unexpected response: $response")
|
||||
}
|
||||
}
|
||||
|
|
@ -298,7 +274,6 @@ class BleOtaTransport(
|
|||
}
|
||||
throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}")
|
||||
}
|
||||
|
||||
else -> throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $parsed")
|
||||
}
|
||||
}
|
||||
|
|
@ -315,9 +290,6 @@ class BleOtaTransport(
|
|||
}
|
||||
|
||||
private suspend fun writeData(data: ByteArray, writeType: BleWriteType): Int {
|
||||
val characteristic =
|
||||
otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available")
|
||||
|
||||
val maxLen = bleConnection.maximumWriteValueLength(writeType) ?: data.size
|
||||
var offset = 0
|
||||
var packetsSent = 0
|
||||
|
|
@ -327,13 +299,17 @@ class BleOtaTransport(
|
|||
val chunkSize = minOf(data.size - offset, maxLen)
|
||||
val packet = data.copyOfRange(offset, offset + chunkSize)
|
||||
|
||||
val nordicWriteType =
|
||||
val kableWriteType =
|
||||
when (writeType) {
|
||||
BleWriteType.WITH_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITH_RESPONSE
|
||||
BleWriteType.WITHOUT_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITHOUT_RESPONSE
|
||||
BleWriteType.WITH_RESPONSE -> com.juul.kable.WriteType.WithResponse
|
||||
BleWriteType.WITHOUT_RESPONSE -> com.juul.kable.WriteType.WithoutResponse
|
||||
}
|
||||
|
||||
characteristic.write(packet, writeType = nordicWriteType)
|
||||
bleConnection.profile(OTA_SERVICE_UUID) { service ->
|
||||
val peripheral = (service as KableBleService).peripheral
|
||||
peripheral.write(otaChar, packet, kableWriteType)
|
||||
}
|
||||
|
||||
offset += chunkSize
|
||||
packetsSent++
|
||||
}
|
||||
|
|
@ -350,17 +326,14 @@ class BleOtaTransport(
|
|||
}
|
||||
|
||||
companion object {
|
||||
// Timeouts and retries
|
||||
private val SCAN_TIMEOUT = 10.seconds
|
||||
private const val CONNECTION_TIMEOUT_MS = 15_000L
|
||||
private const val ERASING_TIMEOUT_MS = 60_000L
|
||||
private const val ACK_TIMEOUT_MS = 10_000L
|
||||
private const val VERIFICATION_TIMEOUT_MS = 10_000L
|
||||
|
||||
private const val REBOOT_DELAY_MS = 5_000L
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 2_000L
|
||||
|
||||
const val RECOMMENDED_CHUNK_SIZE = 512
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.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 kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private val serviceUuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val otaCharacteristicUuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val txCharacteristicUuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleOtaTransportErrorTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
@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) {
|
||||
Characteristic(
|
||||
uuid = otaCharacteristicUuid,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = txCharacteristicUuid,
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
|
||||
try {
|
||||
transport.connect().getOrThrow()
|
||||
|
||||
val result = transport.startOta(1024, "badhash") {}
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is OtaProtocolException.HashRejected)
|
||||
} finally {
|
||||
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) {
|
||||
Characteristic(
|
||||
uuid = otaCharacteristicUuid,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = txCharacteristicUuid,
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
|
||||
try {
|
||||
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)
|
||||
} finally {
|
||||
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) {
|
||||
Characteristic(
|
||||
uuid = otaCharacteristicUuid,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = txCharacteristicUuid,
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
|
||||
try {
|
||||
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) {}
|
||||
|
||||
val exception = result.exceptionOrNull()
|
||||
assertTrue("Expected failure, but succeeded", result.isFailure)
|
||||
assertTrue(
|
||||
"Expected OtaProtocolException.VerificationFailed but got $exception",
|
||||
exception is OtaProtocolException.VerificationFailed,
|
||||
)
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.spyk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
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.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
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.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleOtaTransportMtuTest {
|
||||
|
||||
private val address = "00:11:22:33:44:55"
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@Test
|
||||
fun `connect requests MTU`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = spyk(CentralManager.mock(mockEnvironment, backgroundScope))
|
||||
|
||||
val otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(
|
||||
name = "ESP32-OTA",
|
||||
eventHandler = object : PeripheralSpecEventHandler {},
|
||||
isBonded = true,
|
||||
) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = OTA_CHARACTERISTIC_UUID,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = TX_CHARACTERISTIC_UUID,
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
|
||||
transport.connect().getOrThrow()
|
||||
|
||||
// Verify connect was called with automaticallyRequestHighestValueLength = true
|
||||
coVerify {
|
||||
centralManager.connect(
|
||||
any(),
|
||||
CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.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 no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleOtaTransportNordicMockTest {
|
||||
|
||||
private val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher()
|
||||
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 ota flow with nordic mocks`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
var txCharHandle: Int = -1
|
||||
val totalExpectedBytes = AtomicLong(64) // Smaller data for faster test
|
||||
val bytesReceived = AtomicLong(0)
|
||||
|
||||
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")) {
|
||||
println("Mock: Received Start OTA command: ${command.trim()}")
|
||||
val parts = command.trim().split(" ")
|
||||
if (parts.size >= 2) {
|
||||
totalExpectedBytes.set(parts[1].toLongOrNull() ?: 64L)
|
||||
}
|
||||
backgroundScope.launch(testDispatcher) {
|
||||
delay(50.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray())
|
||||
}
|
||||
}
|
||||
return WriteResponse.Success
|
||||
}
|
||||
|
||||
override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) {
|
||||
val currentTotal = bytesReceived.addAndGet(value.size.toLong())
|
||||
val expected = totalExpectedBytes.get()
|
||||
println("Mock: Received chunk size=${value.size}, total=$currentTotal/$expected")
|
||||
backgroundScope.launch(testDispatcher) {
|
||||
delay(5.milliseconds)
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "ACK\n".toByteArray())
|
||||
|
||||
if (currentTotal >= expected && expected > 0) {
|
||||
delay(10.milliseconds)
|
||||
println("Mock: Sending final OK")
|
||||
otaPeripheral.simulateValueUpdate(txCharHandle, "OK\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 = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = OTA_CHARACTERISTIC_UUID,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = TX_CHARACTERISTIC_UUID,
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
|
||||
// 1. Connect
|
||||
val connectResult = transport.connect()
|
||||
assertTrue("Connection failed: ${connectResult.exceptionOrNull()}", connectResult.isSuccess)
|
||||
|
||||
// 2. Start OTA
|
||||
val startResult = transport.startOta(totalExpectedBytes.get(), "somehash") {}
|
||||
assertTrue("Start OTA failed: ${startResult.exceptionOrNull()}", startResult.isSuccess)
|
||||
|
||||
// 3. Stream firmware
|
||||
val data = ByteArray(totalExpectedBytes.get().toInt()) { it.toByte() }
|
||||
val streamResult = transport.streamFirmware(data, 20) {}
|
||||
assertTrue("Stream firmware failed: ${streamResult.exceptionOrNull()}", streamResult.isSuccess)
|
||||
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
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.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.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
/**
|
||||
* Tests for BleOtaTransport service discovery via Nordic's Peripheral.profile() API. These validate the refactored
|
||||
* connect() path that replaced discoverCharacteristics().
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleOtaTransportServiceDiscoveryTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
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 `connect fails when OTA service not found on device`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Create a peripheral with a DIFFERENT service UUID (not the OTA service)
|
||||
val wrongServiceUuid = Uuid.parse("0000180A-0000-1000-8000-00805F9B34FB") // Device Info
|
||||
val otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(
|
||||
name = "ESP32-OTA",
|
||||
eventHandler = object : PeripheralSpecEventHandler {},
|
||||
isBonded = true,
|
||||
) {
|
||||
Service(uuid = wrongServiceUuid) {
|
||||
Characteristic(
|
||||
uuid = OTA_CHARACTERISTIC_UUID,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
val result = transport.connect()
|
||||
|
||||
assertTrue("Connect should fail when OTA service is missing", result.isFailure)
|
||||
transport.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect fails when TX characteristic is missing`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Create a peripheral with the OTA service but only the OTA characteristic (no TX)
|
||||
val otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(
|
||||
name = "ESP32-OTA",
|
||||
eventHandler = object : PeripheralSpecEventHandler {},
|
||||
isBonded = true,
|
||||
) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = OTA_CHARACTERISTIC_UUID,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
// TX_CHARACTERISTIC intentionally omitted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
val result = transport.connect()
|
||||
|
||||
assertTrue("Connect should fail when TX characteristic is missing", result.isFailure)
|
||||
transport.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect fails when device is not found during scan`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Don't simulate any peripherals — scan will find nothing
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
val result = transport.connect()
|
||||
|
||||
assertTrue("Connect should fail when device is not found", result.isFailure)
|
||||
val exception = result.exceptionOrNull()
|
||||
assertTrue(
|
||||
"Should be ConnectionFailed, got: $exception",
|
||||
exception is OtaProtocolException.ConnectionFailed,
|
||||
)
|
||||
transport.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect succeeds with valid OTA service and characteristics`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
val otaPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
|
||||
advertising(
|
||||
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
|
||||
) {
|
||||
CompleteLocalName("ESP32-OTA")
|
||||
}
|
||||
connectable(
|
||||
name = "ESP32-OTA",
|
||||
eventHandler =
|
||||
object : PeripheralSpecEventHandler {
|
||||
override fun onConnectionRequest(
|
||||
preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>,
|
||||
): ConnectionResult = ConnectionResult.Accept
|
||||
},
|
||||
isBonded = true,
|
||||
) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = OTA_CHARACTERISTIC_UUID,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = TX_CHARACTERISTIC_UUID,
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
val result = transport.connect()
|
||||
|
||||
assertTrue("Connect should succeed: ${result.exceptionOrNull()}", result.isSuccess)
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.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 no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleOtaTransportTest {
|
||||
|
||||
private val address = "00:11:22:33:44:55"
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Test
|
||||
fun `race condition check - response before waitForResponse`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
var txCharHandle: Int = -1
|
||||
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 {
|
||||
// When receiving an OTA command, immediately simulate a response
|
||||
backgroundScope.launch(testDispatcher) {
|
||||
// Use a very small delay to simulate high speed
|
||||
delay(1.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 = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = OTA_CHARACTERISTIC_UUID,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
txCharHandle =
|
||||
Characteristic(
|
||||
uuid = TX_CHARACTERISTIC_UUID,
|
||||
property = CharacteristicProperty.NOTIFY,
|
||||
permission = Permission.READ,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(otaPeripheral))
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
val transport = BleOtaTransport(scanner, connectionFactory, address, testDispatcher)
|
||||
|
||||
// 1. Connect
|
||||
transport.connect().getOrThrow()
|
||||
|
||||
// 2. Start OTA - should succeed even if response is very fast
|
||||
val result = transport.startOta(100L, "hash") {}
|
||||
assert(result.isSuccess)
|
||||
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -85,8 +85,6 @@ kotlin {
|
|||
implementation(libs.markdown.renderer.android)
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer)
|
||||
implementation(libs.nordic.common.core)
|
||||
implementation(libs.nordic.common.permissions.ble)
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(projects.core.testing) }
|
||||
|
|
|
|||
|
|
@ -73,8 +73,6 @@ kotlin {
|
|||
implementation(libs.markdown.renderer.android)
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer)
|
||||
implementation(libs.nordic.common.core)
|
||||
implementation(libs.nordic.common.permissions.ble)
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(projects.core.testing) }
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.feature.settings.radio.component
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -38,6 +40,7 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -47,6 +50,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
|
|
@ -56,7 +60,6 @@ import androidx.compose.ui.text.input.ImeAction
|
|||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import no.nordicsemi.android.common.core.registerReceiver
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.util.toPosixString
|
||||
|
|
@ -252,10 +255,23 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
|
|||
}
|
||||
item {
|
||||
TitledCard(title = stringResource(Res.string.time_zone)) {
|
||||
val context = LocalContext.current
|
||||
var appTzPosixString by remember { mutableStateOf(ZoneId.systemDefault().toPosixString()) }
|
||||
|
||||
registerReceiver(IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)) {
|
||||
appTzPosixString = ZoneId.systemDefault().toPosixString()
|
||||
DisposableEffect(context) {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
appTzPosixString = ZoneId.systemDefault().toPosixString()
|
||||
}
|
||||
}
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(Intent.ACTION_TIMEZONE_CHANGED),
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
onDispose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
EditTextPreference(
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import androidx.compose.ui.platform.LocalFocusManager
|
|||
import androidx.core.location.LocationCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.android.common.permissions.ble.RequireLocation
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.resources.Res
|
||||
|
|
@ -251,16 +250,16 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
|
|||
onValueChanged = { alt: Int -> locationInput = locationInput.copy(altitude = alt) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
RequireLocation { isLocationRequiredAndDisabled: Boolean ->
|
||||
TextButton(
|
||||
enabled = state.connected && !isLocationRequiredAndDisabled,
|
||||
onClick = {
|
||||
@SuppressLint("MissingPermission")
|
||||
coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() }
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(Res.string.position_config_set_fixed_from_phone))
|
||||
}
|
||||
// RequireLocation wrapper removed to complete Nordic removal.
|
||||
// Should be replaced with a generic solution later.
|
||||
TextButton(
|
||||
enabled = state.connected,
|
||||
onClick = {
|
||||
@SuppressLint("MissingPermission")
|
||||
coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() as? Location }
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(Res.string.position_config_set_fixed_from_phone))
|
||||
}
|
||||
} else {
|
||||
HorizontalDivider()
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ spotless = "8.3.0"
|
|||
wire = "6.0.0"
|
||||
vico = "3.0.3"
|
||||
dependency-guard = "0.5.0"
|
||||
nordic-ble = "2.0.0-alpha16"
|
||||
nordic-common = "2.9.2"
|
||||
kable = "0.42.0"
|
||||
nordic-dfu = "2.11.0"
|
||||
|
||||
|
||||
[libraries]
|
||||
|
|
@ -213,19 +213,9 @@ 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-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-core-mock = { module = "no.nordicsemi.kotlin.ble:client-core-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.11.0" }
|
||||
nordic-ble-env-android = { module = "no.nordicsemi.kotlin.ble:environment-android", version.ref = "nordic-ble" }
|
||||
nordic-ble-env-android-compose = { module = "no.nordicsemi.kotlin.ble:environment-android-compose", version.ref = "nordic-ble" }
|
||||
nordic-dfu = { module = "no.nordicsemi.android:dfu", version.ref = "nordic-dfu" }
|
||||
|
||||
nordic-common-core = { module = "no.nordicsemi.android.common:core", version.ref = "nordic-common" }
|
||||
nordic-common-permissions-ble = { module = "no.nordicsemi.android.common:permissions-ble", version.ref = "nordic-common" }
|
||||
nordic-common-permissions-notification = { module = "no.nordicsemi.android.common:permissions-notification", version.ref = "nordic-common" }
|
||||
nordic-common-scanner-ble = { module = "no.nordicsemi.android.common:scanner-ble", version.ref = "nordic-common" }
|
||||
nordic-common-ui = { module = "no.nordicsemi.android.common:ui", version.ref = "nordic-common" }
|
||||
kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" }
|
||||
|
||||
org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }
|
||||
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue