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:
James Rich 2025-11-06 12:27:21 -06:00 committed by GitHub
parent 6cbecdd25e
commit 9e8ffaa0ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 466 additions and 1399 deletions

View file

@ -243,6 +243,8 @@ dependencies {
implementation(libs.accompanist.permissions)
implementation(libs.timber)
implementation(libs.nordic)
debugImplementation(libs.androidx.compose.ui.test.manifest)
googleImplementation(libs.location.services)

View file

@ -17,7 +17,6 @@
<ID>EmptyFunctionBlock:NsdManager.kt$&lt;no name provided&gt;${ }</ID>
<ID>EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}</ID>
<ID>FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>FinalNewline:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt</ID>
<ID>FinalNewline:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt</ID>
<ID>FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
@ -34,14 +33,13 @@
<ID>FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
<ID>FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>ForbiddenComment:SafeBluetooth.kt$SafeBluetooth$// TODO: display some kind of UI about restarting BLE</ID>
<ID>ImplicitDefaultLocale:MeshService.kt$MeshService$String.format("0x%02x", b)</ID>
<ID>ImplicitDefaultLocale:NordicBleInterface.kt$NordicBleInterface$String.format("0x%02x", b)</ID>
<ID>ImplicitDefaultLocale:RadioInterfaceService.kt$RadioInterfaceService$String.format("0x%02x", b)</ID>
<ID>LambdaParameterEventTrailing:Channel.kt$onConfirm</ID>
<ID>LambdaParameterInRestartableEffect:Channel.kt$onConfirm</ID>
<ID>LargeClass:MeshService.kt$MeshService : Service</ID>
<ID>LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>MagicNumber:BluetoothInterface.kt$BluetoothInterface$1000</ID>
<ID>MagicNumber:BluetoothInterface.kt$BluetoothInterface$500</ID>
<ID>MagicNumber:BluetoothInterface.kt$BluetoothInterface$512</ID>
<ID>MagicNumber:Contacts.kt$7</ID>
<ID>MagicNumber:Contacts.kt$8</ID>
<ID>MagicNumber:MQTTRepository.kt$MQTTRepository$512</ID>
@ -58,9 +56,6 @@
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth$10</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth$1000</ID>
<ID>MagicNumber:SafeBluetooth.kt$SafeBluetooth$2500</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>MagicNumber:ServiceClient.kt$ServiceClient$500</ID>
@ -73,7 +68,15 @@
<ID>MagicNumber:TCPInterface.kt$TCPInterface$500</ID>
<ID>MagicNumber:UIState.kt$4</ID>
<ID>MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker</ID>
<ID>MaxLineLength:BluetoothInterface.kt$/* 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 &gt;= 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) */</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"handleConfigComplete called with id=$configCompleteId, configOnly=$configOnlyNonce, nodeInfo=$nodeInfoNonce"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService.&lt;no name provided&gt;$"sendData dest=${p.to}, id=${p.id} &lt;- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"Peripheral not ready, cannot send data. Peripheral is ${if (peripheral == null) "null" else "not null"}. Characteristic is ${if (characteristic == null) "null" else "not null"}."</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"fromNumCharacteristic discovered: uuid=${fromNumCharacteristic?.uuid} instanceId=${fromNumCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"fromRadioCharacteristic discovered (packet queue): uuid=${fromRadioCharacteristic?.uuid} instanceId=${fromRadioCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"service.serviceScope not active while dispatching from packet queue (source=$source); using localScope as fallback"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"toRadioCharacteristic discovered: uuid=${toRadioCharacteristic?.uuid} instanceId=${toRadioCharacteristic?.instanceId}"</ID>
<ID>ModifierClickableOrder:Channel.kt$clickable(onClick = onClick)</ID>
<ID>ModifierMissing:BLEDevices.kt$BLEDevices</ID>
<ID>ModifierMissing:Channel.kt$ChannelScreen</ID>
@ -88,7 +91,6 @@
<ID>NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage)</ID>
<ID>NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>NewLineAtEndOfFile:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt</ID>
<ID>NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
@ -118,20 +120,20 @@
<ID>PreviewPublic:Channel.kt$ModemPresetInfoPreview</ID>
<ID>PreviewPublic:EmptyStateContent.kt$EmptyStateContentPreview</ID>
<ID>RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex</ID>
<ID>SwallowedException:BluetoothInterface.kt$BluetoothInterface$ex: CancellationException</ID>
<ID>SwallowedException:Exceptions.kt$ex: Throwable</ID>
<ID>SwallowedException:MeshService.kt$MeshService$ex: BLEException</ID>
<ID>SwallowedException:MeshService.kt$MeshService$ex: CancellationException</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:BluetoothInterface.kt$BluetoothInterface$ex: Exception</ID>
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService.&lt;no name provided&gt;$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshServiceStarter.kt$ServiceStarter$ex: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$ex: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable</ID>
<ID>TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable</ID>
<ID>TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooGenericExceptionThrown:MeshService.kt$MeshService$throw Exception("Can't set user without a NodeInfo")</ID>
@ -140,11 +142,10 @@
<ID>TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Service not bound")</ID>
<ID>TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("SyncContinuation timeout")</ID>
<ID>TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen")</ID>
<ID>TooManyFunctions:BluetoothInterface.kt$BluetoothInterface : IRadioInterface</ID>
<ID>TooManyFunctions:MeshService.kt$MeshService : Service</ID>
<ID>TooManyFunctions:MeshService.kt$MeshService$&lt;no name provided&gt; : Stub</ID>
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : IRadioInterface</ID>
<ID>TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService</ID>
<ID>TooManyFunctions:SafeBluetooth.kt$SafeBluetooth : Closeable</ID>
<ID>TooManyFunctions:UIState.kt$UIViewModel : ViewModel</ID>
<ID>TopLevelPropertyNaming:Constants.kt$const val prefix = "com.geeksville.mesh"</ID>
<ID>UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule</ID>

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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() {

View file

@ -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<*>

View file

@ -1,156 +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.service
import android.bluetooth.BluetoothGatt
import com.geeksville.mesh.concurrent.Continuation
import com.geeksville.mesh.logAssert
import com.geeksville.mesh.util.exceptionReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
/** A data class representing a schedulable unit of Bluetooth work. */
internal data class BluetoothWorkItem(
val tag: String,
val completion: Continuation<*>,
val timeoutMillis: Long = 0, // If we want to timeout this operation at a certain time, use a non zero value
private val startWorkFn: () -> Boolean,
) {
/** Start running a queued bit of work, return true for success or false for fatal bluetooth error. */
fun startWork(): Boolean {
Timber.d("Starting work: $tag")
return startWorkFn()
}
/** Connection work items are treated specially. */
fun isConnect(): Boolean = tag == "connect" || tag == "reconnect"
}
/** Manages a queue of bluetooth operations to ensure that only one is in flight at a time. */
internal class BluetoothWorkQueue {
@Volatile
var currentWork: BluetoothWorkItem? = null
private set
private val workQueue = mutableListOf<BluetoothWorkItem>()
private val serviceScope = CoroutineScope(Dispatchers.IO)
private var activeTimeout: Job? = null
companion object {
/** Our own custom BLE status code for timeouts */
private const val STATUS_TIMEOUT = 4404
}
var isSettingMtu: Boolean = false
// / If we have work we can do, start doing it.
private fun startNewWork() {
logAssert(currentWork == null)
if (workQueue.isNotEmpty()) {
val newWork = workQueue.removeAt(0)
currentWork = newWork
if (newWork.timeoutMillis != 0L) {
activeTimeout =
serviceScope.launch {
delay(newWork.timeoutMillis)
Timber.e("Failsafe BLE timer for ${newWork.tag} expired!")
completeWork(STATUS_TIMEOUT, Unit) // Throw an exception in that work
}
}
isSettingMtu = false // Most work is not doing MTU stuff, the work that is will re set this flag
if (!newWork.startWork()) {
completeWork(STATUS_TIMEOUT, Unit)
}
}
}
fun <T> queueWork(tag: String, cont: Continuation<T>, timeout: Long, initFn: () -> Boolean) {
val workItem = BluetoothWorkItem(tag, cont, timeout, initFn)
synchronized(workQueue) {
Timber.d("Enqueuing work: ${workItem.tag}")
workQueue.add(workItem)
// if we don't have any outstanding operations, run first item in queue
if (currentWork == null) startNewWork()
}
}
/** Stop any current work */
private fun stopCurrentWork() {
activeTimeout?.cancel()
activeTimeout = null
currentWork = null
}
/** Called from our big GATT callback, completes the current job and then schedules a new one */
fun <T : Any> completeWork(status: Int, res: T) {
exceptionReporter {
// We might unexpectedly fail inside here, but we don't want to pass that exception back up to the bluetooth
// GATT layer
val work =
synchronized(workQueue) {
currentWork.also {
if (it != null) {
stopCurrentWork()
startNewWork()
}
}
}
if (work == null) {
Timber.w("Work completed, but it was already killed (possibly by timeout). status=$status, res=$res")
return@exceptionReporter
}
if (status != BluetoothGatt.GATT_SUCCESS) {
work.completion.resumeWithException(
SafeBluetooth.BLEStatusException(status, "Bluetooth status=$status while doing ${work.tag}"),
)
} else {
@Suppress("UNCHECKED_CAST")
work.completion.resume(Result.success(res) as Result<Nothing>)
}
}
}
/** Something went wrong, abort all queued */
fun failAllWork(ex: Exception) {
synchronized(workQueue) {
Timber.w("Failing ${workQueue.size} works, because ${ex.message}")
workQueue.forEach {
@Suppress("TooGenericExceptionCaught")
try {
it.completion.resumeWithException(ex)
} catch (e: Exception) {
Timber.e(e, "Exception while failing work item ${it.tag}")
}
}
workQueue.clear()
stopCurrentWork()
}
}
}

View file

@ -108,7 +108,6 @@ import org.meshtastic.proto.position
import org.meshtastic.proto.telemetry
import org.meshtastic.proto.user
import timber.log.Timber
import java.util.Random
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ -206,7 +205,9 @@ class MeshService : Service() {
val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION)
val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION)
private var configNonce = 1
// Two-stage config flow nonces to avoid stale BLE packets, mirroring Meshtastic-Apple
private const val DEFAULT_CONFIG_ONLY_NONCE = 69420
private const val DEFAULT_NODE_INFO_NONCE = 69421
}
private val serviceJob = Job()
@ -472,7 +473,7 @@ class MeshService : Service() {
NodeEntity(num = n, user = defaultUser, longName = defaultUser.longName, channel = channel)
}
private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex()
private val hexIdRegex = """!([0-9A-Fa-f]+)""".toRegex()
// Map a userid to a node/ node num, or throw an exception if not found
// We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a
@ -1252,7 +1253,7 @@ class MeshService : Service() {
delay(timeout * 1000L)
Timber.w("Device timeout out, setting disconnected")
onConnectionChanged(ConnectionState.DISCONNECTED)
} catch (ex: CancellationException) {
} catch (_: CancellationException) {
Timber.d("device sleep timeout cancelled")
}
}
@ -1274,27 +1275,17 @@ class MeshService : Service() {
}
fun startConnect() {
// Do our startup init
try {
connectTimeMsec = System.currentTimeMillis()
startConfig()
startConfigOnly()
} catch (ex: InvalidProtocolBufferException) {
Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again")
} catch (ex: RadioNotConnectedException) {
// note: no need to call startDeviceSleep(), because this exception could only have
// reached us if it was
// already called
Timber.e("Lost connection to radio during init - waiting for reconnect ${ex.message}")
} catch (ex: RemoteException) {
// It seems that when the ESP32 goes offline it can briefly come back for a 100ms
// ish which
// causes the phone to try and reconnect. If we fail downloading our initial radio
// state we don't want
// to
// claim we have a valid connection still
connectionStateHolder.setState(ConnectionState.DEVICE_SLEEP)
startDeviceSleep()
throw ex // Important to rethrow so that we don't tell the app all is well
throw ex
}
}
@ -1351,6 +1342,7 @@ class MeshService : Service() {
private val packetHandlers: Map<PayloadVariantCase, ((MeshProtos.FromRadio) -> Unit)> by lazy {
PayloadVariantCase.entries.associateWith { variant: PayloadVariantCase ->
Timber.d("PacketHandler - handling $variant")
when (variant) {
PayloadVariantCase.PACKET -> { proto: MeshProtos.FromRadio -> handleReceivedMeshPacket(proto.packet) }
@ -1396,6 +1388,8 @@ class MeshService : Service() {
// Explicitly handle default/unwanted cases to satisfy the exhaustive `when`
PayloadVariantCase.PAYLOADVARIANT_NOT_SET -> { proto ->
Timber.e("Unexpected or unrecognized FromRadio variant: ${proto.payloadVariantCase}")
// Additional debug: log raw bytes if possible (can't access bytes here) and full proto
Timber.d("Full FromRadio proto: $proto")
}
}
}
@ -1408,6 +1402,13 @@ class MeshService : Service() {
private fun onReceiveFromRadio(bytes: ByteArray) {
try {
val proto = MeshProtos.FromRadio.parseFrom(bytes)
if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) {
Timber.w(
"Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.joinToString(",") { b ->
String.format("0x%02x", b)
}} proto=$proto",
)
}
proto.route()
} catch (ex: InvalidProtocolBufferException) {
Timber.e("Invalid Protobuf from radio, len=${bytes.size}", ex)
@ -1420,6 +1421,10 @@ class MeshService : Service() {
// provisional NodeInfos we will install if all goes well
private val newNodes = mutableListOf<MeshProtos.NodeInfo>()
// Nonces for two-stage config flow (match Meshtastic-Apple)
private var configOnlyNonce: Int = DEFAULT_CONFIG_ONLY_NONCE
private var nodeInfoNonce: Int = DEFAULT_NODE_INFO_NONCE
private fun handleDeviceConfig(config: ConfigProtos.Config) {
Timber.d("Received config ${config.toOneLineString()}")
val packetToSave =
@ -1734,7 +1739,7 @@ class MeshService : Service() {
private fun onHasSettings() {
packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() })
processQueuedPackets() // send any packets that were queued up
processQueuedPackets()
startMqttClientProxy()
serviceBroadcasts.broadcastConnection()
sendAnalytics()
@ -1742,53 +1747,88 @@ class MeshService : Service() {
}
private fun handleConfigComplete(configCompleteId: Int) {
if (configCompleteId == configNonce) {
Timber.d("Received config complete for config-only nonce $configNonce")
handleConfigComplete()
Timber.d(
"handleConfigComplete called with id=$configCompleteId, configOnly=$configOnlyNonce, nodeInfo=$nodeInfoNonce",
)
when (configCompleteId) {
configOnlyNonce -> handleConfigOnlyComplete()
nodeInfoNonce -> handleNodeInfoComplete()
else ->
Timber.w(
"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]",
)
}
}
private fun handleConfigComplete() {
Timber.d("Received config only complete for nonce $configNonce")
private fun handleConfigOnlyComplete() {
Timber.d("Config-only complete for nonce $configOnlyNonce")
val packetToSave =
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "ConfigComplete",
message_type = "ConfigOnlyComplete",
received_date = System.currentTimeMillis(),
raw_message = configNonce.toString(),
fromRadio = fromRadio { this.configCompleteId = configNonce },
raw_message = configOnlyNonce.toString(),
fromRadio = fromRadio { this.configCompleteId = configOnlyNonce },
)
insertMeshLog(packetToSave)
// This was our config request
if (newMyNodeInfo == null) {
Timber.e("Did not receive a valid config")
} else {
myNodeInfo = newMyNodeInfo
}
// This was our config request
// Keep BLE awake and allow the firmware to settle before the node-info stage.
serviceScope.handledLaunch {
sendHeartbeat()
startNodeInfoOnly()
}
}
/** Send a ToRadio heartbeat to keep the link alive without producing mesh traffic. */
private fun sendHeartbeat() {
try {
packetHandler.sendToRadio(
ToRadio.newBuilder().apply { heartbeat = MeshProtos.Heartbeat.getDefaultInstance() },
)
Timber.d("Heartbeat sent between nonce stages")
} catch (ex: Exception) {
Timber.w(ex, "Failed to send heartbeat; proceeding with node-info stage")
}
}
private fun handleNodeInfoComplete() {
Timber.d("NodeInfo complete for nonce $nodeInfoNonce")
val packetToSave =
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "NodeInfoComplete",
received_date = System.currentTimeMillis(),
raw_message = nodeInfoNonce.toString(),
fromRadio = fromRadio { this.configCompleteId = nodeInfoNonce },
)
insertMeshLog(packetToSave)
if (newNodes.isEmpty()) {
Timber.e("Did not receive a valid node info")
} else {
newNodes.forEach(::installNodeInfo)
newNodes.clear()
serviceScope.handledLaunch { nodeRepository.installConfig(myNodeInfo!!, nodeDBbyNodeNum.values.toList()) }
haveNodeDB = true // we now have nodes from real hardware
haveNodeDB = true
sendAnalytics()
onHasSettings()
}
}
/** Start the modern (REV2) API configuration flow */
private fun startConfig() {
configNonce += 1
private fun startConfigOnly() {
newMyNodeInfo = null
Timber.d("Starting config-only nonce=$configOnlyNonce")
packetHandler.sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = configOnlyNonce })
}
private fun startNodeInfoOnly() {
newNodes.clear()
Timber.d("Starting config only nonce=$configNonce")
packetHandler.sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = configNonce })
Timber.d("Starting node-info nonce=$nodeInfoNonce")
packetHandler.sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = nodeInfoNonce })
}
/** Send a position (typically from our built in GPS) into the mesh. */
@ -1799,20 +1839,14 @@ class MeshService : Service() {
val idNum = destNum ?: mi.myNodeNum // when null we just send to the local node
Timber.d("Sending our position/time to=$idNum ${Position(position)}")
// Also update our own map for our nodeNum, by handling the packet just like packets
// from other users
// Also update our own map for our nodeNum, by handling the packet just like packets from other users
if (!localConfig.position.fixedPosition) {
handleReceivedPosition(mi.myNodeNum, position)
}
packetHandler.sendToRadio(
newMeshPacketTo(idNum).buildMeshPacket(
channel =
if (destNum == null) {
0
} else {
nodeDBbyNodeNum[destNum]?.channel ?: 0
},
channel = if (destNum == null) 0 else nodeDBbyNodeNum[destNum]?.channel ?: 0,
priority = MeshPacket.Priority.BACKGROUND,
) {
portnumValue = Portnums.PortNum.POSITION_APP_VALUE
@ -1821,7 +1855,7 @@ class MeshService : Service() {
},
)
}
} catch (ex: BLEException) {
} catch (_: BLEException) {
Timber.w("Ignoring disconnected radio during gps location update")
}
}
@ -1836,13 +1870,10 @@ class MeshService : Service() {
Timber.d("Ignoring nop owner change")
} else {
Timber.d(
"setOwner Id: $id longName: ${longName.anonymize}" +
" shortName: $shortName isLicensed: $isLicensed" +
" isUnmessagable: $isUnmessagable",
"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable",
)
// Also update our own map for our nodeNum, by handling the packet just like packets
// from other users
// Also update our own map for our nodeNum, by handling the packet just like packets from other users
handleReceivedUser(dest.num, user)
// encapsulate our payload in the proper protobuf and fire it off
@ -1851,18 +1882,14 @@ class MeshService : Service() {
}
// Do not use directly, instead call generatePacketId()
private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue
private var currentPacketId = java.util.Random(System.currentTimeMillis()).nextLong().absoluteValue
/** Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) */
@Synchronized
private fun generatePacketId(): Int {
val numPacketIds = ((1L shl 32) - 1) // A mask for only the valid packet ID bits, either 255 or maxint
val numPacketIds = ((1L shl 32) - 1)
currentPacketId++
currentPacketId = currentPacketId and 0xffffffff // keep from exceeding 32 bits
// Use modulus and +1 to ensure we skip 0 on any values we return
currentPacketId = currentPacketId and 0xffffffff
return ((currentPacketId % numPacketIds) + 1L).toInt()
}
@ -1941,7 +1968,6 @@ class MeshService : Service() {
}
},
)
updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
}
private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions {
@ -1994,18 +2020,13 @@ class MeshService : Service() {
radioInterfaceService.setDeviceAddress(deviceAddr)
}
// Note: bound methods don't get properly exception caught/logged, so do that with a
// wrapper
// per https://blog.classycode.com/dealing-with-exceptions-in-aidl-9ba904c6d63
override fun subscribeReceiver(packageName: String, receiverName: String) = toRemoteExceptions {
serviceBroadcasts.subscribeReceiver(receiverName, packageName)
}
override fun getUpdateStatus(): Int = -4 // ProgressNotStarted
override fun getUpdateStatus(): Int = -4
override fun startFirmwareUpdate() = toRemoteExceptions {
// TODO reimplement this after we have a new firmware update mechanism
}
override fun startFirmwareUpdate() = toRemoteExceptions {}
override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo()
@ -2039,24 +2060,17 @@ class MeshService : Service() {
override fun send(p: DataPacket) {
toRemoteExceptions {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes!!
Timber.i(
"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes" +
" (connectionState=${connectionStateHolder.getState()})",
"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})",
)
if (p.dataType == 0) {
throw Exception("Port numbers must be non-zero!") // we are now more strict
}
if (p.dataType == 0) throw Exception("Port numbers must be non-zero!")
if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) {
p.status = MessageStatus.ERROR
throw RemoteException("Message too long")
} else {
p.status = MessageStatus.QUEUED
}
if (connectionStateHolder.getState() == ConnectionState.CONNECTED) {
try {
sendNow(p)
@ -2068,10 +2082,7 @@ class MeshService : Service() {
enqueueForSending(p)
}
serviceBroadcasts.broadcastMessageStatus(p)
// Keep a record of DataPackets, so GUIs can show proper chat history
rememberDataPacket(p, false)
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
}
@ -2080,7 +2091,6 @@ class MeshService : Service() {
this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException()
}
/** Send our current radio config to the device */
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
setRemoteConfig(generatePacketId(), myNodeNum, payload)
}
@ -2089,7 +2099,7 @@ class MeshService : Service() {
Timber.d("Setting new radio config!")
val config = ConfigProtos.Config.parseFrom(payload)
packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config })
if (num == myNodeNum) setLocalConfig(config) // Update our local copy
if (num == myNodeNum) setLocalConfig(config)
}
override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
@ -2104,12 +2114,11 @@ class MeshService : Service() {
)
}
/** Send our current module config to the device */
override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
Timber.d("Setting new module config!")
val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload)
packetHandler.sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config })
if (num == myNodeNum) setLocalModuleConfig(config) // Update our local copy
if (num == myNodeNum) setLocalModuleConfig(config)
}
override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
@ -2176,7 +2185,6 @@ class MeshService : Service() {
override fun getNodes(): MutableList<NodeInfo> = toRemoteExceptions {
val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList()
Timber.i("in getOnline, count=${r.size}")
// return arrayOf("+16508675309")
r
}
@ -2209,31 +2217,22 @@ class MeshService : Service() {
override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions {
if (destNum != myNodeNum) {
// Determine the best position to send based on user preferences and available
// data
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)
val currentPosition =
when {
// Use provided position if valid and user allows phone location sharing
provideLocation && position.isValid() -> position
// Otherwise use the last valid position from nodeDB (node GPS or
// static)
else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
}
if (currentPosition == null) {
Timber.d("Position request skipped - no valid position available")
return@toRemoteExceptions
}
// Convert Position to MeshProtos.Position for the payload
val meshPosition = position {
latitudeI = Position.degI(currentPosition.latitude)
longitudeI = Position.degI(currentPosition.longitude)
altitude = currentPosition.altitude
time = currentSecond()
}
packetHandler.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,

View file

@ -1,400 +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/>.
*/
@file:Suppress("MissingPermission")
package com.geeksville.mesh.service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.geeksville.mesh.concurrent.CallbackContinuation
import com.geeksville.mesh.concurrent.Continuation
import com.geeksville.mesh.concurrent.SyncContinuation
import com.geeksville.mesh.logAssert
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import timber.log.Timber
import java.io.Closeable
import java.util.UUID
private val Context.bluetoothManager
get() = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
// / Return a standard BLE 128 bit UUID from the short 16 bit versions
fun longBLEUUID(hexFour: String): UUID = UUID.fromString("0000$hexFour-0000-1000-8000-00805f9b34fb")
/**
* Uses coroutines to safely access a bluetooth GATT device with a synchronous API
*
* The BTLE API on android is dumb. You can only have one outstanding operation in flight to the device. If you try to
* do something when something is pending, the operation just returns false. You are expected to chain your operations
* from the results callbacks.
*
* This class fixes the API by using coroutines to let you safely do a series of BTLE operations.
*/
class SafeBluetooth(
private val context: Context,
private val device: BluetoothDevice,
private val analytics: PlatformAnalytics,
) : Closeable {
// / Users can access the GATT directly as needed
@Volatile var gatt: BluetoothGatt? = null
@Volatile var state = BluetoothProfile.STATE_DISCONNECTED
internal val workQueue = BluetoothWorkQueue()
// Called for reconnection attemps
@Volatile private var connectionCallback: ((Result<Unit>) -> Unit)? = null
@Volatile private var lostConnectCallback: (() -> Unit)? = null
// / from characteristic UUIDs to the handler function for notfies
internal val notifyHandlers = mutableMapOf<UUID, (BluetoothGattCharacteristic) -> Unit>()
/** A BLE status code based error */
class BLEStatusException(val status: Int, msg: String) : BLEException(msg)
// 0x2902 org.bluetooth.descriptor.gatt.client_characteristic_configuration.xml
private val configurationDescriptorUUID = longBLEUUID("2902")
/**
* skanky hack to restart BLE if it says it is hosed
* https://stackoverflow.com/questions/35103701/ble-android-onconnectionstatechange-not-being-called
*/
private val mainHandler = Handler(Looper.getMainLooper())
internal fun restartBle() {
analytics.track("ble_restart") // record # of times we needed to use this nasty hack
Timber.w("Doing emergency BLE restart")
context.bluetoothManager?.adapter?.let { adp ->
if (adp.isEnabled) {
adp.disable()
// TODO: display some kind of UI about restarting BLE
mainHandler.postDelayed(
{
if (!adp.isEnabled) {
adp.enable()
} else {
mainHandler.postDelayed(this::restartBle, 2500)
}
},
2500,
)
}
}
}
companion object {
// Our own custom BLE status codes
internal const val STATUS_RELIABLE_WRITE_FAILED = 4403
}
// The original implementation had autoReconnect = true, but this was causing issues
// with clients trying to manage their own state and reconnect logic.
// Setting it to false means the client is responsible for initiating reconnects.
internal var autoReconnect = false
private set
private val gattCallback = SafeBluetoothGattCallback(this)
// / helper glue to make sync continuations and then wait for the result
private fun <T> makeSync(wrappedFn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
wrappedFn(cont)
return cont.await() // was timeoutMsec but we now do the timeout at the lower BLE level
}
// Is the gatt trying to repeatedly connect as needed?
// private var autoConnect = false
// / True if the current active connection is auto (possible for this to be false but autoConnect to be true
// / if we are in the first non-automated lowLevel connect.
internal var currentConnectIsAuto = false
private set
internal fun lowLevelConnect(autoNow: Boolean): BluetoothGatt? {
currentConnectIsAuto = autoNow
logAssert(gatt == null)
// MinSdk is 26, so we always use TRANSPORT_LE
val g = device.connectGatt(context, autoNow, gattCallback, BluetoothDevice.TRANSPORT_LE)
gatt = g
return g
}
// If autoConnect is false, it will try to connect now and will timeout and fail in 30 seconds.
// If autoConnect is true, it will attempt to connect immediately and will also attempt to reconnect
// automatically if the connection is lost.
private fun queueConnect(autoConnect: Boolean = false, cont: Continuation<Unit>, timeout: Long = 0) {
this.autoReconnect = autoConnect
// assert(gatt == null) this now might be !null with our new reconnect support
workQueue.queueWork("connect", cont, timeout) {
// Note: To workaround https://issuetracker.google.com/issues/36995652
// Always call BluetoothDevice#connectGatt() with autoConnect=false
// (the race condition does not affect that case). If that connection times out
// you will get a callback with status=133. Then call BluetoothGatt#connect()
// to initiate a background connection.
lowLevelConnect(false) != null
}
}
/**
* start a connection attempt.
*
* Note: if autoConnect is true, the callback you provide will be kept around _even after the connection is
* complete. If we ever lose the connection, this class will immediately requque the attempt (after canceling any
* outstanding queued operations).
*
* So you should expect your callback might be called multiple times, each time to reestablish a new connection.
*/
fun asyncConnect(autoConnect: Boolean = false, cb: (Result<Unit>) -> Unit, lostConnectCb: () -> Unit) {
// logAssert(workQueue.isEmpty())
if (workQueue.currentWork != null) throw AssertionError("currentWork was not null: ${workQueue.currentWork}")
lostConnectCallback = lostConnectCb
connectionCallback = if (autoConnect) cb else null
queueConnect(autoConnect, CallbackContinuation(cb))
}
// / Restart any previous connect attempts
@Suppress("unused")
private fun reconnect() {
// closeGatt() // Get rid of any old gatt
connectionCallback?.let { cb -> queueConnect(true, CallbackContinuation(cb)) }
}
internal fun lostConnection(reason: String) {
workQueue.failAllWork(BLEException(reason))
// Cancel any notifications - because when the device comes back it might have forgotten about us
notifyHandlers.clear()
lostConnectCallback?.invoke()
}
// / Drop our current connection and then requeue a connect as needed
internal fun dropAndReconnect() {
lostConnection("lost connection, reconnecting")
// Queue a new connection attempt
val cb = connectionCallback
if (cb != null) {
Timber.d("queuing a reconnection callback")
assert(workQueue.currentWork == null)
if (
!currentConnectIsAuto
) { // we must have been running during that 1-time manual connect, switch to auto-mode from now on
closeGatt() // Close the old non-auto connection
lowLevelConnect(true)
}
// note - we don't need an init fn (because that would normally redo the connectGatt call - which we don't
// need)
workQueue.queueWork("reconnect", CallbackContinuation(cb), 0) { true }
} else {
Timber.d("No connectionCallback registered")
}
}
fun connect(autoConnect: Boolean = false) = makeSync<Unit> { queueConnect(autoConnect, it) }
private fun queueReadCharacteristic(
c: BluetoothGattCharacteristic,
cont: Continuation<BluetoothGattCharacteristic>,
timeout: Long = 0,
) = workQueue.queueWork("readC ${c.uuid}", cont, timeout) { gatt!!.readCharacteristic(c) }
fun asyncReadCharacteristic(c: BluetoothGattCharacteristic, cb: (Result<BluetoothGattCharacteristic>) -> Unit) =
queueReadCharacteristic(c, CallbackContinuation(cb))
private fun queueDiscoverServices(cont: Continuation<Unit>, timeout: Long = 0) {
workQueue.queueWork("discover", cont, timeout) { gatt?.discoverServices() ?: false }
}
fun asyncDiscoverServices(cb: (Result<Unit>) -> Unit) {
queueDiscoverServices(CallbackContinuation(cb))
}
/**
* mtu operations seem to hang sometimes. To cope with this we have a 5 second timeout before throwing an exception
* and cancelling the work
*/
private fun queueRequestMtu(len: Int, cont: Continuation<Unit>) = workQueue.queueWork("reqMtu", cont, 10 * 1000) {
workQueue.isSettingMtu = true
gatt?.requestMtu(len) ?: false
}
fun asyncRequestMtu(len: Int, cb: (Result<Unit>) -> Unit) {
queueRequestMtu(len, CallbackContinuation(cb))
}
@Volatile internal var currentReliableWrite: ByteArray? = null
private fun queueWriteCharacteristic(
c: BluetoothGattCharacteristic,
v: ByteArray,
cont: Continuation<BluetoothGattCharacteristic>,
timeout: Long = 0,
) = workQueue.queueWork("writeC ${c.uuid}", cont, timeout) {
currentReliableWrite = null
val g = gatt
if (g != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Use modern API for Android 13+
g.writeCharacteristic(c, v, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothStatusCodes.SUCCESS
} else {
// Use deprecated API for older Android versions
@Suppress("DEPRECATION")
c.value = v
@Suppress("DEPRECATION")
g.writeCharacteristic(c)
}
} else {
false
}
}
fun asyncWriteCharacteristic(
c: BluetoothGattCharacteristic,
v: ByteArray,
cb: (Result<BluetoothGattCharacteristic>) -> Unit,
) = queueWriteCharacteristic(c, v, CallbackContinuation(cb))
private fun queueWriteDescriptor(
c: BluetoothGattDescriptor,
value: ByteArray,
cont: Continuation<BluetoothGattDescriptor>,
timeout: Long = 0,
) = workQueue.queueWork("writeD", cont, timeout) {
val g = gatt
if (g != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Use modern API for Android 13+
g.writeDescriptor(c, value) == BluetoothStatusCodes.SUCCESS
} else {
// Use deprecated API for older Android versions
@Suppress("DEPRECATION")
c.value = value
@Suppress("DEPRECATION")
g.writeDescriptor(c)
}
} else {
false
}
}
fun asyncWriteDescriptor(
c: BluetoothGattDescriptor,
value: ByteArray,
cb: (Result<BluetoothGattDescriptor>) -> Unit,
) = queueWriteDescriptor(c, value, CallbackContinuation(cb))
// Added: Support reading remote RSSI
private fun queueReadRemoteRssi(cont: Continuation<Int>, timeout: Long = 0) =
workQueue.queueWork("readRSSI", cont, timeout) { gatt?.readRemoteRssi() ?: false }
fun asyncReadRemoteRssi(cb: (Result<Int>) -> Unit) = queueReadRemoteRssi(CallbackContinuation(cb))
/**
* Some old androids have a bug where calling disconnect doesn't guarantee that the onConnectionStateChange callback
* gets called but the only safe way to call gatt.close is from that callback. So we set a flag once we start
* closing and then poll until we see the callback has set gatt to null (indicating the CALLBACK has close the
* gatt). If the timeout expires we assume the bug has occurred, and we manually close the gatt here.
*
* per https://github.com/don/cordova-plugin-ble-central/issues/473#issuecomment-367687575
*/
@Volatile internal var isClosing = false
/** Close just the GATT device but keep our pending callbacks active */
@Suppress("TooGenericExceptionCaught")
fun closeGatt() {
val g = gatt ?: return
Timber.i("Closing our GATT connection")
isClosing = true
try {
g.disconnect()
g.close()
} catch (e: Exception) {
Timber.w(e, "Ignoring exception in close, probably bluetooth was just disabled")
} finally {
gatt = null
isClosing = false
}
}
/**
* Close down any existing connection, any existing calls (including async connects will be cancelled and you'll
* need to recall connect to use this againt
*/
fun closeConnection() {
// Set these to null _before_ calling gatt.disconnect(), because we don't want the old lostConnectCallback to
// get called
lostConnectCallback = null
connectionCallback = null
// Cancel any notifications - because when the device comes back it might have forgotten about us
notifyHandlers.clear()
closeGatt()
workQueue.failAllWork(BLEConnectionClosing())
}
/** Close and destroy this SafeBluetooth instance. You'll need to make a new instance before using it again */
override fun close() {
closeConnection()
}
// / asyncronously turn notification on/off for a characteristic
fun setNotify(c: BluetoothGattCharacteristic, enable: Boolean, onChanged: (BluetoothGattCharacteristic) -> Unit) {
Timber.d("starting setNotify(${c.uuid}, $enable)")
notifyHandlers[c.uuid] = onChanged
// c.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
gatt!!.setCharacteristicNotification(c, enable)
// per https://stackoverflow.com/questions/27068673/subscribe-to-a-ble-gatt-notification-android
val descriptor: BluetoothGattDescriptor =
c.getDescriptor(configurationDescriptorUUID)
?: throw BLEException(
"Notify descriptor not found for ${c.uuid}",
) // This can happen on buggy BLE implementations
val descriptorValue =
if (enable) {
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
} else {
BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
}
asyncWriteDescriptor(descriptor, descriptorValue) { Timber.d("Notify enable=$enable completed") }
}
}

View file

@ -1,231 +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.service
import android.Manifest
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.os.Build
import androidx.annotation.RequiresPermission
import com.geeksville.mesh.logAssert
import com.geeksville.mesh.util.exceptionReporter
import timber.log.Timber
/**
* Our customized GattCallback.
*
* It is in its own class to keep SafeBluetooth smaller.
*/
@Suppress("TooManyFunctions")
internal class SafeBluetoothGattCallback(private val safeBluetooth: SafeBluetooth) : BluetoothGattCallback() {
private val workQueue = safeBluetooth.workQueue
companion object {
private const val RECONNECT_WORKAROUND_STATUS_CODE = 133
private const val LOST_CONNECTION_STATUS_CODE = 147
private const val MYSTERY_STATUS_CODE = 257
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
@Suppress("CyclomaticComplexMethod")
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) = exceptionReporter {
Timber.i("new bluetooth connection state $newState, status $status")
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
// If autoconnect is on and this connect attempt failed, hopefully some future attempt will
// succeed
if (status != BluetoothGatt.GATT_SUCCESS) {
Timber.e("Connect attempt failed with status $status")
safeBluetooth.lostConnection("connection failed with status $status")
} else {
safeBluetooth.state = newState
workQueue.completeWork(status, Unit)
}
}
BluetoothProfile.STATE_DISCONNECTED -> {
if (safeBluetooth.gatt == null) {
Timber.e("No gatt: ignoring connection state $newState, status $status")
} else if (safeBluetooth.isClosing) {
Timber.i("Got disconnect because we are shutting down, closing gatt")
safeBluetooth.gatt = null
g.close() // Finish closing our gatt here
} else {
// cancel any queued ops if we were already connected
val oldstate = safeBluetooth.state
safeBluetooth.state = newState
if (oldstate == BluetoothProfile.STATE_CONNECTED) {
Timber.i("Lost connection - aborting current work: ${workQueue.currentWork}")
// If autoReconnect is true and we are in a state where reconnecting makes sense
if (
safeBluetooth.autoReconnect &&
(workQueue.currentWork == null || workQueue.currentWork?.isConnect() == true)
) {
safeBluetooth.dropAndReconnect()
} else {
safeBluetooth.lostConnection("lost connection")
}
} else if (status == RECONNECT_WORKAROUND_STATUS_CODE) {
// We were not previously connected and we just failed with our non-auto connection
// attempt. Therefore we now need
// to do an autoconnection attempt. When that attempt succeeds/fails the normal
// callbacks will be called
// Note: To workaround https://issuetracker.google.com/issues/36995652
// Always call BluetoothDevice#connectGatt() with autoConnect=false
// (the race condition does not affect that case). If that connection times out
// you will get a callback with status=133. Then call BluetoothGatt#connect()
// to initiate a background connection.
if (safeBluetooth.autoReconnect) {
Timber.w("Failed on non-auto connect, falling back to auto connect attempt")
safeBluetooth.closeGatt() // Close the old non-auto connection
safeBluetooth.lowLevelConnect(true)
}
} else if (status == LOST_CONNECTION_STATUS_CODE) {
Timber.i("got 147, calling lostConnection()")
safeBluetooth.lostConnection("code 147")
}
if (status == MYSTERY_STATUS_CODE) { // mystery error code when phone is hung
// throw Exception("Mystery bluetooth failure - debug me")
safeBluetooth.restartBle()
}
}
}
else -> {
// Anything that is not a successful connection should be treated as a failure.
safeBluetooth.lostConnection("unexpected connection state: $newState")
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
// For testing lie and claim failure
workQueue.completeWork(status, Unit)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
workQueue.completeWork(status, characteristic)
}
// API 33+ callback with value parameter (overload for modern Android)
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
status: Int,
) {
// Store value in characteristic for compatibility with existing code
// Note: This is safe because we clone the value before using it
@Suppress("DEPRECATION")
characteristic.value = value
workQueue.completeWork(status, characteristic)
}
override fun onReliableWriteCompleted(gatt: BluetoothGatt, status: Int) {
workQueue.completeWork(status, Unit)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
val reliable = safeBluetooth.currentReliableWrite
if (reliable != null) {
@Suppress("DEPRECATION")
val charValue = characteristic.value
if (!charValue.contentEquals(reliable)) {
Timber.e("A reliable write failed!")
gatt.abortReliableWrite()
workQueue.completeWork(
SafeBluetooth.STATUS_RELIABLE_WRITE_FAILED,
characteristic,
) // skanky code to indicate failure
} else {
logAssert(gatt.executeReliableWrite())
// After this execute reliable completes - we can continue with normal operations (see
// onReliableWriteCompleted)
}
} else {
// Just a standard write - do the normal flow
workQueue.completeWork(status, characteristic)
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
// Alas, passing back an Int mtu isn't working and since I don't really care what MTU
// the device was willing to let us have I'm just punting and returning Unit
if (workQueue.isSettingMtu) workQueue.completeWork(status, Unit) else Timber.e("Ignoring bogus onMtuChanged")
}
/** Callback triggered as a result of a remote characteristic notification. */
@Suppress("OVERRIDE_DEPRECATION")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
safeBluetooth.notifyHandlers[characteristic.uuid]?.let { handler ->
exceptionReporter { handler(characteristic) }
} ?: Timber.w("Received notification from $characteristic, but no handler registered")
}
// API 33+ callback with value parameter (overload for modern Android)
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
// Store value in characteristic for compatibility with existing code
@Suppress("DEPRECATION")
characteristic.value = value
onCharacteristicChanged(gatt, characteristic)
}
/** Callback indicating the result of a descriptor write operation. */
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
workQueue.completeWork(status, descriptor)
}
/** Callback reporting the result of a descriptor read operation. */
@Suppress("OVERRIDE_DEPRECATION")
override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
workQueue.completeWork(status, descriptor)
}
// API 33+ callback with value parameter for descriptor read (overload for modern Android)
override fun onDescriptorRead(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
value: ByteArray,
) {
// Store value in descriptor for compatibility with existing code
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@Suppress("DEPRECATION")
descriptor.value = value
}
workQueue.completeWork(status, descriptor)
}
// Added: callback for remote RSSI reads
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
workQueue.completeWork(status, rssi)
}
}

View file

@ -139,6 +139,7 @@ markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-
markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
material = { module = "com.google.android.material:material", version = "1.13.0" }
mgrs = { module = "mil.nga:mgrs", version = "2.1.3" }
nordic = { module = "no.nordicsemi.kotlin.ble:client-android", version = "2.0.0-alpha10" }
org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" }