diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index b0617635a..018d8f9fc 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -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) +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt index 59cf134de..5a8b67ce1 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -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. */ diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index f658d234c..f3b6d9383 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -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) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index d27ba2225..a1f0baaef 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -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 diff --git a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt index 3ad0b6c4d..85eb4fe7f 100644 --- a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt +++ b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt @@ -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 diff --git a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 99ff6885c..462d7345d 100644 --- a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -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 diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt index e5280ec45..f2001da86 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt @@ -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 = 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 } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index f8ff9fcac..447944772 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -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 diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuUploadTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuUploadTransport.kt new file mode 100644 index 000000000..10ae352dc --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuUploadTransport.kt @@ -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 . + */ +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 + + /** Upload the init packet (`.dat`) and have the device validate it. */ + suspend fun transferInitPacket(initPacket: ByteArray): Result + + /** + * 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 + + /** Best-effort abort of any in-flight transfer (for cancellation / error recovery). Never throws. */ + suspend fun abort() + + /** Disconnect and release resources. */ + suspend fun close() +} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocol.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocol.kt new file mode 100644 index 000000000..8dd330f99 --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocol.kt @@ -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 . + */ +@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") +} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt new file mode 100644 index 000000000..f17982c88 --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt @@ -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 . + */ +@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(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 = 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() + 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 = 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 = + 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 (`_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 + // `_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 + } +} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index a2eb5a7a4..ae88e9645 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -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 + } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt index 4dbeba18a..b90910015 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt @@ -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 // --------------------------------------------------------------------------- diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index 42e92c8ac..c9ebac27c 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -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 = 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(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) -> Unit)?, + ) { + try { + withTimeout(profileTimeout) { + bleConnection.profile(serviceUuid, timeout = profileTimeout) { service -> + val char = service.characteristic(characteristicUuid) + + val channel = Channel(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 = safeCatching { + override suspend fun connectToDfuMode(): Result = 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 = safeCatching { + override suspend fun transferInitPacket(initPacket: ByteArray): Result = 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 = + override suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = 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 diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocolTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocolTest.kt new file mode 100644 index 000000000..2b209402f --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocolTest.kt @@ -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 . + */ +@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(r) + assertEquals(LegacyDfuOpcode.START_DFU, r.requestOpcode) + } + + @Test + fun `parse Failure response`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x02, 0x06)) + assertIs(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(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(r) + assertEquals(0xFF000000L, r.bytesReceived) + } + + @Test + fun `parse Unknown for unrecognised prefix`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x42, 0x99.toByte())) + assertIs(r) + } + + @Test + fun `parse Unknown for empty bytes`() { + val r = LegacyDfuResponse.parse(byteArrayOf()) + assertIs(r) + } + + @Test + fun `parse Unknown for too-short response`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x01)) + assertIs(r) + } + + @Test + fun `parse Unknown for too-short packet receipt`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x11, 0x01, 0x02)) + assertIs(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) + } +} diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt new file mode 100644 index 000000000..4504e460d --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt @@ -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 . + */ +@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(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(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() + 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(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(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(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(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 = + service.delegate.writes.filter { it.characteristic.uuid == LegacyDfuUuids.CONTROL_POINT } + + fun packetWrites(): List = + 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 = 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? = when (uuid) { + LegacyDfuUuids.CONTROL_POINT -> handleControlWrite(data) + LEGACY_DFU_PACKET_UUID -> handlePacketWrite(data) + else -> null + } + + @Suppress("ReturnCount") + private fun handleControlWrite(data: ByteArray): List? { + 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? { + // 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() + 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 + get() = delegate.deviceFlow + + override val connectionState: SharedFlow + 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 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) + } +} diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt index da8f84057..454aadaa2 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt @@ -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 {