fix(firmware): surface error state when BLE OTA connection attempts are exhausted (#5700)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-06-01 12:04:20 -05:00
committed by GitHub
parent 409cdb7873
commit cc3b88d005
3 changed files with 39 additions and 20 deletions
@@ -237,10 +237,20 @@ class FirmwareUpdateViewModel(
updateState = { _state.value = it },
)
if (_state.value is FirmwareUpdateState.Success) {
verifyUpdateResult(originalDeviceAddress)
} else if (_state.value is FirmwareUpdateState.Error) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
when (_state.value) {
is FirmwareUpdateState.Success -> verifyUpdateResult(originalDeviceAddress)
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
else -> {
// Defense-in-depth: handler returned without setting a terminal state
Logger.w { "Firmware update returned without terminal state: ${_state.value}" }
_state.value =
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
} catch (e: CancellationException) {
Logger.w(e) { "Firmware update cancelled — cause: ${e.cause} message: ${e.message}" }
@@ -315,10 +325,18 @@ class FirmwareUpdateViewModel(
)
tempFirmwareFile = updateArtifact ?: extractedFile
if (_state.value is FirmwareUpdateState.Success) {
verifyUpdateResult(originalDeviceAddress)
} else if (_state.value is FirmwareUpdateState.Error) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
when (_state.value) {
is FirmwareUpdateState.Success -> verifyUpdateResult(originalDeviceAddress)
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
else -> {
Logger.w { "Firmware update returned without terminal state: ${_state.value}" }
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
} catch (e: CancellationException) {
throw e
@@ -156,7 +156,7 @@ class Esp32OtaUpdateHandler(
delay(GATT_RELEASE_DELAY_MS)
val transport = transportFactory()
if (!connectToDevice(transport, connectionAttempts, updateState)) return@withContext null
connectToDevice(transport, connectionAttempts, updateState)
try {
executeOtaSequence(transport, firmwareBytes, sha256Hash, rebootMode, updateState)
@@ -255,7 +255,7 @@ class Esp32OtaUpdateHandler(
transport: UnifiedOtaProtocol,
attempts: Int,
updateState: (FirmwareUpdateState) -> Unit,
): Boolean {
) {
// Show "waiting for reboot" state before first connection attempt
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
@@ -269,13 +269,12 @@ class Esp32OtaUpdateHandler(
),
)
transport.connect().getOrThrow()
return true
return
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
if (i == attempts) throw e
delay(RETRY_DELAY)
}
}
return false
}
@Suppress("LongMethod")
@@ -141,7 +141,7 @@ class SecureDfuHandler(
var completed = false
try {
// ── 5. Connect to device in DFU mode ─────────────────────────────
if (!connectWithRetry(transport, updateState)) return@withContext null
connectWithRetry(transport, updateState)
// ── 6. Init packet ────────────────────────────────────────────
updateState(
@@ -252,13 +252,11 @@ class SecureDfuHandler(
return if (legacyHit != null) DfuProtocolKind.LEGACY else DfuProtocolKind.SECURE
}
private suspend fun connectWithRetry(
transport: DfuUploadTransport,
updateState: (FirmwareUpdateState) -> Unit,
): Boolean {
private suspend fun connectWithRetry(transport: DfuUploadTransport, updateState: (FirmwareUpdateState) -> Unit) {
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
)
var lastError: Throwable? = null
for (attempt in 1..CONNECT_ATTEMPTS) {
updateState(
FirmwareUpdateState.Processing(
@@ -269,12 +267,16 @@ class SecureDfuHandler(
)
val result = transport.connectToDfuMode()
if (result.isSuccess) {
return true
return
}
Logger.w { "DFU: Connect attempt $attempt/$CONNECT_ATTEMPTS failed: ${result.exceptionOrNull()?.message}" }
lastError = result.exceptionOrNull()
Logger.w { "DFU: Connect attempt $attempt/$CONNECT_ATTEMPTS failed: ${lastError?.message}" }
if (attempt < CONNECT_ATTEMPTS) delay(RETRY_DELAY_MS)
}
return false
throw DfuException.ConnectionFailed(
"Failed to connect to DFU device after $CONNECT_ATTEMPTS attempts",
lastError,
)
}
private suspend fun obtainZipFile(