mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-13 21:59:17 -04:00
fix(firmware): show the erase wait and upload retries during Legacy DFU (#6812)
This commit is contained in:
1 parent
35c13122da
commit
a276cda62c
7 files changed
+208
-43
No files matched your search
Generated
+2
@@ -713,6 +713,7 @@ firmware_update_open
|
||||
firmware_update_open_flasher
|
||||
firmware_update_ota_failed
|
||||
firmware_update_ota_unsupported_reason
|
||||
firmware_update_preparing_flash
|
||||
firmware_update_rak4631_bootloader_hint
|
||||
firmware_update_rebooting
|
||||
firmware_update_release_notes
|
||||
@@ -721,6 +722,7 @@ firmware_update_requires_ota_zip
|
||||
firmware_update_requires_uf2
|
||||
firmware_update_retrieval_failed
|
||||
firmware_update_retry
|
||||
firmware_update_retrying_upload
|
||||
firmware_update_save_dfu_file
|
||||
firmware_update_searching_device
|
||||
firmware_update_select_file
|
||||
|
||||
@@ -743,6 +743,7 @@
|
||||
<string name="firmware_update_open_flasher">Open Meshtastic Flasher</string>
|
||||
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
|
||||
<string name="firmware_update_ota_unsupported_reason">This device's OTA loader rejected the requested update method: %1$s</string>
|
||||
<string name="firmware_update_preparing_flash">Preparing device: erasing flash. This can take up to a minute...</string>
|
||||
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, the vendor's bootloader .zip has to be flashed with a serial DFU tool such as adafruit-nrfutil — copying that .zip to the device's drive won't work. Alternatively, connect this device over USB and use the bootloader upgrade in this app.</string>
|
||||
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
|
||||
<string name="firmware_update_release_notes">Release Notes</string>
|
||||
@@ -751,6 +752,7 @@
|
||||
<string name="firmware_update_requires_uf2">%1$s requires a target-matching .uf2 file.</string>
|
||||
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
|
||||
<string name="firmware_update_retry">Retry</string>
|
||||
<string name="firmware_update_retrying_upload">Device did not respond. Retrying upload (attempt %1$d/%2$d)...</string>
|
||||
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device's DFU drive.</string>
|
||||
<string name="firmware_update_searching_device">Searching for OTA device on the network...</string>
|
||||
<string name="firmware_update_select_file">Select Local File</string>
|
||||
|
||||
+20
@@ -43,6 +43,17 @@ interface DfuUploadTransport {
|
||||
*/
|
||||
suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit>
|
||||
|
||||
/**
|
||||
* As [transferFirmware], additionally reporting coarse [DfuUploadPhase] transitions so the UI can explain a wait
|
||||
* that produces no progress (Legacy DFU erases the whole application region before it accepts a single byte).
|
||||
* Transports without a meaningful prepare step keep the default, which never reports a phase.
|
||||
*/
|
||||
suspend fun transferFirmware(
|
||||
firmware: ByteArray,
|
||||
onPhase: suspend (DfuUploadPhase) -> Unit,
|
||||
onProgress: suspend (Float) -> Unit,
|
||||
): Result<Unit> = transferFirmware(firmware, onProgress)
|
||||
|
||||
/**
|
||||
* Best-effort abort. Operational transport exceptions are swallowed; structured-concurrency cancellation and Error
|
||||
* subtypes propagate.
|
||||
@@ -52,3 +63,12 @@ interface DfuUploadTransport {
|
||||
/** Disconnect and release resources. */
|
||||
suspend fun close()
|
||||
}
|
||||
|
||||
/** Coarse phases inside [DfuUploadTransport.transferFirmware], for UI that must explain a silent wait. */
|
||||
enum class DfuUploadPhase {
|
||||
/** Start request sent; the bootloader is erasing flash and will not accept data until it has finished. */
|
||||
PREPARING,
|
||||
|
||||
/** The bootloader accepted the start; firmware bytes are streaming and progress callbacks follow. */
|
||||
STREAMING,
|
||||
}
|
||||
+51
-41
@@ -237,58 +237,68 @@ internal constructor(
|
||||
* acknowledgement; treat that operational Exception as expected success (structured cancellation and Error
|
||||
* subtypes still propagate).
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit> =
|
||||
safeCatching {
|
||||
val initPacket =
|
||||
pendingInitPacket
|
||||
?: throw DfuException.TransferFailed("transferInitPacket must be called before transferFirmware")
|
||||
Logger.i { "Legacy DFU: Starting upload (init=${initPacket.size}B, firmware=${firmware.size}B)..." }
|
||||
transferFirmware(firmware, onPhase = {}, onProgress = onProgress)
|
||||
|
||||
// ── 1. START_DFU + image sizes on Packet, then response ─────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.START_DFU, LegacyDfuImageType.APPLICATION))
|
||||
writePacket(legacyImageSizesPayload(appSize = firmware.size))
|
||||
handleStartResponse(awaitResponse(START_RESPONSE_TIMEOUT))
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun transferFirmware(
|
||||
firmware: ByteArray,
|
||||
onPhase: suspend (DfuUploadPhase) -> Unit,
|
||||
onProgress: suspend (Float) -> Unit,
|
||||
): Result<Unit> = safeCatching {
|
||||
val initPacket =
|
||||
pendingInitPacket
|
||||
?: throw DfuException.TransferFailed("transferInitPacket must be called before transferFirmware")
|
||||
Logger.i { "Legacy DFU: Starting upload (init=${initPacket.size}B, firmware=${firmware.size}B)..." }
|
||||
|
||||
// ── 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))
|
||||
// ── 1. START_DFU + image sizes on Packet, then response ─────────────
|
||||
// The bootloader erases the application region before it answers; without lazy erase that is tens of
|
||||
// seconds of silence, so tell the UI what the wait is.
|
||||
onPhase(DfuUploadPhase.PREPARING)
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.START_DFU, LegacyDfuImageType.APPLICATION))
|
||||
writePacket(legacyImageSizesPayload(appSize = firmware.size))
|
||||
handleStartResponse(awaitResponse(START_RESPONSE_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" }
|
||||
// ── 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))
|
||||
|
||||
// ── 3. PRN setup ────────────────────────────────────────────────────
|
||||
writeControlPoint(legacyPrnRequestPayload(streamProfile.prnIntervalPackets))
|
||||
// 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" }
|
||||
|
||||
// ── 4. RECEIVE_FIRMWARE_IMAGE ──────────────────────────────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE))
|
||||
// ── 3. PRN setup ────────────────────────────────────────────────────
|
||||
writeControlPoint(legacyPrnRequestPayload(streamProfile.prnIntervalPackets))
|
||||
|
||||
// ── 5. Stream firmware ─────────────────────────────────────────────
|
||||
streamFirmware(firmware, onProgress)
|
||||
// ── 4. RECEIVE_FIRMWARE_IMAGE ──────────────────────────────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE))
|
||||
|
||||
// ── 6. Final RECEIVE_FIRMWARE_IMAGE response ────────────────────────
|
||||
requireSuccess(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE, awaitResponse(VALIDATE_TIMEOUT))
|
||||
// ── 5. Stream firmware ─────────────────────────────────────────────
|
||||
onPhase(DfuUploadPhase.STREAMING)
|
||||
streamFirmware(firmware, onProgress)
|
||||
|
||||
// ── 7. VALIDATE ────────────────────────────────────────────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.VALIDATE))
|
||||
requireSuccess(LegacyDfuOpcode.VALIDATE, awaitResponse(VALIDATE_TIMEOUT))
|
||||
// ── 6. Final RECEIVE_FIRMWARE_IMAGE response ────────────────────────
|
||||
requireSuccess(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE, awaitResponse(VALIDATE_TIMEOUT))
|
||||
|
||||
// ── 8. ACTIVATE_AND_RESET ──────────────────────────────────────────
|
||||
// The device may reset before the GATT write ACK lands; an ordinary disconnect/write Exception is expected
|
||||
// because of that reset — safeCatching treats it as success. Structured cancellation and Error subtypes
|
||||
// still propagate.
|
||||
Logger.i { "Legacy DFU: Sending ACTIVATE_AND_RESET (disconnect during write is expected)" }
|
||||
safeCatching { writeControlPoint(byteArrayOf(LegacyDfuOpcode.ACTIVATE_AND_RESET)) }
|
||||
.onFailure { Logger.i(it) { "Legacy DFU: ACTIVATE write reported failure (expected on reset)" } }
|
||||
// ── 7. VALIDATE ────────────────────────────────────────────────────
|
||||
writeControlPoint(byteArrayOf(LegacyDfuOpcode.VALIDATE))
|
||||
requireSuccess(LegacyDfuOpcode.VALIDATE, awaitResponse(VALIDATE_TIMEOUT))
|
||||
|
||||
onProgress(1f)
|
||||
Logger.i { "Legacy DFU: Upload complete, device rebooting into new firmware." }
|
||||
}
|
||||
// ── 8. ACTIVATE_AND_RESET ──────────────────────────────────────────
|
||||
// The device may reset before the GATT write ACK lands; an ordinary disconnect/write Exception is expected
|
||||
// because of that reset — safeCatching treats it as success. Structured cancellation and Error subtypes
|
||||
// still propagate.
|
||||
Logger.i { "Legacy DFU: Sending ACTIVATE_AND_RESET (disconnect during write is expected)" }
|
||||
safeCatching { 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." }
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-speed when the bootloader did not negotiate a larger MTU, leaving us on the 20-byte packet floor. Valid once
|
||||
|
||||
+31
-2
@@ -39,6 +39,8 @@ import org.meshtastic.core.resources.firmware_update_downloading_percent
|
||||
import org.meshtastic.core.resources.firmware_update_enabling_dfu
|
||||
import org.meshtastic.core.resources.firmware_update_not_found_in_release
|
||||
import org.meshtastic.core.resources.firmware_update_ota_failed
|
||||
import org.meshtastic.core.resources.firmware_update_preparing_flash
|
||||
import org.meshtastic.core.resources.firmware_update_retrying_upload
|
||||
import org.meshtastic.core.resources.firmware_update_slow_bootloader_hint
|
||||
import org.meshtastic.core.resources.firmware_update_starting_dfu
|
||||
import org.meshtastic.core.resources.firmware_update_uploading
|
||||
@@ -252,6 +254,19 @@ private fun DfuProtocolKind.serviceUuid(): Uuid = when (this) {
|
||||
DfuProtocolKind.SECURE -> SecureDfuUuids.SERVICE
|
||||
}
|
||||
|
||||
/**
|
||||
* UI state for a [DfuUploadPhase] reported by the transport during the firmware transfer. PREPARING is a silent wait
|
||||
* the user would otherwise read as a hang, so it is a [FirmwareUpdateState.Processing] with its own copy rather than an
|
||||
* "Uploading 0%" that never moves.
|
||||
*/
|
||||
internal fun dfuUploadPhaseState(phase: DfuUploadPhase, uploadMsg: UiText, slowHint: UiText?): FirmwareUpdateState =
|
||||
when (phase) {
|
||||
DfuUploadPhase.PREPARING ->
|
||||
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_preparing_flash)))
|
||||
|
||||
DfuUploadPhase.STREAMING -> FirmwareUpdateState.Updating(ProgressState(uploadMsg, 0f, hint = slowHint))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the bounded Legacy/Secure DFU upload retry loop. Extracted from [SecureDfuHandler.runDfuUploadWithRetry] so
|
||||
* the retry policy can be unit-tested without bringing up the full BLE stack — callers supply a [runUploadSession]
|
||||
@@ -283,6 +298,7 @@ internal suspend fun runDfuRetryLoop(
|
||||
runUploadSession: suspend (LegacyDfuStreamProfile) -> DfuUploadResult,
|
||||
resetStaleBootloader: suspend () -> Unit,
|
||||
interAttemptDelay: suspend () -> Unit,
|
||||
onRetryScheduled: (nextAttempt: Int, totalAttempts: Int) -> Unit = { _, _ -> },
|
||||
): DfuUploadResult {
|
||||
var uploadAttempts = 0
|
||||
var staleResets = 0
|
||||
@@ -375,7 +391,10 @@ internal suspend fun runDfuRetryLoop(
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadAttempts < activeAttempts) interAttemptDelay()
|
||||
if (uploadAttempts < activeAttempts) {
|
||||
onRetryScheduled(uploadAttempts + 1, activeAttempts)
|
||||
interAttemptDelay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,6 +657,13 @@ class SecureDfuHandler(
|
||||
runUploadSession = { profile -> runUploadSession(protocol, target, pkg, profile, updateState) },
|
||||
resetStaleBootloader = { resetStaleBootloader(protocol, target) },
|
||||
interAttemptDelay = { delay(SESSION_RETRY_DELAY_MS) },
|
||||
onRetryScheduled = { next, total ->
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(UiText.Resource(Res.string.firmware_update_retrying_upload, next, total)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun createTransport(
|
||||
@@ -714,7 +740,10 @@ class SecureDfuHandler(
|
||||
val firmwareSize = pkg.firmware.size
|
||||
val throughputTracker = ThroughputTracker()
|
||||
transport
|
||||
.transferFirmware(pkg.firmware) { progress ->
|
||||
.transferFirmware(
|
||||
pkg.firmware,
|
||||
onPhase = { phase -> updateState(dfuUploadPhaseState(phase, uploadMsg, slowHint)) },
|
||||
) { progress ->
|
||||
val bytesSent = (progress * firmwareSize).toLong()
|
||||
throughputTracker.record(bytesSent)
|
||||
val details = formatTransferProgress(progress, firmwareSize, throughputTracker.bytesPerSecond())
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota.dfu
|
||||
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateState
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class DfuUploadPhaseStateTest {
|
||||
private val uploadMsg = UiText.DynamicString("Uploading firmware...")
|
||||
private val hint = UiText.DynamicString("slow bootloader")
|
||||
|
||||
@Test
|
||||
fun `PREPARING is a Processing state rather than Uploading at zero`() {
|
||||
val state = dfuUploadPhaseState(DfuUploadPhase.PREPARING, uploadMsg, hint)
|
||||
assertIs<FirmwareUpdateState.Processing>(state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STREAMING returns to Uploading at zero with the slow hint preserved`() {
|
||||
val state = dfuUploadPhaseState(DfuUploadPhase.STREAMING, uploadMsg, hint)
|
||||
assertIs<FirmwareUpdateState.Updating>(state)
|
||||
assertEquals(uploadMsg, state.progressState.message)
|
||||
assertEquals(0f, state.progressState.progress)
|
||||
assertEquals(hint, state.progressState.hint)
|
||||
}
|
||||
}
|
||||
+59
@@ -195,6 +195,65 @@ class LegacyDfuRetryPolicyTest {
|
||||
assertEquals(0, staleResets, "ordinary failures must NOT trigger stale cleanup")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every retry that is actually going to happen is announced (next attempt number, total) before the inter-attempt
|
||||
* delay, and the exhausted last attempt is not — the UI must never promise a retry that will not come.
|
||||
*/
|
||||
@Test
|
||||
fun `retries are announced before the delay and not after the last attempt`() = runTest {
|
||||
val announced = mutableListOf<Pair<Int, Int>>()
|
||||
val order = mutableListOf<String>()
|
||||
|
||||
val outcome =
|
||||
runDfuRetryLoop(
|
||||
protocol = DfuProtocolKind.LEGACY,
|
||||
budget = DfuAttemptBudget(LEGACY_SESSION_ATTEMPTS, LEGACY_SESSION_ATTEMPTS),
|
||||
maxStaleResets = MAX_LEGACY_STALE_RESETS,
|
||||
runUploadSession = {
|
||||
order.add("upload")
|
||||
DfuUploadResult.Failure(DfuException.TransferFailed("ordinary failure"), false)
|
||||
},
|
||||
resetStaleBootloader = {},
|
||||
interAttemptDelay = { order.add("delay") },
|
||||
onRetryScheduled = { next, total ->
|
||||
order.add("announce")
|
||||
announced.add(next to total)
|
||||
},
|
||||
)
|
||||
|
||||
assertIs<DfuUploadResult.Failure>(outcome)
|
||||
assertEquals(listOf(2 to 3, 3 to 3), announced)
|
||||
assertEquals(listOf("upload", "announce", "delay", "upload", "announce", "delay", "upload"), order)
|
||||
}
|
||||
|
||||
/** A stale-session cleanup is not a retry of the upload and must not be announced as one. */
|
||||
@Test
|
||||
fun `stale cleanup is not announced as a retry`() = runTest {
|
||||
var announcements = 0
|
||||
var sessions = 0
|
||||
|
||||
val outcome =
|
||||
runDfuRetryLoop(
|
||||
protocol = DfuProtocolKind.LEGACY,
|
||||
budget = DfuAttemptBudget(LEGACY_SESSION_ATTEMPTS, LEGACY_SESSION_ATTEMPTS),
|
||||
maxStaleResets = MAX_LEGACY_STALE_RESETS,
|
||||
runUploadSession = {
|
||||
sessions++
|
||||
if (sessions == 1) {
|
||||
DfuUploadResult.Failure(LegacyDfuException.StaleSessionReset(), true)
|
||||
} else {
|
||||
DfuUploadResult.Success
|
||||
}
|
||||
},
|
||||
resetStaleBootloader = {},
|
||||
interAttemptDelay = {},
|
||||
onRetryScheduled = { _, _ -> announcements++ },
|
||||
)
|
||||
|
||||
assertEquals(DfuUploadResult.Success, outcome)
|
||||
assertEquals(0, announcements)
|
||||
}
|
||||
|
||||
/**
|
||||
* A StaleSessionReset must NOT consume an upload attempt — the cleanup cycle never tried to upload. After the stale
|
||||
* cleanup, the same upload-attempt budget remains, and a subsequent successful attempt succeeds.
|
||||
|
||||
Reference in new issue
Block a user