feat: add bootloader DFU protocol validation

This commit is contained in:
2026-04-29 17:56:32 +02:00
parent eb26c759e8
commit b673c9100d
7 changed files with 534 additions and 86 deletions

View File

@ -1,21 +1,29 @@
import 'dart:typed_data'; import 'dart:typed_data';
class DfuV1FirmwareMetadata { class BootloaderDfuFirmwareMetadata {
const DfuV1FirmwareMetadata({ const BootloaderDfuFirmwareMetadata({
required this.totalLength, required this.totalLength,
required this.crc32, required this.crc32,
required this.appStart,
required this.imageVersion,
required this.sessionId, required this.sessionId,
required this.flags, required this.flags,
required this.vectorStackPointer,
required this.vectorReset,
}); });
final int totalLength; final int totalLength;
final int crc32; final int crc32;
final int appStart;
final int imageVersion;
final int sessionId; final int sessionId;
final int flags; final int flags;
final int vectorStackPointer;
final int vectorReset;
} }
class DfuV1PreparedFirmware { class BootloaderDfuPreparedFirmware {
const DfuV1PreparedFirmware({ const BootloaderDfuPreparedFirmware({
required this.fileName, required this.fileName,
required this.fileBytes, required this.fileBytes,
required this.metadata, required this.metadata,
@ -25,7 +33,7 @@ class DfuV1PreparedFirmware {
final String fileName; final String fileName;
final String? filePath; final String? filePath;
final Uint8List fileBytes; final Uint8List fileBytes;
final DfuV1FirmwareMetadata metadata; final BootloaderDfuFirmwareMetadata metadata;
} }
enum FirmwareSelectionFailureReason { enum FirmwareSelectionFailureReason {
@ -33,6 +41,9 @@ enum FirmwareSelectionFailureReason {
malformedSelection, malformedSelection,
unsupportedExtension, unsupportedExtension,
emptyFile, emptyFile,
imageTooSmall,
imageTooLarge,
invalidVectorTable,
readFailed, readFailed,
} }
@ -52,7 +63,7 @@ class FirmwareFileSelectionResult {
this.failure, this.failure,
}); });
final DfuV1PreparedFirmware? firmware; final BootloaderDfuPreparedFirmware? firmware;
final FirmwareSelectionFailure? failure; final FirmwareSelectionFailure? failure;
bool get isSuccess => firmware != null; bool get isSuccess => firmware != null;
@ -60,7 +71,8 @@ class FirmwareFileSelectionResult {
bool get isCanceled => bool get isCanceled =>
failure?.reason == FirmwareSelectionFailureReason.canceled; failure?.reason == FirmwareSelectionFailureReason.canceled;
static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) { static FirmwareFileSelectionResult success(
BootloaderDfuPreparedFirmware firmware) {
return FirmwareFileSelectionResult._(firmware: firmware); return FirmwareFileSelectionResult._(firmware: firmware);
} }

View File

@ -20,6 +20,8 @@ const String universalShifterDfuDataCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40009'; '0993826f-0ee4-4b37-9614-d13ecba40009';
const String universalShifterDfuAckCharacteristicUuid = const String universalShifterDfuAckCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba4000a'; '0993826f-0ee4-4b37-9614-d13ecba4000a';
const String universalShifterDfuStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba4000a';
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb'; const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb'; const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb';
const String batteryLevelCharacteristicUuid = const String batteryLevelCharacteristicUuid =
@ -36,14 +38,26 @@ bool isFtmsUuid(Uuid uuid) {
const int universalShifterDfuOpcodeStart = 0x01; const int universalShifterDfuOpcodeStart = 0x01;
const int universalShifterDfuOpcodeFinish = 0x02; const int universalShifterDfuOpcodeFinish = 0x02;
const int universalShifterDfuOpcodeAbort = 0x03; const int universalShifterDfuOpcodeAbort = 0x03;
const int universalShifterDfuOpcodeGetStatus = 0x04;
const int universalShifterDfuFrameSizeBytes = 64; const int universalShifterDfuFrameSizeBytes = 64;
const int universalShifterDfuFramePayloadSizeBytes = 63; const int universalShifterDfuFramePayloadSizeBytes = 63;
const int universalShifterBootloaderDfuDataHeaderSizeBytes = 9;
const int universalShifterBootloaderDfuMaxPayloadSizeBytes =
universalShifterDfuFrameSizeBytes -
universalShifterBootloaderDfuDataHeaderSizeBytes;
const int universalShifterBootloaderDfuStatusSizeBytes = 6;
const int universalShifterAttWriteOverheadBytes = 3; const int universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuMinimumMtu = const int universalShifterDfuMinimumMtu =
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes; universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
const int universalShifterDfuPreferredMtu = 128; 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 universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02; const int universalShifterDfuFlagSigned = 0x02;
const int universalShifterDfuFlagNone = 0x00; const int universalShifterDfuFlagNone = 0x00;
@ -134,6 +148,49 @@ class DfuUpdateProgress {
state == DfuUpdateState.failed; 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 { enum DfuPreflightFailureReason {
deviceNotConnected, deviceNotConnected,
wrongConnectedDevice, wrongConnectedDevice,
@ -253,7 +310,8 @@ enum UniversalShifterCommand {
stopScan(0x02), stopScan(0x02),
connectToDevice(0x03), connectToDevice(0x03),
disconnect(0x04), disconnect(0x04),
turnOff(0x05); turnOff(0x05),
enterDfu(0x06);
const UniversalShifterCommand(this.value); const UniversalShifterCommand(this.value);
final int value; final int value;

View File

@ -80,7 +80,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
late final FirmwareFileSelectionService _firmwareFileSelectionService; late final FirmwareFileSelectionService _firmwareFileSelectionService;
FirmwareUpdateService? _firmwareUpdateService; FirmwareUpdateService? _firmwareUpdateService;
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription; StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
DfuV1PreparedFirmware? _selectedFirmware; BootloaderDfuPreparedFirmware? _selectedFirmware;
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress( DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle, state: DfuUpdateState.idle,
totalBytes: 0, totalBytes: 0,
@ -548,7 +548,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
_firmwareUserMessage = null; _firmwareUserMessage = null;
}); });
final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1(); final result =
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
if (!mounted) { if (!mounted) {
return; return;
} }
@ -1061,7 +1062,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
required this.onStartUpdate, required this.onStartUpdate,
}); });
final DfuV1PreparedFirmware? selectedFirmware; final BootloaderDfuPreparedFirmware? selectedFirmware;
final DfuUpdateProgress progress; final DfuUpdateProgress progress;
final bool isSelecting; final bool isSelecting;
final bool isStarting; final bool isStarting;

View File

@ -3,6 +3,39 @@ import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/model/shifter_types.dart';
const int _startPayloadLength = 11; 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 { class DfuStartPayload {
const DfuStartPayload({ const DfuStartPayload({
@ -135,3 +168,145 @@ class DfuProtocol {
return value & 0xFF; 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<int> 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<BootloaderDfuDataFrame> buildDataFrames({
required List<int> imageBytes,
required int sessionId,
int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes,
}) {
final frames = <BootloaderDfuDataFrame>[];
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<int> 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<int> bytes) {
return DfuProtocol.crc32Update(crc, bytes);
}
static int crc32Finalize(int crc) {
return DfuProtocol.crc32Finalize(crc);
}
static int crc32(List<int> bytes) {
return DfuProtocol.crc32(bytes);
}
}

View File

@ -75,7 +75,7 @@ class FirmwareFileSelectionService {
final FirmwareFilePicker _filePicker; final FirmwareFilePicker _filePicker;
final SessionIdGenerator _sessionIdGenerator; final SessionIdGenerator _sessionIdGenerator;
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async { Future<FirmwareFileSelectionResult> selectAndPrepareBootloaderDfu() async {
final FirmwarePickerSelection? selection; final FirmwarePickerSelection? selection;
try { try {
selection = await _filePicker.pickFirmwareFile(); 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, totalLength: selection.fileBytes.length,
crc32: DfuProtocol.crc32(selection.fileBytes), crc32: BootloaderDfuProtocol.crc32(selection.fileBytes),
appStart: universalShifterDfuAppStart,
imageVersion: 0,
sessionId: _sessionIdGenerator() & 0xFF, sessionId: _sessionIdGenerator() & 0xFF,
flags: universalShifterDfuFlagNone, flags: universalShifterDfuFlagNone,
vectorStackPointer: vectorStackPointer,
vectorReset: vectorReset,
); );
return FirmwareFileSelectionResult.success( return FirmwareFileSelectionResult.success(
DfuV1PreparedFirmware( BootloaderDfuPreparedFirmware(
fileName: fileName, fileName: fileName,
filePath: selection.filePath, filePath: selection.filePath,
fileBytes: selection.fileBytes, fileBytes: selection.fileBytes,
@ -148,6 +163,58 @@ class FirmwareFileSelectionService {
return fileName.toLowerCase().endsWith('.bin'); 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() { static int _randomSessionId() {
return Random.secure().nextInt(256); return Random.secure().nextInt(256);
} }

View File

@ -3,97 +3,158 @@ import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('DfuProtocol CRC32', () { group('BootloaderDfuProtocol CRC32', () {
test('matches known vector', () { test('matches known vector', () {
final crc = DfuProtocol.crc32('123456789'.codeUnits); final crc = BootloaderDfuProtocol.crc32('123456789'.codeUnits);
expect(crc, 0xCBF43926); expect(crc, 0xCBF43926);
}); });
}); });
group('DfuProtocol control payload encoding', () { group('BootloaderDfuProtocol control payload encoding', () {
test('encodes START payload with exact 11-byte LE layout', () { test('encodes START payload with exact 19-byte LE layout', () {
final payload = DfuProtocol.encodeStartPayload( final payload = BootloaderDfuProtocol.encodeStartPayload(
const DfuStartPayload( const BootloaderDfuStartPayload(
totalLength: 0x1234, totalLength: 0x1234,
imageCrc32: 0x89ABCDEF, imageCrc32: 0x89ABCDEF,
appStart: universalShifterDfuAppStart,
imageVersion: 0x10203040,
sessionId: 0x22, sessionId: 0x22,
flags: universalShifterDfuFlagEncrypted, flags: universalShifterDfuFlagNone,
), ),
); );
expect(payload.length, 11); expect(payload.length, 19);
expect( expect(payload, [
payload, universalShifterDfuOpcodeStart,
[ 0x34,
universalShifterDfuOpcodeStart, 0x12,
0x34, 0x00,
0x12, 0x00,
0x00, 0xEF,
0x00, 0xCD,
0xEF, 0xAB,
0xCD, 0x89,
0xAB, 0x00,
0x89, 0x00,
0x22, 0x03,
universalShifterDfuFlagEncrypted, 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( expect(
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]); BootloaderDfuProtocol.encodeFinishPayload(0x12),
[universalShifterDfuOpcodeFinish, 0x12],
);
expect( expect(
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]); BootloaderDfuProtocol.encodeAbortPayload(0x34),
[universalShifterDfuOpcodeAbort, 0x34],
);
expect(
BootloaderDfuProtocol.encodeGetStatusPayload(),
[universalShifterDfuOpcodeGetStatus],
);
}); });
}); });
group('DfuProtocol data frame building', () { group('BootloaderDfuProtocol data frame building', () {
test('builds 64-byte frames and handles final partial payload', () { test('builds offset frames with payload CRC and variable final length', () {
final image = List<int>.generate(80, (index) => index); final image = List<int>.generate(60, (index) => index);
final frames = DfuProtocol.buildDataFrames(image); final frames = BootloaderDfuProtocol.buildDataFrames(
imageBytes: image,
sessionId: 0x7A,
);
expect(frames.length, 2); expect(frames.length, 2);
expect(frames[0].sequence, 0); expect(frames[0].sessionId, 0x7A);
expect(frames[0].offset, 0); 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.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, 55);
expect(frames[1].offset, 63); expect(frames[1].payloadLength, 5);
expect(frames[1].payloadLength, 17); expect(frames[1].bytes.length, 14);
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes); expect(frames[1].bytes.sublist(1, 5), [55, 0, 0, 0]);
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80)); expect(frames[1].bytes.sublist(9), image.sublist(55));
}); });
test('uses deterministic wrapping sequence numbers from custom start', () { test('uses caller supplied payload size for low-MTU links', () {
final image = List<int>.generate( final image = List<int>.generate(15, (index) => index);
3 * universalShifterDfuFramePayloadSizeBytes, final frames = BootloaderDfuProtocol.buildDataFrames(
(index) => index & 0xFF); 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); test('calculates safe payload size from negotiated MTU', () {
expect(frames[0].sequence, 0xFE); expect(
expect(frames[1].sequence, 0xFF); BootloaderDfuProtocol.maxPayloadSizeForMtu(64),
expect(frames[2].sequence, 0x00); universalShifterBootloaderDfuMaxPayloadSizeBytes - 3,
);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(23), 11);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(12), 0);
expect(
BootloaderDfuProtocol.maxPayloadSizeForMtu(128),
universalShifterBootloaderDfuMaxPayloadSizeBytes,
);
}); });
}); });
group('DfuProtocol sequence and ACK helpers', () { group('BootloaderDfuProtocol status parsing', () {
test('wraps sequence values and computes ack+1 rewind', () { test('parses bootloader status payload', () {
expect(DfuProtocol.nextSequence(0x00), 0x01); final status = BootloaderDfuProtocol.parseStatusPayload(
expect(DfuProtocol.nextSequence(0xFF), 0x00); [0x00, 0x22, 0x78, 0x56, 0x34, 0x12],
);
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06); expect(status.code, DfuBootloaderStatusCode.ok);
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00); expect(status.rawCode, 0x00);
expect(status.sessionId, 0x22);
expect(status.expectedOffset, 0x12345678);
expect(status.isOk, isTrue);
}); });
test('computes wrapping sequence distance', () { test('preserves unknown status codes', () {
expect(DfuProtocol.sequenceDistance(250, 2), 8); final status = BootloaderDfuProtocol.parseStatusPayload(
expect(DfuProtocol.sequenceDistance(1, 1), 0); [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<int> _leU32Bytes(int value) {
return [
value & 0xFF,
(value >> 8) & 0xFF,
(value >> 16) & 0xFF,
(value >> 24) & 0xFF,
];
}

View File

@ -2,34 +2,40 @@ import 'dart:typed_data';
import 'package:abawo_bt_app/model/firmware_file_selection.dart'; import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.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:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('FirmwareFileSelectionService', () { 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( final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker( filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection( selection: FirmwarePickerSelection(
fileName: 'firmware.BIN', fileName: 'firmware.BIN',
filePath: '/tmp/firmware.BIN', filePath: '/tmp/firmware.BIN',
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]), fileBytes: image,
), ),
), ),
sessionIdGenerator: () => 0x1AB, sessionIdGenerator: () => 0x1AB,
); );
final result = await service.selectAndPrepareDfuV1(); final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isTrue); expect(result.isSuccess, isTrue);
final firmware = result.firmware!; final firmware = result.firmware!;
expect(firmware.fileName, 'firmware.BIN'); expect(firmware.fileName, 'firmware.BIN');
expect(firmware.filePath, '/tmp/firmware.BIN'); expect(firmware.filePath, '/tmp/firmware.BIN');
expect(firmware.fileBytes, <int>[1, 2, 3, 4]); expect(firmware.fileBytes, image);
expect(firmware.metadata.totalLength, 4); expect(firmware.metadata.totalLength, image.length);
expect(firmware.metadata.crc32, 0xB63CFBCD); 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.sessionId, 0xAB);
expect(firmware.metadata.flags, universalShifterDfuFlagNone); expect(firmware.metadata.flags, universalShifterDfuFlagNone);
expect(firmware.metadata.vectorStackPointer, 0x20001000);
expect(firmware.metadata.vectorReset, 0x00030009);
}); });
test('returns canceled result when user dismisses picker', () async { test('returns canceled result when user dismisses picker', () async {
@ -37,7 +43,7 @@ void main() {
filePicker: _FakeFirmwareFilePicker(selection: null), filePicker: _FakeFirmwareFilePicker(selection: null),
); );
final result = await service.selectAndPrepareDfuV1(); final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse); expect(result.isSuccess, isFalse);
expect(result.isCanceled, isTrue); expect(result.isCanceled, isTrue);
@ -49,12 +55,12 @@ void main() {
filePicker: _FakeFirmwareFilePicker( filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection( selection: FirmwarePickerSelection(
fileName: 'firmware.hex', fileName: 'firmware.hex',
fileBytes: Uint8List.fromList(<int>[1]), fileBytes: _validBootloaderImage(),
), ),
), ),
); );
final result = await service.selectAndPrepareDfuV1(); final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse); expect(result.isSuccess, isFalse);
expect(result.failure?.reason, 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.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile); 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(<int>[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 { test('generates session id per run', () async {
var nextSession = 9; var nextSession = 9;
final service = FirmwareFileSelectionService( final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker( filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection( selection: FirmwarePickerSelection(
fileName: 'firmware.bin', fileName: 'firmware.bin',
fileBytes: Uint8List.fromList(<int>[10]), fileBytes: _validBootloaderImage(),
), ),
), ),
sessionIdGenerator: () => nextSession++, sessionIdGenerator: () => nextSession++,
); );
final first = await service.selectAndPrepareDfuV1(); final first = await service.selectAndPrepareBootloaderDfu();
final second = await service.selectAndPrepareDfuV1(); final second = await service.selectAndPrepareBootloaderDfu();
expect(first.firmware?.metadata.sessionId, 9); expect(first.firmware?.metadata.sessionId, 9);
expect(second.firmware?.metadata.sessionId, 10); 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.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed); 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 { class _FakeFirmwareFilePicker implements FirmwareFilePicker {
_FakeFirmwareFilePicker({ _FakeFirmwareFilePicker({
required this.selection, required this.selection,