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

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