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', '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', '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('reconnects and resumes from status after transient data failure', () async { final image = _validImage(130); final transport = _FakeFirmwareUpdateTransport( totalBytes: image.length, failDataWriteAtOffsetOnce: universalShifterBootloaderDfuMaxPayloadSizeBytes, ); final service = FirmwareUpdateService( transport: transport, defaultStatusTimeout: const Duration(milliseconds: 100), defaultBootloaderConnectTimeout: const Duration(milliseconds: 100), ); final result = await service.startUpdate( imageBytes: image, sessionId: 13, ); expect(result.isOk(), isTrue); expect( transport.steps.where((step) => step == 'connectToBootloader').length, 2, ); expect( transport.controlWrites .where((write) => write.first == universalShifterDfuOpcodeGetStatus) .length, 1, ); expect( transport.dataWriteOffsets .where( (offset) => offset == universalShifterBootloaderDfuMaxPayloadSizeBytes, ) .length, 2, ); expect(service.currentProgress.state, DfuUpdateState.completed); await service.dispose(); await transport.dispose(); }); test('restarts START when reconnect status has no active session', () async { final image = _validImage(80); final transport = _FakeFirmwareUpdateTransport( totalBytes: image.length, failDataWriteAtOffsetOnce: universalShifterBootloaderDfuMaxPayloadSizeBytes, resetSessionOnRecoveryStatus: true, ); final service = FirmwareUpdateService( transport: transport, defaultStatusTimeout: const Duration(milliseconds: 100), defaultBootloaderConnectTimeout: const Duration(milliseconds: 100), ); final result = await service.startUpdate( imageBytes: image, sessionId: 14, ); expect(result.isOk(), isTrue); expect( transport.controlWrites .where((write) => write.first == universalShifterDfuOpcodeStart) .length, 2, ); expect(service.currentProgress.state, DfuUpdateState.completed); await service.dispose(); await transport.dispose(); }); test('fails with bootloader status error on rejected START', () async { final image = _validImage(40); final transport = _FakeFirmwareUpdateTransport( 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('cancel after START sends session-scoped ABORT', () async { final image = _validImage(80); final firstFrameSent = Completer(); 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.startStatusCode = DfuBootloaderStatusCode.ok, this.alreadyInBootloader = false, this.failEnterBootloader = false, this.queueFullOnFirstData = false, this.suppressFirstDataStatus = false, this.failDataWriteAtOffsetOnce, this.resetSessionOnRecoveryStatus = false, this.onDataWrite, }); final int totalBytes; final DfuBootloaderStatusCode startStatusCode; final bool alreadyInBootloader; final bool failEnterBootloader; final bool queueFullOnFirstData; final bool suppressFirstDataStatus; final int? failDataWriteAtOffsetOnce; final bool resetSessionOnRecoveryStatus; final void Function()? onDataWrite; final StreamController> _statusController = StreamController>.broadcast(); final List steps = []; final List> controlWrites = >[]; final List> dataWrites = >[]; final List dataWriteOffsets = []; int _sessionId = 0; int _expectedOffset = 0; int _connectCount = 0; bool _sentDataFailure = false; bool _sentQueueFull = false; bool _suppressedDataStatus = false; @override Future> isConnectedToBootloader() async { steps.add('isConnectedToBootloader'); return Ok(alreadyInBootloader); } @override Future> enterBootloader() async { steps.add('enterBootloader'); if (failEnterBootloader) { return bail('app disconnected before write response'); } return Ok(null); } @override Future> waitForAppDisconnect({required Duration timeout}) async { steps.add('waitForAppDisconnect'); return Ok(null); } @override Future> connectToBootloader({required Duration timeout}) async { steps.add('connectToBootloader'); _connectCount += 1; 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.first; if (opcode == universalShifterDfuOpcodeStart) { _sessionId = payload[17]; _expectedOffset = 0; _scheduleStatus(startStatusCode, _sessionId, 0); } else if (opcode == universalShifterDfuOpcodeGetStatus) { if (resetSessionOnRecoveryStatus && _connectCount > 1) { _sessionId = 0; _expectedOffset = 0; } _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); } else if (opcode == universalShifterDfuOpcodeFinish) { _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes); } else if (opcode == universalShifterDfuOpcodeAbort) { _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0); } return Ok(null); } @override Future> writeDataFrame(List frame) async { dataWrites.add(List.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> 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(() { _statusController.add(status); }); } List _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 bytes, int offset) { final data = ByteData.sublistView(Uint8List.fromList(bytes)); return data.getUint32(offset, Endian.little); } Future dispose() async { 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; }