mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
feat(firmware): nRF52 BLE Legacy DFU support (#5209)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -66,3 +66,10 @@ internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? {
|
||||
val mtu = (this as? AndroidPeripheral)?.mtu?.value ?: return null
|
||||
return (mtu - ATT_HEADER_SIZE).takeIf { it > 0 }
|
||||
}
|
||||
|
||||
internal actual fun Peripheral.requestHighConnectionPriority(): Boolean {
|
||||
val androidPeripheral = this as? AndroidPeripheral ?: return false
|
||||
return runCatching { androidPeripheral.requestConnectionPriority(AndroidPeripheral.Priority.High) }
|
||||
.onFailure { Logger.w(it) { "requestConnectionPriority(High) threw" } }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ interface BleConnection {
|
||||
|
||||
/** Returns the maximum write value length for the given write type, or `null` if unknown. */
|
||||
fun maximumWriteValueLength(writeType: BleWriteType): Int?
|
||||
|
||||
/**
|
||||
* Asks the platform to switch to a high-throughput / low-latency BLE connection priority for the duration of the
|
||||
* connection. Used by latency-sensitive flows like firmware updates. Returns `true` if the request was issued.
|
||||
* Default implementation returns `false` for platforms that don't support it.
|
||||
*/
|
||||
fun requestHighConnectionPriority(): Boolean = false
|
||||
}
|
||||
|
||||
/** Represents a BLE service for commonMain. */
|
||||
|
||||
@@ -231,6 +231,8 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength()
|
||||
|
||||
override fun requestHighConnectionPriority(): Boolean = peripheral?.requestHighConnectionPriority() == true
|
||||
|
||||
/** Ensures the previous peripheral's GATT resources are fully released. */
|
||||
private suspend fun cleanUpPeripheral(tag: String) {
|
||||
withContext(NonCancellable) { safeClosePeripheral(tag) }
|
||||
|
||||
@@ -30,3 +30,11 @@ internal expect fun createPeripheral(address: String, builderAction: PeripheralB
|
||||
* MTU has not yet been negotiated on this platform.
|
||||
*/
|
||||
internal expect fun Peripheral.negotiatedMaxWriteLength(): Int?
|
||||
|
||||
/**
|
||||
* Requests the highest-throughput BLE connection priority (smallest connection interval) supported by the platform.
|
||||
*
|
||||
* Returns `true` if the request was issued successfully. On platforms without an equivalent API (JVM/iOS) this is a
|
||||
* no-op returning `false`. Used by latency-sensitive flows such as DFU firmware streaming.
|
||||
*/
|
||||
internal expect fun Peripheral.requestHighConnectionPriority(): Boolean
|
||||
|
||||
@@ -28,3 +28,5 @@ internal actual fun createPeripheral(address: String, builderAction: PeripheralB
|
||||
throw UnsupportedOperationException("iOS Peripheral not yet implemented")
|
||||
|
||||
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null
|
||||
|
||||
internal actual fun Peripheral.requestHighConnectionPriority(): Boolean = false
|
||||
|
||||
@@ -31,4 +31,6 @@ internal actual fun createPeripheral(address: String, builderAction: PeripheralB
|
||||
// so callers can size their writes without falling back to an overly conservative minimum.
|
||||
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = DEFAULT_JVM_MTU
|
||||
|
||||
internal actual fun Peripheral.requestHighConnectionPriority(): Boolean = false
|
||||
|
||||
private const val DEFAULT_JVM_MTU = 512
|
||||
|
||||
@@ -109,6 +109,9 @@ class FakeBleConnection :
|
||||
/** Number of times [disconnect] has been invoked. */
|
||||
var disconnectCalls: Int = 0
|
||||
|
||||
/** Service UUIDs that should appear missing — `profile()` throws `NoSuchElementException` for these. */
|
||||
val missingServices: MutableSet<Uuid> = mutableSetOf()
|
||||
|
||||
val service = FakeBleService()
|
||||
|
||||
override suspend fun connect(device: BleDevice) {
|
||||
@@ -149,7 +152,12 @@ class FakeBleConnection :
|
||||
serviceUuid: Uuid,
|
||||
timeout: Duration,
|
||||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T = CoroutineScope(Dispatchers.Unconfined).setup(service)
|
||||
): T {
|
||||
if (serviceUuid in missingServices) {
|
||||
throw NoSuchElementException("Service $serviceUuid not found")
|
||||
}
|
||||
return CoroutineScope(Dispatchers.Unconfined).setup(service)
|
||||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = maxWriteValueLength
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ class FirmwareUpdateViewModel(
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
Logger.i { "Firmware update cancelled" }
|
||||
Logger.w(e) { "Firmware update cancelled — cause: ${e.cause} message: ${e.message}" }
|
||||
_state.value = FirmwareUpdateState.Idle
|
||||
checkForUpdates()
|
||||
throw e
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025-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.feature.firmware.ota.dfu
|
||||
|
||||
/**
|
||||
* Common upload-time surface implemented by both [SecureDfuTransport] (Nordic Secure DFU, service `FE59`) and
|
||||
* [LegacyDfuTransport] (Nordic Legacy DFU / Adafruit BLEDfu, service `1530`).
|
||||
*
|
||||
* The choice of which implementation to use is made by the handler after the device reboots into bootloader mode, based
|
||||
* on which DFU service is exposed.
|
||||
*/
|
||||
interface DfuUploadTransport {
|
||||
/** Establish the GATT session with the device in DFU mode. */
|
||||
suspend fun connectToDfuMode(): Result<Unit>
|
||||
|
||||
/** Upload the init packet (`.dat`) and have the device validate it. */
|
||||
suspend fun transferInitPacket(initPacket: ByteArray): Result<Unit>
|
||||
|
||||
/**
|
||||
* Upload the firmware binary (`.bin`). [onProgress] is invoked with a value in `[0.0, 1.0]` after each protocol
|
||||
* checkpoint (PRN window or end-of-image).
|
||||
*/
|
||||
suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit>
|
||||
|
||||
/** Best-effort abort of any in-flight transfer (for cancellation / error recovery). Never throws. */
|
||||
suspend fun abort()
|
||||
|
||||
/** Disconnect and release resources. */
|
||||
suspend fun close()
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright (c) 2025-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("MagicNumber")
|
||||
|
||||
package org.meshtastic.feature.firmware.ota.dfu
|
||||
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nordic Legacy DFU – additional service characteristic UUIDs
|
||||
// (Service + Control Point are declared in `LegacyDfuUuids` in
|
||||
// SecureDfuProtocol.kt — they're shared with the Phase-1 buttonless trigger.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Packet characteristic — accepts WRITE_NO_RESPONSE for image sizes, init data, and firmware bytes. */
|
||||
internal val LEGACY_DFU_PACKET_UUID: Uuid = Uuid.parse("00001532-1212-EFDE-1523-785FEABCD123")
|
||||
|
||||
/**
|
||||
* DFU Version characteristic — optional; uint16 LE giving bootloader DFU version. Used to gate the extended init-packet
|
||||
* flow (≥ 5 ⇒ START/COMPLETE bracket; ≤ 4 ⇒ unsupported old SDK).
|
||||
*/
|
||||
internal val LEGACY_DFU_VERSION_UUID: Uuid = Uuid.parse("00001534-1212-EFDE-1523-785FEABCD123")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol opcodes (Nordic SDK 11/12 / Adafruit BLEDfu)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal object LegacyDfuOpcode {
|
||||
const val START_DFU: Byte = 0x01
|
||||
const val INIT_DFU_PARAMS: Byte = 0x02
|
||||
const val RECEIVE_FIRMWARE_IMAGE: Byte = 0x03
|
||||
const val VALIDATE: Byte = 0x04
|
||||
const val ACTIVATE_AND_RESET: Byte = 0x05
|
||||
const val RESET: Byte = 0x06
|
||||
const val PACKET_RECEIPT_NOTIF_REQ: Byte = 0x08
|
||||
|
||||
/** Prefix on every Control-Point response notification. */
|
||||
const val RESPONSE_CODE: Byte = 0x10
|
||||
|
||||
/** Prefix on every Packet-Receipt notification (carries `[bytes_received_u32_le]`). */
|
||||
const val PACKET_RECEIPT: Byte = 0x11
|
||||
|
||||
/** Sub-opcode of `INIT_DFU_PARAMS`: marks beginning of init-packet stream. */
|
||||
const val INIT_PARAMS_START: Byte = 0x00
|
||||
|
||||
/** Sub-opcode of `INIT_DFU_PARAMS`: marks end of init-packet stream. */
|
||||
const val INIT_PARAMS_COMPLETE: Byte = 0x01
|
||||
}
|
||||
|
||||
/**
|
||||
* `START_DFU` image-type bitmask. Meshtastic only ever ships application updates over OTA, so the transport hard-codes
|
||||
* [APPLICATION].
|
||||
*/
|
||||
internal object LegacyDfuImageType {
|
||||
const val SOFT_DEVICE: Byte = 0x01
|
||||
const val BOOTLOADER: Byte = 0x02
|
||||
const val APPLICATION: Byte = 0x04
|
||||
}
|
||||
|
||||
/** Result codes returned in the third byte of a response notification. */
|
||||
internal object LegacyDfuStatus {
|
||||
const val SUCCESS: Byte = 0x01
|
||||
const val INVALID_STATE: Byte = 0x02
|
||||
const val NOT_SUPPORTED: Byte = 0x03
|
||||
const val DATA_SIZE_EXCEEDS_LIMIT: Byte = 0x04
|
||||
const val CRC_ERROR: Byte = 0x05
|
||||
const val OPERATION_FAILED: Byte = 0x06
|
||||
|
||||
fun describe(status: Byte): String = when (status) {
|
||||
SUCCESS -> "SUCCESS"
|
||||
INVALID_STATE -> "INVALID_STATE"
|
||||
NOT_SUPPORTED -> "NOT_SUPPORTED"
|
||||
DATA_SIZE_EXCEEDS_LIMIT -> "DATA_SIZE_EXCEEDS_LIMIT"
|
||||
CRC_ERROR -> "CRC_ERROR"
|
||||
OPERATION_FAILED -> "OPERATION_FAILED"
|
||||
else -> "UNKNOWN(0x${status.toUByte().toString(16).padStart(2, '0')})"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parsed notification from the Legacy DFU Control Point characteristic. */
|
||||
internal sealed class LegacyDfuResponse {
|
||||
|
||||
/** `[0x10, requestOpcode, 0x01]` — request succeeded. */
|
||||
data class Success(val requestOpcode: Byte) : LegacyDfuResponse()
|
||||
|
||||
/** `[0x10, requestOpcode, status]` where `status != 0x01` — device rejected the request. */
|
||||
data class Failure(val requestOpcode: Byte, val status: Byte) : LegacyDfuResponse()
|
||||
|
||||
/** `[0x11, bytes_received_u32_le]` — periodic packet-receipt notification. */
|
||||
data class PacketReceipt(val bytesReceived: Long) : LegacyDfuResponse()
|
||||
|
||||
/** Unrecognised bytes — logged, surfaced as a protocol error. */
|
||||
data class Unknown(val raw: ByteArray) : LegacyDfuResponse() {
|
||||
override fun equals(other: Any?) = other is Unknown && raw.contentEquals(other.raw)
|
||||
|
||||
override fun hashCode() = raw.contentHashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("ReturnCount")
|
||||
fun parse(data: ByteArray): LegacyDfuResponse {
|
||||
if (data.isEmpty()) return Unknown(data)
|
||||
return when (data[0]) {
|
||||
LegacyDfuOpcode.RESPONSE_CODE -> {
|
||||
if (data.size < 3) return Unknown(data)
|
||||
val requestOpcode = data[1]
|
||||
val status = data[2]
|
||||
if (status == LegacyDfuStatus.SUCCESS) {
|
||||
Success(requestOpcode)
|
||||
} else {
|
||||
Failure(requestOpcode, status)
|
||||
}
|
||||
}
|
||||
LegacyDfuOpcode.PACKET_RECEIPT -> {
|
||||
if (data.size < 5) return Unknown(data)
|
||||
val bytes =
|
||||
(data[1].toLong() and 0xFF) or
|
||||
((data[2].toLong() and 0xFF) shl 8) or
|
||||
((data[3].toLong() and 0xFF) shl 16) or
|
||||
((data[4].toLong() and 0xFF) shl 24)
|
||||
PacketReceipt(bytes)
|
||||
}
|
||||
else -> Unknown(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the 12-byte image-sizes payload written to the Packet characteristic immediately after `START_DFU`.
|
||||
*
|
||||
* Layout: `[soft_device_size_u32_le, bootloader_size_u32_le, app_size_u32_le]`. Meshtastic only updates the
|
||||
* application, so [softDeviceSize] and [bootloaderSize] default to 0.
|
||||
*/
|
||||
internal fun legacyImageSizesPayload(appSize: Int, softDeviceSize: Int = 0, bootloaderSize: Int = 0): ByteArray =
|
||||
intToLeBytes(softDeviceSize) + intToLeBytes(bootloaderSize) + intToLeBytes(appSize)
|
||||
|
||||
/** Build the 3-byte `PACKET_RECEIPT_NOTIF_REQ` payload: opcode + uint16-LE PRN value. */
|
||||
internal fun legacyPrnRequestPayload(packets: Int): ByteArray = byteArrayOf(
|
||||
LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ,
|
||||
(packets and 0xFF).toByte(),
|
||||
((packets ushr 8) and 0xFF).toByte(),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exceptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Errors specific to the Nordic Legacy DFU protocol. These are a subtype of [DfuException] so the existing handler
|
||||
* error-path code (which catches `DfuException`) covers both protocols.
|
||||
*/
|
||||
sealed class LegacyDfuException(message: String, cause: Throwable? = null) : DfuException(message, cause) {
|
||||
/** Device returned a non-success status for a given opcode. */
|
||||
class ProtocolError(val requestOpcode: Byte, val status: Byte) :
|
||||
LegacyDfuException(
|
||||
"Legacy DFU protocol error: opcode=0x${requestOpcode.toUByte().toString(16).padStart(2, '0')} " +
|
||||
"status=${LegacyDfuStatus.describe(status)}",
|
||||
)
|
||||
|
||||
/** Bootloader exposes DFU Version characteristic with a value below 5 (Nordic SDK ≤ 6). Unsupported. */
|
||||
class UnsupportedBootloader(version: Int) :
|
||||
LegacyDfuException(
|
||||
"Legacy DFU bootloader version $version is too old (need ≥ 5). Please update the bootloader.",
|
||||
)
|
||||
|
||||
/** Init packet ([dat]) appears to be Secure-DFU shaped (signed/CBOR), not the small legacy 14-32 B form. */
|
||||
class InitPacketNotLegacy(size: Int) :
|
||||
LegacyDfuException(
|
||||
"Init packet is $size bytes — too large for Legacy DFU. " +
|
||||
"This .dat looks like a Secure DFU init packet; the bootloader will reject it.",
|
||||
)
|
||||
|
||||
/** Bytes received reported by device differs from bytes sent past last PRN window. */
|
||||
class PacketReceiptMismatch(expected: Long, actual: Long) :
|
||||
LegacyDfuException("Packet receipt mismatch: expected $expected bytes received, device reports $actual")
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
/*
|
||||
* Copyright (c) 2025-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(
|
||||
"MagicNumber",
|
||||
"TooManyFunctions",
|
||||
"ThrowsCount",
|
||||
"ReturnCount",
|
||||
"SwallowedException",
|
||||
"TooGenericExceptionCaught",
|
||||
)
|
||||
|
||||
package org.meshtastic.feature.firmware.ota.dfu
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
|
||||
import org.meshtastic.feature.firmware.ota.scanForBleDevice
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Kable-based transport for the Nordic **Legacy DFU** protocol (Nordic SDK 11/12 / Adafruit `BLEDfu`).
|
||||
*
|
||||
* Most nRF52 boards in the field — including the RAK4631 with the recommended Adafruit/oltaco "OTAFIX" bootloader —
|
||||
* speak Legacy DFU rather than Secure DFU. The two protocols share nothing at the upload layer:
|
||||
* - Different service & characteristic UUIDs (`1530`/`1531`/`1532` vs `FE59`/`8EC9…`).
|
||||
* - Different opcodes; init packet is sent on the Packet char between two control-point writes (vs Secure's
|
||||
* CREATE/PACKET/EXECUTE object flow).
|
||||
* - PRN payload is bytes-received uint32 (vs Secure's offset+CRC32).
|
||||
* - No CRC32 in the protocol — image integrity relies on the device's CRC16 in the init packet.
|
||||
*
|
||||
* Phase-1 buttonless trigger is shared with [SecureDfuTransport] (see `triggerButtonlessDfu` there).
|
||||
*/
|
||||
class LegacyDfuTransport(
|
||||
private val scanner: BleScanner,
|
||||
connectionFactory: BleConnectionFactory,
|
||||
private val address: String,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
) : DfuUploadTransport {
|
||||
private val transportScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val bleConnection = connectionFactory.create(transportScope, "Legacy DFU")
|
||||
|
||||
/** Receives parsed responses from the Control Point characteristic. */
|
||||
private val notificationChannel = Channel<LegacyDfuResponse>(Channel.UNLIMITED)
|
||||
|
||||
/** Name advertised by the device in DFU mode (e.g. `4631_DFU`). Captured in [connectToDfuMode]. */
|
||||
private var dfuAdvertisedName: String? = null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 2: Connect to device in DFU mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling
|
||||
* notifications on the Control Point.
|
||||
*
|
||||
* Best-effort reads the optional DFU Version characteristic to gate against unsupported old (SDK ≤ 6) bootloaders.
|
||||
*/
|
||||
override suspend fun connectToDfuMode(): Result<Unit> = safeCatching {
|
||||
val dfuAddress = calculateMacPlusOne(address)
|
||||
val targetAddresses = setOf(address, dfuAddress)
|
||||
Logger.i { "Legacy DFU: Scanning for DFU mode device at $targetAddresses..." }
|
||||
|
||||
val device =
|
||||
scanForDevice { d -> d.address in targetAddresses }
|
||||
?: throw DfuException.ConnectionFailed("DFU mode device not found. Tried: $targetAddresses")
|
||||
|
||||
Logger.i { "Legacy DFU: Found DFU mode device at ${device.address} (name=${device.name}), connecting..." }
|
||||
dfuAdvertisedName = device.name
|
||||
|
||||
bleConnection.connectionState
|
||||
.onEach { Logger.d { "Legacy DFU: Connection state → $it" } }
|
||||
.launchIn(transportScope)
|
||||
|
||||
val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT)
|
||||
if (connected is BleConnectionState.Disconnected) {
|
||||
throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}")
|
||||
}
|
||||
|
||||
bleConnection.profile(LegacyDfuUuids.SERVICE) { service ->
|
||||
val controlChar = service.characteristic(LegacyDfuUuids.CONTROL_POINT)
|
||||
|
||||
val subscribed = CompletableDeferred<Unit>()
|
||||
service
|
||||
.observe(controlChar)
|
||||
.onEach { bytes ->
|
||||
if (!subscribed.isCompleted) {
|
||||
Logger.d { "Legacy DFU: Control Point subscribed" }
|
||||
subscribed.complete(Unit)
|
||||
}
|
||||
val parsed = LegacyDfuResponse.parse(bytes)
|
||||
Logger.d { "Legacy DFU: Notification → $parsed" }
|
||||
notificationChannel.trySend(parsed)
|
||||
}
|
||||
.catch { e ->
|
||||
if (!subscribed.isCompleted) subscribed.completeExceptionally(e)
|
||||
Logger.e(e) { "Legacy DFU: Control Point notification error" }
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
delay(SUBSCRIPTION_SETTLE)
|
||||
if (!subscribed.isCompleted) subscribed.complete(Unit)
|
||||
subscribed.await()
|
||||
|
||||
// Best-effort DFU Version read — gate out unsupported old bootloaders (SDK ≤ 6).
|
||||
val versionChar = service.characteristic(LEGACY_DFU_VERSION_UUID)
|
||||
val version =
|
||||
runCatching { service.read(versionChar) }
|
||||
.map { bytes ->
|
||||
if (bytes.size >= 2) (bytes[0].toInt() and 0xFF) or ((bytes[1].toInt() and 0xFF) shl 8) else -1
|
||||
}
|
||||
.getOrElse { -1 }
|
||||
Logger.i { "Legacy DFU: DFU Version characteristic = $version (-1 ⇒ absent / unreadable)" }
|
||||
if (version in 1..MIN_SUPPORTED_DFU_VERSION - 1) {
|
||||
throw LegacyDfuException.UnsupportedBootloader(version)
|
||||
}
|
||||
|
||||
Logger.i { "Legacy DFU: Connected and ready (${device.address})" }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: Init packet transfer (.dat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sends the legacy DFU init packet using the SDK 7+ extended-init flow:
|
||||
* 1. `START_DFU [APP]` → device prepares.
|
||||
* 2. Image sizes `[0u32, 0u32, appSize_u32]` on the Packet characteristic.
|
||||
* 3. `INIT_PARAMS_START` → init bytes on Packet → `INIT_PARAMS_COMPLETE`.
|
||||
*
|
||||
* The legacy init packet for an APP image is typically 14 bytes (SDK 7) or 32 bytes (SDK 11 with signature). Any
|
||||
* `.dat` significantly larger than that almost certainly belongs to a Secure DFU build that has been mis-packaged
|
||||
* for a legacy bootloader — we surface that with a helpful error rather than letting the device reject it.
|
||||
*
|
||||
* The init packet is bracketed by START/COMPLETE, but the upload itself is intermixed with image sizes. To match
|
||||
* Nordic's library, the [initPacket] argument here is the legacy init bytes; the firmware [transferFirmware] method
|
||||
* needs to be called next to provide the actual image (and the size we include here must match its length).
|
||||
*
|
||||
* Because `transferInitPacket` is called before [transferFirmware], we don't yet know the firmware size when
|
||||
* sending image sizes. To keep the [DfuUploadTransport] contract clean we instead send image sizes lazily inside
|
||||
* [transferFirmware]'s START phase. **This method only writes START_DFU + brackets the init packet.** Any
|
||||
* outstanding image-size write happens at the start of [transferFirmware].
|
||||
*/
|
||||
override suspend fun transferInitPacket(initPacket: ByteArray): Result<Unit> = safeCatching {
|
||||
if (initPacket.size > MAX_REASONABLE_LEGACY_INIT_SIZE) {
|
||||
throw LegacyDfuException.InitPacketNotLegacy(initPacket.size)
|
||||
}
|
||||
Logger.i { "Legacy DFU: Stashing init packet (${initPacket.size} bytes) for transfer in Phase 4." }
|
||||
pendingInitPacket = initPacket
|
||||
}
|
||||
|
||||
/** Init packet stashed by [transferInitPacket]; flushed at the start of [transferFirmware]. */
|
||||
private var pendingInitPacket: ByteArray? = null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4: Firmware transfer (.bin)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drives the full upload sequence (START, init-packet brackets, PRN setup, firmware stream, validate, activate).
|
||||
*
|
||||
* Sequence details:
|
||||
* 1. `START_DFU [0x04]` (APP image only).
|
||||
* 2. Image sizes payload on Packet char: `[0u32, 0u32, firmware.size_u32]`.
|
||||
* 3. Await START response.
|
||||
* 4. `INIT_PARAMS_START`, init bytes on Packet, `INIT_PARAMS_COMPLETE`. Await init response.
|
||||
* 5. `PRN_REQ [PRN_LE16]`. (No response.)
|
||||
* 6. `RECEIVE_FIRMWARE_IMAGE`. (No response.)
|
||||
* 7. Stream firmware in MTU-sized chunks. Every PRN packets, await `PacketReceipt(bytesReceived)` and verify count.
|
||||
* 8. After last byte, await final response for `RECEIVE_FIRMWARE_IMAGE`.
|
||||
* 9. `VALIDATE`, await response.
|
||||
* 10. `ACTIVATE_AND_RESET` — device reboots; write may fail with disconnect, treat as success.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit> =
|
||||
safeCatching {
|
||||
val initPacket =
|
||||
pendingInitPacket
|
||||
?: throw DfuException.TransferFailed("transferInitPacket must be called before transferFirmware")
|
||||
Logger.i { "Legacy DFU: Starting upload (init=${initPacket.size}B, firmware=${firmware.size}B)..." }
|
||||
|
||||
// ── 1. START_DFU + image sizes on Packet, then response ─────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.START_DFU, LegacyDfuImageType.APPLICATION))
|
||||
writePacket(legacyImageSizesPayload(appSize = firmware.size))
|
||||
requireSuccess(LegacyDfuOpcode.START_DFU, awaitResponse(START_RESPONSE_TIMEOUT))
|
||||
|
||||
// ── 2. INIT_PARAMS_START → init bytes on Packet → INIT_PARAMS_COMPLETE → response ──
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.INIT_DFU_PARAMS, LegacyDfuOpcode.INIT_PARAMS_START))
|
||||
writePacketChunked(initPacket)
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.INIT_DFU_PARAMS, LegacyDfuOpcode.INIT_PARAMS_COMPLETE))
|
||||
requireSuccess(LegacyDfuOpcode.INIT_DFU_PARAMS, awaitResponse(COMMAND_TIMEOUT))
|
||||
|
||||
// Bump the BLE link to high-throughput mode (~7.5 ms interval) before streaming.
|
||||
// Default Android intervals (~30-50 ms) starve the link during sustained DFU and trigger LSTO. Mirrors
|
||||
// Nordic LegacyDfuImpl.java requestConnectionPriority(CONNECTION_PRIORITY_HIGH).
|
||||
val highPriorityRequested = bleConnection.requestHighConnectionPriority()
|
||||
Logger.i { "Legacy DFU: requestHighConnectionPriority -> $highPriorityRequested" }
|
||||
|
||||
// ── 3. PRN setup ────────────────────────────────────────────────────
|
||||
writeControlPoint(legacyPrnRequestPayload(PRN_INTERVAL_PACKETS))
|
||||
|
||||
// ── 4. RECEIVE_FIRMWARE_IMAGE ──────────────────────────────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE))
|
||||
|
||||
// ── 5. Stream firmware ─────────────────────────────────────────────
|
||||
streamFirmware(firmware, onProgress)
|
||||
|
||||
// ── 6. Final RECEIVE_FIRMWARE_IMAGE response ────────────────────────
|
||||
requireSuccess(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE, awaitResponse(VALIDATE_TIMEOUT))
|
||||
|
||||
// ── 7. VALIDATE ────────────────────────────────────────────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.VALIDATE))
|
||||
requireSuccess(LegacyDfuOpcode.VALIDATE, awaitResponse(VALIDATE_TIMEOUT))
|
||||
|
||||
// ── 8. ACTIVATE_AND_RESET ──────────────────────────────────────────
|
||||
// Device may reset before the GATT write ACK lands — treat any write failure / disconnect as success.
|
||||
Logger.i { "Legacy DFU: Sending ACTIVATE_AND_RESET (disconnect during write is expected)" }
|
||||
runCatching { writeControlPoint(byteArrayOf(LegacyDfuOpcode.ACTIVATE_AND_RESET)) }
|
||||
.onFailure { Logger.i(it) { "Legacy DFU: ACTIVATE write reported failure (expected on reset)" } }
|
||||
|
||||
onProgress(1f)
|
||||
Logger.i { "Legacy DFU: Upload complete, device rebooting into new firmware." }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream [firmware] to the Packet characteristic, awaiting a [LegacyDfuResponse.PacketReceipt] every
|
||||
* [PRN_INTERVAL_PACKETS] packets and verifying the bytes-received count.
|
||||
*
|
||||
* Watches the connection state in parallel with the write loop; if the link drops mid-stream we cancel the write
|
||||
* coroutine and surface a [DfuException.ConnectionFailed] immediately rather than waiting indefinitely for a write
|
||||
* that will never complete.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine the per-packet size for firmware streaming.
|
||||
*
|
||||
* Returns [LEGACY_DFU_PACKET_SIZE] (20) by default for safety against stock Nordic / pre-2.1 OTAFIX bootloaders
|
||||
* that overrun their flash buffer on larger writes. When the device advertises an OTAFIX-2.1+ name (`<board>_DFU`
|
||||
* suffix per https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX#changes-in-otafix-21) and the connection
|
||||
* has negotiated a larger ATT MTU, returns that MTU − 3 (ATT header overhead) capped at 244 bytes.
|
||||
*/
|
||||
private fun computeStreamPacketSize(): Int {
|
||||
val name = dfuAdvertisedName.orEmpty()
|
||||
val isOtafix21 = name.endsWith(OTAFIX_NAME_SUFFIX, ignoreCase = true)
|
||||
if (!isOtafix21) return LEGACY_DFU_PACKET_SIZE
|
||||
val negotiated =
|
||||
bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) ?: return LEGACY_DFU_PACKET_SIZE
|
||||
return negotiated.coerceIn(LEGACY_DFU_PACKET_SIZE, MAX_HIGH_MTU_PACKET_SIZE)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "LongMethod")
|
||||
private suspend fun streamFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit) {
|
||||
// Default: 20-byte ATT packets (the only size accepted by stock Nordic / Adafruit pre-OTAFIX-2.1
|
||||
// bootloaders). Sending larger packets to those bootloaders overruns the flash-write buffer and
|
||||
// silently bricks the device. See .agent_refs/Android-DFU-Library/.../BaseCustomDfuImpl.java:417.
|
||||
//
|
||||
// OTAFIX 2.1+ explicitly supports high-MTU packets ("Enables larger DFU packets for improved
|
||||
// throughput when supported by the client" per the OTAFIX README). It re-uses the standard
|
||||
// `<board>_DFU` advertising-name suffix as a marker for the 2.1 feature set, so we opportunistically
|
||||
// bump the packet size to the negotiated ATT MTU (minus 3 for the ATT header) when we see that name.
|
||||
val mtu = computeStreamPacketSize()
|
||||
var offset = 0
|
||||
Logger.i {
|
||||
"Legacy DFU: Streaming ${firmware.size} bytes with packet size $mtu " +
|
||||
"(advertised='${dfuAdvertisedName ?: "?"}')"
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
// Trip-wire: cancels the streaming coroutine the moment Kable observes a disconnect.
|
||||
val watcher = launch {
|
||||
val state = bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
Logger.w { "Legacy DFU: Link dropped mid-stream at offset $offset/${firmware.size} (state=$state)" }
|
||||
throw DfuException.ConnectionFailed("BLE link dropped mid-upload at byte $offset/${firmware.size}")
|
||||
}
|
||||
|
||||
try {
|
||||
var packetsSincePrn = 0
|
||||
var bytesAtLastPrn = 0L
|
||||
bleConnection.profile(LegacyDfuUuids.SERVICE, timeout = STREAM_TIMEOUT) { service ->
|
||||
val packetChar = service.characteristic(LEGACY_DFU_PACKET_UUID)
|
||||
while (offset < firmware.size) {
|
||||
val end = minOf(offset + mtu, firmware.size)
|
||||
try {
|
||||
service.write(packetChar, firmware.copyOfRange(offset, end), BleWriteType.WITHOUT_RESPONSE)
|
||||
} catch (e: CancellationException) {
|
||||
Logger.w(e) {
|
||||
"Legacy DFU: Write CANCELLED at offset $offset/${firmware.size} cause=${e.cause}"
|
||||
}
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
|
||||
Logger.w(e) { "Legacy DFU: Write FAILED at offset $offset/${firmware.size}: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
offset = end
|
||||
packetsSincePrn++
|
||||
|
||||
if (packetsSincePrn >= PRN_INTERVAL_PACKETS && offset < firmware.size) {
|
||||
Logger.d { "Legacy DFU: Awaiting PRN at offset $offset" }
|
||||
val receipt =
|
||||
try {
|
||||
awaitPacketReceipt()
|
||||
} catch (e: CancellationException) {
|
||||
Logger.w(e) {
|
||||
"Legacy DFU: awaitPacketReceipt CANCELLED at offset $offset cause=${e.cause}"
|
||||
}
|
||||
throw e
|
||||
}
|
||||
val expected = offset.toLong()
|
||||
if (receipt.bytesReceived != expected) {
|
||||
throw LegacyDfuException.PacketReceiptMismatch(expected, receipt.bytesReceived)
|
||||
}
|
||||
bytesAtLastPrn = receipt.bytesReceived
|
||||
packetsSincePrn = 0
|
||||
onProgress(offset.toFloat() / firmware.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.d { "Legacy DFU: Streamed $offset/${firmware.size} bytes (lastPRN=$bytesAtLastPrn)" }
|
||||
} finally {
|
||||
watcher.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Abort & teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send `RESET` to the device, instructing it to discard any in-progress transfer and reboot. Best-effort — the
|
||||
* device may disconnect before the write ACK lands; that's expected.
|
||||
*/
|
||||
override suspend fun abort() {
|
||||
safeCatching {
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.RESET))
|
||||
Logger.i { "Legacy DFU: RESET sent." }
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Legacy DFU: Failed to send RESET (device may already be disconnected)" } }
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "Legacy DFU: Error during disconnect" } }
|
||||
transportScope.cancel()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level GATT helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private suspend fun writeControlPoint(payload: ByteArray) {
|
||||
bleConnection.profile(LegacyDfuUuids.SERVICE) { service ->
|
||||
val controlChar = service.characteristic(LegacyDfuUuids.CONTROL_POINT)
|
||||
service.write(controlChar, payload, BleWriteType.WITH_RESPONSE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writePacket(payload: ByteArray) {
|
||||
bleConnection.profile(LegacyDfuUuids.SERVICE) { service ->
|
||||
val packetChar = service.characteristic(LEGACY_DFU_PACKET_UUID)
|
||||
service.write(packetChar, payload, BleWriteType.WITHOUT_RESPONSE)
|
||||
}
|
||||
}
|
||||
|
||||
/** Write [data] to the Packet char in 20-byte chunks. Legacy DFU bootloaders cap packet size at 20 bytes. */
|
||||
private suspend fun writePacketChunked(data: ByteArray) {
|
||||
bleConnection.profile(LegacyDfuUuids.SERVICE) { service ->
|
||||
val packetChar = service.characteristic(LEGACY_DFU_PACKET_UUID)
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
val end = minOf(pos + LEGACY_DFU_PACKET_SIZE, data.size)
|
||||
service.write(packetChar, data.copyOfRange(pos, end), BleWriteType.WITHOUT_RESPONSE)
|
||||
pos = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitResponse(timeout: Duration): LegacyDfuResponse = try {
|
||||
withTimeout(timeout) {
|
||||
// Drain any stray PRNs that arrive before the response we want.
|
||||
while (true) {
|
||||
val r = notificationChannel.receive()
|
||||
if (r !is LegacyDfuResponse.PacketReceipt) return@withTimeout r
|
||||
}
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
error("unreachable")
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw DfuException.Timeout("No response from Legacy DFU Control Point after $timeout")
|
||||
}
|
||||
|
||||
private suspend fun awaitPacketReceipt(): LegacyDfuResponse.PacketReceipt = try {
|
||||
withTimeout(COMMAND_TIMEOUT) {
|
||||
while (true) {
|
||||
val r = notificationChannel.receive()
|
||||
if (r is LegacyDfuResponse.PacketReceipt) return@withTimeout r
|
||||
if (r is LegacyDfuResponse.Failure) {
|
||||
throw LegacyDfuException.ProtocolError(r.requestOpcode, r.status)
|
||||
}
|
||||
// Stray Success or Unknown → ignore.
|
||||
}
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
error("unreachable")
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw DfuException.Timeout("No packet receipt notification after $COMMAND_TIMEOUT")
|
||||
}
|
||||
|
||||
private fun requireSuccess(expectedOpcode: Byte, response: LegacyDfuResponse) {
|
||||
when (response) {
|
||||
is LegacyDfuResponse.Success ->
|
||||
if (response.requestOpcode != expectedOpcode) {
|
||||
throw DfuException.TransferFailed(
|
||||
"Legacy DFU response opcode mismatch: expected " +
|
||||
"0x${expectedOpcode.toUByte().toString(16).padStart(2, '0')}, " +
|
||||
"got 0x${response.requestOpcode.toUByte().toString(16).padStart(2, '0')}",
|
||||
)
|
||||
}
|
||||
is LegacyDfuResponse.Failure ->
|
||||
throw LegacyDfuException.ProtocolError(response.requestOpcode, response.status)
|
||||
else ->
|
||||
throw DfuException.TransferFailed(
|
||||
"Unexpected Legacy DFU response for opcode 0x${expectedOpcode.toUByte().toString(16)}: $response",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private suspend fun scanForDevice(predicate: (BleDevice) -> Boolean): BleDevice? = scanForBleDevice(
|
||||
scanner = scanner,
|
||||
tag = "Legacy DFU",
|
||||
serviceUuid = LegacyDfuUuids.SERVICE,
|
||||
retryCount = SCAN_RETRY_COUNT,
|
||||
retryDelay = SCAN_RETRY_DELAY,
|
||||
predicate = predicate,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
private val CONNECT_TIMEOUT = 15.seconds
|
||||
private val COMMAND_TIMEOUT = 30.seconds
|
||||
private val START_RESPONSE_TIMEOUT = 30.seconds
|
||||
private val VALIDATE_TIMEOUT = 60.seconds
|
||||
private val SUBSCRIPTION_SETTLE = 500.milliseconds
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private val SCAN_RETRY_DELAY = 2.seconds
|
||||
|
||||
/**
|
||||
* Wall-clock budget for a full firmware streaming session. Must comfortably exceed the upload duration for the
|
||||
* largest expected image at the slowest realistic Legacy DFU throughput (~1-3 KB/s with 20-byte packets). The
|
||||
* per-receipt and per-write watchdogs inside the loop catch real stalls; this cap is just a safety net so a
|
||||
* hung profile block can't sit forever.
|
||||
*/
|
||||
private val STREAM_TIMEOUT = 15.minutes
|
||||
|
||||
/**
|
||||
* Packet-receipt-notification interval (packets between flow-control ACKs). Higher values mean fewer
|
||||
* notification round-trips per byte and therefore faster throughput, at the cost of a slightly longer recovery
|
||||
* window if a packet is dropped (we have to wait until the next PRN boundary to detect the gap).
|
||||
*
|
||||
* Set to 30 per the explicit recommendation from the Adafruit OTAFIX bootloader maintainer
|
||||
* (https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX#recommended-ota-dfu-settings — "Number of
|
||||
* packets: 30"), which is the bootloader Meshtastic nRF52 devices ship with. Nordic's reference library
|
||||
* defaults to 12; values above ~60 are not recommended for any host. Empirically 30 yields ~3x the throughput
|
||||
* of PRN=10 on RAK4631 / OTAFIX without provoking OPERATION_FAILED on the bootloader's flash-write path.
|
||||
*/
|
||||
internal const val PRN_INTERVAL_PACKETS = 30
|
||||
|
||||
/**
|
||||
* Default Legacy DFU packet size (20 bytes — the original ATT_MTU minus the 3-byte ATT header).
|
||||
*
|
||||
* Stock Nordic / pre-OTAFIX-2.1 bootloaders only support this size; sending larger packets to those bootloaders
|
||||
* overruns the flash-write buffer and silently bricks the device. For OTAFIX 2.1+ bootloaders (detected via
|
||||
* `OTAFIX_NAME_SUFFIX`), [computeStreamPacketSize] bumps the per-packet size up to the negotiated MTU (capped
|
||||
* by [MAX_HIGH_MTU_PACKET_SIZE]) for a ~12× throughput win.
|
||||
*/
|
||||
internal const val LEGACY_DFU_PACKET_SIZE = 20
|
||||
|
||||
/**
|
||||
* Cap on the high-MTU packet size used when an OTAFIX-2.1+ bootloader is detected. The largest ATT MTU the BLE
|
||||
* 5.0 LE Data Length extension can give us is 247 bytes (244 of payload), and Adafruit's own flash accumulation
|
||||
* buffer is 240 bytes, so capping at 244 keeps each write to one ATT PDU and one flash-write boundary.
|
||||
*/
|
||||
internal const val MAX_HIGH_MTU_PACKET_SIZE = 244
|
||||
|
||||
/**
|
||||
* Suffix used by the OTAFIX 2.1+ bootloader on every supported board's DFU-mode advertising name (e.g.
|
||||
* `4631_DFU`, `T114_DFU`, `XIAO_DFU`). The 2.0 release advertised generic `AdaDFU`/board names without this
|
||||
* suffix, so its presence is a reliable in-band signal that high-MTU is supported.
|
||||
*/
|
||||
internal const val OTAFIX_NAME_SUFFIX = "_DFU"
|
||||
|
||||
/** Minimum DFU Version we support; older bootloaders use the SDK ≤ 6 single-shot init flow. */
|
||||
private const val MIN_SUPPORTED_DFU_VERSION = 5
|
||||
|
||||
/**
|
||||
* Init packets larger than this are almost certainly Secure-DFU shaped (signed CBOR ≈ 100-300 bytes) rather
|
||||
* than legacy (14 B SDK 7 / 32 B SDK 11). 256 leaves comfortable headroom while still catching the obvious
|
||||
* misuse case where a Secure `.dat` is fed into the Legacy path.
|
||||
*/
|
||||
internal const val MAX_REASONABLE_LEGACY_INIT_SIZE = 256
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,10 @@ import org.meshtastic.feature.firmware.FirmwareUpdateHandler
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateState
|
||||
import org.meshtastic.feature.firmware.ProgressState
|
||||
import org.meshtastic.feature.firmware.ota.ThroughputTracker
|
||||
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
|
||||
import org.meshtastic.feature.firmware.ota.scanForBleDevice
|
||||
import org.meshtastic.feature.firmware.stripFormatArgs
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val PERCENT_MAX = 100
|
||||
private const val GATT_RELEASE_DELAY_MS = 1_500L
|
||||
@@ -60,7 +63,11 @@ private const val CONNECT_ATTEMPTS = 4
|
||||
private const val KIB_DIVISOR = 1024f
|
||||
|
||||
/**
|
||||
* KMP [FirmwareUpdateHandler] for nRF52 devices using the Nordic Secure DFU protocol over Kable BLE.
|
||||
* KMP [FirmwareUpdateHandler] for nRF52 devices.
|
||||
*
|
||||
* Despite its historical name, this handler now drives **both** Nordic Secure DFU (service `FE59`) and Nordic Legacy
|
||||
* DFU / Adafruit `BLEDfu` (service `1530`). After triggering the buttonless reboot it sniffs which DFU service the
|
||||
* bootloader exposes and dispatches to the matching [DfuUploadTransport] implementation.
|
||||
*
|
||||
* All platform I/O (zip extraction, file reading) is delegated to [FirmwareFileHandler].
|
||||
*/
|
||||
@@ -107,28 +114,47 @@ class SecureDfuHandler(
|
||||
radioController.setDeviceAddress("n")
|
||||
delay(GATT_RELEASE_DELAY_MS)
|
||||
|
||||
var transport: SecureDfuTransport? = null
|
||||
var completed = false
|
||||
// The trigger always uses SecureDfuTransport — it speaks both Secure (FE59) and Legacy (1530)
|
||||
// buttonless triggers and falls back automatically (commit f26f610c0).
|
||||
val triggerTransport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
|
||||
try {
|
||||
transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
|
||||
|
||||
transport.triggerButtonlessDfu().onFailure { e ->
|
||||
triggerTransport.triggerButtonlessDfu().onFailure { e ->
|
||||
Logger.w(e) { "DFU: Buttonless trigger failed ($e) — device may already be in DFU mode" }
|
||||
}
|
||||
delay(DFU_REBOOT_WAIT_MS)
|
||||
} finally {
|
||||
withContext(NonCancellable) { triggerTransport.close() }
|
||||
}
|
||||
delay(DFU_REBOOT_WAIT_MS)
|
||||
|
||||
// ── 4. Connect to device in DFU mode ─────────────────────────────
|
||||
// ── 4. Service detection: which DFU protocol does the bootloader speak? ─
|
||||
val protocol = detectBootloaderProtocol(target, updateState)
|
||||
Logger.i { "DFU: Bootloader protocol detected: $protocol" }
|
||||
val transport: DfuUploadTransport =
|
||||
when (protocol) {
|
||||
DfuProtocolKind.LEGACY ->
|
||||
LegacyDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
|
||||
DfuProtocolKind.SECURE ->
|
||||
SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
|
||||
}
|
||||
|
||||
var completed = false
|
||||
try {
|
||||
// ── 5. Connect to device in DFU mode ─────────────────────────────
|
||||
if (!connectWithRetry(transport, updateState)) return@withContext null
|
||||
|
||||
// ── 5. Init packet ────────────────────────────────────────────
|
||||
// ── 6. Init packet ────────────────────────────────────────────
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(UiText.Resource(Res.string.firmware_update_starting_dfu)),
|
||||
),
|
||||
)
|
||||
Logger.i {
|
||||
"DFU: Sending init packet (${pkg.initPacket.size} bytes) and firmware " +
|
||||
"(${pkg.firmware.size} bytes) via $protocol"
|
||||
}
|
||||
transport.transferInitPacket(pkg.initPacket).getOrThrow()
|
||||
|
||||
// ── 6. Firmware ───────────────────────────────────────────────
|
||||
// ── 7. Firmware ───────────────────────────────────────────────
|
||||
val uploadMsg = UiText.Resource(Res.string.firmware_update_uploading)
|
||||
updateState(FirmwareUpdateState.Updating(ProgressState(uploadMsg, 0f)))
|
||||
|
||||
@@ -160,7 +186,7 @@ class SecureDfuHandler(
|
||||
}
|
||||
.getOrThrow()
|
||||
|
||||
// ── 7. Validate ───────────────────────────────────────────────
|
||||
// ── 8. Validate ───────────────────────────────────────────────
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(UiText.Resource(Res.string.firmware_update_validating)),
|
||||
@@ -174,8 +200,8 @@ class SecureDfuHandler(
|
||||
// Send ABORT if cancelled mid-transfer, then always clean up.
|
||||
// NonCancellable ensures this runs even when the coroutine is being cancelled.
|
||||
withContext(NonCancellable) {
|
||||
if (!completed) transport?.abort()
|
||||
transport?.close()
|
||||
if (!completed) transport.abort()
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,8 +224,35 @@ class SecureDfuHandler(
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect which DFU protocol the bootloader speaks by scanning for advertised service UUIDs. We scan for the legacy
|
||||
* service (1530) first with a short timeout — Adafruit/oltaco bootloaders always advertise it, while Nordic Secure
|
||||
* bootloaders never do, so a hit unambiguously means Legacy. Miss ⇒ assume Secure (preserves current behavior on
|
||||
* unaffected devices).
|
||||
*/
|
||||
private suspend fun detectBootloaderProtocol(
|
||||
target: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): DfuProtocolKind {
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
|
||||
)
|
||||
val targetAddresses = setOf(target, calculateMacPlusOne(target))
|
||||
val legacyHit =
|
||||
scanForBleDevice(
|
||||
scanner = bleScanner,
|
||||
tag = "DFU detect",
|
||||
serviceUuid = LegacyDfuUuids.SERVICE,
|
||||
retryCount = 1,
|
||||
retryDelay = 0.seconds,
|
||||
scanTimeout = DETECT_SCAN_TIMEOUT,
|
||||
predicate = { it.address in targetAddresses },
|
||||
)
|
||||
return if (legacyHit != null) DfuProtocolKind.LEGACY else DfuProtocolKind.SECURE
|
||||
}
|
||||
|
||||
private suspend fun connectWithRetry(
|
||||
transport: SecureDfuTransport,
|
||||
transport: DfuUploadTransport,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): Boolean {
|
||||
updateState(
|
||||
@@ -260,4 +313,15 @@ class SecureDfuHandler(
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/** Result of [detectBootloaderProtocol]. */
|
||||
internal enum class DfuProtocolKind {
|
||||
SECURE,
|
||||
LEGACY,
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Detection scan timeout — short because we only want to confirm/refute an advertised legacy service. */
|
||||
private val DETECT_SCAN_TIMEOUT = 8.seconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,35 @@ internal object SecureDfuUuids {
|
||||
val BUTTONLESS_WITH_BONDS: Uuid = Uuid.parse("8EC90004-F315-4F60-9FB8-838830DAEA50")
|
||||
}
|
||||
|
||||
/**
|
||||
* Nordic Legacy DFU service UUIDs (also used by Adafruit's `BLEDfu` helper class). Meshtastic firmware exposes this
|
||||
* service when built **without** `BLE_DFU_SECURE`. The buttonless trigger is a single write of `0x01` (`START_DFU`) to
|
||||
* the Control Point characteristic; the device then disconnects and reboots into the bootloader (which itself runs
|
||||
* Secure DFU on modern Adafruit/oltaco bootloaders).
|
||||
*
|
||||
* Reference: `Adafruit_nRF52_Arduino/libraries/Bluefruit52Lib/src/services/BLEDfu.cpp`.
|
||||
*/
|
||||
internal object LegacyDfuUuids {
|
||||
/** Legacy DFU service — exposed by app firmware to trigger reboot into the bootloader. */
|
||||
val SERVICE: Uuid = Uuid.parse("00001530-1212-EFDE-1523-785FEABCD123")
|
||||
|
||||
/**
|
||||
* Control Point: NOTIFY + WRITE. Notifications must be subscribed before writing or the device returns
|
||||
* `ATTERR_CPS_CCCD_CONFIG_ERROR`.
|
||||
*/
|
||||
val CONTROL_POINT: Uuid = Uuid.parse("00001531-1212-EFDE-1523-785FEABCD123")
|
||||
}
|
||||
|
||||
/** Secure DFU buttonless trigger: single-byte `0x01` (START_DFU) to FE59 service. */
|
||||
internal const val BUTTONLESS_ENTER_BOOTLOADER: Byte = 0x01
|
||||
|
||||
/**
|
||||
* Legacy DFU buttonless trigger payload per Nordic's `LegacyButtonlessDfuImpl.java:53`: `[OP_CODE_START_DFU=0x01,
|
||||
* IMAGE_TYPE_APPLICATION=0x04]`. The Adafruit `BLEDfu` (and original Nordic SDK 6.x bootloader) require both bytes —
|
||||
* sending only the opcode is a spec violation that some bootloader builds silently drop.
|
||||
*/
|
||||
internal val LEGACY_BUTTONLESS_ENTER_BOOTLOADER: ByteArray = byteArrayOf(0x01, 0x04)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol opcodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,10 +34,13 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
@@ -45,12 +48,15 @@ import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH
|
||||
import org.meshtastic.core.ble.MeshtasticBleDevice
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
|
||||
import org.meshtastic.feature.firmware.ota.scanForBleDevice
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Kable-based transport for the Nordic Secure DFU (Secure DFU over BLE) protocol.
|
||||
@@ -67,7 +73,7 @@ class SecureDfuTransport(
|
||||
connectionFactory: BleConnectionFactory,
|
||||
private val address: String,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
) : DfuUploadTransport {
|
||||
private val transportScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val bleConnection = connectionFactory.create(transportScope, "Secure DFU")
|
||||
|
||||
@@ -92,51 +98,21 @@ class SecureDfuTransport(
|
||||
* The caller must have already released the mesh-service BLE connection before calling this.
|
||||
*/
|
||||
suspend fun triggerButtonlessDfu(): Result<Unit> = safeCatching {
|
||||
Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." }
|
||||
|
||||
val device =
|
||||
scanForDevice { d -> d.address == address }
|
||||
?: throw DfuException.ConnectionFailed("Device $address not found for buttonless DFU trigger")
|
||||
|
||||
// No scan needed: the address is already bonded with the OS, so we can connect directly the same way the
|
||||
// Nordic Android DFU library does (BluetoothAdapter.getRemoteDevice(address).connectGatt). Scanning here is
|
||||
// unreliable because the device may not have resumed advertising in the brief window after we released the
|
||||
// mesh-service GATT.
|
||||
Logger.i { "DFU: Connecting to $address to trigger buttonless DFU..." }
|
||||
bleConnection.connectAndAwait(device, CONNECT_TIMEOUT)
|
||||
bleConnection.connectAndAwait(MeshtasticBleDevice(address), CONNECT_TIMEOUT)
|
||||
|
||||
bleConnection.profile(SecureDfuUuids.SERVICE) { service ->
|
||||
val buttonlessChar = service.characteristic(SecureDfuUuids.BUTTONLESS_NO_BONDS)
|
||||
|
||||
// Enable indications by subscribing to the characteristic. The device-side firmware (BLEDfuSecure.cpp)
|
||||
// checks that the CCCD is configured and returns ATTERR_CPS_CCCD_CONFIG_ERROR if not.
|
||||
val indicationChannel = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
val indicationJob =
|
||||
service
|
||||
.observe(buttonlessChar)
|
||||
.onEach { indicationChannel.trySend(it) }
|
||||
.catch { e -> Logger.d(e) { "DFU: Buttonless indication stream ended (expected on disconnect)" } }
|
||||
.launchIn(this)
|
||||
|
||||
delay(SUBSCRIPTION_SETTLE)
|
||||
|
||||
Logger.i { "DFU: Writing buttonless DFU trigger..." }
|
||||
service.write(buttonlessChar, byteArrayOf(0x01), BleWriteType.WITH_RESPONSE)
|
||||
|
||||
// Wait for the indication response (0x20-01-STATUS). The device may disconnect before we receive it —
|
||||
// that's expected and treated as success, matching the Nordic DFU library's behavior.
|
||||
try {
|
||||
withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) {
|
||||
val response = indicationChannel.receive()
|
||||
if (response.size >= 3 && response[0] == BUTTONLESS_RESPONSE_CODE && response[2] != 0x01.toByte()) {
|
||||
Logger.w { "DFU: Buttonless DFU response indicates error: ${response.toHexString()}" }
|
||||
} else {
|
||||
Logger.i { "DFU: Buttonless DFU indication received successfully" }
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Logger.d { "DFU: No buttonless indication received (device may have already disconnected)" }
|
||||
} catch (_: Exception) {
|
||||
Logger.d { "DFU: Buttonless indication wait interrupted (device disconnecting)" }
|
||||
}
|
||||
|
||||
indicationJob.cancel()
|
||||
// Try the Nordic Secure DFU service (FE59) first — used when the firmware is built with BLE_DFU_SECURE.
|
||||
// If it isn't exposed, fall back to the Legacy DFU service (1530) used by Meshtastic builds that rely on
|
||||
// Adafruit's stock BLEDfu helper. Both ultimately reboot the device into the same bootloader.
|
||||
try {
|
||||
triggerSecureButtonless()
|
||||
} catch (e: NoSuchElementException) {
|
||||
Logger.i(e) { "DFU: Secure DFU service (FE59) not present — falling back to legacy DFU service (1530)" }
|
||||
triggerLegacyButtonless()
|
||||
}
|
||||
|
||||
// Device will disconnect and reboot — expected, not an error.
|
||||
@@ -144,6 +120,104 @@ class SecureDfuTransport(
|
||||
bleConnection.disconnect()
|
||||
}
|
||||
|
||||
/** Nordic Secure DFU buttonless trigger — INDICATE + write 0x01, then wait for `0x20-01-STATUS` response. */
|
||||
private suspend fun triggerSecureButtonless() {
|
||||
triggerButtonless(
|
||||
serviceUuid = SecureDfuUuids.SERVICE,
|
||||
characteristicUuid = SecureDfuUuids.BUTTONLESS_NO_BONDS,
|
||||
payload = byteArrayOf(BUTTONLESS_ENTER_BOOTLOADER),
|
||||
profileTimeout = TRIGGER_TIMEOUT,
|
||||
logLabel = "secure",
|
||||
awaitResponse = { channel ->
|
||||
try {
|
||||
withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) {
|
||||
val response = channel.receive()
|
||||
if (
|
||||
response.size >= 3 &&
|
||||
response[0] == BUTTONLESS_RESPONSE_CODE &&
|
||||
response[2] != 0x01.toByte()
|
||||
) {
|
||||
Logger.w { "DFU: Buttonless DFU response indicates error: ${response.toHexString()}" }
|
||||
} else {
|
||||
Logger.i { "DFU: Buttonless DFU indication received successfully" }
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Logger.d { "DFU: No buttonless indication received (device may have already disconnected)" }
|
||||
} catch (_: Exception) {
|
||||
Logger.d { "DFU: Buttonless indication wait interrupted (device disconnecting)" }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nordic Legacy DFU buttonless trigger (Adafruit `BLEDfu`). Subscribe to NOTIFICATIONS on the control point
|
||||
* (required to satisfy the device's CCCD check), then write `[0x01, 0x04]` (START_DFU + IMAGE_TYPE_APPLICATION, per
|
||||
* Nordic's `LegacyButtonlessDfuImpl.java:53`). The device reboots into the bootloader immediately — no notification
|
||||
* response is expected before disconnect.
|
||||
*/
|
||||
private suspend fun triggerLegacyButtonless() {
|
||||
triggerButtonless(
|
||||
serviceUuid = LegacyDfuUuids.SERVICE,
|
||||
characteristicUuid = LegacyDfuUuids.CONTROL_POINT,
|
||||
payload = LEGACY_BUTTONLESS_ENTER_BOOTLOADER,
|
||||
profileTimeout = TRIGGER_TIMEOUT,
|
||||
logLabel = "legacy",
|
||||
awaitResponse = null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared scaffold for both Secure and Legacy buttonless triggers. The Adafruit/Nordic protocol is identical in
|
||||
* shape: enable CCCD on a control characteristic, write a small opcode, optionally await an indication/notification
|
||||
* response. The whole trigger is wrapped in [profileTimeout] and treats timeout as success — by the time we'd time
|
||||
* out, the device has either rebooted (verified by [connectToDfuMode]) or never received the byte (which surfaces
|
||||
* as a useful scan failure later). This escapes the well-known race where Kable's `WITH_RESPONSE` write blocks on
|
||||
* an ATT ACK that never arrives because the device rebooted before sending it.
|
||||
*/
|
||||
private suspend fun triggerButtonless(
|
||||
serviceUuid: Uuid,
|
||||
characteristicUuid: Uuid,
|
||||
payload: ByteArray,
|
||||
profileTimeout: Duration,
|
||||
logLabel: String,
|
||||
awaitResponse: (suspend CoroutineScope.(Channel<ByteArray>) -> Unit)?,
|
||||
) {
|
||||
try {
|
||||
withTimeout(profileTimeout) {
|
||||
bleConnection.profile(serviceUuid, timeout = profileTimeout) { service ->
|
||||
val char = service.characteristic(characteristicUuid)
|
||||
|
||||
val channel = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
val observeJob =
|
||||
service
|
||||
.observe(char)
|
||||
.onEach { channel.trySend(it) }
|
||||
.catch { e ->
|
||||
Logger.d(e) { "DFU: $logLabel notification stream ended (expected on disconnect)" }
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
delay(SUBSCRIPTION_SETTLE)
|
||||
|
||||
Logger.i { "DFU: Writing $logLabel buttonless DFU trigger..." }
|
||||
service.write(char, payload, BleWriteType.WITH_RESPONSE)
|
||||
|
||||
awaitResponse?.invoke(this, channel)
|
||||
|
||||
observeJob.cancel()
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Logger.w {
|
||||
"DFU: $logLabel buttonless trigger timed out — likely cause: stale BLE bond (Meshtastic " +
|
||||
"BLEDfu requires SECMODE_ENC_WITH_MITM). User must Forget+Re-pair the device in Android " +
|
||||
"Bluetooth settings if the next DFU-mode scan also fails."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 2: Connect to device in DFU mode
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -152,7 +226,7 @@ class SecureDfuTransport(
|
||||
* Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling
|
||||
* notifications on the Control Point.
|
||||
*/
|
||||
suspend fun connectToDfuMode(): Result<Unit> = safeCatching {
|
||||
override suspend fun connectToDfuMode(): Result<Unit> = safeCatching {
|
||||
val dfuAddress = calculateMacPlusOne(address)
|
||||
val targetAddresses = setOf(address, dfuAddress)
|
||||
Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." }
|
||||
@@ -210,7 +284,7 @@ class SecureDfuTransport(
|
||||
* PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention — the init packet
|
||||
* is small (<512 bytes, fits in a single object) and does not benefit from flow control.
|
||||
*/
|
||||
suspend fun transferInitPacket(initPacket: ByteArray): Result<Unit> = safeCatching {
|
||||
override suspend fun transferInitPacket(initPacket: ByteArray): Result<Unit> = safeCatching {
|
||||
Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." }
|
||||
setPrn(0)
|
||||
transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null)
|
||||
@@ -231,9 +305,13 @@ class SecureDfuTransport(
|
||||
* @param firmware Raw bytes of the `.bin` file.
|
||||
* @param onProgress Callback receiving progress in [0.0, 1.0].
|
||||
*/
|
||||
suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit> =
|
||||
override suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit> =
|
||||
safeCatching {
|
||||
Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." }
|
||||
// Bump BLE link to high-throughput mode (~7.5 ms interval) before streaming.
|
||||
// Default Android intervals (~30-50 ms) starve the link during sustained DFU and trigger LSTO.
|
||||
val highPriorityRequested = bleConnection.requestHighConnectionPriority()
|
||||
Logger.i { "Secure DFU: requestHighConnectionPriority -> $highPriorityRequested" }
|
||||
setPrn(PRN_INTERVAL)
|
||||
transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress)
|
||||
Logger.i { "DFU: Firmware transferred and executed." }
|
||||
@@ -250,7 +328,7 @@ class SecureDfuTransport(
|
||||
* Call this before [close] when cancelling or recovering from an error so the device doesn't need a power cycle to
|
||||
* accept a fresh DFU session.
|
||||
*/
|
||||
suspend fun abort() {
|
||||
override suspend fun abort() {
|
||||
safeCatching {
|
||||
bleConnection.profile(SecureDfuUuids.SERVICE) { service ->
|
||||
val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT)
|
||||
@@ -262,7 +340,7 @@ class SecureDfuTransport(
|
||||
}
|
||||
|
||||
/** Disconnect from the DFU target and cancel the transport coroutine scope. */
|
||||
suspend fun close() {
|
||||
override suspend fun close() {
|
||||
safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } }
|
||||
transportScope.cancel()
|
||||
}
|
||||
@@ -429,31 +507,41 @@ class SecureDfuTransport(
|
||||
*/
|
||||
private suspend fun writePackets(data: ByteArray, from: Int, until: Int, prnInterval: Int) {
|
||||
val mtu = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) ?: DEFAULT_BLE_WRITE_VALUE_LENGTH
|
||||
var packetsSincePrn = 0
|
||||
var pos = from
|
||||
|
||||
bleConnection.profile(SecureDfuUuids.SERVICE) { service ->
|
||||
val packetChar = service.characteristic(SecureDfuUuids.PACKET)
|
||||
var pos = from
|
||||
coroutineScope {
|
||||
// Trip-wire: cancels the streaming coroutine the moment Kable observes a disconnect.
|
||||
val watcher = launch {
|
||||
val state = bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
Logger.w { "Secure DFU: Link dropped mid-stream at offset $pos/$until (state=$state)" }
|
||||
throw DfuException.ConnectionFailed("BLE link dropped mid-upload at byte $pos/$until")
|
||||
}
|
||||
|
||||
while (pos < until) {
|
||||
val chunkEnd = minOf(pos + mtu, until)
|
||||
service.write(packetChar, data.copyOfRange(pos, chunkEnd), BleWriteType.WITHOUT_RESPONSE)
|
||||
pos = chunkEnd
|
||||
packetsSincePrn++
|
||||
try {
|
||||
var packetsSincePrn = 0
|
||||
bleConnection.profile(SecureDfuUuids.SERVICE, timeout = STREAM_TIMEOUT) { service ->
|
||||
val packetChar = service.characteristic(SecureDfuUuids.PACKET)
|
||||
while (pos < until) {
|
||||
val chunkEnd = minOf(pos + mtu, until)
|
||||
service.write(packetChar, data.copyOfRange(pos, chunkEnd), BleWriteType.WITHOUT_RESPONSE)
|
||||
pos = chunkEnd
|
||||
packetsSincePrn++
|
||||
|
||||
// Wait for the device's PRN receipt notification, then validate CRC.
|
||||
// Skip the wait on the last packet — the final CALCULATE_CHECKSUM covers it.
|
||||
if (prnInterval > 0 && packetsSincePrn >= prnInterval && pos < until) {
|
||||
val response = awaitNotification(COMMAND_TIMEOUT)
|
||||
if (response is DfuResponse.ChecksumResult) {
|
||||
val expectedCrc = DfuCrc32.calculate(data, length = pos)
|
||||
if (response.offset != pos || response.crc32 != expectedCrc) {
|
||||
throw DfuException.ChecksumMismatch(expected = expectedCrc, actual = response.crc32)
|
||||
if (prnInterval > 0 && packetsSincePrn >= prnInterval && pos < until) {
|
||||
val response = awaitNotification(COMMAND_TIMEOUT)
|
||||
if (response is DfuResponse.ChecksumResult) {
|
||||
val expectedCrc = DfuCrc32.calculate(data, length = pos)
|
||||
if (response.offset != pos || response.crc32 != expectedCrc) {
|
||||
throw DfuException.ChecksumMismatch(expected = expectedCrc, actual = response.crc32)
|
||||
}
|
||||
Logger.d { "DFU: PRN checksum OK at offset $pos" }
|
||||
}
|
||||
packetsSincePrn = 0
|
||||
}
|
||||
Logger.d { "DFU: PRN checksum OK at offset $pos" }
|
||||
}
|
||||
packetsSincePrn = 0
|
||||
}
|
||||
} finally {
|
||||
watcher.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,11 +646,28 @@ class SecureDfuTransport(
|
||||
private val COMMAND_TIMEOUT = 30.seconds
|
||||
private val SUBSCRIPTION_SETTLE = 500.milliseconds
|
||||
private val BUTTONLESS_RESPONSE_TIMEOUT = 3.seconds
|
||||
|
||||
/**
|
||||
* Tight wall-clock cap on the buttonless trigger phase (Secure or Legacy). After we send the opcode, the device
|
||||
* reboots into the bootloader and disconnects — but Kable's `WITH_RESPONSE` write blocks on an ATT ACK that
|
||||
* never arrives in this race. If we don't see the disconnect propagate within this window, the device almost
|
||||
* certainly didn't receive the byte, so failing fast lets the caller surface a useful error (or retry) instead
|
||||
* of waiting on the default 30s `profile()` timeout. Comfortably exceeds the secure-path indication wait
|
||||
* ([BUTTONLESS_RESPONSE_TIMEOUT]) plus settle/write overhead.
|
||||
*/
|
||||
private val TRIGGER_TIMEOUT = 5.seconds
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private val SCAN_RETRY_DELAY = 2.seconds
|
||||
private val RETRY_DELAY = 2.seconds
|
||||
private val FIRST_CHUNK_DELAY = 400.milliseconds
|
||||
|
||||
/**
|
||||
* Wall-clock budget for a single firmware-object streaming session. Must comfortably exceed the upload duration
|
||||
* for the largest expected image at the slowest realistic Secure DFU throughput. Per-PRN/per-write watchdogs
|
||||
* inside the loop detect real stalls; this is just a safety net so a hung profile block can't sit forever.
|
||||
*/
|
||||
private val STREAM_TIMEOUT = 15.minutes
|
||||
|
||||
/** Response code prefix for Buttonless DFU indications (0x20 = response). */
|
||||
private const val BUTTONLESS_RESPONSE_CODE: Byte = 0x20
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2025-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("MagicNumber")
|
||||
|
||||
package org.meshtastic.feature.firmware.ota.dfu
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class LegacyDfuProtocolTest {
|
||||
|
||||
@Test
|
||||
fun `parse Success response`() {
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x01, 0x01))
|
||||
assertIs<LegacyDfuResponse.Success>(r)
|
||||
assertEquals(LegacyDfuOpcode.START_DFU, r.requestOpcode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse Failure response`() {
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x02, 0x06))
|
||||
assertIs<LegacyDfuResponse.Failure>(r)
|
||||
assertEquals(LegacyDfuOpcode.INIT_DFU_PARAMS, r.requestOpcode)
|
||||
assertEquals(LegacyDfuStatus.OPERATION_FAILED, r.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse PacketReceipt - little endian uint32`() {
|
||||
// 0x12345678 = 305419896 LE: 78 56 34 12
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf(0x11, 0x78, 0x56, 0x34, 0x12))
|
||||
assertIs<LegacyDfuResponse.PacketReceipt>(r)
|
||||
assertEquals(0x12345678L, r.bytesReceived)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse PacketReceipt with high bit set treats value as unsigned`() {
|
||||
// 0xFF000000 should be parsed as 4278190080 (positive long), not -16777216 (negative int).
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf(0x11, 0x00, 0x00, 0x00, 0xFF.toByte()))
|
||||
assertIs<LegacyDfuResponse.PacketReceipt>(r)
|
||||
assertEquals(0xFF000000L, r.bytesReceived)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse Unknown for unrecognised prefix`() {
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf(0x42, 0x99.toByte()))
|
||||
assertIs<LegacyDfuResponse.Unknown>(r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse Unknown for empty bytes`() {
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf())
|
||||
assertIs<LegacyDfuResponse.Unknown>(r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse Unknown for too-short response`() {
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x01))
|
||||
assertIs<LegacyDfuResponse.Unknown>(r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse Unknown for too-short packet receipt`() {
|
||||
val r = LegacyDfuResponse.parse(byteArrayOf(0x11, 0x01, 0x02))
|
||||
assertIs<LegacyDfuResponse.Unknown>(r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacyImageSizesPayload is 12 bytes LE - app only`() {
|
||||
// 0x1234 = 4660 → LE: 34 12 00 00
|
||||
val payload = legacyImageSizesPayload(appSize = 0x1234)
|
||||
assertEquals(12, payload.size)
|
||||
assertContentEquals(
|
||||
byteArrayOf(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // softdevice
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // bootloader
|
||||
0x34,
|
||||
0x12,
|
||||
0,
|
||||
0, // app
|
||||
),
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacyImageSizesPayload with all three components`() {
|
||||
val payload = legacyImageSizesPayload(appSize = 1, softDeviceSize = 2, bootloaderSize = 3)
|
||||
assertContentEquals(byteArrayOf(2, 0, 0, 0, 3, 0, 0, 0, 1, 0, 0, 0), payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacyPrnRequestPayload is opcode plus uint16 LE`() {
|
||||
val payload = legacyPrnRequestPayload(packets = 10)
|
||||
assertContentEquals(byteArrayOf(LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ, 10, 0), payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacyPrnRequestPayload over 256 spans both bytes`() {
|
||||
val payload = legacyPrnRequestPayload(packets = 0x1234)
|
||||
assertContentEquals(byteArrayOf(LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ, 0x34, 0x12), payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
/*
|
||||
* Copyright (c) 2025-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("MagicNumber", "LargeClass", "TooManyFunctions")
|
||||
|
||||
package org.meshtastic.feature.firmware.ota.dfu
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.ble.BleCharacteristic
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleService
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.testing.FakeBleConnection
|
||||
import org.meshtastic.core.testing.FakeBleConnectionFactory
|
||||
import org.meshtastic.core.testing.FakeBleDevice
|
||||
import org.meshtastic.core.testing.FakeBleScanner
|
||||
import org.meshtastic.core.testing.FakeBleService
|
||||
import org.meshtastic.core.testing.FakeBleWrite
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LegacyDfuTransportTest {
|
||||
|
||||
private val address = "00:11:22:33:44:55"
|
||||
private val dfuAddress = "00:11:22:33:44:56"
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: connectToDfuMode
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `connectToDfuMode succeeds when bootloader exposes 1530 service`() = runTest {
|
||||
val scanner = FakeBleScanner()
|
||||
val connection = FakeBleConnection()
|
||||
val transport =
|
||||
LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined)
|
||||
|
||||
scanner.emitDevice(FakeBleDevice(dfuAddress))
|
||||
|
||||
val result = transport.connectToDfuMode()
|
||||
|
||||
assertTrue(result.isSuccess, "connectToDfuMode failed: ${result.exceptionOrNull()}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connectToDfuMode fails fast on unsupported old DFU Version lt 5`() = runTest {
|
||||
val scanner = FakeBleScanner()
|
||||
val connection = FakeBleConnection()
|
||||
// Pre-seed the version characteristic with a SDK 6 version (0x0004 LE).
|
||||
connection.service.enqueueRead(LEGACY_DFU_VERSION_UUID, byteArrayOf(0x04, 0x00))
|
||||
val transport =
|
||||
LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined)
|
||||
|
||||
scanner.emitDevice(FakeBleDevice(dfuAddress))
|
||||
|
||||
val result = transport.connectToDfuMode()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertIs<LegacyDfuException.UnsupportedBootloader>(result.exceptionOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connectToDfuMode accepts modern DFU Version 8`() = runTest {
|
||||
val scanner = FakeBleScanner()
|
||||
val connection = FakeBleConnection()
|
||||
connection.service.enqueueRead(LEGACY_DFU_VERSION_UUID, byteArrayOf(0x08, 0x00))
|
||||
val transport =
|
||||
LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined)
|
||||
|
||||
scanner.emitDevice(FakeBleDevice(dfuAddress))
|
||||
|
||||
val result = transport.connectToDfuMode()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connectToDfuMode accepts missing DFU Version characteristic`() = runTest {
|
||||
// Default FakeBleService.read returns empty bytes when nothing is enqueued — treated as "absent".
|
||||
val scanner = FakeBleScanner()
|
||||
val connection = FakeBleConnection()
|
||||
val transport =
|
||||
LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined)
|
||||
|
||||
scanner.emitDevice(FakeBleDevice(dfuAddress))
|
||||
|
||||
val result = transport.connectToDfuMode()
|
||||
|
||||
assertTrue(
|
||||
result.isSuccess,
|
||||
"Missing DFU Version should be treated as modern (proceed): ${result.exceptionOrNull()}",
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 3: transferInitPacket preflight
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `transferInitPacket rejects oversized init packet looks like Secure-shaped dat`() = runTest {
|
||||
val transport =
|
||||
LegacyDfuTransport(
|
||||
FakeBleScanner(),
|
||||
FakeBleConnectionFactory(FakeBleConnection()),
|
||||
address,
|
||||
Dispatchers.Unconfined,
|
||||
)
|
||||
val oversized = ByteArray(LegacyDfuTransport.MAX_REASONABLE_LEGACY_INIT_SIZE + 1) { 0x42 }
|
||||
|
||||
val result = transport.transferInitPacket(oversized)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertIs<LegacyDfuException.InitPacketNotLegacy>(result.exceptionOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transferInitPacket accepts typical 14 byte legacy init`() = runTest {
|
||||
val transport =
|
||||
LegacyDfuTransport(
|
||||
FakeBleScanner(),
|
||||
FakeBleConnectionFactory(FakeBleConnection()),
|
||||
address,
|
||||
Dispatchers.Unconfined,
|
||||
)
|
||||
val init = ByteArray(14) { it.toByte() }
|
||||
|
||||
val result = transport.transferInitPacket(init)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 4: transferFirmware happy path
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `transferFirmware happy path writes correct opcode and packet sequence`() = runTest {
|
||||
val env = createConnectedTransport()
|
||||
env.responder.scheme = LegacyResponderScheme.HappyPath
|
||||
|
||||
val init = ByteArray(14) { it.toByte() }
|
||||
val firmware = ByteArray(80) { (0xA0 + it).toByte() } // 4 packets at MTU=20
|
||||
|
||||
env.transport.transferInitPacket(init).getOrThrow()
|
||||
val progress = mutableListOf<Float>()
|
||||
val result = env.transport.transferFirmware(firmware) { progress.add(it) }
|
||||
|
||||
assertTrue(result.isSuccess, "transferFirmware failed: ${result.exceptionOrNull()}")
|
||||
|
||||
// Control-point opcode order:
|
||||
// START_DFU, INIT_DFU_PARAMS_START, INIT_DFU_PARAMS_COMPLETE,
|
||||
// PACKET_RECEIPT_NOTIF_REQ, RECEIVE_FIRMWARE_IMAGE, VALIDATE, ACTIVATE_AND_RESET
|
||||
val cpOpcodes = env.controlPointWrites().map { it.data[0] }
|
||||
assertEquals(
|
||||
listOf(
|
||||
LegacyDfuOpcode.START_DFU,
|
||||
LegacyDfuOpcode.INIT_DFU_PARAMS,
|
||||
LegacyDfuOpcode.INIT_DFU_PARAMS,
|
||||
LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ,
|
||||
LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE,
|
||||
LegacyDfuOpcode.VALIDATE,
|
||||
LegacyDfuOpcode.ACTIVATE_AND_RESET,
|
||||
),
|
||||
cpOpcodes,
|
||||
)
|
||||
|
||||
// First INIT_DFU_PARAMS sub-opcode is START (0x00), second is COMPLETE (0x01).
|
||||
val initParamsWrites = env.controlPointWrites().filter { it.data[0] == LegacyDfuOpcode.INIT_DFU_PARAMS }
|
||||
assertEquals(LegacyDfuOpcode.INIT_PARAMS_START, initParamsWrites[0].data[1])
|
||||
assertEquals(LegacyDfuOpcode.INIT_PARAMS_COMPLETE, initParamsWrites[1].data[1])
|
||||
|
||||
// START_DFU includes APP image type byte.
|
||||
val startWrite = env.controlPointWrites().single { it.data[0] == LegacyDfuOpcode.START_DFU }
|
||||
assertContentEquals(byteArrayOf(LegacyDfuOpcode.START_DFU, LegacyDfuImageType.APPLICATION), startWrite.data)
|
||||
|
||||
// Packet writes should contain: [12B image sizes] then [14B init in chunks] then [firmware in chunks].
|
||||
val packetBytes = env.packetWrites().flatMap { it.data.toList() }.toByteArray()
|
||||
// Image sizes payload (first 12 bytes): app size = firmware.size, others 0.
|
||||
val imageSizes = packetBytes.copyOfRange(0, 12)
|
||||
assertContentEquals(legacyImageSizesPayload(appSize = firmware.size), imageSizes)
|
||||
// Init follows.
|
||||
assertContentEquals(init, packetBytes.copyOfRange(12, 12 + init.size))
|
||||
// Firmware follows.
|
||||
assertContentEquals(firmware, packetBytes.copyOfRange(12 + init.size, packetBytes.size))
|
||||
|
||||
// Final progress should be 1.0.
|
||||
assertEquals(1f, progress.last())
|
||||
|
||||
// Packet writes use WITHOUT_RESPONSE.
|
||||
env.packetWrites().forEach { assertEquals(BleWriteType.WITHOUT_RESPONSE, it.writeType) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 4: error paths
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `transferFirmware fails with ProtocolError when device rejects START_DFU`() = runTest {
|
||||
val env = createConnectedTransport()
|
||||
env.responder.scheme = LegacyResponderScheme.RejectStart
|
||||
|
||||
env.transport.transferInitPacket(ByteArray(14)).getOrThrow()
|
||||
val result = env.transport.transferFirmware(ByteArray(40)) {}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
val ex = assertIs<LegacyDfuException.ProtocolError>(result.exceptionOrNull())
|
||||
assertEquals(LegacyDfuOpcode.START_DFU, ex.requestOpcode)
|
||||
assertEquals(LegacyDfuStatus.NOT_SUPPORTED, ex.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transferFirmware fails with ProtocolError when device rejects INIT_DFU_PARAMS`() = runTest {
|
||||
val env = createConnectedTransport()
|
||||
env.responder.scheme = LegacyResponderScheme.RejectInit
|
||||
|
||||
env.transport.transferInitPacket(ByteArray(14)).getOrThrow()
|
||||
val result = env.transport.transferFirmware(ByteArray(40)) {}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
val ex = assertIs<LegacyDfuException.ProtocolError>(result.exceptionOrNull())
|
||||
assertEquals(LegacyDfuOpcode.INIT_DFU_PARAMS, ex.requestOpcode)
|
||||
assertEquals(LegacyDfuStatus.OPERATION_FAILED, ex.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transferFirmware fails with ProtocolError when device rejects VALIDATE`() = runTest {
|
||||
val env = createConnectedTransport()
|
||||
env.responder.scheme = LegacyResponderScheme.RejectValidate
|
||||
|
||||
env.transport.transferInitPacket(ByteArray(14)).getOrThrow()
|
||||
val result = env.transport.transferFirmware(ByteArray(40)) {}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
val ex = assertIs<LegacyDfuException.ProtocolError>(result.exceptionOrNull())
|
||||
assertEquals(LegacyDfuOpcode.VALIDATE, ex.requestOpcode)
|
||||
assertEquals(LegacyDfuStatus.CRC_ERROR, ex.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transferFirmware fails with PacketReceiptMismatch when device under-reports`() = runTest {
|
||||
val env = createConnectedTransport()
|
||||
env.responder.scheme = LegacyResponderScheme.PrnUnderReport
|
||||
|
||||
env.transport.transferInitPacket(ByteArray(14)).getOrThrow()
|
||||
// Send (PRN_INTERVAL_PACKETS + 1) packets of 20 bytes — guarantees a PRN window fires
|
||||
// before the firmware completes, so the under-reported byte count surfaces as a mismatch.
|
||||
val firmwareSize = (LegacyDfuTransport.PRN_INTERVAL_PACKETS + 1) * 20
|
||||
val result = env.transport.transferFirmware(ByteArray(firmwareSize)) {}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertIs<LegacyDfuException.PacketReceiptMismatch>(result.exceptionOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transferFirmware tolerates ACTIVATE_AND_RESET write failure - disconnect race`() = runTest {
|
||||
val env = createConnectedTransport()
|
||||
env.responder.scheme = LegacyResponderScheme.HappyPath
|
||||
// After VALIDATE response is sent, ACTIVATE write should be treated as success even if the device throws.
|
||||
env.responder.failOnActivateWrite = true
|
||||
|
||||
env.transport.transferInitPacket(ByteArray(14)).getOrThrow()
|
||||
val result = env.transport.transferFirmware(ByteArray(40)) {}
|
||||
|
||||
assertTrue(
|
||||
result.isSuccess,
|
||||
"ACTIVATE write failure must be treated as success; got: ${result.exceptionOrNull()}",
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Abort
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `abort writes RESET opcode through control point`() = runTest {
|
||||
val connection = FakeBleConnection()
|
||||
val transport =
|
||||
LegacyDfuTransport(FakeBleScanner(), FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined)
|
||||
|
||||
transport.abort()
|
||||
|
||||
val write = connection.service.writes.single()
|
||||
assertEquals(LegacyDfuUuids.CONTROL_POINT, write.characteristic.uuid)
|
||||
assertContentEquals(byteArrayOf(LegacyDfuOpcode.RESET), write.data)
|
||||
assertEquals(BleWriteType.WITH_RESPONSE, write.writeType)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test infrastructure
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private class TestEnv(val transport: LegacyDfuTransport, val service: AutoRespondingLegacyService) {
|
||||
val responder = service.responder
|
||||
|
||||
fun controlPointWrites(): List<FakeBleWrite> =
|
||||
service.delegate.writes.filter { it.characteristic.uuid == LegacyDfuUuids.CONTROL_POINT }
|
||||
|
||||
fun packetWrites(): List<FakeBleWrite> =
|
||||
service.delegate.writes.filter { it.characteristic.uuid == LEGACY_DFU_PACKET_UUID }
|
||||
}
|
||||
|
||||
private suspend fun createConnectedTransport(mtu: Int? = null): TestEnv {
|
||||
val scanner = FakeBleScanner()
|
||||
val fakeConnection = FakeBleConnection().apply { maxWriteValueLength = mtu }
|
||||
val autoService = AutoRespondingLegacyService(fakeConnection.service)
|
||||
val wrappedConnection = AutoRespondingBleConnection(fakeConnection, autoService)
|
||||
val factory =
|
||||
object : BleConnectionFactory {
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = wrappedConnection
|
||||
}
|
||||
val transport = LegacyDfuTransport(scanner, factory, address, Dispatchers.Unconfined)
|
||||
|
||||
scanner.emitDevice(FakeBleDevice(dfuAddress))
|
||||
transport.connectToDfuMode().getOrThrow()
|
||||
return TestEnv(transport, autoService)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the simulated bootloader response stream. After each `write()` to control point or packet, this service
|
||||
* synthesises the appropriate notification(s) on the control-point characteristic so the transport's pending
|
||||
* `awaitResponse` / `awaitPacketReceipt` calls unblock.
|
||||
*/
|
||||
private class AutoRespondingLegacyService(val delegate: FakeBleService) : BleService {
|
||||
val responder = LegacyResponder()
|
||||
|
||||
override fun hasCharacteristic(c: BleCharacteristic) = delegate.hasCharacteristic(c)
|
||||
|
||||
override fun observe(c: BleCharacteristic): Flow<ByteArray> = delegate.observe(c)
|
||||
|
||||
override suspend fun read(c: BleCharacteristic): ByteArray = delegate.read(c)
|
||||
|
||||
override fun preferredWriteType(c: BleCharacteristic): BleWriteType = delegate.preferredWriteType(c)
|
||||
|
||||
override suspend fun write(c: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
|
||||
delegate.write(c, data, writeType)
|
||||
val response = responder.onWrite(c.uuid, data) ?: return
|
||||
response.forEach { delegate.emitNotification(LegacyDfuUuids.CONTROL_POINT, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** What the simulated bootloader is meant to do for this test case. */
|
||||
enum class LegacyResponderScheme {
|
||||
HappyPath,
|
||||
RejectStart,
|
||||
RejectInit,
|
||||
RejectValidate,
|
||||
PrnUnderReport,
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesises Legacy DFU notifications based on the transport's current write. Behaviour depends on [scheme]:
|
||||
* - `HappyPath`: returns Success for every control-point opcode that expects a response, plus accurate PRN receipts
|
||||
* as packets accumulate.
|
||||
* - Reject* variants: return Failure with the indicated status for the targeted opcode.
|
||||
* - `PrnUnderReport`: at the first PRN window, report bytesReceived = actual − 1 to trigger PacketReceiptMismatch.
|
||||
*
|
||||
* Image-sizes write (12B on packet) is the trigger for the START_DFU response — matching the real protocol where
|
||||
* the device responds *after* it sees both the opcode and the size payload.
|
||||
*/
|
||||
class LegacyResponder {
|
||||
var scheme: LegacyResponderScheme = LegacyResponderScheme.HappyPath
|
||||
var failOnActivateWrite: Boolean = false
|
||||
|
||||
private var packetBytesReceived = 0L
|
||||
private var packetsSinceLastPrn = 0
|
||||
private var firmwareTransferStarted = false
|
||||
private var imageSizesWritten = false
|
||||
private var expectedFirmwareSize: Int = 0
|
||||
|
||||
fun onWrite(uuid: kotlin.uuid.Uuid, data: ByteArray): List<ByteArray>? = when (uuid) {
|
||||
LegacyDfuUuids.CONTROL_POINT -> handleControlWrite(data)
|
||||
LEGACY_DFU_PACKET_UUID -> handlePacketWrite(data)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private fun handleControlWrite(data: ByteArray): List<ByteArray>? {
|
||||
if (data.isEmpty()) return null
|
||||
val opcode = data[0]
|
||||
return when (opcode) {
|
||||
LegacyDfuOpcode.START_DFU -> null // response comes after image sizes packet write
|
||||
LegacyDfuOpcode.INIT_DFU_PARAMS -> {
|
||||
if (data.size >= 2 && data[1] == LegacyDfuOpcode.INIT_PARAMS_COMPLETE) {
|
||||
listOf(initResponse())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ -> null
|
||||
LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE -> {
|
||||
firmwareTransferStarted = true
|
||||
null
|
||||
}
|
||||
LegacyDfuOpcode.VALIDATE -> listOf(validateResponse())
|
||||
LegacyDfuOpcode.ACTIVATE_AND_RESET -> {
|
||||
if (failOnActivateWrite) {
|
||||
throw RuntimeException("Simulated disconnect during ACTIVATE write")
|
||||
}
|
||||
null
|
||||
}
|
||||
LegacyDfuOpcode.RESET -> null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePacketWrite(data: ByteArray): List<ByteArray>? {
|
||||
// First packet write is the 12-byte image sizes payload (after START_DFU).
|
||||
if (!imageSizesWritten) {
|
||||
imageSizesWritten = true
|
||||
// Parse appSize from bytes 8..11 (LE u32).
|
||||
if (data.size >= 12) {
|
||||
expectedFirmwareSize =
|
||||
(data[8].toInt() and 0xFF) or
|
||||
((data[9].toInt() and 0xFF) shl 8) or
|
||||
((data[10].toInt() and 0xFF) shl 16) or
|
||||
((data[11].toInt() and 0xFF) shl 24)
|
||||
}
|
||||
return listOf(startResponse())
|
||||
}
|
||||
|
||||
if (firmwareTransferStarted) {
|
||||
packetBytesReceived += data.size
|
||||
packetsSinceLastPrn++
|
||||
val responses = mutableListOf<ByteArray>()
|
||||
val firmwareDone = packetBytesReceived >= expectedFirmwareSize
|
||||
if (packetsSinceLastPrn >= LegacyDfuTransport.PRN_INTERVAL_PACKETS && !firmwareDone) {
|
||||
packetsSinceLastPrn = 0
|
||||
val reported =
|
||||
if (scheme == LegacyResponderScheme.PrnUnderReport) {
|
||||
packetBytesReceived - 1
|
||||
} else {
|
||||
packetBytesReceived
|
||||
}
|
||||
responses += packetReceipt(reported)
|
||||
}
|
||||
if (firmwareDone) {
|
||||
responses += success(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE)
|
||||
}
|
||||
return responses.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
// Init-packet writes between START and COMPLETE: silent.
|
||||
return null
|
||||
}
|
||||
|
||||
private fun startResponse(): ByteArray = when (scheme) {
|
||||
LegacyResponderScheme.RejectStart ->
|
||||
byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, LegacyDfuOpcode.START_DFU, LegacyDfuStatus.NOT_SUPPORTED)
|
||||
else -> success(LegacyDfuOpcode.START_DFU)
|
||||
}
|
||||
|
||||
private fun initResponse(): ByteArray = when (scheme) {
|
||||
LegacyResponderScheme.RejectInit ->
|
||||
byteArrayOf(
|
||||
LegacyDfuOpcode.RESPONSE_CODE,
|
||||
LegacyDfuOpcode.INIT_DFU_PARAMS,
|
||||
LegacyDfuStatus.OPERATION_FAILED,
|
||||
)
|
||||
else -> success(LegacyDfuOpcode.INIT_DFU_PARAMS)
|
||||
}
|
||||
|
||||
private fun validateResponse(): ByteArray = when (scheme) {
|
||||
LegacyResponderScheme.RejectValidate ->
|
||||
byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, LegacyDfuOpcode.VALIDATE, LegacyDfuStatus.CRC_ERROR)
|
||||
else -> success(LegacyDfuOpcode.VALIDATE)
|
||||
}
|
||||
|
||||
private fun packetReceipt(bytesReceived: Long): ByteArray = byteArrayOf(
|
||||
LegacyDfuOpcode.PACKET_RECEIPT,
|
||||
(bytesReceived and 0xFF).toByte(),
|
||||
((bytesReceived ushr 8) and 0xFF).toByte(),
|
||||
((bytesReceived ushr 16) and 0xFF).toByte(),
|
||||
((bytesReceived ushr 24) and 0xFF).toByte(),
|
||||
)
|
||||
|
||||
private fun success(opcode: Byte): ByteArray =
|
||||
byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, opcode, LegacyDfuStatus.SUCCESS)
|
||||
}
|
||||
|
||||
/** BleConnection wrapper that swaps in the auto-responding service for `profile()` calls. */
|
||||
private class AutoRespondingBleConnection(
|
||||
private val delegate: FakeBleConnection,
|
||||
val autoService: AutoRespondingLegacyService,
|
||||
) : BleConnection {
|
||||
override val device: BleDevice?
|
||||
get() = delegate.device
|
||||
|
||||
override val deviceFlow: SharedFlow<BleDevice?>
|
||||
get() = delegate.deviceFlow
|
||||
|
||||
override val connectionState: SharedFlow<BleConnectionState>
|
||||
get() = delegate.connectionState
|
||||
|
||||
override suspend fun connect(device: BleDevice) = delegate.connect(device)
|
||||
|
||||
override suspend fun connectAndAwait(device: BleDevice, timeout: Duration) =
|
||||
delegate.connectAndAwait(device, timeout)
|
||||
|
||||
override suspend fun disconnect() = delegate.disconnect()
|
||||
|
||||
override suspend fun <T> profile(
|
||||
serviceUuid: kotlin.uuid.Uuid,
|
||||
timeout: Duration,
|
||||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T = CoroutineScope(Dispatchers.Unconfined).setup(autoService)
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? =
|
||||
delegate.maximumWriteValueLength(writeType)
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,35 @@ class SecureDfuTransportTest {
|
||||
assertEquals(1, connection.disconnectCalls)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: Connect to DFU mode
|
||||
// -----------------------------------------------------------------------
|
||||
@Test
|
||||
fun `triggerButtonlessDfu falls back to legacy DFU service when secure FE59 is missing`() = runTest {
|
||||
val scanner = FakeBleScanner()
|
||||
val connection = FakeBleConnection().apply { missingServices += SecureDfuUuids.SERVICE }
|
||||
val transport =
|
||||
SecureDfuTransport(
|
||||
scanner = scanner,
|
||||
connectionFactory = FakeBleConnectionFactory(connection),
|
||||
address = address,
|
||||
dispatcher = kotlinx.coroutines.Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
scanner.emitDevice(FakeBleDevice(address))
|
||||
|
||||
val result = transport.triggerButtonlessDfu()
|
||||
|
||||
assertTrue(result.isSuccess, "Legacy fallback should succeed when FE59 is absent")
|
||||
// No write should have hit the secure characteristic.
|
||||
assertTrue(
|
||||
connection.service.writes.none { it.characteristic.uuid == SecureDfuUuids.BUTTONLESS_NO_BONDS },
|
||||
"Should not write to secure buttonless characteristic when FE59 is missing",
|
||||
)
|
||||
// Exactly one write of 0x01 (START_DFU) should have hit the legacy control point.
|
||||
val legacyWrites = connection.service.writes.filter { it.characteristic.uuid == LegacyDfuUuids.CONTROL_POINT }
|
||||
assertEquals(1, legacyWrites.size, "Should have exactly one legacy DFU trigger write")
|
||||
assertContentEquals(byteArrayOf(0x01, 0x04), legacyWrites.single().data)
|
||||
assertEquals(BleWriteType.WITH_RESPONSE, legacyWrites.single().writeType)
|
||||
assertEquals(1, connection.disconnectCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connectToDfuMode succeeds using shared BleService observation`() = runTest {
|
||||
|
||||
Reference in New Issue
Block a user