mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor and unify firmware update logic across platforms (#4966)
This commit is contained in:
parent
d8e295cafb
commit
89547afe6b
102 changed files with 7206 additions and 3485 deletions
|
|
@ -45,7 +45,10 @@ 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)
|
||||
|
||||
/** ATT protocol header size (opcode + handle) subtracted from MTU to get the usable payload. */
|
||||
private const val ATT_HEADER_SIZE = 3
|
||||
|
||||
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? {
|
||||
val mtu = (this as? AndroidPeripheral)?.mtu?.value ?: return null
|
||||
return (mtu - 3).takeIf { it > 0 }
|
||||
return (mtu - ATT_HEADER_SIZE).takeIf { it > 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -28,6 +29,12 @@ enum class BleWriteType {
|
|||
WITHOUT_RESPONSE,
|
||||
}
|
||||
|
||||
/** Identifies a characteristic within a profiled BLE service. */
|
||||
data class BleCharacteristic(val uuid: Uuid)
|
||||
|
||||
/** Safe ATT payload length when MTU negotiation is unavailable (23-byte ATT MTU minus 3-byte header). */
|
||||
const val DEFAULT_BLE_WRITE_VALUE_LENGTH = 20
|
||||
|
||||
/** Encapsulates a BLE connection to a [BleDevice]. */
|
||||
interface BleConnection {
|
||||
/** The currently connected [BleDevice], or null if not connected. */
|
||||
|
|
@ -55,11 +62,27 @@ interface BleConnection {
|
|||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T
|
||||
|
||||
/** Returns the maximum write value length for the given write type. */
|
||||
/** Returns the maximum write value length for the given write type, or `null` if unknown. */
|
||||
fun maximumWriteValueLength(writeType: BleWriteType): Int?
|
||||
}
|
||||
|
||||
/** Represents a BLE service for commonMain. */
|
||||
interface BleService {
|
||||
// This will be expanded as needed, but for now we just need a common type to pass around.
|
||||
/** Creates a handle for a characteristic belonging to this service. */
|
||||
fun characteristic(uuid: Uuid): BleCharacteristic = BleCharacteristic(uuid)
|
||||
|
||||
/** Returns true when the characteristic is present on the connected device. */
|
||||
fun hasCharacteristic(characteristic: BleCharacteristic): Boolean
|
||||
|
||||
/** Observes notifications/indications from the characteristic. */
|
||||
fun observe(characteristic: BleCharacteristic): Flow<ByteArray>
|
||||
|
||||
/** Reads the characteristic value once. */
|
||||
suspend fun read(characteristic: BleCharacteristic): ByteArray
|
||||
|
||||
/** Returns the preferred write type for the characteristic on this platform/device. */
|
||||
fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType
|
||||
|
||||
/** Writes a value to the characteristic using the requested BLE write type. */
|
||||
suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,4 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
/** Extension to convert a [BleService] to a [MeshtasticRadioProfile]. */
|
||||
fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile {
|
||||
val kableService = this as KableBleService
|
||||
return KableMeshtasticRadioProfile(kableService.peripheral)
|
||||
}
|
||||
fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile = KableMeshtasticRadioProfile(this)
|
||||
|
|
|
|||
|
|
@ -16,13 +16,19 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.State
|
||||
import com.juul.kable.WriteType
|
||||
import com.juul.kable.characteristicOf
|
||||
import com.juul.kable.writeWithoutResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
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
|
||||
|
|
@ -30,13 +36,44 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class KableBleService(val peripheral: Peripheral) : BleService
|
||||
class KableBleService(private val peripheral: Peripheral, private val serviceUuid: Uuid) : BleService {
|
||||
override fun hasCharacteristic(characteristic: BleCharacteristic): Boolean = peripheral.services.value?.any { svc ->
|
||||
svc.serviceUuid == serviceUuid && svc.characteristics.any { it.characteristicUuid == characteristic.uuid }
|
||||
} == true
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
class KableBleConnection(private val scope: CoroutineScope, private val tag: String) : BleConnection {
|
||||
override fun observe(characteristic: BleCharacteristic) =
|
||||
peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid))
|
||||
|
||||
override suspend fun read(characteristic: BleCharacteristic): ByteArray =
|
||||
peripheral.read(characteristicOf(serviceUuid, characteristic.uuid))
|
||||
|
||||
override fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType {
|
||||
val service = peripheral.services.value?.find { it.serviceUuid == serviceUuid }
|
||||
val char = service?.characteristics?.find { it.characteristicUuid == characteristic.uuid }
|
||||
return if (char?.properties?.writeWithoutResponse == true) {
|
||||
BleWriteType.WITHOUT_RESPONSE
|
||||
} else {
|
||||
BleWriteType.WITH_RESPONSE
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
|
||||
peripheral.write(
|
||||
characteristicOf(serviceUuid, characteristic.uuid),
|
||||
data,
|
||||
when (writeType) {
|
||||
BleWriteType.WITH_RESPONSE -> WriteType.WithResponse
|
||||
BleWriteType.WITHOUT_RESPONSE -> WriteType.WithoutResponse
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||
|
||||
private var peripheral: Peripheral? = null
|
||||
private var stateJob: Job? = null
|
||||
|
|
@ -52,7 +89,7 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
MutableSharedFlow<BleConnectionState>(
|
||||
replay = 1,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
|
|
@ -64,14 +101,14 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
is KableBleDevice ->
|
||||
Peripheral(device.advertisement) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
is DirectBleDevice ->
|
||||
createPeripheral(device.address) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
|
|
@ -113,10 +150,10 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) {
|
||||
@Suppress("MagicNumber")
|
||||
val retryDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(retryDelayMs)
|
||||
delay(retryDelayMs)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -124,17 +161,17 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) {
|
||||
withTimeout(timeoutMs) {
|
||||
connect(device)
|
||||
BleConnectionState.Connected
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
} catch (_: 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) {
|
||||
} catch (_: Exception) {
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
|
||||
|
|
@ -159,9 +196,9 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
): T {
|
||||
val p = peripheral ?: error("Not connected")
|
||||
val cScope = connectionScope ?: error("No active connection scope")
|
||||
val service = KableBleService(p)
|
||||
val service = KableBleService(p, serviceUuid)
|
||||
return cScope.setup(service)
|
||||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() ?: 512
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,5 @@ import org.koin.core.annotation.Single
|
|||
|
||||
@Single
|
||||
class KableBleConnectionFactory : BleConnectionFactory {
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope, tag)
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
|||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state
|
||||
|
||||
// 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.
|
||||
// Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly.
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
|
|
@ -52,8 +47,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
|||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// Bonding for scanned devices is handled at the BluetoothRepository level
|
||||
// (Android) or by the OS during GATT connection (Desktop/JVM).
|
||||
// No-op: bonding is OS-managed on Android and not required on desktop.
|
||||
}
|
||||
|
||||
internal fun updateState(newState: BleConnectionState) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ package org.meshtastic.core.ble
|
|||
|
||||
import com.juul.kable.Scanner
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
|
@ -26,29 +28,21 @@ 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 {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// random resolvable private address.
|
||||
if (address != null) {
|
||||
filters { match { this.address = address } }
|
||||
} else if (serviceUuid != null) {
|
||||
filters { match { services = listOf(serviceUuid) } }
|
||||
}
|
||||
}
|
||||
|
||||
// Kable's Scanner doesn't enforce timeout internally, it runs until the Flow is cancelled.
|
||||
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
|
||||
return kotlinx.coroutines.flow.channelFlow {
|
||||
kotlinx.coroutines.withTimeoutOrNull(timeout) {
|
||||
return channelFlow {
|
||||
withTimeoutOrNull(timeout) {
|
||||
scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
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
|
||||
|
|
@ -31,17 +26,15 @@ 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.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : MeshtasticRadioProfile {
|
||||
class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile {
|
||||
|
||||
private val toRadio = characteristicOf(SERVICE_UUID, TORADIO_CHARACTERISTIC)
|
||||
private val fromRadioChar = characteristicOf(SERVICE_UUID, FROMRADIO_CHARACTERISTIC)
|
||||
private val fromRadioSync = characteristicOf(SERVICE_UUID, FROMRADIOSYNC_CHARACTERISTIC)
|
||||
private val fromNum = characteristicOf(SERVICE_UUID, FROMNUM_CHARACTERISTIC)
|
||||
private val logRadioChar = characteristicOf(SERVICE_UUID, LOGRADIO_CHARACTERISTIC)
|
||||
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)
|
||||
|
||||
// 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
|
||||
|
|
@ -51,19 +44,6 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
private val triggerDrain =
|
||||
MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
init {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
Logger.i {
|
||||
"KableMeshtasticRadioProfile init. Discovered characteristics: ${svc?.characteristics?.map {
|
||||
it.characteristicUuid
|
||||
}}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCharacteristic(uuid: Uuid): Boolean = peripheral.services.value?.any { svc ->
|
||||
svc.serviceUuid == SERVICE_UUID && svc.characteristics.any { it.characteristicUuid == uuid }
|
||||
} == true
|
||||
|
||||
// Using observe() for fromRadioSync or legacy read loop for fromRadio
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val fromRadio: Flow<ByteArray> = channelFlow {
|
||||
|
|
@ -71,19 +51,19 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
// This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
|
||||
launch {
|
||||
try {
|
||||
if (hasCharacteristic(FROMRADIOSYNC_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromRadioSync).collect { send(it) }
|
||||
if (service.hasCharacteristic(fromRadioSync)) {
|
||||
service.observe(fromRadioSync).collect { send(it) }
|
||||
} else {
|
||||
error("fromRadioSync missing")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// 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) }
|
||||
if (service.hasCharacteristic(fromNum)) {
|
||||
service.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||
}
|
||||
}
|
||||
// Seed the replay buffer so the collector below starts draining immediately.
|
||||
|
|
@ -95,13 +75,13 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
var keepReading = true
|
||||
while (keepReading) {
|
||||
try {
|
||||
if (!hasCharacteristic(FROMRADIO_CHARACTERISTIC)) {
|
||||
if (!service.hasCharacteristic(fromRadioChar)) {
|
||||
keepReading = false
|
||||
continue
|
||||
}
|
||||
val packet = peripheral.read(fromRadioChar)
|
||||
val packet = service.read(fromRadioChar)
|
||||
if (packet.isEmpty()) keepReading = false else send(packet)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
keepReading = false
|
||||
}
|
||||
}
|
||||
|
|
@ -113,27 +93,16 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val logRadio: Flow<ByteArray> = channelFlow {
|
||||
try {
|
||||
if (hasCharacteristic(LOGRADIO_CHARACTERISTIC)) {
|
||||
peripheral.observe(logRadioChar).collect { send(it) }
|
||||
if (service.hasCharacteristic(logRadioChar)) {
|
||||
service.observe(logRadioChar).collect { send(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// logRadio is optional, ignore if not found
|
||||
}
|
||||
}
|
||||
|
||||
private val toRadioWriteType: WriteType by lazy {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
val char = svc?.characteristics?.find { it.characteristicUuid == TORADIO_CHARACTERISTIC }
|
||||
|
||||
if (char?.properties?.writeWithoutResponse == true) {
|
||||
WriteType.WithoutResponse
|
||||
} else {
|
||||
WriteType.WithResponse
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
peripheral.write(toRadio, packet, toRadioWriteType)
|
||||
service.write(toRadio, packet, service.preferredWriteType(toRadio))
|
||||
triggerDrain.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue