fix(transport): Kable BLE audit + thread-safety, MQTT, and logging fixes across transport layers (#5071)

This commit is contained in:
James Rich 2026-04-11 17:56:29 -05:00 committed by GitHub
parent 5f0e60eb21
commit a3c0a4832d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1123 additions and 513 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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). */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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