From dc1f53b6e1b16181e93299886b43bb6adea22b23 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Wed, 29 Apr 2026 19:59:11 +0200 Subject: [PATCH] feat: recover bootloader OTA transfers --- lib/pages/device_details_page.dart | 49 ++- lib/service/firmware_update_service.dart | 342 +++++++++++++----- .../service/firmware_update_service_test.dart | 165 +++++++++ 3 files changed, 445 insertions(+), 111 deletions(-) diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index 3f4db98..e9c201d 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -497,10 +497,6 @@ class _DeviceDetailsPageState extends ConsumerState { } Future _ensureFirmwareUpdateService() async { - final shifter = _shifterService; - if (shifter == null) { - return null; - } if (_firmwareUpdateService != null) { return _firmwareUpdateService; } @@ -513,7 +509,7 @@ class _DeviceDetailsPageState extends ConsumerState { final service = FirmwareUpdateService( transport: ShifterFirmwareUpdateTransport( - shifterService: shifter, + shifterService: _shifterService, bluetoothController: bluetooth, buttonDeviceId: widget.deviceAddress, ), @@ -585,7 +581,6 @@ class _DeviceDetailsPageState extends ConsumerState { return; } - await _startStatusStreamingIfNeeded(); final updater = await _ensureFirmwareUpdateService(); if (!mounted) { return; @@ -872,9 +867,10 @@ class _DeviceDetailsPageState extends ConsumerState { currentConnectionStatus == ConnectionStatus.connected; final hasDeviceAccess = isCurrentConnected && _shifterService != null && _latestStatus != null; + final canUseFirmwareUpdate = isCurrentConnected; final canSelectFirmware = - hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy; - final canStartFirmware = hasDeviceAccess && + canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy; + final canStartFirmware = canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy && _selectedFirmware != null; @@ -907,8 +903,26 @@ class _DeviceDetailsPageState extends ConsumerState { status: _latestStatus, ), const SizedBox(height: 20), - if (hasDeviceAccess) ...[ + if (canUseFirmwareUpdate) ...[ + _FirmwareUpdateCard( + selectedFirmware: _selectedFirmware, + progress: _dfuProgress, + isSelecting: _isSelectingFirmware, + isStarting: _isStartingFirmwareUpdate, + canSelect: canSelectFirmware, + canStart: canStartFirmware, + phaseText: _dfuPhaseText(_dfuProgress.state), + statusText: _firmwareUserMessage, + formattedProgressBytes: + '${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}', + onSelectFirmware: _selectFirmwareFile, + onStartUpdate: _startFirmwareUpdate, + expectedOffsetHex: + '0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}', + ), const SizedBox(height: 16), + ], + if (hasDeviceAccess) ...[ _StatusBanner( status: _latestStatus, onTap: _showStatusHistory, @@ -1009,23 +1023,6 @@ class _DeviceDetailsPageState extends ConsumerState { ), ), ), - const SizedBox(height: 16), - _FirmwareUpdateCard( - selectedFirmware: _selectedFirmware, - progress: _dfuProgress, - isSelecting: _isSelectingFirmware, - isStarting: _isStartingFirmwareUpdate, - canSelect: canSelectFirmware, - canStart: canStartFirmware, - phaseText: _dfuPhaseText(_dfuProgress.state), - statusText: _firmwareUserMessage, - formattedProgressBytes: - '${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}', - onSelectFirmware: _selectFirmwareFile, - onStartUpdate: _startFirmwareUpdate, - expectedOffsetHex: - '0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}', - ), ] else if (isCurrentConnected) ...[ _PairingRequiredCard( isChecking: _isPairingCheckRunning, diff --git a/lib/service/firmware_update_service.dart b/lib/service/firmware_update_service.dart index b2251c7..ae2196b 100644 --- a/lib/service/firmware_update_service.dart +++ b/lib/service/firmware_update_service.dart @@ -12,11 +12,13 @@ class FirmwareUpdateService { FirmwareUpdateService({ required FirmwareUpdateTransport transport, this.defaultStatusTimeout = const Duration(seconds: 2), - this.defaultBootloaderConnectTimeout = const Duration(seconds: 20), + this.defaultBootloaderConnectTimeout = const Duration(seconds: 60), this.defaultPostFinishResetTimeout = const Duration(seconds: 8), this.defaultReconnectTimeout = const Duration(seconds: 12), this.defaultVerificationTimeout = const Duration(seconds: 5), + this.queueFullBackoff = const Duration(milliseconds: 200), this.maxNoProgressRetries = 5, + this.maxReconnectResumeAttempts = 3, }) : _transport = transport; final FirmwareUpdateTransport _transport; @@ -25,7 +27,9 @@ class FirmwareUpdateService { final Duration defaultPostFinishResetTimeout; final Duration defaultReconnectTimeout; final Duration defaultVerificationTimeout; + final Duration queueFullBackoff; final int maxNoProgressRetries; + final int maxReconnectResumeAttempts; final StreamController _progressController = StreamController.broadcast(); @@ -87,8 +91,17 @@ class FirmwareUpdateService { reconnectTimeout ?? defaultReconnectTimeout; final effectiveVerificationTimeout = verificationTimeout ?? defaultVerificationTimeout; - final normalizedSessionId = sessionId & 0xFF; + final rawSessionId = sessionId & 0xFF; + final normalizedSessionId = rawSessionId == 0 ? 1 : rawSessionId; final imageCrc32 = BootloaderDfuProtocol.crc32(imageBytes); + final startPayload = BootloaderDfuStartPayload( + totalLength: imageBytes.length, + imageCrc32: imageCrc32, + appStart: appStart, + imageVersion: imageVersion, + sessionId: normalizedSessionId, + flags: flags.rawValue, + ); _isRunning = true; _cancelRequested = false; @@ -114,30 +127,25 @@ class FirmwareUpdateService { try { _throwIfCancelled(); _emitProgress(state: DfuUpdateState.enteringBootloader); - final enterResult = await _transport.enterBootloader(); - if (enterResult.isErr()) { - throw _DfuFailure( - 'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}', + final alreadyInBootloader = await _isConnectedToBootloader(); + if (!alreadyInBootloader) { + final enterResult = await _transport.enterBootloader(); + final appDisconnectResult = await _transport.waitForAppDisconnect( + timeout: effectiveBootloaderConnectTimeout, ); - } + if (appDisconnectResult.isErr()) { + if (enterResult.isErr()) { + throw _DfuFailure( + 'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}', + ); + } + throw _DfuFailure( + 'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}', + ); + } - final appDisconnectResult = await _transport.waitForAppDisconnect( - timeout: effectiveBootloaderConnectTimeout, - ); - if (appDisconnectResult.isErr()) { - throw _DfuFailure( - 'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}', - ); - } - - _emitProgress(state: DfuUpdateState.connectingBootloader); - final bootloaderConnectResult = await _transport.connectToBootloader( - timeout: effectiveBootloaderConnectTimeout, - ); - if (bootloaderConnectResult.isErr()) { - throw _DfuFailure( - 'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}', - ); + _emitProgress(state: DfuUpdateState.connectingBootloader); + await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout); } final mtuResult = @@ -161,17 +169,8 @@ class FirmwareUpdateService { await _readInitialStatus(); _emitProgress(state: DfuUpdateState.erasing); - final startStatus = await _writeControlAndWaitForStatus( - BootloaderDfuProtocol.encodeStartPayload( - BootloaderDfuStartPayload( - totalLength: imageBytes.length, - imageCrc32: imageCrc32, - appStart: appStart, - imageVersion: imageVersion, - sessionId: normalizedSessionId, - flags: flags.rawValue, - ), - ), + final startStatus = await _sendStartAndWaitForStatus( + startPayload, timeout: effectiveStatusTimeout, ); _requireOkStatus( @@ -187,7 +186,9 @@ class FirmwareUpdateService { imageBytes: imageBytes, sessionId: normalizedSessionId, payloadSize: payloadSize, + startPayload: startPayload, statusTimeout: effectiveStatusTimeout, + bootloaderConnectTimeout: effectiveBootloaderConnectTimeout, ); _emitProgress(state: DfuUpdateState.finishing); @@ -289,6 +290,7 @@ class FirmwareUpdateService { Future _subscribeToStatus() async { await _statusSubscription?.cancel(); + _statusStreamError = null; _statusSubscription = _transport.subscribeToStatus().listen( _handleStatusPayload, onError: (Object error) { @@ -313,80 +315,195 @@ class FirmwareUpdateService { required List imageBytes, required int sessionId, required int payloadSize, + required BootloaderDfuStartPayload startPayload, required Duration statusTimeout, + required Duration bootloaderConnectTimeout, }) async { var expectedOffset = _currentProgress.expectedOffset; var retriesWithoutProgress = 0; + var reconnectResumeAttempts = 0; while (expectedOffset < imageBytes.length) { _throwIfCancelled(); - _throwIfStatusStreamErrored(); + _throwIfStatusStreamErrored(recoverable: true); - final frame = BootloaderDfuProtocol.buildDataFrame( - imageBytes: imageBytes, - sessionId: sessionId, - offset: expectedOffset, - payloadSize: payloadSize, - ); - final status = await _writeDataFrameAndWaitForStatus( - frame, - timeout: statusTimeout, - ); - - if (status.code == DfuBootloaderStatusCode.queueFull || - status.code == DfuBootloaderStatusCode.stateError) { - final recoveredStatus = await _requestStatus(timeout: statusTimeout); - _requireOkStatus( - recoveredStatus, + try { + final frame = BootloaderDfuProtocol.buildDataFrame( + imageBytes: imageBytes, sessionId: sessionId, - operation: 'GET_STATUS after ${_statusLabel(status)}', + offset: expectedOffset, + payloadSize: payloadSize, + ); + final status = await _writeDataFrameAndWaitForStatus( + frame, + timeout: statusTimeout, ); - expectedOffset = - recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt(); - _emitTransferProgress(recoveredStatus, imageBytes.length); - continue; - } - _requireOkStatus(status, sessionId: sessionId, operation: 'DATA'); - - final nextOffset = - status.expectedOffset.clamp(0, imageBytes.length).toInt(); - if (nextOffset <= expectedOffset) { - retriesWithoutProgress += 1; - if (retriesWithoutProgress > maxNoProgressRetries) { - throw _DfuFailure( - 'Bootloader DFU stalled at offset $expectedOffset after $retriesWithoutProgress status checks without progress.', + if (status.code == DfuBootloaderStatusCode.queueFull || + status.code == DfuBootloaderStatusCode.stateError) { + if (status.code == DfuBootloaderStatusCode.queueFull) { + await _delayWithCancel(queueFullBackoff); + } + final recoveredStatus = await _requestStatus(timeout: statusTimeout); + _requireOkStatus( + recoveredStatus, + sessionId: sessionId, + operation: 'GET_STATUS after ${_statusLabel(status)}', ); + expectedOffset = recoveredStatus.expectedOffset + .clamp(0, imageBytes.length) + .toInt(); + _emitTransferProgress(recoveredStatus, imageBytes.length); + reconnectResumeAttempts = 0; + continue; + } + + _requireOkStatus(status, sessionId: sessionId, operation: 'DATA'); + + final nextOffset = + status.expectedOffset.clamp(0, imageBytes.length).toInt(); + if (nextOffset <= expectedOffset) { + retriesWithoutProgress += 1; + if (retriesWithoutProgress > maxNoProgressRetries) { + throw _DfuFailure( + 'Bootloader DFU stalled at offset $expectedOffset after $retriesWithoutProgress status checks without progress.', + ); + } + final recoveredStatus = await _requestStatus(timeout: statusTimeout); + _requireOkStatus( + recoveredStatus, + sessionId: sessionId, + operation: 'GET_STATUS after no progress', + ); + expectedOffset = recoveredStatus.expectedOffset + .clamp(0, imageBytes.length) + .toInt(); + _emitTransferProgress(recoveredStatus, imageBytes.length); + continue; + } + + retriesWithoutProgress = 0; + reconnectResumeAttempts = 0; + expectedOffset = nextOffset; + _emitTransferProgress(status, imageBytes.length); + } on _DfuFailure catch (failure) { + if (!failure.recoverable || + reconnectResumeAttempts >= maxReconnectResumeAttempts) { + rethrow; + } + reconnectResumeAttempts += 1; + final recoveredStatus = await _recoverTransferStatus( + timeout: statusTimeout, + bootloaderConnectTimeout: bootloaderConnectTimeout, + failure: failure, + ); + if (recoveredStatus.isOk && + recoveredStatus.sessionId == 0 && + recoveredStatus.expectedOffset == 0) { + _emitProgress( + state: DfuUpdateState.erasing, + sentBytes: 0, + expectedOffset: 0, + ); + final startStatus = await _sendStartAndWaitForStatus( + startPayload, + timeout: statusTimeout, + ); + _requireOkStatus( + startStatus, + sessionId: sessionId, + expectedOffset: 0, + operation: 'START after reconnect', + ); + expectedOffset = 0; + retriesWithoutProgress = 0; + _emitProgress(state: DfuUpdateState.transferring); + continue; } - final recoveredStatus = await _requestStatus(timeout: statusTimeout); _requireOkStatus( recoveredStatus, sessionId: sessionId, - operation: 'GET_STATUS after no progress', + operation: 'GET_STATUS after reconnect', ); expectedOffset = recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt(); _emitTransferProgress(recoveredStatus, imageBytes.length); + _emitProgress(state: DfuUpdateState.transferring); continue; } - - retriesWithoutProgress = 0; - expectedOffset = nextOffset; - _emitTransferProgress(status, imageBytes.length); } } + Future _isConnectedToBootloader() async { + final result = await _transport.isConnectedToBootloader(); + if (result.isErr()) { + throw _DfuFailure( + 'Could not check bootloader DFU connection: ${result.unwrapErr()}', + ); + } + return result.unwrap(); + } + + Future _connectToBootloader({required Duration timeout}) async { + final bootloaderConnectResult = await _transport.connectToBootloader( + timeout: timeout, + ); + if (bootloaderConnectResult.isErr()) { + throw _DfuFailure( + 'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}', + ); + } + } + + Future _sendStartAndWaitForStatus( + BootloaderDfuStartPayload payload, { + required Duration timeout, + }) { + return _writeControlAndWaitForStatus( + BootloaderDfuProtocol.encodeStartPayload(payload), + timeout: timeout, + ); + } + + Future _recoverTransferStatus({ + required Duration timeout, + required Duration bootloaderConnectTimeout, + required _DfuFailure failure, + }) async { + await _statusSubscription?.cancel(); + _statusSubscription = null; + _statusSignal = null; + _latestStatus = null; + _statusStreamError = null; + _statusEventCount = 0; + _emitProgress( + state: DfuUpdateState.connectingBootloader, + errorMessage: failure.message, + ); + await _connectToBootloader(timeout: bootloaderConnectTimeout); + await _subscribeToStatus(); + _emitProgress(state: DfuUpdateState.waitingForStatus); + return _requestStatus(timeout: timeout); + } + Future _writeControlAndWaitForStatus( List payload, { required Duration timeout, + bool recoverable = false, }) async { final eventCount = _statusEventCount; final result = await _transport.writeControl(payload); if (result.isErr()) { throw _DfuFailure( - 'Failed to write bootloader control command: ${result.unwrapErr()}'); + 'Failed to write bootloader control command: ${result.unwrapErr()}', + recoverable: recoverable, + ); } - return _waitForStatus(afterEventCount: eventCount, timeout: timeout); + return _waitForStatus( + afterEventCount: eventCount, + timeout: timeout, + recoverable: recoverable, + ); } Future _writeDataFrameAndWaitForStatus( @@ -398,28 +515,35 @@ class FirmwareUpdateService { if (result.isErr()) { throw _DfuFailure( 'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}', + recoverable: true, ); } - return _waitForStatus(afterEventCount: eventCount, timeout: timeout); + return _waitForStatus( + afterEventCount: eventCount, + timeout: timeout, + recoverable: true, + ); } Future _requestStatus({required Duration timeout}) { return _writeControlAndWaitForStatus( BootloaderDfuProtocol.encodeGetStatusPayload(), timeout: timeout, + recoverable: true, ); } Future _waitForStatus({ required int afterEventCount, required Duration timeout, + bool recoverable = false, }) async { final deadline = DateTime.now().add(timeout); var observedEvents = afterEventCount; while (true) { _throwIfCancelled(); - _throwIfStatusStreamErrored(); + _throwIfStatusStreamErrored(recoverable: recoverable); if (_statusEventCount > observedEvents && _latestStatus != null) { return _latestStatus!; @@ -427,14 +551,16 @@ class FirmwareUpdateService { final remaining = deadline.difference(DateTime.now()); if (remaining <= Duration.zero) { - throw const _DfuFailure( + throw _DfuFailure( 'Timed out waiting for bootloader DFU status. Reconnect and retry the update.', + recoverable: recoverable, ); } final gotEvent = await _waitForNextStatusEvent( afterEventCount: observedEvents, timeout: remaining, + recoverable: recoverable, ); if (gotEvent) { observedEvents = _statusEventCount - 1; @@ -445,6 +571,7 @@ class FirmwareUpdateService { Future _waitForNextStatusEvent({ required int afterEventCount, required Duration timeout, + required bool recoverable, }) async { if (_statusEventCount > afterEventCount) { return true; @@ -467,7 +594,7 @@ class FirmwareUpdateService { } _throwIfCancelled(); - _throwIfStatusStreamErrored(); + _throwIfStatusStreamErrored(recoverable: recoverable); return _statusEventCount > afterEventCount; } @@ -584,10 +711,21 @@ class FirmwareUpdateService { } } - void _throwIfStatusStreamErrored() { + Future _delayWithCancel(Duration duration) async { + if (duration <= Duration.zero) { + return; + } + await Future.any([ + Future.delayed(duration), + _cancelSignal?.future ?? Future.value(), + ]); + _throwIfCancelled(); + } + + void _throwIfStatusStreamErrored({bool recoverable = false}) { final error = _statusStreamError; if (error != null) { - throw _DfuFailure(error); + throw _DfuFailure(error, recoverable: recoverable); } } @@ -600,6 +738,8 @@ class FirmwareUpdateService { } abstract interface class FirmwareUpdateTransport { + Future> isConnectedToBootloader(); + Future> enterBootloader(); Future> waitForAppDisconnect({required Duration timeout}); @@ -630,15 +770,39 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { required this.buttonDeviceId, }); - final ShifterService shifterService; + final ShifterService? shifterService; final BluetoothController bluetoothController; final String buttonDeviceId; String? _bootloaderDeviceId; + @override + Future> isConnectedToBootloader() async { + final currentState = bluetoothController.currentConnectionState; + if (currentState.$1 != ConnectionStatus.connected || + currentState.$2 == null) { + return Ok(false); + } + + final statusResult = await bluetoothController.readCharacteristic( + currentState.$2!, + universalShifterControlServiceUuid, + universalShifterDfuStatusCharacteristicUuid, + ); + if (statusResult.isErr()) { + return Ok(false); + } + _bootloaderDeviceId = currentState.$2; + return Ok(true); + } + @override Future> enterBootloader() { - return shifterService.writeCommand(UniversalShifterCommand.enterDfu); + final shifter = shifterService; + if (shifter == null) { + return Future.value(bail('Normal app control service is not available.')); + } + return shifter.writeCommand(UniversalShifterCommand.enterDfu); } @override @@ -774,10 +938,17 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { Future> verifyDeviceReachable( {required Duration timeout}) async { try { - final statusResult = await shifterService.readStatus().timeout(timeout); + final statusResult = await bluetoothController + .readCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterStatusCharacteristicUuid, + ) + .timeout(timeout); if (statusResult.isErr()) { return Err(statusResult.unwrapErr()); } + CentralStatus.fromBytes(statusResult.unwrap()); return Ok(null); } on TimeoutException { return bail( @@ -874,9 +1045,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { } class _DfuFailure implements Exception { - const _DfuFailure(this.message); + const _DfuFailure(this.message, {this.recoverable = false}); final String message; + final bool recoverable; @override String toString() => message; diff --git a/test/service/firmware_update_service_test.dart b/test/service/firmware_update_service_test.dart index d276895..e67cd6a 100644 --- a/test/service/firmware_update_service_test.dart +++ b/test/service/firmware_update_service_test.dart @@ -24,6 +24,7 @@ void main() { expect(result.isOk(), isTrue); expect(transport.steps, [ + 'isConnectedToBootloader', 'enterBootloader', 'waitForAppDisconnect', 'connectToBootloader', @@ -48,6 +49,63 @@ void main() { await transport.dispose(); }); + test('starts directly when already connected to bootloader', () async { + final image = _validImage(80); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + alreadyInBootloader: true, + ); + final service = FirmwareUpdateService( + transport: transport, + defaultStatusTimeout: const Duration(milliseconds: 100), + ); + + final result = await service.startUpdate( + imageBytes: image, + sessionId: 8, + ); + + expect(result.isOk(), isTrue); + expect(transport.steps, [ + 'isConnectedToBootloader', + 'negotiateMtu', + 'readStatus', + 'waitForBootloaderDisconnect', + 'reconnectForVerification', + 'verifyDeviceReachable', + ]); + expect( + transport.controlWrites.first.first, universalShifterDfuOpcodeStart); + + await service.dispose(); + await transport.dispose(); + }); + + test('tolerates enter bootloader write error when app disconnects', + () async { + final image = _validImage(80); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + failEnterBootloader: true, + ); + final service = FirmwareUpdateService( + transport: transport, + defaultStatusTimeout: const Duration(milliseconds: 100), + ); + + final result = await service.startUpdate( + imageBytes: image, + sessionId: 12, + ); + + expect(result.isOk(), isTrue); + expect(transport.steps, contains('waitForAppDisconnect')); + expect(service.currentProgress.state, DfuUpdateState.completed); + + await service.dispose(); + await transport.dispose(); + }); + test('backs off on queue-full status and resumes from GET_STATUS', () async { final image = _validImage(80); @@ -80,6 +138,84 @@ void main() { await transport.dispose(); }); + test('reconnects and resumes from status after transient data failure', + () async { + final image = _validImage(130); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + failDataWriteAtOffsetOnce: + universalShifterBootloaderDfuMaxPayloadSizeBytes, + ); + final service = FirmwareUpdateService( + transport: transport, + defaultStatusTimeout: const Duration(milliseconds: 100), + defaultBootloaderConnectTimeout: const Duration(milliseconds: 100), + ); + + final result = await service.startUpdate( + imageBytes: image, + sessionId: 13, + ); + + expect(result.isOk(), isTrue); + expect( + transport.steps.where((step) => step == 'connectToBootloader').length, + 2, + ); + expect( + transport.controlWrites + .where((write) => write.first == universalShifterDfuOpcodeGetStatus) + .length, + 1, + ); + expect( + transport.dataWriteOffsets + .where( + (offset) => + offset == universalShifterBootloaderDfuMaxPayloadSizeBytes, + ) + .length, + 2, + ); + expect(service.currentProgress.state, DfuUpdateState.completed); + + await service.dispose(); + await transport.dispose(); + }); + + test('restarts START when reconnect status has no active session', + () async { + final image = _validImage(80); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + failDataWriteAtOffsetOnce: + universalShifterBootloaderDfuMaxPayloadSizeBytes, + resetSessionOnRecoveryStatus: true, + ); + final service = FirmwareUpdateService( + transport: transport, + defaultStatusTimeout: const Duration(milliseconds: 100), + defaultBootloaderConnectTimeout: const Duration(milliseconds: 100), + ); + + final result = await service.startUpdate( + imageBytes: image, + sessionId: 14, + ); + + expect(result.isOk(), isTrue); + expect( + transport.controlWrites + .where((write) => write.first == universalShifterDfuOpcodeStart) + .length, + 2, + ); + expect(service.currentProgress.state, DfuUpdateState.completed); + + await service.dispose(); + await transport.dispose(); + }); + test('fails with bootloader status error on rejected START', () async { final image = _validImage(40); final transport = _FakeFirmwareUpdateTransport( @@ -147,15 +283,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { _FakeFirmwareUpdateTransport({ required this.totalBytes, this.startStatusCode = DfuBootloaderStatusCode.ok, + this.alreadyInBootloader = false, + this.failEnterBootloader = false, this.queueFullOnFirstData = false, this.suppressFirstDataStatus = false, + this.failDataWriteAtOffsetOnce, + this.resetSessionOnRecoveryStatus = false, this.onDataWrite, }); final int totalBytes; final DfuBootloaderStatusCode startStatusCode; + final bool alreadyInBootloader; + final bool failEnterBootloader; final bool queueFullOnFirstData; final bool suppressFirstDataStatus; + final int? failDataWriteAtOffsetOnce; + final bool resetSessionOnRecoveryStatus; final void Function()? onDataWrite; final StreamController> _statusController = @@ -167,12 +311,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { int _sessionId = 0; int _expectedOffset = 0; + int _connectCount = 0; + bool _sentDataFailure = false; bool _sentQueueFull = false; bool _suppressedDataStatus = false; + @override + Future> isConnectedToBootloader() async { + steps.add('isConnectedToBootloader'); + return Ok(alreadyInBootloader); + } + @override Future> enterBootloader() async { steps.add('enterBootloader'); + if (failEnterBootloader) { + return bail('app disconnected before write response'); + } return Ok(null); } @@ -185,6 +340,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { @override Future> connectToBootloader({required Duration timeout}) async { steps.add('connectToBootloader'); + _connectCount += 1; return Ok(null); } @@ -212,6 +368,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { _expectedOffset = 0; _scheduleStatus(startStatusCode, _sessionId, 0); } else if (opcode == universalShifterDfuOpcodeGetStatus) { + if (resetSessionOnRecoveryStatus && _connectCount > 1) { + _sessionId = 0; + _expectedOffset = 0; + } _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); } else if (opcode == universalShifterDfuOpcodeFinish) { _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes); @@ -229,6 +389,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { final offset = _readLeU32(frame, 1); dataWriteOffsets.add(offset); + if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) { + _sentDataFailure = true; + return bail('simulated BLE write failure'); + } + if (queueFullOnFirstData && !_sentQueueFull) { _sentQueueFull = true; _scheduleStatus(