mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(ble): Replace custom BLE implementation with Nordic (#3595)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
6cbecdd25e
commit
9e8ffaa0ba
13 changed files with 466 additions and 1399 deletions
|
|
@ -1,489 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.repository.radio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattService
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
||||
import com.geeksville.mesh.service.BLECharacteristicNotFoundException
|
||||
import com.geeksville.mesh.service.BLEConnectionClosing
|
||||
import com.geeksville.mesh.service.BLEException
|
||||
import com.geeksville.mesh.service.RadioNotConnectedException
|
||||
import com.geeksville.mesh.service.SafeBluetooth
|
||||
import com.geeksville.mesh.util.exceptionReporter
|
||||
import com.geeksville.mesh.util.ignoreException
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import org.meshtastic.core.analytics.platform.PlatformAnalytics
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.Method
|
||||
import java.util.UUID
|
||||
|
||||
/* Info for the esp32 device side code. See that source for the 'gold' standard docs on this interface.
|
||||
|
||||
MeshBluetoothService UUID 6ba1b218-15a8-461f-9fa8-5dcae273eafd
|
||||
|
||||
FIXME - notify vs indication for fromradio output. Using notify for now, not sure if that is best
|
||||
FIXME - in the esp32 mesh management code, occasionally mirror the current net db to flash, so that if we reboot we still have a good guess of users who are out there.
|
||||
FIXME - make sure this protocol is guaranteed robust and won't drop packets
|
||||
|
||||
"According to the BLE specification the notification length can be max ATT_MTU - 3. The 3 bytes subtracted is the 3-byte header(OP-code (operation, 1 byte) and the attribute handle (2 bytes)).
|
||||
In BLE 4.1 the ATT_MTU is 23 bytes (20 bytes for payload), but in BLE 4.2 the ATT_MTU can be negotiated up to 247 bytes."
|
||||
|
||||
MAXPACKET is 256? look into what the lora lib uses. FIXME
|
||||
|
||||
Characteristics:
|
||||
UUID
|
||||
properties
|
||||
description
|
||||
|
||||
8ba2bcc2-ee02-4a55-a531-c525c5e454d5
|
||||
read
|
||||
fromradio - contains a newly received packet destined towards the phone (up to MAXPACKET bytes? per packet).
|
||||
After reading the esp32 will put the next packet in this mailbox. If the FIFO is empty it will put an empty packet in this
|
||||
mailbox.
|
||||
|
||||
f75c76d2-129e-4dad-a1dd-7866124401e7
|
||||
write
|
||||
toradio - write ToRadio protobufs to this charstic to send them (up to MAXPACKET len)
|
||||
|
||||
ed9da18c-a800-4f66-a670-aa7547e34453
|
||||
read|notify|write
|
||||
fromnum - the current packet # in the message waiting inside fromradio, if the phone sees this notify it should read messages
|
||||
until it catches up with this number.
|
||||
The phone can write to this register to go backwards up to FIXME packets, to handle the rare case of a fromradio packet was dropped after the esp32
|
||||
callback was called, but before it arrives at the phone. If the phone writes to this register the esp32 will discard older packets and put the next packet >= fromnum in fromradio.
|
||||
When the esp32 advances fromnum, it will delay doing the notify by 100ms, in the hopes that the notify will never actally need to be sent if the phone is already pulling from fromradio.
|
||||
Note: that if the phone ever sees this number decrease, it means the esp32 has rebooted.
|
||||
|
||||
Re: queue management
|
||||
Not all messages are kept in the fromradio queue (filtered based on SubPacket):
|
||||
* only the most recent Position and User messages for a particular node are kept
|
||||
* all Data SubPackets are kept
|
||||
* No WantNodeNum / DenyNodeNum messages are kept
|
||||
A variable keepAllPackets, if set to true will suppress this behavior and instead keep everything for forwarding to the phone (for debugging)
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles the bluetooth link with a mesh radio device. Does not cache any device state, just does bluetooth comms
|
||||
* etc...
|
||||
*
|
||||
* This service is not exposed outside of this process.
|
||||
*
|
||||
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
|
||||
* can be stubbed out with a simulated version as needed.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BluetoothInterface
|
||||
@AssistedInject
|
||||
constructor(
|
||||
context: Application,
|
||||
bluetoothRepository: BluetoothRepository,
|
||||
private val service: RadioInterfaceService,
|
||||
analytics: PlatformAnalytics,
|
||||
@Assisted val address: String,
|
||||
) : IRadioInterface {
|
||||
|
||||
companion object {
|
||||
// this service UUID is publicly visible for scanning
|
||||
val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
|
||||
|
||||
val BTM_FROMRADIO_CHARACTER: UUID = UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002")
|
||||
val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7")
|
||||
val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453")
|
||||
|
||||
/**
|
||||
* this is created in onCreate() We do an ugly hack of keeping it in the singleton so we can share it for the
|
||||
* rare software update case
|
||||
*/
|
||||
@Volatile var safe: SafeBluetooth? = null
|
||||
}
|
||||
|
||||
// Our BLE device
|
||||
val device
|
||||
get() =
|
||||
(safe ?: throw RadioNotConnectedException("No SafeBluetooth")).gatt
|
||||
?: throw RadioNotConnectedException("No GATT")
|
||||
|
||||
// Our service - note - it is possible to get back a null response for getService if the device services haven't
|
||||
// yet been found
|
||||
private val bservice
|
||||
get(): BluetoothGattService =
|
||||
device.getService(BTM_SERVICE_UUID) ?: throw RadioNotConnectedException("BLE service not found")
|
||||
|
||||
@Volatile private var reconnectAttempts = 0
|
||||
|
||||
private lateinit var fromNum: BluetoothGattCharacteristic
|
||||
|
||||
/**
|
||||
* If we think we are connected, but we don't hear anything from the device, we might be in a zombie state. This
|
||||
* function forces a read of a characteristic to see if we are really connected.
|
||||
*/
|
||||
override fun keepAlive() {
|
||||
if (reconnectJob == null) {
|
||||
// We are not currently trying to reconnect, so lets see if we are really connected
|
||||
Timber.d("Bluetooth keep-alive, checking connection by reading fromNum")
|
||||
// This will force a reconnect if the read fails
|
||||
service.serviceScope.handledLaunch {
|
||||
if (safe != null) { // if we are closing this will be null
|
||||
doReadFromRadio(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* With the new rev2 api, our first send is to start the configure readbacks. In that case, rather than waiting for
|
||||
* FromNum notifies - we try to just aggressively read all of the responses.
|
||||
*/
|
||||
private var isFirstSend = true
|
||||
|
||||
// NRF52 targets do not need the nasty force refresh hack that ESP32 needs (because they keep their
|
||||
// BLE handles stable. So turn the hack off for these devices. FIXME - find a better way to know that the board is
|
||||
// NRF52 based
|
||||
// and Amazon fire devices seem to not need this hack either
|
||||
// Build.MANUFACTURER != "Amazon" &&
|
||||
private var needForceRefresh = !address.startsWith("FD:10:04")
|
||||
|
||||
init {
|
||||
// Note: this call does no comms, it just creates the device object (even if the
|
||||
// device is off/not connected)
|
||||
val device = bluetoothRepository.getRemoteDevice(address)
|
||||
if (device != null) {
|
||||
Timber.i("Creating radio interface service. device=${address.anonymize}")
|
||||
|
||||
// Note this constructor also does no comm
|
||||
val s = SafeBluetooth(context, device, analytics)
|
||||
safe = s
|
||||
|
||||
startConnect()
|
||||
} else {
|
||||
Timber.e("Bluetooth adapter not found, assuming running on the emulator!")
|
||||
}
|
||||
}
|
||||
|
||||
// / Send a packet/command out the radio link
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
try {
|
||||
safe?.let { s ->
|
||||
val uuid = BTM_TORADIO_CHARACTER
|
||||
Timber.d("queuing ${p.size} bytes to $uuid")
|
||||
|
||||
// Note: we generate a new characteristic each time, because we are about to
|
||||
// change the data and we want the data stored in the closure
|
||||
val toRadio = getCharacteristic(uuid)
|
||||
|
||||
s.asyncWriteCharacteristic(toRadio, p) { r ->
|
||||
try {
|
||||
r.getOrThrow()
|
||||
Timber.d("write of ${p.size} bytes to $uuid completed")
|
||||
|
||||
if (isFirstSend) {
|
||||
isFirstSend = false
|
||||
doReadFromRadio(false)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
scheduleReconnect("error during asyncWriteCharacteristic - disconnecting, ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: BLEException) {
|
||||
scheduleReconnect("error during handleSendToRadio ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var reconnectJob: Job? = null
|
||||
|
||||
/** We had some problem, schedule a reconnection attempt (if one isn't already queued) */
|
||||
private fun scheduleReconnect(reason: String) {
|
||||
// stopRssiPolling() is no longer needed, as flow management handles polling lifecycle
|
||||
if (reconnectJob == null) {
|
||||
Timber.w("Scheduling reconnect because $reason")
|
||||
reconnectJob = service.serviceScope.handledLaunch { retryDueToException() }
|
||||
} else {
|
||||
Timber.w("Skipping reconnect for $reason")
|
||||
}
|
||||
}
|
||||
|
||||
// / Attempt to read from the fromRadio mailbox, if data is found broadcast it to android apps
|
||||
private fun doReadFromRadio(firstRead: Boolean) {
|
||||
safe?.let { s ->
|
||||
val fromRadio = getCharacteristic(BTM_FROMRADIO_CHARACTER)
|
||||
s.asyncReadCharacteristic(fromRadio) {
|
||||
try {
|
||||
val b =
|
||||
it.getOrThrow()
|
||||
.value
|
||||
.clone() // We clone the array just in case, I'm not sure if they keep reusing the array
|
||||
|
||||
if (b.isNotEmpty()) {
|
||||
Timber.d("Received ${b.size} bytes from radio")
|
||||
service.handleFromRadio(b)
|
||||
|
||||
// Queue up another read, until we run out of packets
|
||||
doReadFromRadio(firstRead)
|
||||
} else {
|
||||
Timber.d("Done reading from radio, fromradio is empty")
|
||||
if (firstRead) {
|
||||
// If we just finished our initial download, now we want to start listening for notifies
|
||||
startWatchingFromNum()
|
||||
}
|
||||
}
|
||||
} catch (ex: BLEException) {
|
||||
scheduleReconnect("error during doReadFromRadio - disconnecting, ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android caches old services. But our service is still changing often, so force it to reread the service
|
||||
* definitions every time
|
||||
*/
|
||||
private fun forceServiceRefresh() {
|
||||
exceptionReporter {
|
||||
// If the gatt has been destroyed, skip the refresh attempt
|
||||
safe?.gatt?.let { gatt ->
|
||||
Timber.d("DOING FORCE REFRESH")
|
||||
val refresh: Method = gatt.javaClass.getMethod("refresh")
|
||||
refresh.invoke(gatt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// / We only force service refresh the _first_ time we connect to the device. Thereafter it is assumed the firmware
|
||||
// didn't change
|
||||
@Suppress("UnusedPrivateMember")
|
||||
private var hasForcedRefresh = false
|
||||
|
||||
@Volatile var fromNumChanged = false
|
||||
|
||||
private fun startWatchingFromNum() {
|
||||
safe?.setNotify(fromNum, true) {
|
||||
// We might get multiple notifies before we get around to reading from the radio - so just set one flag
|
||||
fromNumChanged = true
|
||||
service.serviceScope.handledLaunch {
|
||||
try {
|
||||
if (fromNumChanged) {
|
||||
fromNumChanged = false
|
||||
Timber.d("fromNum changed, so we are reading new messages")
|
||||
doReadFromRadio(false)
|
||||
}
|
||||
} catch (e: RadioNotConnectedException) {
|
||||
// Don't report autobugs for this, getting an exception here is expected behavior
|
||||
Timber.e(e, "Ending FromNum read, radio not connected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val maxReconnectionAttempts = 6
|
||||
|
||||
/**
|
||||
* Some buggy BLE stacks can fail on initial connect, with either missing services or missing characteristics. If
|
||||
* that happens we disconnect and try again when the device reenumerates.
|
||||
*/
|
||||
private suspend fun retryDueToException() = try {
|
||||
// / We gracefully handle safe being null because this can occur if someone has unpaired from our device -
|
||||
// just abandon the reconnect attempt
|
||||
val s = safe
|
||||
if (s != null) {
|
||||
val backoffMillis = (1000 * (1 shl reconnectAttempts.coerceAtMost(maxReconnectionAttempts))).toLong()
|
||||
// Exponential backoff, capped at 64s
|
||||
reconnectAttempts++
|
||||
Timber.w(
|
||||
"Forcing disconnect and hopefully device will comeback" +
|
||||
" (disabling forced refresh). Reconnect attempt $reconnectAttempts," +
|
||||
" waiting ${backoffMillis}ms.",
|
||||
)
|
||||
|
||||
// The following optimization is not currently correct - because the device might be sleeping and come
|
||||
// back with different BLE handles
|
||||
// hasForcedRefresh = true // We've already tossed any old service caches, no need to do it again
|
||||
|
||||
// Make sure the old connection was killed
|
||||
ignoreException { s.closeConnection() }
|
||||
|
||||
service.onDisconnect(false) // assume we will fail
|
||||
delay(backoffMillis) // Give some nasty time for buggy BLE stacks to shutdown
|
||||
reconnectJob = null // Any new reconnect requests after this will be allowed to run
|
||||
Timber.w("Attempting reconnect")
|
||||
if (safe != null) {
|
||||
// check again, because we just slept, and someone might have closed our interface
|
||||
startConnect()
|
||||
} else {
|
||||
Timber.w("Not connecting, because safe==null, someone must have closed us")
|
||||
}
|
||||
} else {
|
||||
Timber.w("Abandoning reconnect because safe==null, someone must have closed the device")
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
Timber.w("retryDueToException was cancelled")
|
||||
} finally {
|
||||
reconnectJob = null
|
||||
}
|
||||
|
||||
// / We only try to set MTU once, because some buggy implementations fail
|
||||
@Volatile private var shouldSetMtu = true
|
||||
|
||||
// / For testing
|
||||
@Suppress("UnusedPrivateMember")
|
||||
@Volatile
|
||||
private var isFirstTime = true
|
||||
|
||||
private fun doDiscoverServicesAndInit() {
|
||||
val s = safe
|
||||
if (s == null) {
|
||||
Timber.w("Interface is shutting down, so skipping discover")
|
||||
} else {
|
||||
s.asyncDiscoverServices { discRes ->
|
||||
try {
|
||||
discRes.getOrThrow()
|
||||
|
||||
service.serviceScope.handledLaunch {
|
||||
try {
|
||||
Timber.d("Discovered services!")
|
||||
delay(
|
||||
1000,
|
||||
) // android BLE is buggy and needs a 1000ms sleep before calling getChracteristic, or you
|
||||
// might get back null
|
||||
|
||||
/* if (isFirstTime) {
|
||||
isFirstTime = false
|
||||
throw BLEException("Faking a BLE failure")
|
||||
} */
|
||||
|
||||
fromNum = getCharacteristic(BTM_FROMNUM_CHARACTER)
|
||||
|
||||
// We treat the first send by a client as special
|
||||
isFirstSend = true
|
||||
|
||||
// Now tell clients they can (finally use the api)
|
||||
service.onConnect()
|
||||
|
||||
// Immediately broadcast any queued packets sitting on the device
|
||||
doReadFromRadio(true)
|
||||
} catch (ex: BLEException) {
|
||||
scheduleReconnect("Unexpected error in initial device enumeration, forcing disconnect $ex")
|
||||
}
|
||||
}
|
||||
} catch (ex: BLEException) {
|
||||
if (s.gatt == null) {
|
||||
Timber.w("GATT was closed while discovering, assume we are shutting down")
|
||||
} else {
|
||||
scheduleReconnect("Unexpected error discovering services, forcing disconnect $ex")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onConnect(connRes: Result<Unit>) {
|
||||
// This callback is invoked after we are connected
|
||||
|
||||
connRes.getOrThrow()
|
||||
|
||||
reconnectAttempts = 0 // Reset backoff on successful connection
|
||||
|
||||
service.serviceScope.handledLaunch {
|
||||
Timber.i("Connected to radio!")
|
||||
// The RSSI flow is now managed by its subscription count (WhileSubscribed)
|
||||
|
||||
// After connecting, request a high connection priority for better stability
|
||||
val success = safe?.gatt?.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||
Timber.d("Requested high connection priority: $success")
|
||||
|
||||
if (
|
||||
needForceRefresh
|
||||
) { // Our ESP32 code doesn't properly generate "service changed" indications. Therefore we need to force a
|
||||
// refresh on initial start
|
||||
// needForceRefresh = false // In fact, because of tearing down BLE in sleep on the ESP32, our handle #
|
||||
// assignments are not stable across sleep - so we much refetch every time
|
||||
forceServiceRefresh() // this article says android should not be caching, but it does on some phones:
|
||||
// https://punchthrough.com/attribute-caching-in-ble-advantages-and-pitfalls/
|
||||
|
||||
delay(
|
||||
500,
|
||||
) // From looking at the android C code it seems that we need to give some time for the refresh message
|
||||
// to reach that worked _before_ we try to set mtu/get services
|
||||
// 200ms was not enough on an Amazon Fire
|
||||
}
|
||||
|
||||
// we begin by setting our MTU size as high as it can go (if we can)
|
||||
if (shouldSetMtu) {
|
||||
safe?.asyncRequestMtu(512) { mtuRes ->
|
||||
try {
|
||||
mtuRes.getOrThrow()
|
||||
Timber.d("MTU change attempted")
|
||||
|
||||
// throw BLEException("Test MTU set failed")
|
||||
|
||||
doDiscoverServicesAndInit()
|
||||
} catch (ex: BLEException) {
|
||||
shouldSetMtu = false
|
||||
scheduleReconnect("Giving up on setting MTUs, forcing disconnect $ex")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
doDiscoverServicesAndInit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
reconnectJob?.cancel() // Cancel any queued reconnect attempts
|
||||
// stopRssiPolling() is no longer needed, as flow management handles polling lifecycle
|
||||
|
||||
if (safe != null) {
|
||||
Timber.i("Closing BluetoothInterface")
|
||||
val s = safe
|
||||
safe = null // We do this first, because if we throw we still want to mark that we no longer have a valid
|
||||
// connection
|
||||
|
||||
try {
|
||||
s?.close()
|
||||
} catch (_: BLEConnectionClosing) {
|
||||
Timber.w("Ignoring BLE errors while closing")
|
||||
}
|
||||
} else {
|
||||
Timber.d("Radio was not connected, skipping disable")
|
||||
}
|
||||
}
|
||||
|
||||
// / Start a connection attempt
|
||||
private fun startConnect() {
|
||||
// we pass in true for autoconnect - so we will autoconnect whenever the radio
|
||||
// comes in range (even if we made this connect call long ago when we got powered on)
|
||||
// see https://stackoverflow.com/questions/40156699/which-correct-flag-of-autoconnect-in-connectgatt-of-ble for
|
||||
// more info
|
||||
safe!!.asyncConnect(true, cb = ::onConnect, lostConnectCb = { scheduleReconnect("connection dropped") })
|
||||
}
|
||||
|
||||
/** Get a chracteristic, but in a safe manner because some buggy BLE implementations might return null */
|
||||
private fun getCharacteristic(uuid: UUID) =
|
||||
bservice.getCharacteristic(uuid) ?: throw BLECharacteristicNotFoundException(uuid)
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.repository.radio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import com.geeksville.mesh.service.RadioNotConnectedException
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.client.android.native
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.toKotlinUuid
|
||||
|
||||
/**
|
||||
* A [IRadioInterface] implementation for BLE devices using Nordic Kotlin BLE Library.
|
||||
* https://github.com/NordicSemiconductor/Kotlin-BLE-Library.
|
||||
*
|
||||
* This class is responsible for connecting to and communicating with a Meshtastic device over BLE.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @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
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val context: Application,
|
||||
private val service: RadioInterfaceService,
|
||||
@Assisted val address: String,
|
||||
) : IRadioInterface {
|
||||
|
||||
private var peripheral: Peripheral? = null
|
||||
private val localScope: CoroutineScope
|
||||
get() = service.serviceScope
|
||||
|
||||
private lateinit var centralManager: CentralManager
|
||||
|
||||
private var toRadioCharacteristic: RemoteCharacteristic? = null
|
||||
private var fromNumCharacteristic: RemoteCharacteristic? = null
|
||||
private var fromRadioCharacteristic: RemoteCharacteristic? = null
|
||||
|
||||
private fun packetQueueFlow(): Flow<ByteArray> = channelFlow {
|
||||
while (isActive) {
|
||||
val packet = fromRadioCharacteristic?.read()
|
||||
if (packet == null || packet.isEmpty()) {
|
||||
break
|
||||
}
|
||||
send(packet)
|
||||
delay(INTER_READ_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainPacketQueueAndDispatch(source: String) {
|
||||
var drainedCount = 0
|
||||
packetQueueFlow()
|
||||
.onEach { packet ->
|
||||
drainedCount++
|
||||
Timber.d(
|
||||
"[$source] Read packet queue returned ${packet.size} bytes: ${
|
||||
packet.joinToString(
|
||||
prefix = "[",
|
||||
postfix = "]",
|
||||
) { b ->
|
||||
String.format("0x%02x", b)
|
||||
}
|
||||
} - dispatching to service.handleFromRadio()",
|
||||
)
|
||||
dispatchPacket(packet, source)
|
||||
}
|
||||
.catch { ex -> Timber.w(ex, "Exception while draining packet queue (source=$source)") }
|
||||
.onCompletion {
|
||||
if (drainedCount > 0) {
|
||||
Timber.d("[$source] Drained $drainedCount packets from packet queue")
|
||||
}
|
||||
}
|
||||
.launchIn(localScope)
|
||||
}
|
||||
|
||||
private fun dispatchPacket(packet: ByteArray, source: String) {
|
||||
try {
|
||||
if (service.serviceScope.coroutineContext[Job]?.isActive == true) {
|
||||
service.serviceScope.launch { service.handleFromRadio(p = packet) }
|
||||
} else {
|
||||
Timber.w(
|
||||
"service.serviceScope not active while dispatching from packet queue (source=$source); using localScope as fallback",
|
||||
)
|
||||
localScope.launch { service.handleFromRadio(p = packet) }
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "Failed to schedule service.handleFromRadio (source=$source)")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
|
||||
val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7")
|
||||
val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453")
|
||||
val BTM_FROMRADIO_CHARACTER: UUID = UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002")
|
||||
|
||||
private const val INTER_READ_DELAY_MS: Long = 5L
|
||||
private const val POST_WRITE_DELAY_MS: Long = 25L
|
||||
}
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
private suspend fun findPeripheral(): Peripheral =
|
||||
centralManager.scan().mapNotNull { it.peripheral }.firstOrNull { it.address == address }
|
||||
?: throw RadioNotConnectedException("Device not found")
|
||||
|
||||
private fun connect() {
|
||||
localScope.launch {
|
||||
try {
|
||||
centralManager = CentralManager.native(context, localScope)
|
||||
peripheral = findAndConnectPeripheral()
|
||||
peripheral?.let {
|
||||
observePeripheralChanges()
|
||||
discoverServicesAndSetupCharacteristics(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error during connection setup")
|
||||
service.onDisconnect(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findAndConnectPeripheral(): Peripheral {
|
||||
val p = findPeripheral()
|
||||
centralManager.connect(
|
||||
peripheral = p,
|
||||
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
p.requestConnectionPriority(ConnectionPriority.HIGH)
|
||||
return p
|
||||
}
|
||||
|
||||
private fun observePeripheralChanges() {
|
||||
peripheral?.let { p ->
|
||||
p.phy.onEach { phy -> Timber.d("PHY changed to $phy") }.launchIn(localScope)
|
||||
p.connectionParameters.onEach { Timber.d("Connection parameters changed to $it") }.launchIn(localScope)
|
||||
p.state
|
||||
.onEach { state ->
|
||||
Timber.d("Peripheral state changed to $state")
|
||||
if (!state.isConnected) {
|
||||
toRadioCharacteristic = null
|
||||
service.onDisconnect(false)
|
||||
}
|
||||
}
|
||||
.launchIn(localScope)
|
||||
}
|
||||
centralManager.state.onEach { state -> Timber.d("CentralManager state changed to $state") }.launchIn(localScope)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun discoverServicesAndSetupCharacteristics(peripheral: Peripheral) {
|
||||
localScope.launch {
|
||||
peripheral
|
||||
.services(listOf(BTM_SERVICE_UUID.toKotlinUuid()))
|
||||
.onEach { services ->
|
||||
val meshtasticService = services?.find { it.uuid == BTM_SERVICE_UUID.toKotlinUuid() }
|
||||
if (meshtasticService != null) {
|
||||
toRadioCharacteristic =
|
||||
meshtasticService.characteristics.find { it.uuid == BTM_TORADIO_CHARACTER.toKotlinUuid() }
|
||||
fromNumCharacteristic =
|
||||
meshtasticService.characteristics.find { it.uuid == BTM_FROMNUM_CHARACTER.toKotlinUuid() }
|
||||
fromRadioCharacteristic =
|
||||
meshtasticService.characteristics.find { it.uuid == BTM_FROMRADIO_CHARACTER.toKotlinUuid() }
|
||||
|
||||
if (
|
||||
toRadioCharacteristic == null ||
|
||||
fromNumCharacteristic == null ||
|
||||
fromRadioCharacteristic == null
|
||||
) {
|
||||
Timber.e("Critical: Meshtastic characteristics not found! Cannot connect.")
|
||||
service.onDisconnect(false)
|
||||
} else {
|
||||
logCharacteristicInfo()
|
||||
setupNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(localScope)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun logCharacteristicInfo() {
|
||||
try {
|
||||
Timber.d(
|
||||
"toRadioCharacteristic discovered: uuid=${toRadioCharacteristic?.uuid} instanceId=${toRadioCharacteristic?.instanceId}",
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
Timber.d("toRadioCharacteristic discovered (minimal info)")
|
||||
}
|
||||
try {
|
||||
Timber.d(
|
||||
"fromNumCharacteristic discovered: uuid=${fromNumCharacteristic?.uuid} instanceId=${fromNumCharacteristic?.instanceId}",
|
||||
)
|
||||
Timber.d(
|
||||
"fromRadioCharacteristic discovered (packet queue): uuid=${fromRadioCharacteristic?.uuid} instanceId=${fromRadioCharacteristic?.instanceId}",
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
Timber.d("fromRadioCharacteristic discovered (minimal info)")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun setupNotifications() {
|
||||
localScope.launch {
|
||||
fromNumCharacteristic
|
||||
?.subscribe()
|
||||
?.onEach { notifyBytes ->
|
||||
try {
|
||||
Timber.d(
|
||||
"FROMNUM notify, ${notifyBytes.size} bytes: ${
|
||||
notifyBytes.joinToString(
|
||||
prefix = "[",
|
||||
postfix = "]",
|
||||
) { b -> String.format("0x%02x", b) }
|
||||
} - reading packet queue",
|
||||
)
|
||||
drainPacketQueueAndDispatch("notify")
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error handling incoming FROMNUM notify")
|
||||
}
|
||||
}
|
||||
?.catch { e -> Timber.e(e, "Error in subscribe flow for fromNumCharacteristic") }
|
||||
?.onCompletion { cause -> Timber.d("fromNum subscribe flow completed, cause=$cause") }
|
||||
?.launchIn(scope = localScope)
|
||||
}
|
||||
|
||||
localScope.launch {
|
||||
try {
|
||||
fromNumCharacteristic?.setNotifying(true)
|
||||
drainPacketQueueAndDispatch("initial")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to enable notifications or perform initial drain")
|
||||
service.onDisconnect(false)
|
||||
}
|
||||
}
|
||||
service.onConnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a packet to the radio.
|
||||
*
|
||||
* @param p The packet to send.
|
||||
*/
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
val characteristic = toRadioCharacteristic
|
||||
if (peripheral == null || characteristic == null) {
|
||||
return
|
||||
}
|
||||
|
||||
localScope.launch {
|
||||
try {
|
||||
characteristic.write(p, writeType = WriteType.WITHOUT_RESPONSE)
|
||||
localScope.launch {
|
||||
delay(POST_WRITE_DELAY_MS)
|
||||
drainPacketQueueAndDispatch("post-write")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error writing to characteristic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the connection to the device. */
|
||||
override fun close() {
|
||||
val fn = fromNumCharacteristic
|
||||
localScope.launch {
|
||||
try {
|
||||
fn?.setNotifying(false)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error disabling notifications on close")
|
||||
}
|
||||
try {
|
||||
peripheral?.disconnect()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error while closing NordicBleInterface")
|
||||
}
|
||||
}
|
||||
toRadioCharacteristic = null
|
||||
fromNumCharacteristic = null
|
||||
fromRadioCharacteristic = null
|
||||
}
|
||||
}
|
||||
|
|
@ -19,10 +19,8 @@ package com.geeksville.mesh.repository.radio
|
|||
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
||||
/**
|
||||
* Factory for creating `BluetoothInterface` instances.
|
||||
*/
|
||||
/** Factory for creating `NordicBleInterface` instances. */
|
||||
@AssistedFactory
|
||||
interface BluetoothInterfaceFactory {
|
||||
fun create(rest: String): BluetoothInterface
|
||||
}
|
||||
interface NordicBleInterfaceFactory {
|
||||
fun create(rest: String): NordicBleInterface
|
||||
}
|
||||
|
|
@ -23,13 +23,13 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Bluetooth backend implementation. */
|
||||
class BluetoothInterfaceSpec
|
||||
class NordicBleInterfaceSpec
|
||||
@Inject
|
||||
constructor(
|
||||
private val factory: BluetoothInterfaceFactory,
|
||||
private val factory: NordicBleInterfaceFactory,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
) : InterfaceSpec<BluetoothInterface> {
|
||||
override fun createInterface(rest: String): BluetoothInterface = factory.create(rest)
|
||||
) : InterfaceSpec<NordicBleInterface> {
|
||||
override fun createInterface(rest: String): NordicBleInterface = factory.create(rest)
|
||||
|
||||
/** Return true if this address is still acceptable. For BLE that means, still bonded */
|
||||
override fun addressValid(rest: String): Boolean {
|
||||
|
|
@ -118,7 +118,7 @@ constructor(
|
|||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (radioIf is BluetoothInterface) {
|
||||
} else if (radioIf is NordicBleInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
|
|
@ -221,9 +221,22 @@ constructor(
|
|||
|
||||
// Handle an incoming packet from the radio, broadcasts it as an android intent
|
||||
fun handleFromRadio(p: ByteArray) {
|
||||
Timber.d(
|
||||
"RadioInterfaceService.handleFromRadio called with ${p.size} bytes: ${p.joinToString(
|
||||
prefix = "[",
|
||||
postfix = "]",
|
||||
) { b ->
|
||||
String.format("0x%02x", b)
|
||||
}}",
|
||||
)
|
||||
|
||||
if (logReceives) {
|
||||
receivedPacketsLog.write(p)
|
||||
receivedPacketsLog.flush()
|
||||
try {
|
||||
receivedPacketsLog.write(p)
|
||||
receivedPacketsLog.flush()
|
||||
} catch (t: Throwable) {
|
||||
Timber.w(t, "Failed to write receive log in handleFromRadio")
|
||||
}
|
||||
}
|
||||
|
||||
if (radioIf is SerialInterface) {
|
||||
|
|
@ -232,8 +245,13 @@ constructor(
|
|||
|
||||
// ignoreException { Timber.d("FromRadio: ${MeshProtos.FromRadio.parseFrom(p)}") }
|
||||
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) }
|
||||
emitReceiveActivity()
|
||||
try {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) }
|
||||
emitReceiveActivity()
|
||||
Timber.d("RadioInterfaceService.handleFromRadio dispatched successfully")
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "RadioInterfaceService.handleFromRadio failed while emitting data")
|
||||
}
|
||||
}
|
||||
|
||||
fun onConnect() {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ abstract class RadioRepositoryModule {
|
|||
@Multibinds abstract fun interfaceMap(): Map<InterfaceId, @JvmSuppressWildcards InterfaceSpec<*>>
|
||||
|
||||
@[Binds IntoMap InterfaceMapKey(InterfaceId.BLUETOOTH)]
|
||||
abstract fun bindBluetoothInterfaceSpec(spec: BluetoothInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
|
||||
abstract fun bindBluetoothInterfaceSpec(spec: NordicBleInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
|
||||
|
||||
@[Binds IntoMap InterfaceMapKey(InterfaceId.MOCK)]
|
||||
abstract fun bindMockInterfaceSpec(spec: MockInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue