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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,12 @@ import kotlinx.coroutines.cancel
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
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.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 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.
|
||||
|
|
@ -71,7 +69,7 @@ private val SCAN_TIMEOUT = 5.seconds
|
|||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class NordicBleInterface(
|
||||
class BleRadioInterface(
|
||||
private val serviceScope: CoroutineScope,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
|
|
@ -104,6 +102,8 @@ class NordicBleInterface(
|
|||
private var bytesReceived: Long = 0
|
||||
private var bytesSent: Long = 0
|
||||
|
||||
@Volatile private var isFullyConnected = false
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
|
@ -121,8 +121,17 @@ 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(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)
|
||||
|
|
@ -134,34 +143,68 @@ class NordicBleInterface(
|
|||
|
||||
private fun connect() {
|
||||
connectionScope.launch {
|
||||
try {
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
val device = findDevice()
|
||||
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
if (state is BleConnectionState.Disconnected) {
|
||||
onDisconnected(state)
|
||||
}
|
||||
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)
|
||||
|
||||
val device = findDevice()
|
||||
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
|
||||
onConnected()
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
|
||||
handleFailure(e)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -169,8 +212,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 +244,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 +270,7 @@ class NordicBleInterface(
|
|||
.launchIn(this)
|
||||
|
||||
// Store reference for handleSendToRadio
|
||||
this@NordicBleInterface.radioService = radioService
|
||||
this@BleRadioInterface.radioService = radioService
|
||||
|
||||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
|
||||
|
|
@ -237,7 +278,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@BleRadioInterface.service.onConnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
|
||||
|
|
@ -246,7 +287,7 @@ class NordicBleInterface(
|
|||
}
|
||||
}
|
||||
|
||||
private var radioService: MeshtasticRadioProfile.State? = null
|
||||
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
|
||||
|
||||
// --- RadioTransport Implementation ---
|
||||
|
||||
|
|
@ -296,15 +337,15 @@ class NordicBleInterface(
|
|||
0
|
||||
}
|
||||
Logger.i {
|
||||
"[$address] BLE close() called - " +
|
||||
"[$address] Disconnecting. " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
serviceScope.launch {
|
||||
connectionScope.cancel()
|
||||
connectionScope.launch {
|
||||
bleConnection.disconnect()
|
||||
service.onDisconnect(true)
|
||||
connectionScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -325,16 +366,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)
|
||||
}
|
||||
|
|
@ -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,33 +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
|
||||
|
||||
/** 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 log packets from the radio. */
|
||||
val logRadio: Flow<ByteArray>
|
||||
|
||||
/** Sends a packet to the radio. */
|
||||
suspend fun sendToRadio(packet: ByteArray)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue