feat: implement XModem file transfers and enhance BLE connection robustness (#4959)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run

This commit is contained in:
James Rich 2026-03-30 22:49:31 -05:00 committed by GitHub
parent ae4465d7c8
commit c75c9b34d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1100 additions and 120 deletions

View file

@ -173,9 +173,23 @@ class AndroidBluetoothRepository(
}
@SuppressLint("MissingPermission")
private fun getBondedAppPeripherals(): List<BleDevice> = bluetoothAdapter?.bondedDevices?.map { device ->
deviceCache.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
} ?: emptyList()
private fun getBondedAppPeripherals(): List<BleDevice> {
val bonded = bluetoothAdapter?.bondedDevices ?: return emptyList()
val bondedAddresses = bonded.mapTo(mutableSetOf()) { it.address }
// Evict entries for devices that are no longer bonded and update names in case the
// 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)
}
}
}
}
@SuppressLint("MissingPermission")
override fun isBonded(address: String): Boolean = try {

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import com.juul.kable.AndroidPeripheral
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
import com.juul.kable.toIdentifier
@ -43,3 +44,8 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? {
val mtu = (this as? AndroidPeripheral)?.mtu?.value ?: return null
return (mtu - 3).takeIf { it > 0 }
}

View file

@ -43,11 +43,7 @@ interface BleConnection {
suspend fun connect(device: BleDevice)
/** Connects to the given [BleDevice] and waits for a terminal state. */
suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit = {},
): BleConnectionState
suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState
/** Disconnects from the current device. */
suspend fun disconnect()

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -49,6 +50,7 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
private val _connectionState =
MutableSharedFlow<BleConnectionState>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
)
@ -121,22 +123,19 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
onRegister()
return try {
kotlinx.coroutines.withTimeout(timeoutMs) {
connect(device)
BleConnectionState.Connected
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
BleConnectionState.Disconnected
override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try {
kotlinx.coroutines.withTimeout(timeoutMs) {
connect(device)
BleConnectionState.Connected
}
} catch (e: TimeoutCancellationException) {
// Our own timeout expired — treat as a failed attempt so callers can retry.
BleConnectionState.Disconnected
} catch (e: CancellationException) {
// External cancellation (scope closed) — must propagate.
throw e
} catch (e: Exception) {
BleConnectionState.Disconnected
}
override suspend fun disconnect() = withContext(NonCancellable) {
@ -164,8 +163,5 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
return cScope.setup(service)
}
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
// Desktop MTU isn't always easily exposed, provide a safe default for Meshtastic
return 512
}
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() ?: 512
}

View file

@ -30,8 +30,12 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
override val state: StateFlow<BleConnectionState> = _state
// On desktop, bonding isn't strictly required before connecting via Kable,
// and we don't have a pairing flow. Defaulting to true lets the UI connect directly.
// Scanned devices can be connected directly without an explicit bonding step.
// On Android, Kable's connectGatt triggers the OS pairing dialog transparently
// when the firmware requires an encrypted link. On Desktop, btleplug delegates
// to the OS Bluetooth stack which handles pairing the same way.
// The BleRadioInterface.connect() reconnection path has a separate isBonded
// check for the case where a previously bonded device loses its bond.
override val isBonded: Boolean = true
override val isConnected: Boolean
@ -48,7 +52,8 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
}
override suspend fun bond() {
// Not supported/needed on jvmMain desktop currently
// Bonding for scanned devices is handled at the BluetoothRepository level
// (Android) or by the OS during GATT connection (Desktop/JVM).
}
internal fun updateState(newState: BleConnectionState) {

View file

@ -26,15 +26,20 @@ import kotlin.uuid.Uuid
class KableBleScanner : BleScanner {
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
val scanner = Scanner {
// When both serviceUuid and address are provided (the findDevice reconnect path),
// filter by service UUID only. The caller applies address filtering post-collection.
// Using a single match{} with both creates an AND filter that silently drops results
// on some OEM BLE stacks (Samsung, Xiaomi) when the device uses a random resolvable
// private address. Using separate match{} blocks creates OR semantics which would
// return all Meshtastic devices, so we only filter by service UUID in that case.
if (serviceUuid != null || address != null) {
filters {
match {
if (serviceUuid != null) {
services = listOf(serviceUuid)
}
if (address != null) {
this.address = address
}
if (serviceUuid != null) {
match { services = listOf(serviceUuid) }
} else if (address != null) {
// Address-only scan (no service UUID filter). BLE MAC addresses are
// normalized to uppercase on Android; uppercase() covers any edge cases.
match { this.address = address.uppercase() }
}
}
}

View file

@ -21,6 +21,8 @@ import com.juul.kable.Peripheral
import com.juul.kable.WriteType
import com.juul.kable.characteristicOf
import com.juul.kable.writeWithoutResponse
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
@ -41,7 +43,13 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
private val fromNum = characteristicOf(SERVICE_UUID, FROMNUM_CHARACTERISTIC)
private val logRadioChar = characteristicOf(SERVICE_UUID, LOGRADIO_CHARACTERISTIC)
private val triggerDrain = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
// 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 triggerDrain =
MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
init {
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
@ -68,13 +76,21 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
} else {
error("fromRadioSync missing")
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Fallback to legacy
// Fallback to legacy FROMNUM/FROMRADIO polling.
// Wire up FROMNUM notifications for steady-state packet delivery.
launch {
if (hasCharacteristic(FROMNUM_CHARACTERISTIC)) {
peripheral.observe(fromNum).collect { triggerDrain.tryEmit(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) {

View file

@ -24,3 +24,9 @@ internal expect fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
/** Platform-specific instantiation of a Peripheral by address. */
internal expect fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral
/**
* Returns the negotiated maximum write payload length in bytes (i.e. ATT MTU minus the 3-byte ATT header), or `null` if
* MTU has not yet been negotiated on this platform.
*/
internal expect fun Peripheral.negotiatedMaxWriteLength(): Int?

View file

@ -26,3 +26,5 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
throw UnsupportedOperationException("iOS Peripheral not yet implemented")
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null

View file

@ -26,3 +26,6 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
// JVM/desktop Kable does not expose an MTU StateFlow; fall back to null so callers use their default.
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null