feat: add bootloader DFU protocol validation
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -80,7 +80,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
late final FirmwareFileSelectionService _firmwareFileSelectionService;
|
||||
FirmwareUpdateService? _firmwareUpdateService;
|
||||
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
|
||||
DfuV1PreparedFirmware? _selectedFirmware;
|
||||
BootloaderDfuPreparedFirmware? _selectedFirmware;
|
||||
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
@ -548,7 +548,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
_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;
|
||||
|
||||
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ class FirmwareFileSelectionService {
|
||||
final FirmwareFilePicker _filePicker;
|
||||
final SessionIdGenerator _sessionIdGenerator;
|
||||
|
||||
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async {
|
||||
Future<FirmwareFileSelectionResult> 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user