feat(firmware): nRF52 BLE Legacy DFU support (#5209)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-04-22 10:12:15 -05:00
committed by GitHub
parent 6b0fcc771c
commit f22e5a70d9
17 changed files with 1795 additions and 87 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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