653 lines
20 KiB
Dart
653 lines
20 KiB
Dart
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';
|
|
import 'package:anyhow/anyhow.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
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,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 7,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(transport.steps, [
|
|
'isConnectedToBootloader',
|
|
'enterBootloader',
|
|
'waitForAppDisconnect',
|
|
'connectToBootloader',
|
|
'optimizeBootloaderConnection',
|
|
'negotiateMtu',
|
|
'readStatus',
|
|
'waitForBootloaderDisconnect',
|
|
'reconnectForVerification',
|
|
'verifyDeviceReachable',
|
|
]);
|
|
expect(
|
|
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
|
expect(
|
|
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('starts directly when already connected to bootloader', () async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
alreadyInBootloader: true,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 8,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(transport.steps, [
|
|
'isConnectedToBootloader',
|
|
'optimizeBootloaderConnection',
|
|
'negotiateMtu',
|
|
'readStatus',
|
|
'waitForBootloaderDisconnect',
|
|
'reconnectForVerification',
|
|
'verifyDeviceReachable',
|
|
]);
|
|
expect(
|
|
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('tolerates enter bootloader write error when app disconnects',
|
|
() async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
failEnterBootloader: true,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 12,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(transport.steps, contains('waitForAppDisconnect'));
|
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('backs off on queue-full status and resumes from GET_STATUS',
|
|
() async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
queueFullOnFirstData: true,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 9,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
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('completes when FINISH status is lost but bootloader disconnects',
|
|
() async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
suppressFinishStatus: true,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 20),
|
|
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 15,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(
|
|
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 15]);
|
|
expect(transport.steps, contains('reconnectForVerification'));
|
|
expect(transport.steps, contains('verifyDeviceReachable'));
|
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('fails when FINISH status is lost and bootloader stays connected',
|
|
() async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
suppressFinishStatus: true,
|
|
disconnectAfterFinish: false,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 10),
|
|
defaultPostFinishResetTimeout: const Duration(milliseconds: 30),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 16,
|
|
);
|
|
|
|
expect(result.isErr(), isTrue);
|
|
expect(result.unwrapErr().toString(), contains('post-FINISH reset'));
|
|
expect(service.currentProgress.state, DfuUpdateState.failed);
|
|
expect(transport.steps, isNot(contains('reconnectForVerification')));
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('fails when FINISH returns explicit bootloader error', () async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
finishStatusCode: DfuBootloaderStatusCode.flashError,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 20),
|
|
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 17,
|
|
);
|
|
|
|
expect(result.isErr(), isTrue);
|
|
expect(result.unwrapErr().toString(), contains('flash error'));
|
|
expect(service.currentProgress.state, DfuUpdateState.failed);
|
|
expect(transport.steps, isNot(contains('reconnectForVerification')));
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('reconnects and resumes from status after transient data failure',
|
|
() async {
|
|
final image = _validImage(130);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
failDataWriteAtOffsetOnce:
|
|
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 13,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(
|
|
transport.steps.where((step) => step == 'connectToBootloader').length,
|
|
2,
|
|
);
|
|
expect(
|
|
transport.steps
|
|
.where((step) => step == 'optimizeBootloaderConnection')
|
|
.length,
|
|
2,
|
|
);
|
|
expect(
|
|
transport.controlWrites
|
|
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
|
|
.length,
|
|
1,
|
|
);
|
|
expect(
|
|
transport.dataWriteOffsets
|
|
.where(
|
|
(offset) =>
|
|
offset == universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
|
)
|
|
.length,
|
|
2,
|
|
);
|
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('restarts START when reconnect status has no active session',
|
|
() async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
failDataWriteAtOffsetOnce:
|
|
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
|
resetSessionOnRecoveryStatus: true,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 14,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(
|
|
transport.controlWrites
|
|
.where((write) => write.first == universalShifterDfuOpcodeStart)
|
|
.length,
|
|
2,
|
|
);
|
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('ignores stale previous-session status while waiting for START',
|
|
() async {
|
|
final image = _validImage(80);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
staleStartStatusSessionId: 20,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 21,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(
|
|
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
|
expect(transport.dataWrites.first[0], 21);
|
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('fails with bootloader status error on rejected START', () async {
|
|
final image = _validImage(40);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
startStatusCode: DfuBootloaderStatusCode.vectorError,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 10,
|
|
);
|
|
|
|
expect(result.isErr(), isTrue);
|
|
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('fails early on boot metadata error before START', () async {
|
|
final image = _validImage(40);
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
initialStatusCode: DfuBootloaderStatusCode.bootMetadataError,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 18,
|
|
);
|
|
|
|
expect(result.isErr(), isTrue);
|
|
expect(result.unwrapErr().toString(),
|
|
startsWith(universalShifterBootMetadataWarningMessage));
|
|
expect(
|
|
transport.controlWrites
|
|
.where((write) => write.first == universalShifterDfuOpcodeStart),
|
|
isEmpty);
|
|
expect(service.currentProgress.state, DfuUpdateState.failed);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('cancel after START sends session-scoped ABORT', () async {
|
|
final image = _validImage(80);
|
|
final firstFrameSent = Completer<void>();
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
totalBytes: image.length,
|
|
suppressFirstDataStatus: true,
|
|
onDataWrite: () {
|
|
if (!firstFrameSent.isCompleted) {
|
|
firstFrameSent.complete();
|
|
}
|
|
},
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultStatusTimeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
final future = service.startUpdate(
|
|
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, 11]);
|
|
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
});
|
|
}
|
|
|
|
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|
_FakeFirmwareUpdateTransport({
|
|
required this.totalBytes,
|
|
this.initialStatusCode = DfuBootloaderStatusCode.ok,
|
|
this.startStatusCode = DfuBootloaderStatusCode.ok,
|
|
this.alreadyInBootloader = false,
|
|
this.failEnterBootloader = false,
|
|
this.queueFullOnFirstData = false,
|
|
this.suppressFirstDataStatus = false,
|
|
this.failDataWriteAtOffsetOnce,
|
|
this.resetSessionOnRecoveryStatus = false,
|
|
this.staleStartStatusSessionId,
|
|
this.suppressFinishStatus = false,
|
|
this.disconnectAfterFinish = true,
|
|
this.finishStatusCode = DfuBootloaderStatusCode.ok,
|
|
this.onDataWrite,
|
|
});
|
|
|
|
final int totalBytes;
|
|
final DfuBootloaderStatusCode initialStatusCode;
|
|
final DfuBootloaderStatusCode startStatusCode;
|
|
final bool alreadyInBootloader;
|
|
final bool failEnterBootloader;
|
|
final bool queueFullOnFirstData;
|
|
final bool suppressFirstDataStatus;
|
|
final int? failDataWriteAtOffsetOnce;
|
|
final bool resetSessionOnRecoveryStatus;
|
|
final int? staleStartStatusSessionId;
|
|
final bool suppressFinishStatus;
|
|
final bool disconnectAfterFinish;
|
|
final DfuBootloaderStatusCode finishStatusCode;
|
|
final void Function()? onDataWrite;
|
|
|
|
final StreamController<List<int>> _statusController =
|
|
StreamController<List<int>>.broadcast();
|
|
final List<String> steps = <String>[];
|
|
final List<List<int>> controlWrites = <List<int>>[];
|
|
final List<List<int>> dataWrites = <List<int>>[];
|
|
final List<int> dataWriteOffsets = <int>[];
|
|
|
|
int _sessionId = 0;
|
|
int _expectedOffset = 0;
|
|
int _connectCount = 0;
|
|
bool _sentDataFailure = false;
|
|
bool _sentQueueFull = false;
|
|
bool _suppressedDataStatus = false;
|
|
bool _finishDisconnectAvailable = false;
|
|
|
|
@override
|
|
Future<Result<bool>> isConnectedToBootloader() async {
|
|
steps.add('isConnectedToBootloader');
|
|
return Ok(alreadyInBootloader);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> enterBootloader() async {
|
|
steps.add('enterBootloader');
|
|
if (failEnterBootloader) {
|
|
return bail('app disconnected before write response');
|
|
}
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> waitForAppDisconnect({required Duration timeout}) async {
|
|
steps.add('waitForAppDisconnect');
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
|
|
steps.add('connectToBootloader');
|
|
_connectCount += 1;
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> optimizeBootloaderConnection() async {
|
|
steps.add('optimizeBootloaderConnection');
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<int>> negotiateMtu({required int requestedMtu}) async {
|
|
steps.add('negotiateMtu');
|
|
expect(requestedMtu, universalShifterDfuPreferredMtu);
|
|
return Ok(128);
|
|
}
|
|
|
|
@override
|
|
Stream<List<int>> subscribeToStatus() => _statusController.stream;
|
|
|
|
@override
|
|
Future<Result<List<int>>> readStatus() async {
|
|
steps.add('readStatus');
|
|
return Ok(_status(initialStatusCode, 0, 0));
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> writeControl(List<int> payload) async {
|
|
controlWrites.add(List<int>.from(payload, growable: false));
|
|
final opcode = payload.first;
|
|
if (opcode == universalShifterDfuOpcodeStart) {
|
|
_sessionId = payload[17];
|
|
_expectedOffset = 0;
|
|
final staleSessionId = staleStartStatusSessionId;
|
|
if (staleSessionId != null) {
|
|
_scheduleStatus(DfuBootloaderStatusCode.ok, staleSessionId, 0);
|
|
}
|
|
_scheduleStatus(startStatusCode, _sessionId, 0);
|
|
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
|
|
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
|
|
_sessionId = 0;
|
|
_expectedOffset = 0;
|
|
}
|
|
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
|
} else if (opcode == universalShifterDfuOpcodeFinish) {
|
|
if (suppressFinishStatus) {
|
|
_finishDisconnectAvailable = disconnectAfterFinish;
|
|
} else {
|
|
_scheduleStatus(finishStatusCode, payload[1], totalBytes);
|
|
}
|
|
} else if (opcode == universalShifterDfuOpcodeAbort) {
|
|
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
|
|
}
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> writeDataFrame(List<int> frame) async {
|
|
dataWrites.add(List<int>.from(frame, growable: false));
|
|
onDataWrite?.call();
|
|
|
|
final offset = _readLeU32(frame, 1);
|
|
dataWriteOffsets.add(offset);
|
|
|
|
if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) {
|
|
_sentDataFailure = true;
|
|
return bail('simulated BLE write failure');
|
|
}
|
|
|
|
if (queueFullOnFirstData && !_sentQueueFull) {
|
|
_sentQueueFull = true;
|
|
_scheduleStatus(
|
|
DfuBootloaderStatusCode.queueFull, _sessionId, _expectedOffset);
|
|
return Ok(null);
|
|
}
|
|
|
|
if (suppressFirstDataStatus && !_suppressedDataStatus) {
|
|
_suppressedDataStatus = true;
|
|
return Ok(null);
|
|
}
|
|
|
|
final payloadLength =
|
|
frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes;
|
|
_expectedOffset = offset + payloadLength;
|
|
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> waitForBootloaderDisconnect(
|
|
{required Duration timeout}) async {
|
|
if (timeout == Duration.zero && !_finishDisconnectAvailable) {
|
|
return bail('still connected');
|
|
}
|
|
steps.add('waitForBootloaderDisconnect');
|
|
_finishDisconnectAvailable = true;
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> reconnectForVerification(
|
|
{required Duration timeout}) async {
|
|
steps.add('reconnectForVerification');
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> 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(() {
|
|
_statusController.add(status);
|
|
});
|
|
}
|
|
|
|
List<int> _status(DfuBootloaderStatusCode code, int sessionId, int offset) {
|
|
return [
|
|
code.value,
|
|
sessionId & 0xFF,
|
|
offset & 0xFF,
|
|
(offset >> 8) & 0xFF,
|
|
(offset >> 16) & 0xFF,
|
|
(offset >> 24) & 0xFF,
|
|
];
|
|
}
|
|
|
|
int _readLeU32(List<int> bytes, int offset) {
|
|
final data = ByteData.sublistView(Uint8List.fromList(bytes));
|
|
return data.getUint32(offset, Endian.little);
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await _statusController.close();
|
|
}
|
|
}
|
|
|
|
List<int> _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;
|
|
}
|