feat(dfu): add firmware transfer engine with ack retries
This commit is contained in:
551
lib/service/firmware_update_service.dart
Normal file
551
lib/service/firmware_update_service.dart
Normal file
@ -0,0 +1,551 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
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;
|
||||
|
||||
class FirmwareUpdateService {
|
||||
FirmwareUpdateService({
|
||||
required FirmwareUpdateTransport transport,
|
||||
this.defaultWindowSize = 8,
|
||||
this.maxNoProgressRetries = 5,
|
||||
this.defaultAckTimeout = const Duration(milliseconds: 800),
|
||||
}) : _transport = transport;
|
||||
|
||||
final FirmwareUpdateTransport _transport;
|
||||
final int defaultWindowSize;
|
||||
final int maxNoProgressRetries;
|
||||
final Duration defaultAckTimeout;
|
||||
|
||||
final StreamController<DfuUpdateProgress> _progressController =
|
||||
StreamController<DfuUpdateProgress>.broadcast();
|
||||
|
||||
DfuUpdateProgress _currentProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: _initialAckSequence,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
|
||||
StreamSubscription<List<int>>? _ackSubscription;
|
||||
Completer<void>? _ackSignal;
|
||||
Completer<void>? _cancelSignal;
|
||||
int _ackEventCount = 0;
|
||||
String? _ackStreamError;
|
||||
bool _isRunning = false;
|
||||
bool _cancelRequested = false;
|
||||
int _latestAckSequence = _initialAckSequence;
|
||||
int _ackedFrames = 0;
|
||||
int _totalFrames = 0;
|
||||
int _totalBytes = 0;
|
||||
|
||||
Stream<DfuUpdateProgress> get progressStream => _progressController.stream;
|
||||
|
||||
DfuUpdateProgress get currentProgress => _currentProgress;
|
||||
|
||||
bool get isUpdating => _isRunning;
|
||||
|
||||
Future<Result<void>> startUpdate({
|
||||
required List<int> imageBytes,
|
||||
required int sessionId,
|
||||
DfuUpdateFlags flags = const DfuUpdateFlags(),
|
||||
int requestedMtu = universalShifterDfuPreferredMtu,
|
||||
int? windowSize,
|
||||
Duration? ackTimeout,
|
||||
int? noProgressRetries,
|
||||
}) async {
|
||||
if (_isRunning) {
|
||||
return bail(
|
||||
'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;
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
_cancelRequested = false;
|
||||
_cancelSignal = Completer<void>();
|
||||
_ackSignal = null;
|
||||
_ackEventCount = 0;
|
||||
_ackStreamError = null;
|
||||
_latestAckSequence = _initialAckSequence;
|
||||
_ackedFrames = 0;
|
||||
_totalFrames =
|
||||
(imageBytes.length + universalShifterDfuFramePayloadSizeBytes - 1) ~/
|
||||
universalShifterDfuFramePayloadSizeBytes;
|
||||
_totalBytes = imageBytes.length;
|
||||
|
||||
final normalizedSessionId = sessionId & 0xFF;
|
||||
final crc32 = DfuProtocol.crc32(imageBytes);
|
||||
final frames = DfuProtocol.buildDataFrames(imageBytes);
|
||||
var shouldAbortForCleanup = false;
|
||||
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.starting,
|
||||
totalBytes: imageBytes.length,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: _initialAckSequence,
|
||||
sessionId: normalizedSessionId,
|
||||
flags: flags,
|
||||
);
|
||||
|
||||
try {
|
||||
final preflightResult = await _transport.runPreflight(
|
||||
requestedMtu: requestedMtu,
|
||||
);
|
||||
if (preflightResult.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.',
|
||||
);
|
||||
}
|
||||
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = _transport.subscribeToAck().listen(
|
||||
_handleAckPayload,
|
||||
onError: (Object error) {
|
||||
_ackStreamError =
|
||||
'ACK indication stream failed: $error. Reconnect and retry the update.';
|
||||
_signalAckWaiters();
|
||||
},
|
||||
);
|
||||
|
||||
_emitProgress(state: DfuUpdateState.waitingForAck);
|
||||
final startEventCount = _ackEventCount;
|
||||
final startWriteResult = await _transport.writeControl(
|
||||
DfuProtocol.encodeStartPayload(
|
||||
DfuStartPayload(
|
||||
totalLength: imageBytes.length,
|
||||
imageCrc32: crc32,
|
||||
sessionId: normalizedSessionId,
|
||||
flags: flags.rawValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (startWriteResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Failed to send DFU START command: ${startWriteResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
shouldAbortForCleanup = true;
|
||||
|
||||
final initialAck = await _waitForInitialAck(
|
||||
afterEventCount: startEventCount,
|
||||
timeout: effectiveAckTimeout,
|
||||
);
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
_emitProgress(state: DfuUpdateState.transferring);
|
||||
|
||||
var nextFrameIndex = 0;
|
||||
var retriesWithoutProgress = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
_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()}',
|
||||
);
|
||||
}
|
||||
|
||||
shouldAbortForCleanup = false;
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
|
||||
return Ok(null);
|
||||
} on _DfuCancelled {
|
||||
if (shouldAbortForCleanup) {
|
||||
await _sendAbortForCleanup();
|
||||
}
|
||||
_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.';
|
||||
_emitProgress(state: DfuUpdateState.failed, errorMessage: message);
|
||||
return bail(message);
|
||||
} finally {
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
_isRunning = false;
|
||||
_cancelRequested = false;
|
||||
_cancelSignal = null;
|
||||
_ackSignal = null;
|
||||
_ackEventCount = 0;
|
||||
_ackStreamError = null;
|
||||
_latestAckSequence = _currentProgress.lastAckedSequence;
|
||||
_ackedFrames = 0;
|
||||
_totalFrames = 0;
|
||||
_totalBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelUpdate() async {
|
||||
if (!_isRunning || _cancelRequested) {
|
||||
return;
|
||||
}
|
||||
_cancelRequested = true;
|
||||
_cancelSignal?.complete();
|
||||
_signalAckWaiters();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await cancelUpdate();
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
await _progressController.close();
|
||||
}
|
||||
|
||||
void _handleAckPayload(List<int> payload) {
|
||||
try {
|
||||
final sequence = DfuProtocol.parseAckPayload(payload);
|
||||
final previousAck = _latestAckSequence;
|
||||
_latestAckSequence = sequence;
|
||||
|
||||
if (_totalFrames > 0 &&
|
||||
_currentProgress.state == DfuUpdateState.transferring) {
|
||||
final delta = DfuProtocol.sequenceDistance(previousAck, sequence);
|
||||
if (delta > 0) {
|
||||
_ackedFrames = (_ackedFrames + delta).clamp(0, _totalFrames);
|
||||
}
|
||||
|
||||
_emitProgress(
|
||||
lastAckedSequence: sequence,
|
||||
sentBytes:
|
||||
_ackedBytesFromFrames(_ackedFrames, _totalFrames, _totalBytes),
|
||||
);
|
||||
} else {
|
||||
_emitProgress(lastAckedSequence: sequence);
|
||||
}
|
||||
} on FormatException catch (error) {
|
||||
_ackStreamError =
|
||||
'Received malformed ACK indication: $error. Reconnect and retry.';
|
||||
} finally {
|
||||
_ackEventCount += 1;
|
||||
_signalAckWaiters();
|
||||
}
|
||||
}
|
||||
|
||||
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<int> _waitForInitialAck({
|
||||
required int afterEventCount,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
var observedEvents = afterEventCount;
|
||||
|
||||
while (true) {
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
final gotEvent = await _waitForNextAckEvent(
|
||||
afterEventCount: observedEvents,
|
||||
timeout: remaining,
|
||||
);
|
||||
if (!gotEvent) {
|
||||
continue;
|
||||
}
|
||||
observedEvents = _ackEventCount;
|
||||
return _latestAckSequence;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _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<bool> _waitForNextAckEvent({
|
||||
required int afterEventCount,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
if (_ackEventCount > afterEventCount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_ackSignal ??= Completer<void>();
|
||||
final signal = _ackSignal!;
|
||||
|
||||
try {
|
||||
await Future.any<void>([
|
||||
signal.future,
|
||||
_cancelSignal?.future ?? Future<void>.value(),
|
||||
]).timeout(timeout);
|
||||
} on TimeoutException {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (identical(_ackSignal, signal)) {
|
||||
_ackSignal = null;
|
||||
}
|
||||
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
return _ackEventCount > afterEventCount;
|
||||
}
|
||||
|
||||
void _throwIfCancelled() {
|
||||
if (_cancelRequested) {
|
||||
throw const _DfuCancelled();
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfAckStreamErrored() {
|
||||
final error = _ackStreamError;
|
||||
if (error != null) {
|
||||
throw _DfuFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
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<Result<DfuPreflightResult>> runPreflight({required int requestedMtu});
|
||||
|
||||
Stream<List<int>> subscribeToAck();
|
||||
|
||||
Future<Result<void>> writeControl(List<int> payload);
|
||||
|
||||
Future<Result<void>> writeDataFrame(List<int> frame);
|
||||
}
|
||||
|
||||
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
ShifterFirmwareUpdateTransport({
|
||||
required this.shifterService,
|
||||
required this.bluetoothController,
|
||||
required this.buttonDeviceId,
|
||||
});
|
||||
|
||||
final ShifterService shifterService;
|
||||
final BluetoothController bluetoothController;
|
||||
final String buttonDeviceId;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) {
|
||||
return shifterService.runDfuPreflight(requestedMtu: requestedMtu);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() {
|
||||
return bluetoothController.subscribeToCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuAckCharacteristicUuid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeControl(List<int> payload) {
|
||||
return bluetoothController.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuControlCharacteristicUuid,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) {
|
||||
return bluetoothController.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuDataCharacteristicUuid,
|
||||
frame,
|
||||
withResponse: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DfuFailure implements Exception {
|
||||
const _DfuFailure(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class _DfuCancelled implements Exception {
|
||||
const _DfuCancelled();
|
||||
}
|
||||
200
test/service/firmware_update_service_test.dart
Normal file
200
test/service/firmware_update_service_test.dart
Normal file
@ -0,0 +1,200 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
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();
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.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.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
||||
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
expect(service.currentProgress.sentBytes, image.length);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('rewinds to ack+1 and retransmits after ACK stall', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 3,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
maxNoProgressRetries: 4,
|
||||
);
|
||||
|
||||
final image = List<int>.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(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('cancel sends ABORT and reports aborted state', () async {
|
||||
final firstFrameSent = Completer<void>();
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
onDataWrite: (frame) {
|
||||
if (!firstFrameSent.isCompleted) {
|
||||
firstFrameSent.complete();
|
||||
}
|
||||
},
|
||||
suppressDataAcks: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 1,
|
||||
defaultAckTimeout: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
final future = service.startUpdate(
|
||||
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
|
||||
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(service.currentProgress.state, DfuUpdateState.aborted);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
_FakeFirmwareUpdateTransport({
|
||||
this.dropFirstSequence,
|
||||
this.onDataWrite,
|
||||
this.suppressDataAcks = false,
|
||||
});
|
||||
|
||||
final int? dropFirstSequence;
|
||||
final void Function(List<int> frame)? onDataWrite;
|
||||
final bool suppressDataAcks;
|
||||
|
||||
final StreamController<List<int>> _ackController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
|
||||
final List<List<int>> controlWrites = <List<int>>[];
|
||||
final List<List<int>> dataWrites = <List<int>>[];
|
||||
final Set<int> _droppedOnce = <int>{};
|
||||
int _lastAck = 0xFF;
|
||||
int _expectedSequence = 0;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) async {
|
||||
return Ok(
|
||||
DfuPreflightResult.ready(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: 128,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() => _ackController.stream;
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeControl(List<int> payload) async {
|
||||
controlWrites.add(List<int>.from(payload, growable: false));
|
||||
|
||||
final opcode = payload.isEmpty ? -1 : payload.first;
|
||||
if (opcode == universalShifterDfuOpcodeStart) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([0xFF]);
|
||||
});
|
||||
}
|
||||
|
||||
if (opcode == universalShifterDfuOpcodeAbort) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) async {
|
||||
dataWrites.add(List<int>.from(frame, growable: false));
|
||||
onDataWrite?.call(frame);
|
||||
|
||||
if (suppressDataAcks) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
final sequence = frame.first;
|
||||
final shouldDrop = dropFirstSequence != null &&
|
||||
sequence == dropFirstSequence &&
|
||||
!_droppedOnce.contains(sequence);
|
||||
|
||||
if (shouldDrop) {
|
||||
_droppedOnce.add(sequence);
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([_lastAck]);
|
||||
});
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
if (sequence == _expectedSequence) {
|
||||
_lastAck = sequence;
|
||||
_expectedSequence = (_expectedSequence + 1) & 0xFF;
|
||||
}
|
||||
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([_lastAck]);
|
||||
});
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
int sequenceWriteCount(int sequence) {
|
||||
var count = 0;
|
||||
for (final frame in dataWrites) {
|
||||
if (frame.first == sequence) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _ackController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user