feat: switch firmware updates to bootloader OTA

This commit is contained in:
2026-04-29 18:02:48 +02:00
parent b673c9100d
commit 06834a0cc0
7 changed files with 781 additions and 1097 deletions

View File

@ -18,8 +18,6 @@ const String universalShifterDfuControlCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40008';
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';
@ -41,15 +39,12 @@ 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;
@ -78,9 +73,14 @@ const int trainerScanDeviceFlagConnectable = 0x08;
enum DfuUpdateState {
idle,
starting,
waitingForAck,
enteringBootloader,
connectingBootloader,
waitingForStatus,
erasing,
transferring,
finishing,
rebooting,
verifying,
completed,
aborted,
failed,
@ -119,18 +119,20 @@ class DfuUpdateProgress {
required this.state,
required this.totalBytes,
required this.sentBytes,
required this.lastAckedSequence,
required this.expectedOffset,
required this.sessionId,
required this.flags,
this.bootloaderStatus,
this.errorMessage,
});
final DfuUpdateState state;
final int totalBytes;
final int sentBytes;
final int lastAckedSequence;
final int expectedOffset;
final int sessionId;
final DfuUpdateFlags flags;
final DfuBootloaderStatus? bootloaderStatus;
final String? errorMessage;
double get fractionComplete {
@ -191,61 +193,6 @@ class DfuBootloaderStatus {
bool get isOk => code == DfuBootloaderStatusCode.ok;
}
enum DfuPreflightFailureReason {
deviceNotConnected,
wrongConnectedDevice,
mtuRequestFailed,
mtuTooLow,
}
class DfuPreflightResult {
const DfuPreflightResult._({
required this.requestedMtu,
required this.requiredMtu,
required this.negotiatedMtu,
required this.failureReason,
required this.message,
});
final int requestedMtu;
final int requiredMtu;
final int? negotiatedMtu;
final DfuPreflightFailureReason? failureReason;
final String? message;
bool get canStart => failureReason == null;
static DfuPreflightResult ready({
required int requestedMtu,
required int negotiatedMtu,
int requiredMtu = universalShifterDfuMinimumMtu,
}) {
return DfuPreflightResult._(
requestedMtu: requestedMtu,
requiredMtu: requiredMtu,
negotiatedMtu: negotiatedMtu,
failureReason: null,
message: null,
);
}
static DfuPreflightResult failed({
required int requestedMtu,
required DfuPreflightFailureReason failureReason,
required String message,
int requiredMtu = universalShifterDfuMinimumMtu,
int? negotiatedMtu,
}) {
return DfuPreflightResult._(
requestedMtu: requestedMtu,
requiredMtu: requiredMtu,
negotiatedMtu: negotiatedMtu,
failureReason: failureReason,
message: message,
);
}
}
class ShifterErrorInfo {
const ShifterErrorInfo({
required this.code,

View File

@ -85,7 +85,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
lastAckedSequence: 0xFF,
expectedOffset: 0,
sessionId: 0,
flags: DfuUpdateFlags(),
);
@ -99,9 +99,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
switch (_dfuProgress.state) {
case DfuUpdateState.starting:
case DfuUpdateState.waitingForAck:
case DfuUpdateState.enteringBootloader:
case DfuUpdateState.connectingBootloader:
case DfuUpdateState.waitingForStatus:
case DfuUpdateState.erasing:
case DfuUpdateState.transferring:
case DfuUpdateState.finishing:
case DfuUpdateState.rebooting:
case DfuUpdateState.verifying:
return true;
case DfuUpdateState.idle:
case DfuUpdateState.completed:
@ -627,13 +632,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
case DfuUpdateState.idle:
return 'Idle';
case DfuUpdateState.starting:
return 'Sending START command';
case DfuUpdateState.waitingForAck:
return 'Waiting for ACK from button';
return 'Preparing update';
case DfuUpdateState.enteringBootloader:
return 'Requesting bootloader mode';
case DfuUpdateState.connectingBootloader:
return 'Connecting to bootloader';
case DfuUpdateState.waitingForStatus:
return 'Waiting for bootloader status';
case DfuUpdateState.erasing:
return 'Starting destructive bootloader update';
case DfuUpdateState.transferring:
return 'Transferring firmware frames';
return 'Transferring firmware image';
case DfuUpdateState.finishing:
return 'Finalizing update and waiting for reboot/reconnect';
return 'Finalizing bootloader update';
case DfuUpdateState.rebooting:
return 'Waiting for updated app reboot';
case DfuUpdateState.verifying:
return 'Verifying updated app';
case DfuUpdateState.completed:
return 'Update completed';
case DfuUpdateState.aborted:
@ -653,10 +668,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
String _hexByte(int value) {
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
}
Future<void> _manualReconnect() async {
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
return;
@ -1010,7 +1021,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
ackSequenceHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
),
] else if (isCurrentConnected) ...[
_PairingRequiredCard(

View File

@ -2,8 +2,7 @@ import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.dart';
const int _startPayloadLength = 11;
const int _bootloaderStartPayloadLength = 19;
const int _startPayloadLength = 19;
class BootloaderDfuStartPayload {
const BootloaderDfuStartPayload({
@ -37,143 +36,14 @@ class BootloaderDfuDataFrame {
final Uint8List bytes;
}
class DfuStartPayload {
const DfuStartPayload({
required this.totalLength,
required this.imageCrc32,
required this.sessionId,
required this.flags,
});
final int totalLength;
final int imageCrc32;
final int sessionId;
final int flags;
}
class DfuDataFrame {
const DfuDataFrame({
required this.sequence,
required this.offset,
required this.payloadLength,
required this.bytes,
});
final int sequence;
final int offset;
final int payloadLength;
final Uint8List bytes;
}
class DfuProtocol {
const DfuProtocol._();
static Uint8List encodeStartPayload(DfuStartPayload payload) {
final data = ByteData(_startPayloadLength);
data.setUint8(0, universalShifterDfuOpcodeStart);
data.setUint32(1, payload.totalLength, Endian.little);
data.setUint32(5, payload.imageCrc32, Endian.little);
data.setUint8(9, payload.sessionId);
data.setUint8(10, payload.flags);
return data.buffer.asUint8List();
}
static Uint8List encodeFinishPayload() {
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
}
static Uint8List encodeAbortPayload() {
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
}
static List<DfuDataFrame> buildDataFrames(
List<int> imageBytes, {
int startSequence = 0,
}) {
final frames = <DfuDataFrame>[];
var seq = _asU8(startSequence);
var offset = 0;
while (offset < imageBytes.length) {
final remaining = imageBytes.length - offset;
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
? remaining
: universalShifterDfuFramePayloadSizeBytes;
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
frame[0] = seq;
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
frames.add(
DfuDataFrame(
sequence: seq,
offset: offset,
payloadLength: chunkLength,
bytes: frame,
),
);
offset += chunkLength;
seq = nextSequence(seq);
}
return frames;
}
static int nextSequence(int sequence) {
return _asU8(sequence + 1);
}
static int rewindSequenceFromAck(int acknowledgedSequence) {
return nextSequence(acknowledgedSequence);
}
static int sequenceDistance(int from, int to) {
return _asU8(to - from);
}
static int parseAckPayload(List<int> payload) {
if (payload.length != 1) {
throw const FormatException('ACK payload must be exactly 1 byte.');
}
return _asU8(payload.first);
}
class BootloaderDfuProtocol {
const BootloaderDfuProtocol._();
static const int crc32Initial = 0xFFFFFFFF;
static const int _crc32PolynomialReflected = 0xEDB88320;
static int crc32Update(int crc, List<int> bytes) {
var next = crc & 0xFFFFFFFF;
for (final byte in bytes) {
next ^= byte;
for (var bit = 0; bit < 8; bit++) {
if ((next & 0x1) != 0) {
next = (next >> 1) ^ _crc32PolynomialReflected;
} else {
next >>= 1;
}
}
}
return next & 0xFFFFFFFF;
}
static int crc32Finalize(int crc) {
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
}
static int crc32(List<int> bytes) {
return crc32Finalize(crc32Update(crc32Initial, bytes));
}
static int _asU8(int value) {
return value & 0xFF;
}
}
class BootloaderDfuProtocol {
const BootloaderDfuProtocol._();
static Uint8List encodeStartPayload(BootloaderDfuStartPayload payload) {
final data = ByteData(_bootloaderStartPayloadLength);
final data = ByteData(_startPayloadLength);
data.setUint8(0, universalShifterDfuOpcodeStart);
data.setUint32(1, payload.totalLength, Endian.little);
data.setUint32(5, payload.imageCrc32, Endian.little);
@ -296,17 +166,26 @@ class BootloaderDfuProtocol {
);
}
static const int crc32Initial = DfuProtocol.crc32Initial;
static int crc32Update(int crc, List<int> bytes) {
return DfuProtocol.crc32Update(crc, bytes);
var next = crc & 0xFFFFFFFF;
for (final byte in bytes) {
next ^= byte;
for (var bit = 0; bit < 8; bit++) {
if ((next & 0x1) != 0) {
next = (next >> 1) ^ _crc32PolynomialReflected;
} else {
next >>= 1;
}
}
}
return next & 0xFFFFFFFF;
}
static int crc32Finalize(int crc) {
return DfuProtocol.crc32Finalize(crc);
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
}
static int crc32(List<int> bytes) {
return DfuProtocol.crc32(bytes);
return crc32Finalize(crc32Update(crc32Initial, bytes));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,29 +10,15 @@ final _log = Logger('ShifterService');
class ShifterService {
ShifterService({
BluetoothController? bluetooth,
required BluetoothController bluetooth,
required this.buttonDeviceId,
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
}) : _bluetooth = bluetooth,
_dfuPreflightBluetooth =
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
if (bluetooth == null && dfuPreflightBluetooth == null) {
throw ArgumentError(
'Either bluetooth or dfuPreflightBluetooth must be provided.',
);
}
}
}) : _bluetooth = bluetooth;
final BluetoothController? _bluetooth;
final BluetoothController _bluetooth;
final String buttonDeviceId;
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
BluetoothController get _requireBluetooth {
final bluetooth = _bluetooth;
if (bluetooth == null) {
throw StateError('Bluetooth controller is not available.');
}
return bluetooth;
return _bluetooth;
}
final StreamController<CentralStatus> _statusController =
@ -243,72 +229,6 @@ class ShifterService {
);
}
Future<Result<DfuPreflightResult>> runDfuPreflight({
int requestedMtu = universalShifterDfuPreferredMtu,
}) async {
final currentConnection = _dfuPreflightBluetooth.currentConnectionState;
final connectionStatus = currentConnection.$1;
final connectedDeviceId = currentConnection.$2;
if (connectionStatus != ConnectionStatus.connected ||
connectedDeviceId == null) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.deviceNotConnected,
message:
'No button connection is active. Connect the target button, then retry the firmware update.',
),
);
}
if (connectedDeviceId != buttonDeviceId) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.wrongConnectedDevice,
message:
'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.',
),
);
}
final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue(
buttonDeviceId,
mtu: requestedMtu,
);
if (mtuResult.isErr()) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.mtuRequestFailed,
message:
'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}',
),
);
}
final negotiatedMtu = mtuResult.unwrap();
if (negotiatedMtu < universalShifterDfuMinimumMtu) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
negotiatedMtu: negotiatedMtu,
failureReason: DfuPreflightFailureReason.mtuTooLow,
message:
'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.',
),
);
}
return Ok(
DfuPreflightResult.ready(
requestedMtu: requestedMtu,
negotiatedMtu: negotiatedMtu,
),
);
}
void startStatusNotifications() {
if (_statusSubscription != null) {
return;
@ -369,32 +289,6 @@ class ShifterService {
}
}
abstract interface class DfuPreflightBluetoothAdapter {
(ConnectionStatus, String?) get currentConnectionState;
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
});
}
class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter {
const _BluetoothDfuPreflightAdapter(this._bluetooth);
final BluetoothController _bluetooth;
@override
(ConnectionStatus, String?) get currentConnectionState =>
_bluetooth.currentConnectionState;
@override
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) {
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
}
}
class GearRatiosData {
const GearRatiosData({
required this.ratios,