From b673c9100d1b9a8b5d228d17eed8cb415898bcf5 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Wed, 29 Apr 2026 17:56:32 +0200 Subject: [PATCH] feat: add bootloader DFU protocol validation --- lib/model/firmware_file_selection.dart | 26 ++- lib/model/shifter_types.dart | 60 +++++- lib/pages/device_details_page.dart | 7 +- lib/service/dfu_protocol.dart | 175 ++++++++++++++++++ .../firmware_file_selection_service.dart | 75 +++++++- test/service/dfu_protocol_test.dart | 175 ++++++++++++------ .../firmware_file_selection_service_test.dart | 102 ++++++++-- 7 files changed, 534 insertions(+), 86 deletions(-) diff --git a/lib/model/firmware_file_selection.dart b/lib/model/firmware_file_selection.dart index 22e952a..87ec7cc 100644 --- a/lib/model/firmware_file_selection.dart +++ b/lib/model/firmware_file_selection.dart @@ -1,21 +1,29 @@ import 'dart:typed_data'; -class DfuV1FirmwareMetadata { - const DfuV1FirmwareMetadata({ +class BootloaderDfuFirmwareMetadata { + const BootloaderDfuFirmwareMetadata({ required this.totalLength, required this.crc32, + required this.appStart, + required this.imageVersion, required this.sessionId, required this.flags, + required this.vectorStackPointer, + required this.vectorReset, }); final int totalLength; final int crc32; + final int appStart; + final int imageVersion; final int sessionId; final int flags; + final int vectorStackPointer; + final int vectorReset; } -class DfuV1PreparedFirmware { - const DfuV1PreparedFirmware({ +class BootloaderDfuPreparedFirmware { + const BootloaderDfuPreparedFirmware({ required this.fileName, required this.fileBytes, required this.metadata, @@ -25,7 +33,7 @@ class DfuV1PreparedFirmware { final String fileName; final String? filePath; final Uint8List fileBytes; - final DfuV1FirmwareMetadata metadata; + final BootloaderDfuFirmwareMetadata metadata; } enum FirmwareSelectionFailureReason { @@ -33,6 +41,9 @@ enum FirmwareSelectionFailureReason { malformedSelection, unsupportedExtension, emptyFile, + imageTooSmall, + imageTooLarge, + invalidVectorTable, readFailed, } @@ -52,7 +63,7 @@ class FirmwareFileSelectionResult { this.failure, }); - final DfuV1PreparedFirmware? firmware; + final BootloaderDfuPreparedFirmware? firmware; final FirmwareSelectionFailure? failure; bool get isSuccess => firmware != null; @@ -60,7 +71,8 @@ class FirmwareFileSelectionResult { bool get isCanceled => failure?.reason == FirmwareSelectionFailureReason.canceled; - static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) { + static FirmwareFileSelectionResult success( + BootloaderDfuPreparedFirmware firmware) { return FirmwareFileSelectionResult._(firmware: firmware); } diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index 780f6f2..d40ffb6 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -20,6 +20,8 @@ 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'; const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb'; const String batteryLevelCharacteristicUuid = @@ -36,14 +38,26 @@ bool isFtmsUuid(Uuid uuid) { const int universalShifterDfuOpcodeStart = 0x01; const int universalShifterDfuOpcodeFinish = 0x02; 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; +const int universalShifterDfuAppSlotSizeBytes = 0x00042000; +const int universalShifterDfuMinimumImageLengthBytes = 8; +const int universalShifterDfuRamStart = 0x20000000; +const int universalShifterDfuRamEnd = 0x20010000; + const int universalShifterDfuFlagEncrypted = 0x01; const int universalShifterDfuFlagSigned = 0x02; const int universalShifterDfuFlagNone = 0x00; @@ -134,6 +148,49 @@ class DfuUpdateProgress { state == DfuUpdateState.failed; } +enum DfuBootloaderStatusCode { + ok(0x00), + parseError(0x01), + stateError(0x02), + boundsError(0x03), + crcError(0x04), + flashError(0x05), + unsupportedError(0x06), + vectorError(0x07), + queueFull(0x08), + bootMetadataError(0x09), + unknown(-1); + + const DfuBootloaderStatusCode(this.value); + + final int value; + + static DfuBootloaderStatusCode fromRaw(int value) { + for (final code in values) { + if (code.value == value) { + return code; + } + } + return DfuBootloaderStatusCode.unknown; + } +} + +class DfuBootloaderStatus { + const DfuBootloaderStatus({ + required this.code, + required this.rawCode, + required this.sessionId, + required this.expectedOffset, + }); + + final DfuBootloaderStatusCode code; + final int rawCode; + final int sessionId; + final int expectedOffset; + + bool get isOk => code == DfuBootloaderStatusCode.ok; +} + enum DfuPreflightFailureReason { deviceNotConnected, wrongConnectedDevice, @@ -253,7 +310,8 @@ enum UniversalShifterCommand { stopScan(0x02), connectToDevice(0x03), disconnect(0x04), - turnOff(0x05); + turnOff(0x05), + enterDfu(0x06); const UniversalShifterCommand(this.value); final int value; diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index b88f4ba..d686e41 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -80,7 +80,7 @@ class _DeviceDetailsPageState extends ConsumerState { late final FirmwareFileSelectionService _firmwareFileSelectionService; FirmwareUpdateService? _firmwareUpdateService; StreamSubscription? _firmwareProgressSubscription; - DfuV1PreparedFirmware? _selectedFirmware; + BootloaderDfuPreparedFirmware? _selectedFirmware; DfuUpdateProgress _dfuProgress = const DfuUpdateProgress( state: DfuUpdateState.idle, totalBytes: 0, @@ -548,7 +548,8 @@ class _DeviceDetailsPageState extends ConsumerState { _firmwareUserMessage = null; }); - final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1(); + final result = + await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu(); if (!mounted) { return; } @@ -1061,7 +1062,7 @@ class _FirmwareUpdateCard extends StatelessWidget { required this.onStartUpdate, }); - final DfuV1PreparedFirmware? selectedFirmware; + final BootloaderDfuPreparedFirmware? selectedFirmware; final DfuUpdateProgress progress; final bool isSelecting; final bool isStarting; diff --git a/lib/service/dfu_protocol.dart b/lib/service/dfu_protocol.dart index 6a83147..77fbb3b 100644 --- a/lib/service/dfu_protocol.dart +++ b/lib/service/dfu_protocol.dart @@ -3,6 +3,39 @@ import 'dart:typed_data'; import 'package:abawo_bt_app/model/shifter_types.dart'; const int _startPayloadLength = 11; +const int _bootloaderStartPayloadLength = 19; + +class BootloaderDfuStartPayload { + const BootloaderDfuStartPayload({ + required this.totalLength, + required this.imageCrc32, + this.appStart = universalShifterDfuAppStart, + this.imageVersion = 0, + required this.sessionId, + required this.flags, + }); + + final int totalLength; + final int imageCrc32; + final int appStart; + final int imageVersion; + final int sessionId; + final int flags; +} + +class BootloaderDfuDataFrame { + const BootloaderDfuDataFrame({ + required this.sessionId, + required this.offset, + required this.payloadLength, + required this.bytes, + }); + + final int sessionId; + final int offset; + final int payloadLength; + final Uint8List bytes; +} class DfuStartPayload { const DfuStartPayload({ @@ -135,3 +168,145 @@ class DfuProtocol { return value & 0xFF; } } + +class BootloaderDfuProtocol { + const BootloaderDfuProtocol._(); + + static Uint8List encodeStartPayload(BootloaderDfuStartPayload payload) { + final data = ByteData(_bootloaderStartPayloadLength); + data.setUint8(0, universalShifterDfuOpcodeStart); + data.setUint32(1, payload.totalLength, Endian.little); + data.setUint32(5, payload.imageCrc32, Endian.little); + data.setUint32(9, payload.appStart, Endian.little); + data.setUint32(13, payload.imageVersion, Endian.little); + data.setUint8(17, payload.sessionId & 0xFF); + data.setUint8(18, payload.flags & 0xFF); + return data.buffer.asUint8List(); + } + + static Uint8List encodeFinishPayload(int sessionId) { + return Uint8List.fromList([ + universalShifterDfuOpcodeFinish, + sessionId & 0xFF, + ]); + } + + static Uint8List encodeAbortPayload(int sessionId) { + return Uint8List.fromList([ + universalShifterDfuOpcodeAbort, + sessionId & 0xFF, + ]); + } + + static Uint8List encodeGetStatusPayload() { + return Uint8List.fromList([universalShifterDfuOpcodeGetStatus]); + } + + static BootloaderDfuDataFrame buildDataFrame({ + required List imageBytes, + required int sessionId, + required int offset, + int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes, + }) { + if (offset < 0 || offset >= imageBytes.length) { + throw RangeError.range(offset, 0, imageBytes.length - 1, 'offset'); + } + if (payloadSize <= 0 || + payloadSize > universalShifterBootloaderDfuMaxPayloadSizeBytes) { + throw RangeError.range( + payloadSize, + 1, + universalShifterBootloaderDfuMaxPayloadSizeBytes, + 'payloadSize', + ); + } + + final remaining = imageBytes.length - offset; + final payloadLength = remaining < payloadSize ? remaining : payloadSize; + final payloadEnd = offset + payloadLength; + final payload = imageBytes.sublist(offset, payloadEnd); + final frame = Uint8List( + universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength, + ); + + frame[0] = sessionId & 0xFF; + final data = ByteData.view(frame.buffer); + data.setUint32(1, offset, Endian.little); + data.setUint32(5, crc32(payload), Endian.little); + frame.setRange( + universalShifterBootloaderDfuDataHeaderSizeBytes, + universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength, + payload, + ); + + return BootloaderDfuDataFrame( + sessionId: sessionId & 0xFF, + offset: offset, + payloadLength: payloadLength, + bytes: frame, + ); + } + + static List buildDataFrames({ + required List imageBytes, + required int sessionId, + int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes, + }) { + final frames = []; + var offset = 0; + while (offset < imageBytes.length) { + final frame = buildDataFrame( + imageBytes: imageBytes, + sessionId: sessionId, + offset: offset, + payloadSize: payloadSize, + ); + frames.add(frame); + offset += frame.payloadLength; + } + return frames; + } + + static int maxPayloadSizeForMtu(int negotiatedMtu) { + final writePayloadBytes = + negotiatedMtu - universalShifterAttWriteOverheadBytes; + final availablePayload = + writePayloadBytes - universalShifterBootloaderDfuDataHeaderSizeBytes; + if (availablePayload <= 0) { + return 0; + } + if (availablePayload > universalShifterBootloaderDfuMaxPayloadSizeBytes) { + return universalShifterBootloaderDfuMaxPayloadSizeBytes; + } + return availablePayload; + } + + static DfuBootloaderStatus parseStatusPayload(List payload) { + if (payload.length != universalShifterBootloaderDfuStatusSizeBytes) { + throw const FormatException( + 'DFU status payload must be exactly 6 bytes.'); + } + final data = ByteData.sublistView(Uint8List.fromList(payload)); + final rawCode = data.getUint8(0); + return DfuBootloaderStatus( + code: DfuBootloaderStatusCode.fromRaw(rawCode), + rawCode: rawCode, + sessionId: data.getUint8(1), + expectedOffset: data.getUint32(2, Endian.little), + ); + } + + static const int crc32Initial = DfuProtocol.crc32Initial; + + static int crc32Update(int crc, List bytes) { + return DfuProtocol.crc32Update(crc, bytes); + } + + static int crc32Finalize(int crc) { + return DfuProtocol.crc32Finalize(crc); + } + + static int crc32(List bytes) { + return DfuProtocol.crc32(bytes); + } +} diff --git a/lib/service/firmware_file_selection_service.dart b/lib/service/firmware_file_selection_service.dart index 1f9be2f..f8f62a4 100644 --- a/lib/service/firmware_file_selection_service.dart +++ b/lib/service/firmware_file_selection_service.dart @@ -75,7 +75,7 @@ class FirmwareFileSelectionService { final FirmwareFilePicker _filePicker; final SessionIdGenerator _sessionIdGenerator; - Future selectAndPrepareDfuV1() async { + Future selectAndPrepareBootloaderDfu() async { final FirmwarePickerSelection? selection; try { selection = await _filePicker.pickFirmwareFile(); @@ -127,15 +127,30 @@ class FirmwareFileSelectionService { ); } - final metadata = DfuV1FirmwareMetadata( + final imageValidationFailure = _validateBootloaderImage( + selection.fileBytes, + fileName, + ); + if (imageValidationFailure != null) { + return FirmwareFileSelectionResult.failed(imageValidationFailure); + } + + final vectorStackPointer = _readLeU32(selection.fileBytes, 0); + final vectorReset = _readLeU32(selection.fileBytes, 4); + + final metadata = BootloaderDfuFirmwareMetadata( totalLength: selection.fileBytes.length, - crc32: DfuProtocol.crc32(selection.fileBytes), + crc32: BootloaderDfuProtocol.crc32(selection.fileBytes), + appStart: universalShifterDfuAppStart, + imageVersion: 0, sessionId: _sessionIdGenerator() & 0xFF, flags: universalShifterDfuFlagNone, + vectorStackPointer: vectorStackPointer, + vectorReset: vectorReset, ); return FirmwareFileSelectionResult.success( - DfuV1PreparedFirmware( + BootloaderDfuPreparedFirmware( fileName: fileName, filePath: selection.filePath, fileBytes: selection.fileBytes, @@ -148,6 +163,58 @@ class FirmwareFileSelectionService { return fileName.toLowerCase().endsWith('.bin'); } + FirmwareSelectionFailure? _validateBootloaderImage( + Uint8List imageBytes, + String fileName, + ) { + if (imageBytes.length < universalShifterDfuMinimumImageLengthBytes) { + return FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.imageTooSmall, + message: + 'Selected firmware file "$fileName" is too small for a bootloader application image. Need at least $universalShifterDfuMinimumImageLengthBytes bytes.', + ); + } + + if (imageBytes.length > universalShifterDfuAppSlotSizeBytes) { + return FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.imageTooLarge, + message: + 'Selected firmware file "$fileName" is ${imageBytes.length} bytes, which exceeds the $universalShifterDfuAppSlotSizeBytes byte application slot.', + ); + } + + final vectorStackPointer = _readLeU32(imageBytes, 0); + final vectorReset = _readLeU32(imageBytes, 4); + final resetAddress = vectorReset & ~0x1; + final imageEnd = universalShifterDfuAppStart + imageBytes.length; + + if (vectorStackPointer < universalShifterDfuRamStart || + vectorStackPointer > universalShifterDfuRamEnd || + (vectorStackPointer & 0x3) != 0) { + return FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.invalidVectorTable, + message: + 'Selected firmware file "$fileName" has an invalid initial stack pointer (0x${vectorStackPointer.toRadixString(16).padLeft(8, '0').toUpperCase()}).', + ); + } + + if ((vectorReset & 0x1) == 0 || + resetAddress < universalShifterDfuAppStart + 8 || + resetAddress >= imageEnd) { + return FirmwareSelectionFailure( + reason: FirmwareSelectionFailureReason.invalidVectorTable, + message: + 'Selected firmware file "$fileName" has an invalid reset vector (0x${vectorReset.toRadixString(16).padLeft(8, '0').toUpperCase()}). Ensure the image starts at application address 0x${universalShifterDfuAppStart.toRadixString(16).padLeft(8, '0').toUpperCase()}.', + ); + } + + return null; + } + + int _readLeU32(Uint8List bytes, int offset) { + return ByteData.sublistView(bytes).getUint32(offset, Endian.little); + } + static int _randomSessionId() { return Random.secure().nextInt(256); } diff --git a/test/service/dfu_protocol_test.dart b/test/service/dfu_protocol_test.dart index b1a1b45..c024c98 100644 --- a/test/service/dfu_protocol_test.dart +++ b/test/service/dfu_protocol_test.dart @@ -3,97 +3,158 @@ import 'package:abawo_bt_app/service/dfu_protocol.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('DfuProtocol CRC32', () { + group('BootloaderDfuProtocol CRC32', () { test('matches known vector', () { - final crc = DfuProtocol.crc32('123456789'.codeUnits); + final crc = BootloaderDfuProtocol.crc32('123456789'.codeUnits); expect(crc, 0xCBF43926); }); }); - group('DfuProtocol control payload encoding', () { - test('encodes START payload with exact 11-byte LE layout', () { - final payload = DfuProtocol.encodeStartPayload( - const DfuStartPayload( + group('BootloaderDfuProtocol control payload encoding', () { + test('encodes START payload with exact 19-byte LE layout', () { + final payload = BootloaderDfuProtocol.encodeStartPayload( + const BootloaderDfuStartPayload( totalLength: 0x1234, imageCrc32: 0x89ABCDEF, + appStart: universalShifterDfuAppStart, + imageVersion: 0x10203040, sessionId: 0x22, - flags: universalShifterDfuFlagEncrypted, + flags: universalShifterDfuFlagNone, ), ); - expect(payload.length, 11); - expect( - payload, - [ - universalShifterDfuOpcodeStart, - 0x34, - 0x12, - 0x00, - 0x00, - 0xEF, - 0xCD, - 0xAB, - 0x89, - 0x22, - universalShifterDfuFlagEncrypted, - ], - ); + expect(payload.length, 19); + expect(payload, [ + universalShifterDfuOpcodeStart, + 0x34, + 0x12, + 0x00, + 0x00, + 0xEF, + 0xCD, + 0xAB, + 0x89, + 0x00, + 0x00, + 0x03, + 0x00, + 0x40, + 0x30, + 0x20, + 0x10, + 0x22, + universalShifterDfuFlagNone, + ]); }); - test('encodes FINISH and ABORT payloads as one byte', () { + test('encodes FINISH, ABORT, and GET_STATUS payloads', () { expect( - DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]); + BootloaderDfuProtocol.encodeFinishPayload(0x12), + [universalShifterDfuOpcodeFinish, 0x12], + ); expect( - DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]); + BootloaderDfuProtocol.encodeAbortPayload(0x34), + [universalShifterDfuOpcodeAbort, 0x34], + ); + expect( + BootloaderDfuProtocol.encodeGetStatusPayload(), + [universalShifterDfuOpcodeGetStatus], + ); }); }); - group('DfuProtocol data frame building', () { - test('builds 64-byte frames and handles final partial payload', () { - final image = List.generate(80, (index) => index); - final frames = DfuProtocol.buildDataFrames(image); + group('BootloaderDfuProtocol data frame building', () { + test('builds offset frames with payload CRC and variable final length', () { + final image = List.generate(60, (index) => index); + final frames = BootloaderDfuProtocol.buildDataFrames( + imageBytes: image, + sessionId: 0x7A, + ); expect(frames.length, 2); - expect(frames[0].sequence, 0); + expect(frames[0].sessionId, 0x7A); expect(frames[0].offset, 0); - expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes); + expect(frames[0].payloadLength, + universalShifterBootloaderDfuMaxPayloadSizeBytes); expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes); - expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63)); + expect(frames[0].bytes[0], 0x7A); + expect(frames[0].bytes.sublist(1, 5), [0, 0, 0, 0]); + expect( + frames[0].bytes.sublist(5, 9), + _leU32Bytes(BootloaderDfuProtocol.crc32(image.sublist(0, 55))), + ); + expect(frames[0].bytes.sublist(9), image.sublist(0, 55)); - expect(frames[1].sequence, 1); - expect(frames[1].offset, 63); - expect(frames[1].payloadLength, 17); - expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes); - expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80)); + expect(frames[1].offset, 55); + expect(frames[1].payloadLength, 5); + expect(frames[1].bytes.length, 14); + expect(frames[1].bytes.sublist(1, 5), [55, 0, 0, 0]); + expect(frames[1].bytes.sublist(9), image.sublist(55)); }); - test('uses deterministic wrapping sequence numbers from custom start', () { - final image = List.generate( - 3 * universalShifterDfuFramePayloadSizeBytes, - (index) => index & 0xFF); + test('uses caller supplied payload size for low-MTU links', () { + final image = List.generate(15, (index) => index); + final frames = BootloaderDfuProtocol.buildDataFrames( + imageBytes: image, + sessionId: 0x01, + payloadSize: 4, + ); - final frames = DfuProtocol.buildDataFrames(image, startSequence: 0xFE); + expect(frames.map((frame) => frame.payloadLength), [4, 4, 4, 3]); + expect(frames.map((frame) => frame.offset), [0, 4, 8, 12]); + }); - expect(frames.length, 3); - expect(frames[0].sequence, 0xFE); - expect(frames[1].sequence, 0xFF); - expect(frames[2].sequence, 0x00); + test('calculates safe payload size from negotiated MTU', () { + expect( + BootloaderDfuProtocol.maxPayloadSizeForMtu(64), + universalShifterBootloaderDfuMaxPayloadSizeBytes - 3, + ); + expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(23), 11); + expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(12), 0); + expect( + BootloaderDfuProtocol.maxPayloadSizeForMtu(128), + universalShifterBootloaderDfuMaxPayloadSizeBytes, + ); }); }); - group('DfuProtocol sequence and ACK helpers', () { - test('wraps sequence values and computes ack+1 rewind', () { - expect(DfuProtocol.nextSequence(0x00), 0x01); - expect(DfuProtocol.nextSequence(0xFF), 0x00); + group('BootloaderDfuProtocol status parsing', () { + test('parses bootloader status payload', () { + final status = BootloaderDfuProtocol.parseStatusPayload( + [0x00, 0x22, 0x78, 0x56, 0x34, 0x12], + ); - expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06); - expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00); + expect(status.code, DfuBootloaderStatusCode.ok); + expect(status.rawCode, 0x00); + expect(status.sessionId, 0x22); + expect(status.expectedOffset, 0x12345678); + expect(status.isOk, isTrue); }); - test('computes wrapping sequence distance', () { - expect(DfuProtocol.sequenceDistance(250, 2), 8); - expect(DfuProtocol.sequenceDistance(1, 1), 0); + test('preserves unknown status codes', () { + final status = BootloaderDfuProtocol.parseStatusPayload( + [0xFE, 0x00, 0x00, 0x00, 0x00, 0x00], + ); + + expect(status.code, DfuBootloaderStatusCode.unknown); + expect(status.rawCode, 0xFE); + }); + + test('rejects malformed status payloads', () { + expect( + () => BootloaderDfuProtocol.parseStatusPayload(const [0, 1]), + throwsFormatException, + ); }); }); } + +List _leU32Bytes(int value) { + return [ + value & 0xFF, + (value >> 8) & 0xFF, + (value >> 16) & 0xFF, + (value >> 24) & 0xFF, + ]; +} diff --git a/test/service/firmware_file_selection_service_test.dart b/test/service/firmware_file_selection_service_test.dart index 7876b97..c24e77c 100644 --- a/test/service/firmware_file_selection_service_test.dart +++ b/test/service/firmware_file_selection_service_test.dart @@ -2,34 +2,40 @@ import 'dart:typed_data'; import 'package:abawo_bt_app/model/firmware_file_selection.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/firmware_file_selection_service.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('FirmwareFileSelectionService', () { - test('prepares v1 metadata for selected .bin firmware', () async { + test('prepares bootloader metadata for selected .bin firmware', () async { + final image = _validBootloaderImage(); final service = FirmwareFileSelectionService( filePicker: _FakeFirmwareFilePicker( selection: FirmwarePickerSelection( fileName: 'firmware.BIN', filePath: '/tmp/firmware.BIN', - fileBytes: Uint8List.fromList([1, 2, 3, 4]), + fileBytes: image, ), ), sessionIdGenerator: () => 0x1AB, ); - final result = await service.selectAndPrepareDfuV1(); + final result = await service.selectAndPrepareBootloaderDfu(); expect(result.isSuccess, isTrue); final firmware = result.firmware!; expect(firmware.fileName, 'firmware.BIN'); expect(firmware.filePath, '/tmp/firmware.BIN'); - expect(firmware.fileBytes, [1, 2, 3, 4]); - expect(firmware.metadata.totalLength, 4); - expect(firmware.metadata.crc32, 0xB63CFBCD); + expect(firmware.fileBytes, image); + expect(firmware.metadata.totalLength, image.length); + expect(firmware.metadata.crc32, BootloaderDfuProtocol.crc32(image)); + expect(firmware.metadata.appStart, universalShifterDfuAppStart); + expect(firmware.metadata.imageVersion, 0); expect(firmware.metadata.sessionId, 0xAB); expect(firmware.metadata.flags, universalShifterDfuFlagNone); + expect(firmware.metadata.vectorStackPointer, 0x20001000); + expect(firmware.metadata.vectorReset, 0x00030009); }); test('returns canceled result when user dismisses picker', () async { @@ -37,7 +43,7 @@ void main() { filePicker: _FakeFirmwareFilePicker(selection: null), ); - final result = await service.selectAndPrepareDfuV1(); + final result = await service.selectAndPrepareBootloaderDfu(); expect(result.isSuccess, isFalse); expect(result.isCanceled, isTrue); @@ -49,12 +55,12 @@ void main() { filePicker: _FakeFirmwareFilePicker( selection: FirmwarePickerSelection( fileName: 'firmware.hex', - fileBytes: Uint8List.fromList([1]), + fileBytes: _validBootloaderImage(), ), ), ); - final result = await service.selectAndPrepareDfuV1(); + final result = await service.selectAndPrepareBootloaderDfu(); expect(result.isSuccess, isFalse); expect(result.failure?.reason, @@ -71,26 +77,82 @@ void main() { ), ); - final result = await service.selectAndPrepareDfuV1(); + final result = await service.selectAndPrepareBootloaderDfu(); expect(result.isSuccess, isFalse); expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile); }); + test('rejects images that are too small for a vector table', () async { + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: FirmwarePickerSelection( + fileName: 'firmware.bin', + fileBytes: Uint8List.fromList([1, 2, 3, 4]), + ), + ), + ); + + final result = await service.selectAndPrepareBootloaderDfu(); + + expect(result.isSuccess, isFalse); + expect( + result.failure?.reason, FirmwareSelectionFailureReason.imageTooSmall); + }); + + test('rejects images larger than the application slot', () async { + final image = Uint8List(universalShifterDfuAppSlotSizeBytes + 1); + _writeLeU32(image, 0, 0x20001000); + _writeLeU32(image, 4, 0x00030009); + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: FirmwarePickerSelection( + fileName: 'firmware.bin', + fileBytes: image, + ), + ), + ); + + final result = await service.selectAndPrepareBootloaderDfu(); + + expect(result.isSuccess, isFalse); + expect( + result.failure?.reason, FirmwareSelectionFailureReason.imageTooLarge); + }); + + test('rejects images with invalid vector table', () async { + final image = _validBootloaderImage(); + _writeLeU32(image, 0, 0x10001000); + final service = FirmwareFileSelectionService( + filePicker: _FakeFirmwareFilePicker( + selection: FirmwarePickerSelection( + fileName: 'firmware.bin', + fileBytes: image, + ), + ), + ); + + final result = await service.selectAndPrepareBootloaderDfu(); + + expect(result.isSuccess, isFalse); + expect(result.failure?.reason, + FirmwareSelectionFailureReason.invalidVectorTable); + }); + test('generates session id per run', () async { var nextSession = 9; final service = FirmwareFileSelectionService( filePicker: _FakeFirmwareFilePicker( selection: FirmwarePickerSelection( fileName: 'firmware.bin', - fileBytes: Uint8List.fromList([10]), + fileBytes: _validBootloaderImage(), ), ), sessionIdGenerator: () => nextSession++, ); - final first = await service.selectAndPrepareDfuV1(); - final second = await service.selectAndPrepareDfuV1(); + final first = await service.selectAndPrepareBootloaderDfu(); + final second = await service.selectAndPrepareBootloaderDfu(); expect(first.firmware?.metadata.sessionId, 9); expect(second.firmware?.metadata.sessionId, 10); @@ -104,7 +166,7 @@ void main() { ), ); - final result = await service.selectAndPrepareDfuV1(); + final result = await service.selectAndPrepareBootloaderDfu(); expect(result.isSuccess, isFalse); expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed); @@ -113,6 +175,18 @@ void main() { }); } +Uint8List _validBootloaderImage() { + final image = Uint8List(16); + _writeLeU32(image, 0, 0x20001000); + _writeLeU32(image, 4, 0x00030009); + return image; +} + +void _writeLeU32(Uint8List bytes, int offset, int value) { + final data = ByteData.sublistView(bytes); + data.setUint32(offset, value, Endian.little); +} + class _FakeFirmwareFilePicker implements FirmwareFilePicker { _FakeFirmwareFilePicker({ required this.selection,