diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index d40ffb6..aae5b29 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -18,8 +18,6 @@ const String universalShifterDfuControlCharacteristicUuid = '0993826f-0ee4-4b37-9614-d13ecba40008'; const String universalShifterDfuDataCharacteristicUuid = '0993826f-0ee4-4b37-9614-d13ecba40009'; -const String universalShifterDfuAckCharacteristicUuid = - '0993826f-0ee4-4b37-9614-d13ecba4000a'; const String universalShifterDfuStatusCharacteristicUuid = '0993826f-0ee4-4b37-9614-d13ecba4000a'; const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb'; @@ -41,15 +39,12 @@ const int universalShifterDfuOpcodeAbort = 0x03; const int universalShifterDfuOpcodeGetStatus = 0x04; const int universalShifterDfuFrameSizeBytes = 64; -const int universalShifterDfuFramePayloadSizeBytes = 63; const int universalShifterBootloaderDfuDataHeaderSizeBytes = 9; const int universalShifterBootloaderDfuMaxPayloadSizeBytes = universalShifterDfuFrameSizeBytes - universalShifterBootloaderDfuDataHeaderSizeBytes; const int universalShifterBootloaderDfuStatusSizeBytes = 6; const int universalShifterAttWriteOverheadBytes = 3; -const int universalShifterDfuMinimumMtu = - universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes; const int universalShifterDfuPreferredMtu = 128; const int universalShifterDfuAppStart = 0x00030000; @@ -78,9 +73,14 @@ const int trainerScanDeviceFlagConnectable = 0x08; enum DfuUpdateState { idle, starting, - waitingForAck, + enteringBootloader, + connectingBootloader, + waitingForStatus, + erasing, transferring, finishing, + rebooting, + verifying, completed, aborted, failed, @@ -119,18 +119,20 @@ class DfuUpdateProgress { required this.state, required this.totalBytes, required this.sentBytes, - required this.lastAckedSequence, + required this.expectedOffset, required this.sessionId, required this.flags, + this.bootloaderStatus, this.errorMessage, }); final DfuUpdateState state; final int totalBytes; final int sentBytes; - final int lastAckedSequence; + final int expectedOffset; final int sessionId; final DfuUpdateFlags flags; + final DfuBootloaderStatus? bootloaderStatus; final String? errorMessage; double get fractionComplete { @@ -191,61 +193,6 @@ class DfuBootloaderStatus { bool get isOk => code == DfuBootloaderStatusCode.ok; } -enum DfuPreflightFailureReason { - deviceNotConnected, - wrongConnectedDevice, - mtuRequestFailed, - mtuTooLow, -} - -class DfuPreflightResult { - const DfuPreflightResult._({ - required this.requestedMtu, - required this.requiredMtu, - required this.negotiatedMtu, - required this.failureReason, - required this.message, - }); - - final int requestedMtu; - final int requiredMtu; - final int? negotiatedMtu; - final DfuPreflightFailureReason? failureReason; - final String? message; - - bool get canStart => failureReason == null; - - static DfuPreflightResult ready({ - required int requestedMtu, - required int negotiatedMtu, - int requiredMtu = universalShifterDfuMinimumMtu, - }) { - return DfuPreflightResult._( - requestedMtu: requestedMtu, - requiredMtu: requiredMtu, - negotiatedMtu: negotiatedMtu, - failureReason: null, - message: null, - ); - } - - static DfuPreflightResult failed({ - required int requestedMtu, - required DfuPreflightFailureReason failureReason, - required String message, - int requiredMtu = universalShifterDfuMinimumMtu, - int? negotiatedMtu, - }) { - return DfuPreflightResult._( - requestedMtu: requestedMtu, - requiredMtu: requiredMtu, - negotiatedMtu: negotiatedMtu, - failureReason: failureReason, - message: message, - ); - } -} - class ShifterErrorInfo { const ShifterErrorInfo({ required this.code, diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index d686e41..816c76a 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -85,7 +85,7 @@ class _DeviceDetailsPageState extends ConsumerState { state: DfuUpdateState.idle, totalBytes: 0, sentBytes: 0, - lastAckedSequence: 0xFF, + expectedOffset: 0, sessionId: 0, flags: DfuUpdateFlags(), ); @@ -99,9 +99,14 @@ class _DeviceDetailsPageState extends ConsumerState { } switch (_dfuProgress.state) { case DfuUpdateState.starting: - case DfuUpdateState.waitingForAck: + case DfuUpdateState.enteringBootloader: + case DfuUpdateState.connectingBootloader: + case DfuUpdateState.waitingForStatus: + case DfuUpdateState.erasing: case DfuUpdateState.transferring: case DfuUpdateState.finishing: + case DfuUpdateState.rebooting: + case DfuUpdateState.verifying: return true; case DfuUpdateState.idle: case DfuUpdateState.completed: @@ -627,13 +632,23 @@ class _DeviceDetailsPageState extends ConsumerState { case DfuUpdateState.idle: return 'Idle'; case DfuUpdateState.starting: - return 'Sending START command'; - case DfuUpdateState.waitingForAck: - return 'Waiting for ACK from button'; + return 'Preparing update'; + case DfuUpdateState.enteringBootloader: + return 'Requesting bootloader mode'; + case DfuUpdateState.connectingBootloader: + return 'Connecting to bootloader'; + case DfuUpdateState.waitingForStatus: + return 'Waiting for bootloader status'; + case DfuUpdateState.erasing: + return 'Starting destructive bootloader update'; case DfuUpdateState.transferring: - return 'Transferring firmware frames'; + return 'Transferring firmware image'; case DfuUpdateState.finishing: - return 'Finalizing update and waiting for reboot/reconnect'; + return 'Finalizing bootloader update'; + case DfuUpdateState.rebooting: + return 'Waiting for updated app reboot'; + case DfuUpdateState.verifying: + return 'Verifying updated app'; case DfuUpdateState.completed: return 'Update completed'; case DfuUpdateState.aborted: @@ -653,10 +668,6 @@ class _DeviceDetailsPageState extends ConsumerState { return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; } - String _hexByte(int value) { - return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}'; - } - Future _manualReconnect() async { if (_isManualReconnectRunning || _isFirmwareUpdateBusy) { return; @@ -1010,7 +1021,8 @@ class _DeviceDetailsPageState extends ConsumerState { '${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}', onSelectFirmware: _selectFirmwareFile, onStartUpdate: _startFirmwareUpdate, - ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence), + ackSequenceHex: + '0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}', ), ] else if (isCurrentConnected) ...[ _PairingRequiredCard( diff --git a/lib/service/dfu_protocol.dart b/lib/service/dfu_protocol.dart index 77fbb3b..1368e8c 100644 --- a/lib/service/dfu_protocol.dart +++ b/lib/service/dfu_protocol.dart @@ -2,8 +2,7 @@ import 'dart:typed_data'; import 'package:abawo_bt_app/model/shifter_types.dart'; -const int _startPayloadLength = 11; -const int _bootloaderStartPayloadLength = 19; +const int _startPayloadLength = 19; class BootloaderDfuStartPayload { const BootloaderDfuStartPayload({ @@ -37,143 +36,14 @@ class BootloaderDfuDataFrame { final Uint8List bytes; } -class DfuStartPayload { - const DfuStartPayload({ - required this.totalLength, - required this.imageCrc32, - required this.sessionId, - required this.flags, - }); - - final int totalLength; - final int imageCrc32; - final int sessionId; - final int flags; -} - -class DfuDataFrame { - const DfuDataFrame({ - required this.sequence, - required this.offset, - required this.payloadLength, - required this.bytes, - }); - - final int sequence; - final int offset; - final int payloadLength; - final Uint8List bytes; -} - -class DfuProtocol { - const DfuProtocol._(); - - static Uint8List encodeStartPayload(DfuStartPayload payload) { - final data = ByteData(_startPayloadLength); - data.setUint8(0, universalShifterDfuOpcodeStart); - data.setUint32(1, payload.totalLength, Endian.little); - data.setUint32(5, payload.imageCrc32, Endian.little); - data.setUint8(9, payload.sessionId); - data.setUint8(10, payload.flags); - return data.buffer.asUint8List(); - } - - static Uint8List encodeFinishPayload() { - return Uint8List.fromList([universalShifterDfuOpcodeFinish]); - } - - static Uint8List encodeAbortPayload() { - return Uint8List.fromList([universalShifterDfuOpcodeAbort]); - } - - static List buildDataFrames( - List imageBytes, { - int startSequence = 0, - }) { - final frames = []; - var seq = _asU8(startSequence); - var offset = 0; - while (offset < imageBytes.length) { - final remaining = imageBytes.length - offset; - final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes - ? remaining - : universalShifterDfuFramePayloadSizeBytes; - - final frame = Uint8List(universalShifterDfuFrameSizeBytes); - frame[0] = seq; - frame.setRange(1, 1 + chunkLength, imageBytes, offset); - - frames.add( - DfuDataFrame( - sequence: seq, - offset: offset, - payloadLength: chunkLength, - bytes: frame, - ), - ); - - offset += chunkLength; - seq = nextSequence(seq); - } - - return frames; - } - - static int nextSequence(int sequence) { - return _asU8(sequence + 1); - } - - static int rewindSequenceFromAck(int acknowledgedSequence) { - return nextSequence(acknowledgedSequence); - } - - static int sequenceDistance(int from, int to) { - return _asU8(to - from); - } - - static int parseAckPayload(List payload) { - if (payload.length != 1) { - throw const FormatException('ACK payload must be exactly 1 byte.'); - } - return _asU8(payload.first); - } +class BootloaderDfuProtocol { + const BootloaderDfuProtocol._(); static const int crc32Initial = 0xFFFFFFFF; static const int _crc32PolynomialReflected = 0xEDB88320; - static int crc32Update(int crc, List bytes) { - var next = crc & 0xFFFFFFFF; - for (final byte in bytes) { - next ^= byte; - for (var bit = 0; bit < 8; bit++) { - if ((next & 0x1) != 0) { - next = (next >> 1) ^ _crc32PolynomialReflected; - } else { - next >>= 1; - } - } - } - return next & 0xFFFFFFFF; - } - - static int crc32Finalize(int crc) { - return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF; - } - - static int crc32(List bytes) { - return crc32Finalize(crc32Update(crc32Initial, bytes)); - } - - static int _asU8(int value) { - return value & 0xFF; - } -} - -class BootloaderDfuProtocol { - const BootloaderDfuProtocol._(); - static Uint8List encodeStartPayload(BootloaderDfuStartPayload payload) { - final data = ByteData(_bootloaderStartPayloadLength); + final data = ByteData(_startPayloadLength); data.setUint8(0, universalShifterDfuOpcodeStart); data.setUint32(1, payload.totalLength, Endian.little); data.setUint32(5, payload.imageCrc32, Endian.little); @@ -296,17 +166,26 @@ class BootloaderDfuProtocol { ); } - static const int crc32Initial = DfuProtocol.crc32Initial; - static int crc32Update(int crc, List bytes) { - return DfuProtocol.crc32Update(crc, bytes); + var next = crc & 0xFFFFFFFF; + for (final byte in bytes) { + next ^= byte; + for (var bit = 0; bit < 8; bit++) { + if ((next & 0x1) != 0) { + next = (next >> 1) ^ _crc32PolynomialReflected; + } else { + next >>= 1; + } + } + } + return next & 0xFFFFFFFF; } static int crc32Finalize(int crc) { - return DfuProtocol.crc32Finalize(crc); + return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF; } static int crc32(List bytes) { - return DfuProtocol.crc32(bytes); + return crc32Finalize(crc32Update(crc32Initial, bytes)); } } diff --git a/lib/service/firmware_update_service.dart b/lib/service/firmware_update_service.dart index 9dcaec2..b2251c7 100644 --- a/lib/service/firmware_update_service.dart +++ b/lib/service/firmware_update_service.dart @@ -5,27 +5,27 @@ import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/dfu_protocol.dart'; import 'package:abawo_bt_app/service/shifter_service.dart'; import 'package:anyhow/anyhow.dart'; - -const int _initialAckSequence = 0xFF; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' + show DiscoveredDevice, Uuid; class FirmwareUpdateService { FirmwareUpdateService({ required FirmwareUpdateTransport transport, - this.defaultWindowSize = 8, - this.maxNoProgressRetries = 5, - this.defaultAckTimeout = const Duration(milliseconds: 800), + this.defaultStatusTimeout = const Duration(seconds: 2), + this.defaultBootloaderConnectTimeout = const Duration(seconds: 20), this.defaultPostFinishResetTimeout = const Duration(seconds: 8), this.defaultReconnectTimeout = const Duration(seconds: 12), this.defaultVerificationTimeout = const Duration(seconds: 5), + this.maxNoProgressRetries = 5, }) : _transport = transport; final FirmwareUpdateTransport _transport; - final int defaultWindowSize; - final int maxNoProgressRetries; - final Duration defaultAckTimeout; + final Duration defaultStatusTimeout; + final Duration defaultBootloaderConnectTimeout; final Duration defaultPostFinishResetTimeout; final Duration defaultReconnectTimeout; final Duration defaultVerificationTimeout; + final int maxNoProgressRetries; final StreamController _progressController = StreamController.broadcast(); @@ -34,21 +34,19 @@ class FirmwareUpdateService { state: DfuUpdateState.idle, totalBytes: 0, sentBytes: 0, - lastAckedSequence: _initialAckSequence, + expectedOffset: 0, sessionId: 0, flags: DfuUpdateFlags(), ); - StreamSubscription>? _ackSubscription; - Completer? _ackSignal; + StreamSubscription>? _statusSubscription; + Completer? _statusSignal; Completer? _cancelSignal; - int _ackEventCount = 0; - String? _ackStreamError; + DfuBootloaderStatus? _latestStatus; + String? _statusStreamError; + int _statusEventCount = 0; bool _isRunning = false; bool _cancelRequested = false; - int _latestAckSequence = _initialAckSequence; - int _ackedFrames = 0; - int _totalFrames = 0; int _totalBytes = 0; Stream get progressStream => _progressController.stream; @@ -60,211 +58,172 @@ class FirmwareUpdateService { Future> startUpdate({ required List imageBytes, required int sessionId, + int appStart = universalShifterDfuAppStart, + int imageVersion = 0, DfuUpdateFlags flags = const DfuUpdateFlags(), int requestedMtu = universalShifterDfuPreferredMtu, - int? windowSize, - Duration? ackTimeout, - int? noProgressRetries, + Duration? statusTimeout, + Duration? bootloaderConnectTimeout, Duration? postFinishResetTimeout, Duration? reconnectTimeout, Duration? verificationTimeout, }) async { if (_isRunning) { return bail( - 'Firmware update is already running. Cancel or wait for completion before starting a new upload.'); + 'Firmware update is already running. Cancel or wait for completion before starting a new upload.', + ); } if (imageBytes.isEmpty) { return bail( 'Firmware image is empty. Select a valid .bin file and retry.'); } - final effectiveWindowSize = windowSize ?? defaultWindowSize; - final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout; - final effectiveNoProgressRetries = - noProgressRetries ?? maxNoProgressRetries; + final effectiveStatusTimeout = statusTimeout ?? defaultStatusTimeout; + final effectiveBootloaderConnectTimeout = + bootloaderConnectTimeout ?? defaultBootloaderConnectTimeout; final effectivePostFinishResetTimeout = postFinishResetTimeout ?? defaultPostFinishResetTimeout; final effectiveReconnectTimeout = reconnectTimeout ?? defaultReconnectTimeout; final effectiveVerificationTimeout = verificationTimeout ?? defaultVerificationTimeout; - - if (effectiveWindowSize <= 0) { - return bail( - 'DFU window size must be at least 1 frame. Got $effectiveWindowSize.'); - } - if (effectiveNoProgressRetries < 0) { - return bail( - 'No-progress retry limit cannot be negative. Got $effectiveNoProgressRetries.'); - } + final normalizedSessionId = sessionId & 0xFF; + final imageCrc32 = BootloaderDfuProtocol.crc32(imageBytes); _isRunning = true; _cancelRequested = false; _cancelSignal = Completer(); - _ackSignal = null; - _ackEventCount = 0; - _ackStreamError = null; - _latestAckSequence = _initialAckSequence; - _ackedFrames = 0; - _totalFrames = - (imageBytes.length + universalShifterDfuFramePayloadSizeBytes - 1) ~/ - universalShifterDfuFramePayloadSizeBytes; + _statusSignal = null; + _latestStatus = null; + _statusStreamError = null; + _statusEventCount = 0; _totalBytes = imageBytes.length; - final normalizedSessionId = sessionId & 0xFF; - final crc32 = DfuProtocol.crc32(imageBytes); - final frames = DfuProtocol.buildDataFrames(imageBytes); - var shouldAbortForCleanup = false; + var startAccepted = false; _emitProgress( state: DfuUpdateState.starting, totalBytes: imageBytes.length, sentBytes: 0, - lastAckedSequence: _initialAckSequence, + expectedOffset: 0, sessionId: normalizedSessionId, flags: flags, + bootloaderStatus: null, ); try { - final preflightResult = await _transport.runPreflight( - requestedMtu: requestedMtu, - ); - if (preflightResult.isErr()) { + _throwIfCancelled(); + _emitProgress(state: DfuUpdateState.enteringBootloader); + final enterResult = await _transport.enterBootloader(); + if (enterResult.isErr()) { throw _DfuFailure( - 'DFU preflight check failed due to transport error: ${preflightResult.unwrapErr()}', - ); - } - final preflight = preflightResult.unwrap(); - if (!preflight.canStart) { - throw _DfuFailure( - preflight.message ?? - 'DFU preflight failed. Ensure button connection and MTU are ready, then retry.', + 'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}', ); } - await _ackSubscription?.cancel(); - _ackSubscription = _transport.subscribeToAck().listen( - _handleAckPayload, - onError: (Object error) { - _ackStreamError = - 'ACK indication stream failed: $error. Reconnect and retry the update.'; - _signalAckWaiters(); - }, + final appDisconnectResult = await _transport.waitForAppDisconnect( + timeout: effectiveBootloaderConnectTimeout, ); + if (appDisconnectResult.isErr()) { + throw _DfuFailure( + 'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}', + ); + } - _emitProgress(state: DfuUpdateState.waitingForAck); - final startEventCount = _ackEventCount; - final startWriteResult = await _transport.writeControl( - DfuProtocol.encodeStartPayload( - DfuStartPayload( + _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()}', + ); + } + + final mtuResult = + await _transport.negotiateMtu(requestedMtu: requestedMtu); + if (mtuResult.isErr()) { + throw _DfuFailure( + 'Could not negotiate bootloader DFU MTU: ${mtuResult.unwrapErr()}', + ); + } + final payloadSize = BootloaderDfuProtocol.maxPayloadSizeForMtu( + mtuResult.unwrap(), + ); + if (payloadSize <= 0) { + throw _DfuFailure( + 'Negotiated MTU ${mtuResult.unwrap()} is too small for bootloader DFU frames.', + ); + } + + await _subscribeToStatus(); + _emitProgress(state: DfuUpdateState.waitingForStatus); + await _readInitialStatus(); + + _emitProgress(state: DfuUpdateState.erasing); + final startStatus = await _writeControlAndWaitForStatus( + BootloaderDfuProtocol.encodeStartPayload( + BootloaderDfuStartPayload( totalLength: imageBytes.length, - imageCrc32: crc32, + imageCrc32: imageCrc32, + appStart: appStart, + imageVersion: imageVersion, sessionId: normalizedSessionId, flags: flags.rawValue, ), ), + timeout: effectiveStatusTimeout, ); - if (startWriteResult.isErr()) { - throw _DfuFailure( - 'Failed to send DFU START command: ${startWriteResult.unwrapErr()}', - ); - } - shouldAbortForCleanup = true; - - final initialAck = await _waitForInitialAck( - afterEventCount: startEventCount, - timeout: effectiveAckTimeout, + _requireOkStatus( + startStatus, + sessionId: normalizedSessionId, + expectedOffset: 0, + operation: 'START', ); - if (initialAck != _initialAckSequence) { - throw _DfuFailure( - 'Device did not acknowledge START correctly (expected ACK 0xFF, got 0x${initialAck.toRadixString(16).padLeft(2, '0').toUpperCase()}). Send ABORT, reconnect if needed, and retry.', - ); - } + startAccepted = true; _emitProgress(state: DfuUpdateState.transferring); + await _transferImage( + imageBytes: imageBytes, + sessionId: normalizedSessionId, + payloadSize: payloadSize, + statusTimeout: effectiveStatusTimeout, + ); - var nextFrameIndex = 0; - var retriesWithoutProgress = 0; + _emitProgress(state: DfuUpdateState.finishing); + final finishStatus = await _writeControlAndWaitForStatus( + BootloaderDfuProtocol.encodeFinishPayload(normalizedSessionId), + timeout: effectiveStatusTimeout, + ); + _requireOkStatus( + finishStatus, + sessionId: normalizedSessionId, + expectedOffset: imageBytes.length, + operation: 'FINISH', + ); - while (_ackedFrames < _totalFrames) { - _throwIfCancelled(); - _throwIfAckStreamErrored(); - - final ackedBeforeWindow = _ackedFrames; - final endExclusive = - (nextFrameIndex + effectiveWindowSize).clamp(0, frames.length); - - for (var frameIndex = nextFrameIndex; - frameIndex < endExclusive; - frameIndex++) { - _throwIfCancelled(); - final writeResult = - await _transport.writeDataFrame(frames[frameIndex].bytes); - if (writeResult.isErr()) { - throw _DfuFailure( - 'Failed sending DFU data frame #$frameIndex (seq 0x${frames[frameIndex].sequence.toRadixString(16).padLeft(2, '0').toUpperCase()}): ${writeResult.unwrapErr()}', - ); - } - } - - nextFrameIndex = endExclusive; - - if (_ackedFrames > ackedBeforeWindow) { - retriesWithoutProgress = 0; - nextFrameIndex = _ackedFrames; - continue; - } - - final gotProgress = await _waitForAckProgress( - ackedFramesBeforeWait: ackedBeforeWindow, - timeout: effectiveAckTimeout, - ); - - if (gotProgress) { - retriesWithoutProgress = 0; - nextFrameIndex = _ackedFrames; - continue; - } - - retriesWithoutProgress += 1; - if (retriesWithoutProgress > effectiveNoProgressRetries) { - throw _DfuFailure( - 'Upload stalled: no ACK progress after $retriesWithoutProgress retries (last ACK 0x${_latestAckSequence.toRadixString(16).padLeft(2, '0').toUpperCase()}). Check BLE signal quality and retry.', - ); - } - - nextFrameIndex = _ackedFrames; - } + await _statusSubscription?.cancel(); + _statusSubscription = null; _emitProgress( - state: DfuUpdateState.finishing, sentBytes: imageBytes.length); - final finishResult = - await _transport.writeControl(DfuProtocol.encodeFinishPayload()); - if (finishResult.isErr()) { - throw _DfuFailure( - 'Failed to send DFU FINISH command: ${finishResult.unwrapErr()}', - ); - } - - await _ackSubscription?.cancel(); - _ackSubscription = null; - + state: DfuUpdateState.rebooting, sentBytes: imageBytes.length); final resetDisconnectResult = - await _transport.waitForExpectedResetDisconnect( + await _transport.waitForBootloaderDisconnect( timeout: effectivePostFinishResetTimeout, ); if (resetDisconnectResult.isErr()) { throw _DfuFailure( - 'Device did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}', + 'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}', ); } + _emitProgress(state: DfuUpdateState.verifying); final reconnectResult = await _transport.reconnectForVerification( timeout: effectiveReconnectTimeout, ); if (reconnectResult.isErr()) { throw _DfuFailure( - 'Device did not reconnect after DFU reset: ${reconnectResult.unwrapErr()}', + 'Updated app did not reconnect after bootloader reset: ${reconnectResult.unwrapErr()}', ); } @@ -273,48 +232,41 @@ class FirmwareUpdateService { ); if (verificationResult.isErr()) { throw _DfuFailure( - 'Device reconnected but post-update verification failed: ${verificationResult.unwrapErr()} ' - 'Firmware version cannot be compared yet because the device does not expose a version characteristic.', + 'Updated app reconnected but verification failed: ${verificationResult.unwrapErr()}', ); } - shouldAbortForCleanup = false; _emitProgress( - state: DfuUpdateState.completed, sentBytes: imageBytes.length); + state: DfuUpdateState.completed, + sentBytes: imageBytes.length, + expectedOffset: imageBytes.length, + ); return Ok(null); } on _DfuCancelled { - if (shouldAbortForCleanup) { - await _sendAbortForCleanup(); + if (startAccepted) { + await _sendAbortForCancel(normalizedSessionId); } _emitProgress(state: DfuUpdateState.aborted); return bail('Firmware update canceled by user.'); } on _DfuFailure catch (failure) { - if (shouldAbortForCleanup) { - await _sendAbortForCleanup(); - } _emitProgress( state: DfuUpdateState.failed, errorMessage: failure.message); return bail(failure.message); } catch (error) { - if (shouldAbortForCleanup) { - await _sendAbortForCleanup(); - } final message = - 'Firmware update failed unexpectedly: $error. Reconnect to the button and retry.'; + 'Firmware update failed unexpectedly: $error. Reconnect to the button or bootloader and retry.'; _emitProgress(state: DfuUpdateState.failed, errorMessage: message); return bail(message); } finally { - await _ackSubscription?.cancel(); - _ackSubscription = null; + await _statusSubscription?.cancel(); + _statusSubscription = null; _isRunning = false; _cancelRequested = false; _cancelSignal = null; - _ackSignal = null; - _ackEventCount = 0; - _ackStreamError = null; - _latestAckSequence = _currentProgress.lastAckedSequence; - _ackedFrames = 0; - _totalFrames = 0; + _statusSignal = null; + _statusStreamError = null; + _latestStatus = null; + _statusEventCount = 0; _totalBytes = 0; } } @@ -325,70 +277,140 @@ class FirmwareUpdateService { } _cancelRequested = true; _cancelSignal?.complete(); - _signalAckWaiters(); + _signalStatusWaiters(); } Future dispose() async { await cancelUpdate(); - await _ackSubscription?.cancel(); - _ackSubscription = null; + await _statusSubscription?.cancel(); + _statusSubscription = null; await _progressController.close(); } - void _handleAckPayload(List payload) { - try { - final sequence = DfuProtocol.parseAckPayload(payload); - final previousAck = _latestAckSequence; - _latestAckSequence = sequence; + Future _subscribeToStatus() async { + await _statusSubscription?.cancel(); + _statusSubscription = _transport.subscribeToStatus().listen( + _handleStatusPayload, + onError: (Object error) { + _statusStreamError = + 'Bootloader status indication stream failed: $error. Reconnect and retry the update.'; + _signalStatusWaiters(); + }, + ); + } - if (_totalFrames > 0 && - _currentProgress.state == DfuUpdateState.transferring) { - final delta = DfuProtocol.sequenceDistance(previousAck, sequence); - if (delta > 0) { - _ackedFrames = (_ackedFrames + delta).clamp(0, _totalFrames); - } + Future _readInitialStatus() async { + final statusResult = await _transport.readStatus(); + if (statusResult.isErr()) { + throw _DfuFailure( + 'Could not read initial bootloader DFU status: ${statusResult.unwrapErr()}', + ); + } + _handleStatusPayload(statusResult.unwrap()); + } - _emitProgress( - lastAckedSequence: sequence, - sentBytes: - _ackedBytesFromFrames(_ackedFrames, _totalFrames, _totalBytes), + Future _transferImage({ + required List imageBytes, + required int sessionId, + required int payloadSize, + required Duration statusTimeout, + }) async { + var expectedOffset = _currentProgress.expectedOffset; + var retriesWithoutProgress = 0; + + while (expectedOffset < imageBytes.length) { + _throwIfCancelled(); + _throwIfStatusStreamErrored(); + + 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, + sessionId: sessionId, + operation: 'GET_STATUS after ${_statusLabel(status)}', ); - } else { - _emitProgress(lastAckedSequence: sequence); + expectedOffset = + recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt(); + _emitTransferProgress(recoveredStatus, imageBytes.length); + continue; } - } on FormatException catch (error) { - _ackStreamError = - 'Received malformed ACK indication: $error. Reconnect and retry.'; - } finally { - _ackEventCount += 1; - _signalAckWaiters(); + + _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; + expectedOffset = nextOffset; + _emitTransferProgress(status, imageBytes.length); } } - void _emitProgress({ - DfuUpdateState? state, - int? totalBytes, - int? sentBytes, - int? lastAckedSequence, - int? sessionId, - DfuUpdateFlags? flags, - String? errorMessage, - }) { - final next = DfuUpdateProgress( - state: state ?? _currentProgress.state, - totalBytes: totalBytes ?? _currentProgress.totalBytes, - sentBytes: sentBytes ?? _currentProgress.sentBytes, - lastAckedSequence: - lastAckedSequence ?? _currentProgress.lastAckedSequence, - sessionId: sessionId ?? _currentProgress.sessionId, - flags: flags ?? _currentProgress.flags, - errorMessage: errorMessage, - ); - _currentProgress = next; - _progressController.add(next); + Future _writeControlAndWaitForStatus( + List payload, { + required Duration timeout, + }) async { + final eventCount = _statusEventCount; + final result = await _transport.writeControl(payload); + if (result.isErr()) { + throw _DfuFailure( + 'Failed to write bootloader control command: ${result.unwrapErr()}'); + } + return _waitForStatus(afterEventCount: eventCount, timeout: timeout); } - Future _waitForInitialAck({ + Future _writeDataFrameAndWaitForStatus( + BootloaderDfuDataFrame frame, { + required Duration timeout, + }) async { + final eventCount = _statusEventCount; + final result = await _transport.writeDataFrame(frame.bytes); + if (result.isErr()) { + throw _DfuFailure( + 'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}', + ); + } + return _waitForStatus(afterEventCount: eventCount, timeout: timeout); + } + + Future _requestStatus({required Duration timeout}) { + return _writeControlAndWaitForStatus( + BootloaderDfuProtocol.encodeGetStatusPayload(), + timeout: timeout, + ); + } + + Future _waitForStatus({ required int afterEventCount, required Duration timeout, }) async { @@ -397,69 +419,39 @@ class FirmwareUpdateService { while (true) { _throwIfCancelled(); - _throwIfAckStreamErrored(); + _throwIfStatusStreamErrored(); + + if (_statusEventCount > observedEvents && _latestStatus != null) { + return _latestStatus!; + } + final remaining = deadline.difference(DateTime.now()); if (remaining <= Duration.zero) { - throw _DfuFailure( - 'Timed out waiting for initial DFU ACK after START. Ensure indications are enabled and retry.', + throw const _DfuFailure( + 'Timed out waiting for bootloader DFU status. Reconnect and retry the update.', ); } - final gotEvent = await _waitForNextAckEvent( + final gotEvent = await _waitForNextStatusEvent( afterEventCount: observedEvents, timeout: remaining, ); - if (!gotEvent) { - continue; + if (gotEvent) { + observedEvents = _statusEventCount - 1; } - observedEvents = _ackEventCount; - return _latestAckSequence; } } - Future _waitForAckProgress({ - required int ackedFramesBeforeWait, - required Duration timeout, - }) async { - final deadline = DateTime.now().add(timeout); - var observedEvents = _ackEventCount; - - while (true) { - _throwIfCancelled(); - _throwIfAckStreamErrored(); - - if (_ackedFrames > ackedFramesBeforeWait) { - return true; - } - - final remaining = deadline.difference(DateTime.now()); - if (remaining <= Duration.zero) { - return false; - } - - final gotEvent = await _waitForNextAckEvent( - afterEventCount: observedEvents, - timeout: remaining, - ); - - if (!gotEvent) { - continue; - } - - observedEvents = _ackEventCount; - } - } - - Future _waitForNextAckEvent({ + Future _waitForNextStatusEvent({ required int afterEventCount, required Duration timeout, }) async { - if (_ackEventCount > afterEventCount) { + if (_statusEventCount > afterEventCount) { return true; } - _ackSignal ??= Completer(); - final signal = _ackSignal!; + _statusSignal ??= Completer(); + final signal = _statusSignal!; try { await Future.any([ @@ -470,13 +462,120 @@ class FirmwareUpdateService { return false; } - if (identical(_ackSignal, signal)) { - _ackSignal = null; + if (identical(_statusSignal, signal)) { + _statusSignal = null; } _throwIfCancelled(); - _throwIfAckStreamErrored(); - return _ackEventCount > afterEventCount; + _throwIfStatusStreamErrored(); + return _statusEventCount > afterEventCount; + } + + void _handleStatusPayload(List payload) { + try { + final status = BootloaderDfuProtocol.parseStatusPayload(payload); + _latestStatus = status; + final sentBytes = status.expectedOffset.clamp(0, _totalBytes).toInt(); + _emitProgress( + bootloaderStatus: status, + expectedOffset: status.expectedOffset, + sentBytes: sentBytes, + ); + } on FormatException catch (error) { + _statusStreamError = + 'Received malformed bootloader DFU status: $error. Reconnect and retry.'; + } finally { + _statusEventCount += 1; + _signalStatusWaiters(); + } + } + + void _emitTransferProgress(DfuBootloaderStatus status, int totalBytes) { + final offset = status.expectedOffset.clamp(0, totalBytes).toInt(); + _emitProgress( + bootloaderStatus: status, + expectedOffset: offset, + sentBytes: offset, + ); + } + + void _emitProgress({ + DfuUpdateState? state, + int? totalBytes, + int? sentBytes, + int? expectedOffset, + int? sessionId, + DfuUpdateFlags? flags, + DfuBootloaderStatus? bootloaderStatus, + String? errorMessage, + }) { + final next = DfuUpdateProgress( + state: state ?? _currentProgress.state, + totalBytes: totalBytes ?? _currentProgress.totalBytes, + sentBytes: sentBytes ?? _currentProgress.sentBytes, + expectedOffset: expectedOffset ?? _currentProgress.expectedOffset, + sessionId: sessionId ?? _currentProgress.sessionId, + flags: flags ?? _currentProgress.flags, + bootloaderStatus: bootloaderStatus ?? _currentProgress.bootloaderStatus, + errorMessage: errorMessage, + ); + _currentProgress = next; + _progressController.add(next); + } + + void _requireOkStatus( + DfuBootloaderStatus status, { + required int sessionId, + int? expectedOffset, + required String operation, + }) { + if (!status.isOk) { + throw _DfuFailure(_statusFailureMessage(status, operation)); + } + if (status.sessionId != sessionId) { + throw _DfuFailure( + '$operation returned status for session ${status.sessionId}, expected $sessionId.', + ); + } + if (expectedOffset != null && status.expectedOffset != expectedOffset) { + throw _DfuFailure( + '$operation returned expected offset ${status.expectedOffset}, expected $expectedOffset.', + ); + } + } + + String _statusFailureMessage(DfuBootloaderStatus status, String operation) { + return '$operation failed with bootloader status ${_statusLabel(status)} ' + '(session ${status.sessionId}, expected offset ${status.expectedOffset}).'; + } + + String _statusLabel(DfuBootloaderStatus status) { + return switch (status.code) { + DfuBootloaderStatusCode.ok => 'OK', + DfuBootloaderStatusCode.parseError => 'parse error', + DfuBootloaderStatusCode.stateError => 'state error', + DfuBootloaderStatusCode.boundsError => 'bounds error', + DfuBootloaderStatusCode.crcError => 'CRC error', + DfuBootloaderStatusCode.flashError => 'flash error', + DfuBootloaderStatusCode.unsupportedError => 'unsupported flags', + DfuBootloaderStatusCode.vectorError => 'vector table error', + DfuBootloaderStatusCode.queueFull => 'queue full', + DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error', + DfuBootloaderStatusCode.unknown => + 'unknown status 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}', + }; + } + + Future _sendAbortForCancel(int sessionId) async { + final result = await _transport.writeControl( + BootloaderDfuProtocol.encodeAbortPayload(sessionId), + ); + if (result.isErr()) { + _emitProgress( + errorMessage: + 'Could not send bootloader DFU ABORT during cancel: ${result.unwrapErr()}', + ); + } } void _throwIfCancelled() { @@ -485,70 +584,43 @@ class FirmwareUpdateService { } } - void _throwIfAckStreamErrored() { - final error = _ackStreamError; + void _throwIfStatusStreamErrored() { + final error = _statusStreamError; if (error != null) { throw _DfuFailure(error); } } - Future _sendAbortForCleanup() async { - final result = - await _transport.writeControl(DfuProtocol.encodeAbortPayload()); - if (result.isErr()) { - final cleanupMessage = - 'Could not send DFU ABORT during cleanup: ${result.unwrapErr()}'; - if (_currentProgress.state == DfuUpdateState.failed && - _currentProgress.errorMessage != null) { - _emitProgress( - errorMessage: '${_currentProgress.errorMessage} $cleanupMessage', - ); - } - } - } - - void _signalAckWaiters() { - final signal = _ackSignal; + void _signalStatusWaiters() { + final signal = _statusSignal; if (signal != null && !signal.isCompleted) { signal.complete(); } } - - int _ackedBytesFromFrames(int ackedFrames, int totalFrames, int totalBytes) { - if (totalFrames == 0 || ackedFrames <= 0) { - return 0; - } - if (ackedFrames >= totalFrames) { - return totalBytes; - } - return ackedFrames * universalShifterDfuFramePayloadSizeBytes; - } } abstract interface class FirmwareUpdateTransport { - Future> runPreflight({required int requestedMtu}); + Future> enterBootloader(); - Stream> subscribeToAck(); + Future> waitForAppDisconnect({required Duration timeout}); + + Future> connectToBootloader({required Duration timeout}); + + Future> negotiateMtu({required int requestedMtu}); + + Stream> subscribeToStatus(); + + Future>> readStatus(); Future> writeControl(List payload); Future> writeDataFrame(List frame); - Future> waitForExpectedResetDisconnect({ - required Duration timeout, - }); + Future> waitForBootloaderDisconnect({required Duration timeout}); - Future> reconnectForVerification({ - required Duration timeout, - }); + Future> reconnectForVerification({required Duration timeout}); - /// Verifies that the device is reachable after reconnect. - /// - /// Current limitation: strict firmware image comparison is not possible from - /// the selected binary metadata alone, even though DIS exposes a revision. - Future> verifyDeviceReachable({ - required Duration timeout, - }); + Future> verifyDeviceReachable({required Duration timeout}); } class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { @@ -562,26 +634,83 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { final BluetoothController bluetoothController; final String buttonDeviceId; + String? _bootloaderDeviceId; + @override - Future> runPreflight({ - required int requestedMtu, - }) { - return shifterService.runDfuPreflight(requestedMtu: requestedMtu); + Future> enterBootloader() { + return shifterService.writeCommand(UniversalShifterCommand.enterDfu); } @override - Stream> subscribeToAck() { + Future> waitForAppDisconnect({required Duration timeout}) { + return _waitForDisconnect(timeout: timeout, label: 'app reset'); + } + + @override + Future> connectToBootloader({required Duration timeout}) async { + final currentState = bluetoothController.currentConnectionState; + if (currentState.$1 == ConnectionStatus.connected && + currentState.$2 == _bootloaderDeviceId && + _bootloaderDeviceId != null) { + return Ok(null); + } + + final scanResult = await _scanForBootloader(timeout: timeout); + if (scanResult.isErr()) { + return Err(scanResult.unwrapErr()); + } + + final bootloaderDevice = scanResult.unwrap(); + _bootloaderDeviceId = bootloaderDevice.id; + return bluetoothController.connectById( + bootloaderDevice.id, + timeout: timeout, + ); + } + + @override + Future> negotiateMtu({required int requestedMtu}) { + final deviceId = _requireBootloaderDeviceId(); + if (deviceId.isErr()) { + return Future.value(Err(deviceId.unwrapErr())); + } + return bluetoothController.requestMtuAndGetValue( + deviceId.unwrap(), + mtu: requestedMtu, + ); + } + + @override + Stream> subscribeToStatus() { + final deviceId = _requireBootloaderDeviceId().unwrap(); return bluetoothController.subscribeToCharacteristic( - buttonDeviceId, + deviceId, universalShifterControlServiceUuid, - universalShifterDfuAckCharacteristicUuid, + universalShifterDfuStatusCharacteristicUuid, + ); + } + + @override + Future>> readStatus() { + final deviceId = _requireBootloaderDeviceId(); + if (deviceId.isErr()) { + return Future.value(Err(deviceId.unwrapErr())); + } + return bluetoothController.readCharacteristic( + deviceId.unwrap(), + universalShifterControlServiceUuid, + universalShifterDfuStatusCharacteristicUuid, ); } @override Future> writeControl(List payload) { + final deviceId = _requireBootloaderDeviceId(); + if (deviceId.isErr()) { + return Future.value(Err(deviceId.unwrapErr())); + } return bluetoothController.writeCharacteristic( - buttonDeviceId, + deviceId.unwrap(), universalShifterControlServiceUuid, universalShifterDfuControlCharacteristicUuid, payload, @@ -590,42 +719,27 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { @override Future> writeDataFrame(List frame) { + final deviceId = _requireBootloaderDeviceId(); + if (deviceId.isErr()) { + return Future.value(Err(deviceId.unwrapErr())); + } return bluetoothController.writeCharacteristic( - buttonDeviceId, + deviceId.unwrap(), universalShifterControlServiceUuid, universalShifterDfuDataCharacteristicUuid, frame, - withResponse: false, ); } @override - Future> waitForExpectedResetDisconnect({ - required Duration timeout, - }) async { - final currentState = bluetoothController.currentConnectionState; - if (currentState.$1 == ConnectionStatus.disconnected) { - return Ok(null); - } - - try { - await bluetoothController.connectionStateStream - .firstWhere((state) => state.$1 == ConnectionStatus.disconnected) - .timeout(timeout); - return Ok(null); - } on TimeoutException { - return bail( - 'Timed out after ${timeout.inMilliseconds}ms waiting for the expected reset disconnect.', - ); - } catch (error) { - return bail('Failed while waiting for expected reset disconnect: $error'); - } + Future> waitForBootloaderDisconnect( + {required Duration timeout}) { + return _waitForDisconnect(timeout: timeout, label: 'bootloader reset'); } @override - Future> reconnectForVerification({ - required Duration timeout, - }) async { + Future> reconnectForVerification( + {required Duration timeout}) async { final connectResult = await bluetoothController.connectById(buttonDeviceId, timeout: timeout); if (connectResult.isErr()) { @@ -649,17 +763,16 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { return Ok(null); } on TimeoutException { return bail( - 'Timed out after ${timeout.inMilliseconds}ms waiting for reconnect.', + 'Timed out after ${timeout.inMilliseconds}ms waiting for updated app reconnect.', ); } catch (error) { - return bail('Reconnect wait failed: $error'); + return bail('Updated app reconnect wait failed: $error'); } } @override - Future> verifyDeviceReachable({ - required Duration timeout, - }) async { + Future> verifyDeviceReachable( + {required Duration timeout}) async { try { final statusResult = await shifterService.readStatus().timeout(timeout); if (statusResult.isErr()) { @@ -674,6 +787,90 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { return bail('Post-update verification failed: $error'); } } + + Future> _waitForDisconnect({ + required Duration timeout, + required String label, + }) async { + final currentState = bluetoothController.currentConnectionState; + if (currentState.$1 == ConnectionStatus.disconnected) { + return Ok(null); + } + + try { + await bluetoothController.connectionStateStream + .firstWhere((state) => state.$1 == ConnectionStatus.disconnected) + .timeout(timeout); + return Ok(null); + } on TimeoutException { + return bail( + 'Timed out after ${timeout.inMilliseconds}ms waiting for $label disconnect.', + ); + } catch (error) { + return bail('Failed while waiting for $label disconnect: $error'); + } + } + + Future> _scanForBootloader({ + required Duration timeout, + }) async { + final serviceUuid = Uuid.parse(universalShifterControlServiceUuid); + final scanResult = await bluetoothController.startScan( + withServices: [serviceUuid], + timeout: timeout, + ); + if (scanResult.isErr()) { + return Err(scanResult.unwrapErr()); + } + + try { + DiscoveredDevice? immediate; + for (final device in bluetoothController.scanResults) { + if (_isBootloaderAdvertisement(device)) { + immediate = device; + break; + } + } + if (immediate != null) { + return Ok(immediate); + } + + final device = await bluetoothController.scanResultsStream + .expand((devices) => devices) + .firstWhere(_isBootloaderAdvertisement) + .timeout(timeout); + return Ok(device); + } on TimeoutException { + return bail( + 'Timed out after ${timeout.inMilliseconds}ms scanning for US-DFU bootloader.', + ); + } catch (error) { + return bail('Bootloader scan failed: $error'); + } finally { + await bluetoothController.stopScan(); + } + } + + bool _isBootloaderAdvertisement(DiscoveredDevice device) { + final name = device.name.trim(); + if (name == 'US-DFU' || name == 'UniversalShifters DFU') { + return true; + } + return name.toLowerCase().contains('dfu') && + device.serviceUuids.any( + (uuid) => + uuid.expanded == + Uuid.parse(universalShifterControlServiceUuid).expanded, + ); + } + + Result _requireBootloaderDeviceId() { + final deviceId = _bootloaderDeviceId; + if (deviceId == null || deviceId.trim().isEmpty) { + return bail('Bootloader device is not connected yet.'); + } + return Ok(deviceId); + } } class _DfuFailure implements Exception { diff --git a/lib/service/shifter_service.dart b/lib/service/shifter_service.dart index ab35c6e..78d133d 100644 --- a/lib/service/shifter_service.dart +++ b/lib/service/shifter_service.dart @@ -10,29 +10,15 @@ final _log = Logger('ShifterService'); class ShifterService { ShifterService({ - BluetoothController? bluetooth, + required BluetoothController bluetooth, required this.buttonDeviceId, - DfuPreflightBluetoothAdapter? dfuPreflightBluetooth, - }) : _bluetooth = bluetooth, - _dfuPreflightBluetooth = - dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) { - if (bluetooth == null && dfuPreflightBluetooth == null) { - throw ArgumentError( - 'Either bluetooth or dfuPreflightBluetooth must be provided.', - ); - } - } + }) : _bluetooth = bluetooth; - final BluetoothController? _bluetooth; + final BluetoothController _bluetooth; final String buttonDeviceId; - final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth; BluetoothController get _requireBluetooth { - final bluetooth = _bluetooth; - if (bluetooth == null) { - throw StateError('Bluetooth controller is not available.'); - } - return bluetooth; + return _bluetooth; } final StreamController _statusController = @@ -243,72 +229,6 @@ class ShifterService { ); } - Future> runDfuPreflight({ - int requestedMtu = universalShifterDfuPreferredMtu, - }) async { - final currentConnection = _dfuPreflightBluetooth.currentConnectionState; - final connectionStatus = currentConnection.$1; - final connectedDeviceId = currentConnection.$2; - - if (connectionStatus != ConnectionStatus.connected || - connectedDeviceId == null) { - return Ok( - DfuPreflightResult.failed( - requestedMtu: requestedMtu, - failureReason: DfuPreflightFailureReason.deviceNotConnected, - message: - 'No button connection is active. Connect the target button, then retry the firmware update.', - ), - ); - } - - if (connectedDeviceId != buttonDeviceId) { - return Ok( - DfuPreflightResult.failed( - requestedMtu: requestedMtu, - failureReason: DfuPreflightFailureReason.wrongConnectedDevice, - message: - 'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.', - ), - ); - } - - final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue( - buttonDeviceId, - mtu: requestedMtu, - ); - if (mtuResult.isErr()) { - return Ok( - DfuPreflightResult.failed( - requestedMtu: requestedMtu, - failureReason: DfuPreflightFailureReason.mtuRequestFailed, - message: - 'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}', - ), - ); - } - - final negotiatedMtu = mtuResult.unwrap(); - if (negotiatedMtu < universalShifterDfuMinimumMtu) { - return Ok( - DfuPreflightResult.failed( - requestedMtu: requestedMtu, - negotiatedMtu: negotiatedMtu, - failureReason: DfuPreflightFailureReason.mtuTooLow, - message: - 'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.', - ), - ); - } - - return Ok( - DfuPreflightResult.ready( - requestedMtu: requestedMtu, - negotiatedMtu: negotiatedMtu, - ), - ); - } - void startStatusNotifications() { if (_statusSubscription != null) { return; @@ -369,32 +289,6 @@ class ShifterService { } } -abstract interface class DfuPreflightBluetoothAdapter { - (ConnectionStatus, String?) get currentConnectionState; - Future> requestMtuAndGetValue( - String deviceId, { - required int mtu, - }); -} - -class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter { - const _BluetoothDfuPreflightAdapter(this._bluetooth); - - final BluetoothController _bluetooth; - - @override - (ConnectionStatus, String?) get currentConnectionState => - _bluetooth.currentConnectionState; - - @override - Future> requestMtuAndGetValue( - String deviceId, { - required int mtu, - }) { - return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu); - } -} - class GearRatiosData { const GearRatiosData({ required this.ratios, diff --git a/test/service/dfu_preflight_test.dart b/test/service/dfu_preflight_test.dart deleted file mode 100644 index 005814a..0000000 --- a/test/service/dfu_preflight_test.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:abawo_bt_app/controller/bluetooth.dart'; -import 'package:abawo_bt_app/model/shifter_types.dart'; -import 'package:abawo_bt_app/service/shifter_service.dart'; -import 'package:anyhow/anyhow.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ShifterService.runDfuPreflight', () { - test('fails when no active button connection exists', () async { - final adapter = _FakeDfuPreflightBluetoothAdapter( - currentConnectionState: (ConnectionStatus.disconnected, null), - mtuResult: Ok(128), - ); - final service = ShifterService( - buttonDeviceId: 'target-device', - dfuPreflightBluetooth: adapter, - ); - - final result = await service.runDfuPreflight(); - - expect(result.isOk(), isTrue); - final preflight = result.unwrap(); - expect(preflight.canStart, isFalse); - expect(preflight.failureReason, - DfuPreflightFailureReason.deviceNotConnected); - expect(adapter.requestMtuCallCount, 0); - }); - - test('fails when connected to a different button', () async { - final adapter = _FakeDfuPreflightBluetoothAdapter( - currentConnectionState: (ConnectionStatus.connected, 'wrong-device'), - mtuResult: Ok(128), - ); - final service = ShifterService( - buttonDeviceId: 'target-device', - dfuPreflightBluetooth: adapter, - ); - - final result = await service.runDfuPreflight(); - - expect(result.isOk(), isTrue); - final preflight = result.unwrap(); - expect(preflight.canStart, isFalse); - expect(preflight.failureReason, - DfuPreflightFailureReason.wrongConnectedDevice); - expect(adapter.requestMtuCallCount, 0); - }); - - test('fails when MTU negotiation fails', () async { - final adapter = _FakeDfuPreflightBluetoothAdapter( - currentConnectionState: (ConnectionStatus.connected, 'target-device'), - mtuResult: bail('adapter rejected mtu request'), - ); - final service = ShifterService( - buttonDeviceId: 'target-device', - dfuPreflightBluetooth: adapter, - ); - - final result = await service.runDfuPreflight(requestedMtu: 247); - - expect(result.isOk(), isTrue); - final preflight = result.unwrap(); - expect(preflight.canStart, isFalse); - expect( - preflight.failureReason, DfuPreflightFailureReason.mtuRequestFailed); - expect(preflight.message, contains('adapter rejected mtu request')); - expect(adapter.requestedMtuValues, [247]); - }); - - test('fails when negotiated MTU is too low for 64-byte frame writes', - () async { - final adapter = _FakeDfuPreflightBluetoothAdapter( - currentConnectionState: (ConnectionStatus.connected, 'target-device'), - mtuResult: Ok(universalShifterDfuMinimumMtu - 1), - ); - final service = ShifterService( - buttonDeviceId: 'target-device', - dfuPreflightBluetooth: adapter, - ); - - final result = await service.runDfuPreflight(); - - expect(result.isOk(), isTrue); - final preflight = result.unwrap(); - expect(preflight.canStart, isFalse); - expect(preflight.failureReason, DfuPreflightFailureReason.mtuTooLow); - expect(preflight.negotiatedMtu, universalShifterDfuMinimumMtu - 1); - expect(preflight.requiredMtu, universalShifterDfuMinimumMtu); - }); - - test('passes when connected to target and MTU is sufficient', () async { - final adapter = _FakeDfuPreflightBluetoothAdapter( - currentConnectionState: (ConnectionStatus.connected, 'target-device'), - mtuResult: Ok(128), - ); - final service = ShifterService( - buttonDeviceId: 'target-device', - dfuPreflightBluetooth: adapter, - ); - - final result = await service.runDfuPreflight(); - - expect(result.isOk(), isTrue); - final preflight = result.unwrap(); - expect(preflight.canStart, isTrue); - expect(preflight.failureReason, isNull); - expect(preflight.negotiatedMtu, 128); - expect(preflight.requestedMtu, universalShifterDfuPreferredMtu); - }); - }); -} - -class _FakeDfuPreflightBluetoothAdapter - implements DfuPreflightBluetoothAdapter { - _FakeDfuPreflightBluetoothAdapter({ - required this.currentConnectionState, - required Result mtuResult, - }) : _mtuResult = mtuResult; - - @override - final (ConnectionStatus, String?) currentConnectionState; - - final Result _mtuResult; - - int requestMtuCallCount = 0; - final List requestedMtuValues = []; - - @override - Future> requestMtuAndGetValue( - String deviceId, { - required int mtu, - }) async { - requestMtuCallCount += 1; - requestedMtuValues.add(mtu); - return _mtuResult; - } -} diff --git a/test/service/firmware_update_service_test.dart b/test/service/firmware_update_service_test.dart index d971040..d276895 100644 --- a/test/service/firmware_update_service_test.dart +++ b/test/service/firmware_update_service_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/firmware_update_service.dart'; @@ -6,413 +7,304 @@ import 'package:anyhow/anyhow.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('FirmwareUpdateService', () { - test('completes happy path with START, data frames, and FINISH', () async { - final transport = _FakeFirmwareUpdateTransport(); + group('FirmwareUpdateService bootloader flow', () { + test('completes happy path with START, offset data, FINISH, and verify', + () async { + final image = _validImage(130); + final transport = _FakeFirmwareUpdateTransport(totalBytes: image.length); final service = FirmwareUpdateService( transport: transport, - defaultWindowSize: 4, - defaultAckTimeout: const Duration(milliseconds: 100), + defaultStatusTimeout: const Duration(milliseconds: 100), ); - final image = List.generate(130, (index) => index & 0xFF); final result = await service.startUpdate( imageBytes: image, sessionId: 7, ); expect(result.isOk(), isTrue); - expect(transport.controlWrites.length, 2); + expect(transport.steps, [ + 'enterBootloader', + 'waitForAppDisconnect', + 'connectToBootloader', + 'negotiateMtu', + 'readStatus', + 'waitForBootloaderDisconnect', + 'reconnectForVerification', + 'verifyDeviceReachable', + ]); expect( transport.controlWrites.first.first, universalShifterDfuOpcodeStart); - expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]); - expect(transport.dataWrites.length, greaterThanOrEqualTo(3)); expect( - transport.postFinishSteps, - [ - 'waitForExpectedResetDisconnect', - 'reconnectForVerification', - 'verifyDeviceReachable', - ], - ); + transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 7]); + expect(transport.dataWrites, isNotEmpty); + expect(transport.dataWrites.first[0], 7); + expect(transport.dataWrites.first.sublist(1, 5), [0, 0, 0, 0]); expect(service.currentProgress.state, DfuUpdateState.completed); expect(service.currentProgress.sentBytes, image.length); + expect(service.currentProgress.expectedOffset, image.length); await service.dispose(); await transport.dispose(); }); - test('rewinds to ack+1 and retransmits after ACK stall', () async { - final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1); + test('backs off on queue-full status and resumes from GET_STATUS', + () async { + final image = _validImage(80); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + queueFullOnFirstData: true, + ); final service = FirmwareUpdateService( transport: transport, - defaultWindowSize: 3, - defaultAckTimeout: const Duration(milliseconds: 100), - maxNoProgressRetries: 4, + defaultStatusTimeout: const Duration(milliseconds: 100), ); - final image = List.generate(190, (index) => index & 0xFF); final result = await service.startUpdate( imageBytes: image, sessionId: 9, ); expect(result.isOk(), isTrue); - expect(transport.dataWrites.length, greaterThan(4)); - expect(transport.sequenceWriteCount(1), greaterThan(1)); + expect( + transport.controlWrites + .where((write) => write.first == universalShifterDfuOpcodeGetStatus) + .length, + 1, + ); + expect( + transport.dataWriteOffsets.where((offset) => offset == 0).length, 2); expect(service.currentProgress.state, DfuUpdateState.completed); await service.dispose(); await transport.dispose(); }); - test('fails after bounded retries when ACK progress times out', () async { - final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true); + test('fails with bootloader status error on rejected START', () async { + final image = _validImage(40); + final transport = _FakeFirmwareUpdateTransport( + totalBytes: image.length, + startStatusCode: DfuBootloaderStatusCode.vectorError, + ); final service = FirmwareUpdateService( transport: transport, - defaultWindowSize: 1, - defaultAckTimeout: const Duration(milliseconds: 40), - maxNoProgressRetries: 2, + defaultStatusTimeout: const Duration(milliseconds: 100), ); - final image = List.generate(90, (index) => index & 0xFF); final result = await service.startUpdate( imageBytes: image, sessionId: 10, ); expect(result.isErr(), isTrue); - expect(result.unwrapErr().toString(), contains('Upload stalled')); - expect(result.unwrapErr().toString(), contains('after 3 retries')); - expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]); - expect(transport.sequenceWriteCount(0), 3); + expect(result.unwrapErr().toString(), contains('vector table error')); expect(service.currentProgress.state, DfuUpdateState.failed); + expect( + transport.controlWrites.last.first, universalShifterDfuOpcodeStart); await service.dispose(); await transport.dispose(); }); - test('cancel sends ABORT and reports aborted state', () async { + test('cancel after START sends session-scoped ABORT', () async { + final image = _validImage(80); final firstFrameSent = Completer(); final transport = _FakeFirmwareUpdateTransport( - onDataWrite: (frame) { + totalBytes: image.length, + suppressFirstDataStatus: true, + onDataWrite: () { if (!firstFrameSent.isCompleted) { firstFrameSent.complete(); } }, - suppressDataAcks: true, ); final service = FirmwareUpdateService( transport: transport, - defaultWindowSize: 1, - defaultAckTimeout: const Duration(milliseconds: 500), + defaultStatusTimeout: const Duration(seconds: 1), ); final future = service.startUpdate( - imageBytes: List.generate(90, (index) => index & 0xFF), + imageBytes: image, sessionId: 11, ); - await firstFrameSent.future.timeout(const Duration(seconds: 1)); await service.cancelUpdate(); final result = await future; expect(result.isErr(), isTrue); expect(result.unwrapErr().toString(), contains('canceled')); - expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]); + expect( + transport.controlWrites.last, [universalShifterDfuOpcodeAbort, 11]); expect(service.currentProgress.state, DfuUpdateState.aborted); await service.dispose(); await transport.dispose(); }); - - test('fails when reconnect does not succeed after expected reset', - () async { - final transport = _FakeFirmwareUpdateTransport( - reconnectError: 'simulated reconnect timeout', - ); - final service = FirmwareUpdateService( - transport: transport, - defaultWindowSize: 4, - defaultAckTimeout: const Duration(milliseconds: 100), - ); - - final image = List.generate(130, (index) => index & 0xFF); - final result = await service.startUpdate( - imageBytes: image, - sessionId: 13, - ); - - expect(result.isErr(), isTrue); - expect(result.unwrapErr().toString(), contains('did not reconnect')); - expect(service.currentProgress.state, DfuUpdateState.failed); - expect( - transport.postFinishSteps, - [ - 'waitForExpectedResetDisconnect', - 'reconnectForVerification', - ], - ); - - await service.dispose(); - await transport.dispose(); - }); - - test('fails when expected reset disconnect is not observed', () async { - final transport = _FakeFirmwareUpdateTransport( - resetDisconnectError: 'simulated missing disconnect', - ); - final service = FirmwareUpdateService( - transport: transport, - defaultWindowSize: 4, - defaultAckTimeout: const Duration(milliseconds: 100), - ); - - final image = List.generate(130, (index) => index & 0xFF); - final result = await service.startUpdate( - imageBytes: image, - sessionId: 15, - ); - - expect(result.isErr(), isTrue); - expect( - result.unwrapErr().toString(), - contains('expected post-FINISH reset disconnect'), - ); - expect(service.currentProgress.state, DfuUpdateState.failed); - expect( - transport.postFinishSteps, - ['waitForExpectedResetDisconnect'], - ); - - await service.dispose(); - await transport.dispose(); - }); - - test('fails when post-update status verification read fails', () async { - final transport = _FakeFirmwareUpdateTransport( - verificationError: 'simulated status read failure', - ); - final service = FirmwareUpdateService( - transport: transport, - defaultWindowSize: 4, - defaultAckTimeout: const Duration(milliseconds: 100), - ); - - final image = List.generate(130, (index) => index & 0xFF); - final result = await service.startUpdate( - imageBytes: image, - sessionId: 14, - ); - - expect(result.isErr(), isTrue); - expect( - result.unwrapErr().toString(), - contains('post-update verification failed'), - ); - expect( - result.unwrapErr().toString(), - contains('does not expose a version characteristic'), - ); - expect(service.currentProgress.state, DfuUpdateState.failed); - expect( - transport.postFinishSteps, - [ - 'waitForExpectedResetDisconnect', - 'reconnectForVerification', - 'verifyDeviceReachable', - ], - ); - - await service.dispose(); - await transport.dispose(); - }); - - test('handles deterministic ACK sequence wrap-around across 0xFF->0x00', - () async { - const frameCount = 260; - final transport = _FakeFirmwareUpdateTransport(); - final service = FirmwareUpdateService( - transport: transport, - defaultWindowSize: 16, - defaultAckTimeout: const Duration(milliseconds: 100), - ); - - final image = List.generate( - frameCount * universalShifterDfuFramePayloadSizeBytes, - (index) => index & 0xFF, - ); - - final result = await service.startUpdate( - imageBytes: image, - sessionId: 16, - ); - - expect(result.isOk(), isTrue); - - var ffToZeroTransitions = 0; - for (var i = 1; i < transport.ackNotifications.length; i++) { - if (transport.ackNotifications[i - 1] == 0xFF && - transport.ackNotifications[i] == 0x00) { - ffToZeroTransitions += 1; - } - } - - expect(ffToZeroTransitions, greaterThanOrEqualTo(2)); - expect(service.currentProgress.lastAckedSequence, 0x03); - expect(service.currentProgress.sentBytes, image.length); - expect(service.currentProgress.state, DfuUpdateState.completed); - - await service.dispose(); - await transport.dispose(); - }); }); } class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { _FakeFirmwareUpdateTransport({ - this.dropFirstSequence, + required this.totalBytes, + this.startStatusCode = DfuBootloaderStatusCode.ok, + this.queueFullOnFirstData = false, + this.suppressFirstDataStatus = false, this.onDataWrite, - this.suppressDataAcks = false, - this.resetDisconnectError, - this.reconnectError, - this.verificationError, }); - final int? dropFirstSequence; - final void Function(List frame)? onDataWrite; - final bool suppressDataAcks; - final String? resetDisconnectError; - final String? reconnectError; - final String? verificationError; + final int totalBytes; + final DfuBootloaderStatusCode startStatusCode; + final bool queueFullOnFirstData; + final bool suppressFirstDataStatus; + final void Function()? onDataWrite; - final StreamController> _ackController = + final StreamController> _statusController = StreamController>.broadcast(); - + final List steps = []; final List> controlWrites = >[]; final List> dataWrites = >[]; - final List ackNotifications = []; - final List postFinishSteps = []; - final Set _droppedOnce = {}; - int _lastAck = 0xFF; - int _expectedSequence = 0; + final List dataWriteOffsets = []; + + int _sessionId = 0; + int _expectedOffset = 0; + bool _sentQueueFull = false; + bool _suppressedDataStatus = false; @override - Future> runPreflight({ - required int requestedMtu, - }) async { - return Ok( - DfuPreflightResult.ready( - requestedMtu: requestedMtu, - negotiatedMtu: 128, - ), - ); + Future> enterBootloader() async { + steps.add('enterBootloader'); + return Ok(null); } @override - Stream> subscribeToAck() => _ackController.stream; + Future> waitForAppDisconnect({required Duration timeout}) async { + steps.add('waitForAppDisconnect'); + return Ok(null); + } + + @override + Future> connectToBootloader({required Duration timeout}) async { + steps.add('connectToBootloader'); + return Ok(null); + } + + @override + Future> negotiateMtu({required int requestedMtu}) async { + steps.add('negotiateMtu'); + return Ok(128); + } + + @override + Stream> subscribeToStatus() => _statusController.stream; + + @override + Future>> readStatus() async { + steps.add('readStatus'); + return Ok(_status(DfuBootloaderStatusCode.ok, 0, 0)); + } @override Future> writeControl(List payload) async { controlWrites.add(List.from(payload, growable: false)); - - final opcode = payload.isEmpty ? -1 : payload.first; + final opcode = payload.first; if (opcode == universalShifterDfuOpcodeStart) { - _lastAck = 0xFF; - _expectedSequence = 0; - _scheduleAck(0xFF); + _sessionId = payload[17]; + _expectedOffset = 0; + _scheduleStatus(startStatusCode, _sessionId, 0); + } else if (opcode == universalShifterDfuOpcodeGetStatus) { + _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); + } else if (opcode == universalShifterDfuOpcodeFinish) { + _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes); + } else if (opcode == universalShifterDfuOpcodeAbort) { + _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0); } - - if (opcode == universalShifterDfuOpcodeAbort) { - _lastAck = 0xFF; - _expectedSequence = 0; - } - return Ok(null); } @override Future> writeDataFrame(List frame) async { dataWrites.add(List.from(frame, growable: false)); - onDataWrite?.call(frame); + onDataWrite?.call(); - if (suppressDataAcks) { + final offset = _readLeU32(frame, 1); + dataWriteOffsets.add(offset); + + if (queueFullOnFirstData && !_sentQueueFull) { + _sentQueueFull = true; + _scheduleStatus( + DfuBootloaderStatusCode.queueFull, _sessionId, _expectedOffset); return Ok(null); } - final sequence = frame.first; - final shouldDrop = dropFirstSequence != null && - sequence == dropFirstSequence && - !_droppedOnce.contains(sequence); - - if (shouldDrop) { - _droppedOnce.add(sequence); - _scheduleAck(_lastAck); + if (suppressFirstDataStatus && !_suppressedDataStatus) { + _suppressedDataStatus = true; return Ok(null); } - if (sequence == _expectedSequence) { - _lastAck = sequence; - _expectedSequence = (_expectedSequence + 1) & 0xFF; - } - - _scheduleAck(_lastAck); - + final payloadLength = + frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes; + _expectedOffset = offset + payloadLength; + _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); return Ok(null); } - void _scheduleAck(int sequence) { - final ack = sequence & 0xFF; - ackNotifications.add(ack); + @override + Future> waitForBootloaderDisconnect( + {required Duration timeout}) async { + steps.add('waitForBootloaderDisconnect'); + return Ok(null); + } + + @override + Future> reconnectForVerification( + {required Duration timeout}) async { + steps.add('reconnectForVerification'); + return Ok(null); + } + + @override + Future> verifyDeviceReachable( + {required Duration timeout}) async { + steps.add('verifyDeviceReachable'); + return Ok(null); + } + + void _scheduleStatus( + DfuBootloaderStatusCode code, int sessionId, int offset) { + final status = _status(code, sessionId, offset); scheduleMicrotask(() { - _ackController.add([ack]); + _statusController.add(status); }); } - @override - Future> waitForExpectedResetDisconnect({ - required Duration timeout, - }) async { - postFinishSteps.add('waitForExpectedResetDisconnect'); - if (resetDisconnectError != null) { - return bail(resetDisconnectError!); - } - return Ok(null); + List _status(DfuBootloaderStatusCode code, int sessionId, int offset) { + return [ + code.value, + sessionId & 0xFF, + offset & 0xFF, + (offset >> 8) & 0xFF, + (offset >> 16) & 0xFF, + (offset >> 24) & 0xFF, + ]; } - @override - Future> reconnectForVerification({ - required Duration timeout, - }) async { - postFinishSteps.add('reconnectForVerification'); - if (reconnectError != null) { - return bail(reconnectError!); - } - return Ok(null); - } - - @override - Future> verifyDeviceReachable({ - required Duration timeout, - }) async { - postFinishSteps.add('verifyDeviceReachable'); - if (verificationError != null) { - return bail(verificationError!); - } - return Ok(null); - } - - int sequenceWriteCount(int sequence) { - var count = 0; - for (final frame in dataWrites) { - if (frame.first == sequence) { - count += 1; - } - } - return count; + int _readLeU32(List bytes, int offset) { + final data = ByteData.sublistView(Uint8List.fromList(bytes)); + return data.getUint32(offset, Endian.little); } Future dispose() async { - await _ackController.close(); + await _statusController.close(); } } + +List _validImage(int length) { + final image = Uint8List(length); + final data = ByteData.sublistView(image); + data.setUint32(0, 0x20001000, Endian.little); + data.setUint32(4, 0x00030009, Endian.little); + for (var index = 8; index < image.length; index++) { + image[index] = index & 0xFF; + } + return image; +}