mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
fix(transport): Kable BLE audit + thread-safety, MQTT, and logging fixes across transport layers (#5071)
This commit is contained in:
parent
5f0e60eb21
commit
a3c0a4832d
44 changed files with 1123 additions and 513 deletions
|
|
@ -49,7 +49,7 @@ class AndroidBluetoothRepository(
|
|||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
|
||||
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
|
||||
|
||||
private val deviceCache = mutableMapOf<String, DirectBleDevice>()
|
||||
private val deviceCache = mutableMapOf<String, MeshtasticBleDevice>()
|
||||
|
||||
init {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
|
||||
|
|
@ -180,14 +180,15 @@ class AndroidBluetoothRepository(
|
|||
// user renamed the device in firmware since the cache was populated.
|
||||
deviceCache.keys.retainAll(bondedAddresses)
|
||||
return bonded.map { device ->
|
||||
deviceCache
|
||||
.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
|
||||
.also { cached ->
|
||||
// Refresh name if it changed (firmware rename, etc.)
|
||||
if (cached.name != device.name) {
|
||||
deviceCache[device.address] = DirectBleDevice(device.address, device.name)
|
||||
}
|
||||
}
|
||||
val cached = deviceCache.getOrPut(device.address) { MeshtasticBleDevice(device.address, device.name) }
|
||||
// If the name changed (firmware rename, etc.), replace the cached entry and return the new one.
|
||||
if (cached.name != device.name) {
|
||||
val updated = MeshtasticBleDevice(device.address, device.name)
|
||||
deviceCache[device.address] = updated
|
||||
updated
|
||||
} else {
|
||||
cached
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,15 +20,29 @@ import co.touchlab.kermit.Logger
|
|||
import com.juul.kable.AndroidPeripheral
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
import com.juul.kable.PooledThreadingStrategy
|
||||
import com.juul.kable.toIdentifier
|
||||
|
||||
/**
|
||||
* Shared thread pool for Kable BLE connections.
|
||||
*
|
||||
* [PooledThreadingStrategy] reuses handler threads across reconnect cycles, avoiding the overhead of creating a new
|
||||
* thread per connection attempt that [OnDemandThreadingStrategy][com.juul.kable.OnDemandThreadingStrategy] incurs. Idle
|
||||
* threads are evicted after 1 minute (default).
|
||||
*
|
||||
* A single app-wide instance is used because Kable recommends exactly one pool per application.
|
||||
*/
|
||||
private val sharedThreadingStrategy = PooledThreadingStrategy()
|
||||
|
||||
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
||||
// If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice),
|
||||
// we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail
|
||||
// immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses.
|
||||
// If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster.
|
||||
// Bonded devices without a fresh advertisement must use autoConnect = true. Otherwise,
|
||||
// Android's direct connect algorithm often fails with GATT 133 or times out, especially
|
||||
// if the device uses random resolvable addresses. Scanned devices (advertisement != null)
|
||||
// use direct connection (autoConnect = false) for faster initial connects.
|
||||
autoConnectIf(autoConnect)
|
||||
|
||||
threadingStrategy = sharedThreadingStrategy
|
||||
|
||||
onServicesDiscovered {
|
||||
try {
|
||||
// Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes.
|
||||
|
|
|
|||
|
|
@ -19,14 +19,17 @@ package org.meshtastic.core.ble
|
|||
import com.juul.kable.Peripheral
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/** Snapshot of the currently active BLE peripheral and its address, updated atomically. */
|
||||
internal data class ActiveConnection(val peripheral: Peripheral, val address: String)
|
||||
|
||||
/**
|
||||
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
|
||||
* dynamically created UI devices (scanned vs bonded) and the actual connection.
|
||||
*
|
||||
* Fields are volatile to ensure visibility across AIDL binder threads and coroutine dispatchers.
|
||||
* [active] is a single volatile reference so readers always see a consistent peripheral/address pair — the previous
|
||||
* two-field design (`activePeripheral` + `activeAddress`) was susceptible to TOCTOU races when fields were updated
|
||||
* non-atomically.
|
||||
*/
|
||||
internal object ActiveBleConnection {
|
||||
@Volatile var activePeripheral: Peripheral? = null
|
||||
|
||||
@Volatile var activeAddress: String? = null
|
||||
@Volatile var active: ActiveConnection? = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package org.meshtastic.core.ble
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
|
@ -49,8 +50,8 @@ interface BleConnection {
|
|||
/** Connects to the given [BleDevice]. */
|
||||
suspend fun connect(device: BleDevice)
|
||||
|
||||
/** Connects to the given [BleDevice] and waits for a terminal state. */
|
||||
suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState
|
||||
/** Connects to the given [BleDevice] and waits for a terminal state or [timeout]. */
|
||||
suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState
|
||||
|
||||
/** Disconnects from the current device. */
|
||||
suspend fun disconnect()
|
||||
|
|
@ -77,6 +78,17 @@ interface BleService {
|
|||
/** Observes notifications/indications from the characteristic. */
|
||||
fun observe(characteristic: BleCharacteristic): Flow<ByteArray>
|
||||
|
||||
/**
|
||||
* Observes notifications/indications from the characteristic with an [onSubscription] action that fires **after**
|
||||
* notifications are enabled (CCCD written).
|
||||
*
|
||||
* The [onSubscription] is re-invoked on every reconnect while the returned [Flow] is active. The default
|
||||
* implementation invokes [onSubscription] eagerly on flow start so non-Kable implementations still signal
|
||||
* readiness.
|
||||
*/
|
||||
fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit): Flow<ByteArray> =
|
||||
observe(characteristic).onStart { onSubscription() }
|
||||
|
||||
/** Reads the characteristic value once. */
|
||||
suspend fun read(characteristic: BleCharacteristic): ByteArray
|
||||
|
||||
|
|
|
|||
|
|
@ -17,16 +17,53 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
/** Represents the state of a BLE connection. */
|
||||
sealed class BleConnectionState {
|
||||
/** The peripheral is disconnected. */
|
||||
object Disconnected : BleConnectionState()
|
||||
sealed interface BleConnectionState {
|
||||
|
||||
/**
|
||||
* The peripheral is disconnected.
|
||||
*
|
||||
* @param reason why the disconnect occurred. [DisconnectReason.Unknown] when the platform doesn't provide status
|
||||
* information (e.g. JavaScript) or when the disconnect was synthesised locally without a GATT callback.
|
||||
*/
|
||||
data class Disconnected(val reason: DisconnectReason = DisconnectReason.Unknown) : BleConnectionState
|
||||
|
||||
/** The peripheral is connecting. */
|
||||
object Connecting : BleConnectionState()
|
||||
data object Connecting : BleConnectionState
|
||||
|
||||
/** The peripheral is connected. */
|
||||
object Connected : BleConnectionState()
|
||||
data object Connected : BleConnectionState
|
||||
|
||||
/** The peripheral is disconnecting. */
|
||||
object Disconnecting : BleConnectionState()
|
||||
data object Disconnecting : BleConnectionState
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-agnostic reason for a BLE disconnect.
|
||||
*
|
||||
* Mapped from Kable's [com.juul.kable.State.Disconnected.Status] in `KableStateMapping`.
|
||||
*/
|
||||
sealed interface DisconnectReason {
|
||||
/** Cause is unknown or the platform did not report one. */
|
||||
data object Unknown : DisconnectReason
|
||||
|
||||
/** The local app/central initiated the disconnect. */
|
||||
data object LocalDisconnect : DisconnectReason
|
||||
|
||||
/** The remote peripheral (firmware) initiated the disconnect. */
|
||||
data object RemoteDisconnect : DisconnectReason
|
||||
|
||||
/** A connection attempt failed to establish. */
|
||||
data object ConnectionFailed : DisconnectReason
|
||||
|
||||
/** The BLE link supervision timed out (device went out of range). */
|
||||
data object Timeout : DisconnectReason
|
||||
|
||||
/** The connection was explicitly cancelled. */
|
||||
data object Cancelled : DisconnectReason
|
||||
|
||||
/** An encryption or authentication failure occurred. */
|
||||
data object EncryptionFailed : DisconnectReason
|
||||
|
||||
/** Platform-specific status code that doesn't map to a known reason. */
|
||||
data class PlatformSpecific(val code: Int) : DisconnectReason
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("MatchingDeclarationName") // File groups the classifier function and its result type.
|
||||
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.GattRequestRejectedException
|
||||
import com.juul.kable.GattStatusException
|
||||
import com.juul.kable.NotConnectedException
|
||||
import com.juul.kable.UnmetRequirementException
|
||||
|
||||
/**
|
||||
* Classification of a BLE-layer exception for the transport layer to act on.
|
||||
*
|
||||
* @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
|
||||
* @property gattStatus the platform GATT status code when available (Android-specific).
|
||||
* @property message a human-readable description of the failure.
|
||||
*/
|
||||
data class BleExceptionInfo(val isPermanent: Boolean, val gattStatus: Int? = null, val message: String)
|
||||
|
||||
/**
|
||||
* Inspects this [Throwable] and returns a [BleExceptionInfo] if it is a known Kable exception, or `null` if it is
|
||||
* unrelated to the BLE layer.
|
||||
*
|
||||
* This keeps Kable type knowledge inside `core:ble` so that `core:network` (and other consumers) can classify BLE
|
||||
* exceptions without depending on Kable directly.
|
||||
*/
|
||||
fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
|
||||
is GattStatusException ->
|
||||
BleExceptionInfo(
|
||||
isPermanent = false,
|
||||
gattStatus = status,
|
||||
message = "GATT error (status $status): $message",
|
||||
)
|
||||
is NotConnectedException -> BleExceptionInfo(isPermanent = false, message = "Not connected")
|
||||
is GattRequestRejectedException ->
|
||||
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
|
||||
is UnmetRequirementException ->
|
||||
BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
|
||||
else -> null
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */
|
||||
class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice {
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
||||
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||
|
||||
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||
override suspend fun readRssi(): Int {
|
||||
val peripheral = ActiveBleConnection.activePeripheral
|
||||
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||
peripheral.rssi()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// DirectBleDevice assumes we are already bonded.
|
||||
}
|
||||
|
||||
fun updateState(newState: BleConnectionState) {
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,11 @@ package org.meshtastic.core.ble
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
import com.juul.kable.State
|
||||
import com.juul.kable.WriteType
|
||||
import com.juul.kable.characteristicOf
|
||||
import com.juul.kable.logs.Logging
|
||||
import com.juul.kable.writeWithoutResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -30,7 +32,6 @@ import kotlinx.coroutines.TimeoutCancellationException
|
|||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
@ -39,6 +40,7 @@ import kotlinx.coroutines.job
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */
|
||||
|
|
@ -50,6 +52,9 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
|
|||
override fun observe(characteristic: BleCharacteristic) =
|
||||
peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid))
|
||||
|
||||
override fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit) =
|
||||
peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid), onSubscription)
|
||||
|
||||
override suspend fun read(characteristic: BleCharacteristic): ByteArray =
|
||||
peripheral.read(characteristicOf(serviceUuid, characteristic.uuid))
|
||||
|
||||
|
|
@ -78,8 +83,11 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
|
|||
/**
|
||||
* [BleConnection] implementation using Kable for cross-platform BLE communication.
|
||||
*
|
||||
* Manages peripheral lifecycle (connect with exponential backoff, disconnect, reconnect), connection state tracking,
|
||||
* and GATT service profile access.
|
||||
* Manages peripheral lifecycle, connection state tracking, and GATT service profile access.
|
||||
*
|
||||
* Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then
|
||||
* fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller
|
||||
* ([BleRadioInterface]) owns the macro-level retry/backoff loop.
|
||||
*/
|
||||
class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||
|
||||
|
|
@ -88,10 +96,8 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
|||
private var connectionScope: CoroutineScope? = null
|
||||
|
||||
companion object {
|
||||
private const val INITIAL_RETRY_DELAY_MS = 1000L
|
||||
private const val MAX_RETRY_DELAY_MS = 30_000L
|
||||
private const val MAX_CONNECT_RETRIES = 15
|
||||
private const val BACKOFF_MULTIPLIER = 2
|
||||
/** Settle delay between a direct connect failure and the autoConnect fallback attempt. */
|
||||
private val AUTOCONNECT_FALLBACK_DELAY = 1.seconds
|
||||
}
|
||||
|
||||
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
|
||||
|
|
@ -108,47 +114,32 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
|||
)
|
||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
override suspend fun connect(device: BleDevice) {
|
||||
val autoConnect = MutableStateFlow(device is DirectBleDevice)
|
||||
val meshtasticDevice = device as? MeshtasticBleDevice ?: error("Unsupported BleDevice type: ${device::class}")
|
||||
var autoConnect = meshtasticDevice.advertisement == null
|
||||
|
||||
/** Applies logging, observation exception handling, and platform config shared by both peripheral types. */
|
||||
fun PeripheralBuilder.commonConfig() {
|
||||
logging {
|
||||
engine = KermitLogEngine
|
||||
level = Logging.Level.Events
|
||||
identifier = device.address
|
||||
}
|
||||
observationExceptionHandler { cause ->
|
||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect }
|
||||
}
|
||||
|
||||
val p =
|
||||
when (device) {
|
||||
is KableBleDevice ->
|
||||
Peripheral(device.advertisement) {
|
||||
observationExceptionHandler { cause ->
|
||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
is DirectBleDevice ->
|
||||
createPeripheral(device.address) {
|
||||
observationExceptionHandler { cause ->
|
||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
else -> error("Unsupported BleDevice type: ${device::class}")
|
||||
}
|
||||
meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } }
|
||||
?: createPeripheral(device.address) { commonConfig() }
|
||||
|
||||
// Clean up previous peripheral under NonCancellable to prevent GATT resource leaks
|
||||
// if the calling coroutine is cancelled during teardown.
|
||||
withContext(NonCancellable) {
|
||||
try {
|
||||
peripheral?.disconnect()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "[${device.address}] Failed to disconnect previous peripheral" }
|
||||
}
|
||||
try {
|
||||
peripheral?.close()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "[${device.address}] Failed to close previous peripheral" }
|
||||
}
|
||||
}
|
||||
cleanUpPeripheral(device.address)
|
||||
peripheral = p
|
||||
|
||||
ActiveBleConnection.activePeripheral = p
|
||||
ActiveBleConnection.activeAddress = device.address
|
||||
ActiveBleConnection.active = ActiveConnection(p, device.address)
|
||||
|
||||
_deviceFlow.emit(device)
|
||||
|
||||
|
|
@ -162,21 +153,15 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
|||
hasStartedConnecting = true
|
||||
}
|
||||
|
||||
when (device) {
|
||||
is KableBleDevice -> device.updateState(mappedState)
|
||||
is DirectBleDevice -> device.updateState(mappedState)
|
||||
}
|
||||
meshtasticDevice.updateState(mappedState)
|
||||
|
||||
_connectionState.emit(mappedState)
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
var retryCount = 0
|
||||
var retryDelayMs = INITIAL_RETRY_DELAY_MS
|
||||
while (p.state.value !is State.Connected) {
|
||||
autoConnect.value =
|
||||
autoConnect =
|
||||
try {
|
||||
// Cancel any previous connectionScope to avoid leaking the old coroutine scope.
|
||||
connectionScope?.let { oldScope ->
|
||||
Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" }
|
||||
oldScope.coroutineContext.job.cancel()
|
||||
|
|
@ -185,52 +170,50 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
|||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) {
|
||||
retryCount++
|
||||
if (retryCount > MAX_CONNECT_RETRIES) {
|
||||
Logger.w { "[${device.address}] Max connect retries ($MAX_CONNECT_RETRIES) exceeded" }
|
||||
_connectionState.emit(BleConnectionState.Disconnected)
|
||||
return
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
|
||||
if (autoConnect) {
|
||||
// autoConnect already true and still failed — don't loop forever.
|
||||
Logger.w { "[${device.address}] autoConnect attempt failed, giving up" }
|
||||
_connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed))
|
||||
throw e
|
||||
}
|
||||
Logger.d { "[${device.address}] Connect retry $retryCount, backoff ${retryDelayMs}ms" }
|
||||
delay(retryDelayMs)
|
||||
retryDelayMs = (retryDelayMs * BACKOFF_MULTIPLIER).coerceAtMost(MAX_RETRY_DELAY_MS)
|
||||
Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" }
|
||||
delay(AUTOCONNECT_FALLBACK_DELAY)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try {
|
||||
withTimeout(timeoutMs) {
|
||||
override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState = try {
|
||||
withTimeout(timeout) {
|
||||
connect(device)
|
||||
BleConnectionState.Connected
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
// Our own timeout expired — treat as a failed attempt so callers can retry.
|
||||
BleConnectionState.Disconnected
|
||||
BleConnectionState.Disconnected(DisconnectReason.Timeout)
|
||||
} catch (e: CancellationException) {
|
||||
// External cancellation (scope closed) — must propagate.
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
BleConnectionState.Disconnected
|
||||
BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed)
|
||||
}
|
||||
|
||||
override suspend fun disconnect() = withContext(NonCancellable) {
|
||||
// Emit Disconnected before cancelling stateJob so downstream collectors see the
|
||||
// state transition. If we cancel stateJob first, the peripheral's state flow
|
||||
// emission of Disconnected is never forwarded to _connectionState.
|
||||
_connectionState.emit(BleConnectionState.Disconnected)
|
||||
_connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.LocalDisconnect))
|
||||
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
peripheral?.disconnect()
|
||||
peripheral?.close()
|
||||
|
||||
safeClosePeripheral("disconnect")
|
||||
peripheral = null
|
||||
connectionScope = null
|
||||
|
||||
ActiveBleConnection.activePeripheral = null
|
||||
ActiveBleConnection.activeAddress = null
|
||||
ActiveBleConnection.active = null
|
||||
|
||||
_deviceFlow.emit(null)
|
||||
}
|
||||
|
|
@ -247,4 +230,29 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
|||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength()
|
||||
|
||||
/** Ensures the previous peripheral's GATT resources are fully released. */
|
||||
private suspend fun cleanUpPeripheral(tag: String) {
|
||||
withContext(NonCancellable) { safeClosePeripheral(tag) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely disconnects and closes the current [peripheral], logging any failures.
|
||||
*
|
||||
* Kable requires `close()` to release broadcast receivers on Android (Kable issue #359). Separate try/catch blocks
|
||||
* ensure `close()` always runs even if `disconnect()` throws.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private suspend fun safeClosePeripheral(tag: String) {
|
||||
try {
|
||||
peripheral?.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$tag] Failed to disconnect peripheral" }
|
||||
}
|
||||
try {
|
||||
peripheral?.close()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$tag] Failed to close peripheral" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,11 @@ import org.koin.core.annotation.Single
|
|||
|
||||
@Single
|
||||
class KableBleConnectionFactory : BleConnectionFactory {
|
||||
/**
|
||||
* Creates a new [KableBleConnection].
|
||||
*
|
||||
* [tag] is unused because Kable's own log identifier is set per-peripheral inside [KableBleConnection.connect]
|
||||
* using the device address, which provides more precise context than a factory-time tag.
|
||||
*/
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Scanner
|
||||
import com.juul.kable.logs.Logging
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
|
@ -28,6 +29,10 @@ import kotlin.uuid.Uuid
|
|||
class KableBleScanner : BleScanner {
|
||||
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
|
||||
val scanner = Scanner {
|
||||
logging {
|
||||
engine = KermitLogEngine
|
||||
level = Logging.Level.Events
|
||||
}
|
||||
// Use separate match blocks so each filter is evaluated independently (OR semantics).
|
||||
// Combining address and service UUID in a single match{} creates an AND filter which
|
||||
// silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a
|
||||
|
|
@ -43,7 +48,15 @@ class KableBleScanner : BleScanner {
|
|||
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
|
||||
return channelFlow {
|
||||
withTimeoutOrNull(timeout) {
|
||||
scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
|
||||
scanner.advertisements.collect { advertisement ->
|
||||
send(
|
||||
MeshtasticBleDevice(
|
||||
address = advertisement.identifier.toString(),
|
||||
name = advertisement.name,
|
||||
advertisement = advertisement,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,110 +18,101 @@ package org.meshtastic.core.ble
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* [MeshtasticRadioProfile] implementation using Kable BLE characteristics.
|
||||
*
|
||||
* Supports both the modern `FROMRADIOSYNC` characteristic (single observe stream) and the legacy `FROMNUM` +
|
||||
* `FROMRADIO` polling fallback for older firmware versions.
|
||||
* Uses the standard Meshtastic BLE protocol: FROMNUM notifications trigger polling reads on the FROMRADIO
|
||||
* characteristic. The firmware gates FROMNUM notifications behind `STATE_SEND_PACKETS`, so during the config handshake
|
||||
* we seed the drain trigger to poll proactively.
|
||||
*/
|
||||
class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile {
|
||||
|
||||
private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC)
|
||||
private val fromRadioChar = service.characteristic(FROMRADIO_CHARACTERISTIC)
|
||||
private val fromRadioSync = service.characteristic(FROMRADIOSYNC_CHARACTERISTIC)
|
||||
private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC)
|
||||
private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC)
|
||||
|
||||
companion object {
|
||||
private const val TRANSIENT_RETRY_DELAY_MS = 500L
|
||||
private val TRANSIENT_RETRY_DELAY = 500.milliseconds
|
||||
}
|
||||
|
||||
// replay = 1: a seed emission placed here before the collector starts is replayed to the
|
||||
// collector immediately on subscription. This is what drives the initial FROMRADIO poll
|
||||
// during the config-handshake phase, where the firmware suppresses FROMNUM notifications
|
||||
// (it only emits them in STATE_SEND_PACKETS). Without the initial replay the entire config
|
||||
// stream would be silently skipped on devices that lack FROMRADIOSYNC.
|
||||
private val subscriptionReady = CompletableDeferred<Unit>()
|
||||
|
||||
/** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */
|
||||
private val triggerDrain =
|
||||
MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
// Using observe() for fromRadioSync or legacy read loop for fromRadio
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val fromRadio: Flow<ByteArray> = channelFlow {
|
||||
// Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO.
|
||||
// This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
|
||||
launch {
|
||||
try {
|
||||
if (service.hasCharacteristic(fromRadioSync)) {
|
||||
service.observe(fromRadioSync).collect { send(it) }
|
||||
} else {
|
||||
error("fromRadioSync missing")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// Fallback to legacy FROMNUM/FROMRADIO polling.
|
||||
// Wire up FROMNUM notifications for steady-state packet delivery.
|
||||
launch {
|
||||
if (service.hasCharacteristic(fromNum)) {
|
||||
service.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||
if (service.hasCharacteristic(fromNum)) {
|
||||
service
|
||||
.observe(fromNum) {
|
||||
Logger.d { "FROMNUM CCCD written — notifications enabled" }
|
||||
subscriptionReady.complete(Unit)
|
||||
}
|
||||
}
|
||||
// Seed the replay buffer so the collector below starts draining immediately.
|
||||
// The firmware does NOT send FROMNUM notifications during the config handshake
|
||||
// (it gates them on STATE_SEND_PACKETS). Without this seed the entire config
|
||||
// stream would never be read on devices that lack FROMRADIOSYNC.
|
||||
triggerDrain.tryEmit(Unit)
|
||||
triggerDrain.collect {
|
||||
var keepReading = true
|
||||
while (keepReading) {
|
||||
try {
|
||||
if (!service.hasCharacteristic(fromRadioChar)) {
|
||||
keepReading = false
|
||||
continue
|
||||
}
|
||||
val packet = service.read(fromRadioChar)
|
||||
if (packet.isEmpty()) keepReading = false else send(packet)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" }
|
||||
keepReading = false
|
||||
// Don't permanently stop — the next triggerDrain emission will retry.
|
||||
delay(TRANSIENT_RETRY_DELAY_MS)
|
||||
}
|
||||
.collect { triggerDrain.tryEmit(Unit) }
|
||||
} else {
|
||||
subscriptionReady.complete(Unit)
|
||||
}
|
||||
}
|
||||
triggerDrain.tryEmit(Unit)
|
||||
triggerDrain.collect {
|
||||
var keepReading = true
|
||||
while (keepReading) {
|
||||
try {
|
||||
if (!service.hasCharacteristic(fromRadioChar)) {
|
||||
keepReading = false
|
||||
continue
|
||||
}
|
||||
val packet = service.read(fromRadioChar)
|
||||
if (packet.isEmpty()) keepReading = false else send(packet)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" }
|
||||
keepReading = false
|
||||
delay(TRANSIENT_RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val logRadio: Flow<ByteArray> = channelFlow {
|
||||
try {
|
||||
if (service.hasCharacteristic(logRadioChar)) {
|
||||
service.observe(logRadioChar).collect { send(it) }
|
||||
override val logRadio: Flow<ByteArray> =
|
||||
if (service.hasCharacteristic(logRadioChar)) {
|
||||
service.observe(logRadioChar).catch { e ->
|
||||
if (e is CancellationException) throw e
|
||||
// logRadio is optional — swallow observation errors silently.
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// logRadio is optional, ignore if not found
|
||||
} else {
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
service.write(toRadio, packet, service.preferredWriteType(toRadio))
|
||||
triggerDrain.tryEmit(Unit)
|
||||
}
|
||||
|
||||
override fun requestDrain() {
|
||||
triggerDrain.tryEmit(Unit)
|
||||
}
|
||||
|
||||
override suspend fun awaitSubscriptionReady() {
|
||||
subscriptionReady.await()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,33 @@ import com.juul.kable.State
|
|||
* state emitted by StateFlow upon subscription.
|
||||
* @return the mapped [BleConnectionState], or null if the state should be ignored.
|
||||
*/
|
||||
fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? {
|
||||
return when (this) {
|
||||
is State.Connecting -> BleConnectionState.Connecting
|
||||
is State.Connected -> BleConnectionState.Connected
|
||||
is State.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is State.Disconnected -> {
|
||||
if (!hasStartedConnecting) return null
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? = when (this) {
|
||||
is State.Connecting -> BleConnectionState.Connecting
|
||||
is State.Connected -> BleConnectionState.Connected
|
||||
is State.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is State.Disconnected ->
|
||||
if (hasStartedConnecting) BleConnectionState.Disconnected(status.toDisconnectReason()) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Kable's [State.Disconnected.Status] to [DisconnectReason].
|
||||
*
|
||||
* Groups platform-specific GATT/CBError codes into broad categories that the reconnect logic can act on without leaking
|
||||
* platform details.
|
||||
*/
|
||||
fun State.Disconnected.Status?.toDisconnectReason(): DisconnectReason = when (this) {
|
||||
null -> DisconnectReason.Unknown
|
||||
State.Disconnected.Status.CentralDisconnected -> DisconnectReason.LocalDisconnect
|
||||
State.Disconnected.Status.PeripheralDisconnected -> DisconnectReason.RemoteDisconnect
|
||||
State.Disconnected.Status.Failed,
|
||||
State.Disconnected.Status.L2CapFailure,
|
||||
-> DisconnectReason.ConnectionFailed
|
||||
State.Disconnected.Status.Timeout,
|
||||
State.Disconnected.Status.LinkManagerProtocolTimeout,
|
||||
-> DisconnectReason.Timeout
|
||||
State.Disconnected.Status.Cancelled -> DisconnectReason.Cancelled
|
||||
State.Disconnected.Status.EncryptionTimedOut -> DisconnectReason.EncryptionFailed
|
||||
State.Disconnected.Status.ConnectionLimitReached -> DisconnectReason.ConnectionFailed
|
||||
State.Disconnected.Status.UnknownDevice -> DisconnectReason.ConnectionFailed
|
||||
is State.Disconnected.Status.Unknown -> DisconnectReason.PlatformSpecific(status)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.logs.LogEngine
|
||||
|
||||
/**
|
||||
* Bridges Kable's internal logging to [Kermit][Logger] so BLE lifecycle events (connect, disconnect, subscribe, GATT
|
||||
* operations) appear in the standard app logs rather than going to [System.out] via Kable's default
|
||||
* [com.juul.kable.logs.SystemLogEngine].
|
||||
*/
|
||||
internal object KermitLogEngine : LogEngine {
|
||||
override fun verbose(throwable: Throwable?, tag: String, message: String) {
|
||||
Logger.v(throwable) { "[$tag] $message" }
|
||||
}
|
||||
|
||||
override fun debug(throwable: Throwable?, tag: String, message: String) {
|
||||
Logger.d(throwable) { "[$tag] $message" }
|
||||
}
|
||||
|
||||
override fun info(throwable: Throwable?, tag: String, message: String) {
|
||||
Logger.i(throwable) { "[$tag] $message" }
|
||||
}
|
||||
|
||||
override fun warn(throwable: Throwable?, tag: String, message: String) {
|
||||
Logger.w(throwable) { "[$tag] $message" }
|
||||
}
|
||||
|
||||
override fun error(throwable: Throwable?, tag: String, message: String) {
|
||||
Logger.e(throwable) { "[$tag] $message" }
|
||||
}
|
||||
|
||||
override fun assert(throwable: Throwable?, tag: String, message: String) {
|
||||
Logger.e(throwable) { "[$tag] $message" }
|
||||
}
|
||||
}
|
||||
|
|
@ -38,8 +38,6 @@ object MeshtasticBleConstants {
|
|||
/** Characteristic for receiving log notifications from the radio. */
|
||||
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
||||
|
||||
val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d")
|
||||
|
||||
// --- OTA Characteristics ---
|
||||
|
||||
/** The Meshtastic OTA service UUID (ESP32 Unified OTA). */
|
||||
|
|
|
|||
|
|
@ -19,30 +19,41 @@ package org.meshtastic.core.ble
|
|||
import com.juul.kable.Advertisement
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
||||
override val name: String?
|
||||
get() = advertisement.name
|
||||
/**
|
||||
* Unified [BleDevice] implementation for all BLE devices — scanned, bonded, or both.
|
||||
*
|
||||
* When created from a live BLE scan, [advertisement] is populated and used for optimal peripheral construction via
|
||||
* `Peripheral(advertisement)`. When created from the OS bonded device list (address only), [advertisement] is `null`
|
||||
* and the peripheral is constructed via `createPeripheral(address)` with `autoConnect = true`.
|
||||
*
|
||||
* @param address The device's MAC address (or platform identifier string).
|
||||
* @param name The device's display name, if known.
|
||||
* @param advertisement The Kable [Advertisement] from a live scan, or `null` for bonded-only devices.
|
||||
*/
|
||||
class MeshtasticBleDevice(
|
||||
override val address: String,
|
||||
override val name: String? = null,
|
||||
val advertisement: Advertisement? = null,
|
||||
) : BleDevice {
|
||||
|
||||
override val address: String
|
||||
get() = advertisement.identifier.toString()
|
||||
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected())
|
||||
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
||||
|
||||
// Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly.
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address
|
||||
|
||||
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||
override suspend fun readRssi(): Int {
|
||||
val peripheral = ActiveBleConnection.activePeripheral
|
||||
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||
peripheral.rssi()
|
||||
val active = ActiveBleConnection.active
|
||||
return if (active != null && active.address == address) {
|
||||
active.peripheral.rssi()
|
||||
} else {
|
||||
advertisement.rssi
|
||||
advertisement?.rssi ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +61,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
|||
// No-op: bonding is OS-managed on Android and not required on desktop.
|
||||
}
|
||||
|
||||
/** Updates the tracked connection state. Called by [KableBleConnection] when the peripheral state changes. */
|
||||
internal fun updateState(newState: BleConnectionState) {
|
||||
_state.value = newState
|
||||
}
|
||||
|
|
@ -28,4 +28,22 @@ interface MeshtasticRadioProfile {
|
|||
|
||||
/** Sends a packet to the radio. */
|
||||
suspend fun sendToRadio(packet: ByteArray)
|
||||
|
||||
/**
|
||||
* Requests a drain of the FROMRADIO characteristic without writing to TORADIO.
|
||||
*
|
||||
* This is useful when the firmware has queued a response (e.g. `queueStatus` after a heartbeat) but did not send a
|
||||
* FROMNUM notification. Without an explicit drain trigger the response would sit unread until the next unrelated
|
||||
* FROMNUM notification arrives.
|
||||
*/
|
||||
fun requestDrain() {}
|
||||
|
||||
/**
|
||||
* Suspends until GATT notifications are enabled (CCCD written) for the primary observation characteristic.
|
||||
*
|
||||
* Callers should await this before triggering the Meshtastic handshake (`want_config_id`) to guarantee that FROMNUM
|
||||
* notifications will be delivered. The default implementation returns immediately for profiles where CCCD readiness
|
||||
* is not observable (e.g. fakes and non-BLE transports).
|
||||
*/
|
||||
suspend fun awaitSubscriptionReady() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.GattStatusException
|
||||
import com.juul.kable.NotConnectedException
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for [classifyBleException] — the boundary between Kable types and the transport layer.
|
||||
*
|
||||
* [GattRequestRejectedException] and [UnmetRequirementException] have `internal` constructors in Kable, so they cannot
|
||||
* be instantiated from outside the library. The `else -> null` branch covers the fallback for any unrecognised
|
||||
* throwable.
|
||||
*/
|
||||
class BleExceptionClassifierTest {
|
||||
|
||||
@Test
|
||||
fun `GattStatusException maps to non-permanent with status code`() {
|
||||
val ex = GattStatusException(message = "GATT failure", status = 133)
|
||||
val info = ex.classifyBleException()
|
||||
assertNotNull(info)
|
||||
assertFalse(info.isPermanent)
|
||||
assertEquals(133, info.gattStatus)
|
||||
assertTrue(info.message.contains("133"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NotConnectedException maps to non-permanent without status code`() {
|
||||
val ex = NotConnectedException("disconnected")
|
||||
val info = ex.classifyBleException()
|
||||
assertNotNull(info)
|
||||
assertFalse(info.isPermanent)
|
||||
assertNull(info.gattStatus)
|
||||
assertEquals("Not connected", info.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated exception returns null`() {
|
||||
val ex = IllegalStateException("something else")
|
||||
assertNull(ex.classifyBleException())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RuntimeException returns null`() {
|
||||
assertNull(RuntimeException("boom").classifyBleException())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** Tests for [DisconnectReason] and [BleConnectionState.Disconnected]. */
|
||||
class DisconnectReasonTest {
|
||||
|
||||
@Test
|
||||
@Suppress("MagicNumber")
|
||||
fun `PlatformSpecific toString includes status code`() {
|
||||
val reason = DisconnectReason.PlatformSpecific(133)
|
||||
val str = reason.toString()
|
||||
assertContains(str, "133", message = "PlatformSpecific.toString() should include the status code")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected default reason is Unknown`() {
|
||||
val state = BleConnectionState.Disconnected()
|
||||
assertEquals(DisconnectReason.Unknown, state.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected preserves explicit reason`() {
|
||||
val state = BleConnectionState.Disconnected(DisconnectReason.Timeout)
|
||||
assertEquals(DisconnectReason.Timeout, state.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data object reasons are singletons`() {
|
||||
assertEquals(DisconnectReason.Unknown, DisconnectReason.Unknown)
|
||||
assertEquals(DisconnectReason.LocalDisconnect, DisconnectReason.LocalDisconnect)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.testing.FakeBleService
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for [KableMeshtasticRadioProfile] — the GATT characteristic orchestration layer.
|
||||
*
|
||||
* Uses [FakeBleService] from `core:testing`. Since [FakeBleService] inherits the default [BleService.observe] overload
|
||||
* (which invokes `onSubscription` via `onStart`), `awaitSubscriptionReady()` completes immediately — matching the
|
||||
* behaviour expected from non-Kable implementations.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class KableMeshtasticRadioProfileTest {
|
||||
|
||||
private fun createService(): FakeBleService = FakeBleService().apply {
|
||||
addCharacteristic(MeshtasticBleConstants.FROMNUM_CHARACTERISTIC)
|
||||
addCharacteristic(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC)
|
||||
addCharacteristic(MeshtasticBleConstants.TORADIO_CHARACTERISTIC)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `awaitSubscriptionReady completes when using FakeBleService`() = runTest {
|
||||
val service = createService()
|
||||
val profile = KableMeshtasticRadioProfile(service)
|
||||
|
||||
// Start collecting fromRadio to activate the observe() flow (which triggers onSubscription)
|
||||
val collectJob = launch { profile.fromRadio.first() }
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should not hang — FakeBleService's default observe(char, onSubscription) fires onSubscription eagerly
|
||||
profile.awaitSubscriptionReady()
|
||||
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendToRadio writes to TORADIO and triggers drain`() = runTest {
|
||||
val service = createService()
|
||||
val profile = KableMeshtasticRadioProfile(service)
|
||||
val testData = byteArrayOf(1, 2, 3)
|
||||
|
||||
// Enqueue empty read so the drain loop terminates
|
||||
service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0))
|
||||
|
||||
profile.sendToRadio(testData)
|
||||
|
||||
assertEquals(1, service.writes.size)
|
||||
assertTrue(service.writes[0].data.contentEquals(testData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromRadio emits packets from FROMRADIO reads`() = runTest {
|
||||
val service = createService()
|
||||
val profile = KableMeshtasticRadioProfile(service)
|
||||
|
||||
val packet1 = byteArrayOf(10, 20, 30)
|
||||
service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, packet1)
|
||||
// Empty read terminates the drain loop
|
||||
service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0))
|
||||
|
||||
val received = async { profile.fromRadio.first() }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(received.await().contentEquals(packet1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestDrain triggers additional FROMRADIO reads`() = runTest {
|
||||
val service = createService()
|
||||
val profile = KableMeshtasticRadioProfile(service)
|
||||
|
||||
val received = mutableListOf<ByteArray>()
|
||||
|
||||
// Start the fromRadio collector
|
||||
val collectJob = launch { profile.fromRadio.collect { received.add(it) } }
|
||||
advanceUntilIdle()
|
||||
|
||||
// First drain should have completed (initial seed) with nothing queued.
|
||||
// Now enqueue a packet and trigger a manual drain.
|
||||
val latePacket = byteArrayOf(99)
|
||||
service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, latePacket)
|
||||
service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0))
|
||||
profile.requestDrain()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, received.size)
|
||||
assertTrue(received[0].contentEquals(latePacket))
|
||||
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MeshtasticRadioProfile default awaitSubscriptionReady returns immediately`() = runTest {
|
||||
val profile =
|
||||
object : MeshtasticRadioProfile {
|
||||
override val fromRadio = kotlinx.coroutines.flow.emptyFlow<ByteArray>()
|
||||
override val logRadio = kotlinx.coroutines.flow.emptyFlow<ByteArray>()
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {}
|
||||
}
|
||||
// Should not hang — default implementation is a no-op
|
||||
profile.awaitSubscriptionReady()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.State
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/** Tests for [toBleConnectionState] and [toDisconnectReason] mappings. */
|
||||
class KableStateMappingTest {
|
||||
|
||||
// --- toBleConnectionState ---
|
||||
|
||||
@Test
|
||||
fun `Connecting maps to BleConnectionState Connecting`() {
|
||||
val result = State.Connecting.Bluetooth.toBleConnectionState(hasStartedConnecting = false)
|
||||
assertIs<BleConnectionState.Connecting>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Connected maps to BleConnectionState Connected`() {
|
||||
val scope = TestScope()
|
||||
val result = State.Connected(scope).toBleConnectionState(hasStartedConnecting = true)
|
||||
assertIs<BleConnectionState.Connected>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnecting maps to BleConnectionState Disconnecting`() {
|
||||
val result = State.Disconnecting.toBleConnectionState(hasStartedConnecting = true)
|
||||
assertIs<BleConnectionState.Disconnecting>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected before connecting started returns null`() {
|
||||
val result = State.Disconnected(status = null).toBleConnectionState(hasStartedConnecting = false)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected after connecting started maps with reason`() {
|
||||
val result =
|
||||
State.Disconnected(State.Disconnected.Status.Timeout).toBleConnectionState(hasStartedConnecting = true)
|
||||
assertIs<BleConnectionState.Disconnected>(result)
|
||||
assertEquals(DisconnectReason.Timeout, result.reason)
|
||||
}
|
||||
|
||||
// --- toDisconnectReason ---
|
||||
|
||||
@Test
|
||||
fun `null status maps to Unknown`() {
|
||||
assertEquals(DisconnectReason.Unknown, null.toDisconnectReason())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CentralDisconnected maps to LocalDisconnect`() {
|
||||
assertEquals(
|
||||
DisconnectReason.LocalDisconnect,
|
||||
State.Disconnected.Status.CentralDisconnected.toDisconnectReason(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PeripheralDisconnected maps to RemoteDisconnect`() {
|
||||
assertEquals(
|
||||
DisconnectReason.RemoteDisconnect,
|
||||
State.Disconnected.Status.PeripheralDisconnected.toDisconnectReason(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Failed maps to ConnectionFailed`() {
|
||||
assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.Failed.toDisconnectReason())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Timeout maps to Timeout`() {
|
||||
assertEquals(DisconnectReason.Timeout, State.Disconnected.Status.Timeout.toDisconnectReason())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LinkManagerProtocolTimeout maps to Timeout`() {
|
||||
assertEquals(
|
||||
DisconnectReason.Timeout,
|
||||
State.Disconnected.Status.LinkManagerProtocolTimeout.toDisconnectReason(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancelled maps to Cancelled`() {
|
||||
assertEquals(DisconnectReason.Cancelled, State.Disconnected.Status.Cancelled.toDisconnectReason())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EncryptionTimedOut maps to EncryptionFailed`() {
|
||||
assertEquals(
|
||||
DisconnectReason.EncryptionFailed,
|
||||
State.Disconnected.Status.EncryptionTimedOut.toDisconnectReason(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `L2CapFailure maps to ConnectionFailed`() {
|
||||
assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.L2CapFailure.toDisconnectReason())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConnectionLimitReached maps to ConnectionFailed`() {
|
||||
assertEquals(
|
||||
DisconnectReason.ConnectionFailed,
|
||||
State.Disconnected.Status.ConnectionLimitReached.toDisconnectReason(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UnknownDevice maps to ConnectionFailed`() {
|
||||
assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.UnknownDevice.toDisconnectReason())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MagicNumber")
|
||||
fun `Unknown status maps to PlatformSpecific with code`() {
|
||||
val result = State.Disconnected.Status.Unknown(status = 42).toDisconnectReason()
|
||||
assertIs<DisconnectReason.PlatformSpecific>(result)
|
||||
assertEquals(42, result.code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue