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'; '0993826f-0ee4-4b37-9614-d13ecba40008';
const String universalShifterDfuDataCharacteristicUuid = const String universalShifterDfuDataCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40009'; '0993826f-0ee4-4b37-9614-d13ecba40009';
const String universalShifterDfuAckCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba4000a';
const String universalShifterDfuStatusCharacteristicUuid = const String universalShifterDfuStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba4000a'; '0993826f-0ee4-4b37-9614-d13ecba4000a';
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb'; const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
@ -41,15 +39,12 @@ const int universalShifterDfuOpcodeAbort = 0x03;
const int universalShifterDfuOpcodeGetStatus = 0x04; const int universalShifterDfuOpcodeGetStatus = 0x04;
const int universalShifterDfuFrameSizeBytes = 64; const int universalShifterDfuFrameSizeBytes = 64;
const int universalShifterDfuFramePayloadSizeBytes = 63;
const int universalShifterBootloaderDfuDataHeaderSizeBytes = 9; const int universalShifterBootloaderDfuDataHeaderSizeBytes = 9;
const int universalShifterBootloaderDfuMaxPayloadSizeBytes = const int universalShifterBootloaderDfuMaxPayloadSizeBytes =
universalShifterDfuFrameSizeBytes - universalShifterDfuFrameSizeBytes -
universalShifterBootloaderDfuDataHeaderSizeBytes; universalShifterBootloaderDfuDataHeaderSizeBytes;
const int universalShifterBootloaderDfuStatusSizeBytes = 6; const int universalShifterBootloaderDfuStatusSizeBytes = 6;
const int universalShifterAttWriteOverheadBytes = 3; const int universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuMinimumMtu =
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
const int universalShifterDfuPreferredMtu = 128; const int universalShifterDfuPreferredMtu = 128;
const int universalShifterDfuAppStart = 0x00030000; const int universalShifterDfuAppStart = 0x00030000;
@ -78,9 +73,14 @@ const int trainerScanDeviceFlagConnectable = 0x08;
enum DfuUpdateState { enum DfuUpdateState {
idle, idle,
starting, starting,
waitingForAck, enteringBootloader,
connectingBootloader,
waitingForStatus,
erasing,
transferring, transferring,
finishing, finishing,
rebooting,
verifying,
completed, completed,
aborted, aborted,
failed, failed,
@ -119,18 +119,20 @@ class DfuUpdateProgress {
required this.state, required this.state,
required this.totalBytes, required this.totalBytes,
required this.sentBytes, required this.sentBytes,
required this.lastAckedSequence, required this.expectedOffset,
required this.sessionId, required this.sessionId,
required this.flags, required this.flags,
this.bootloaderStatus,
this.errorMessage, this.errorMessage,
}); });
final DfuUpdateState state; final DfuUpdateState state;
final int totalBytes; final int totalBytes;
final int sentBytes; final int sentBytes;
final int lastAckedSequence; final int expectedOffset;
final int sessionId; final int sessionId;
final DfuUpdateFlags flags; final DfuUpdateFlags flags;
final DfuBootloaderStatus? bootloaderStatus;
final String? errorMessage; final String? errorMessage;
double get fractionComplete { double get fractionComplete {
@ -191,61 +193,6 @@ class DfuBootloaderStatus {
bool get isOk => code == DfuBootloaderStatusCode.ok; 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 { class ShifterErrorInfo {
const ShifterErrorInfo({ const ShifterErrorInfo({
required this.code, required this.code,

View File

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

View File

@ -2,8 +2,7 @@ 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 = 19;
const int _bootloaderStartPayloadLength = 19;
class BootloaderDfuStartPayload { class BootloaderDfuStartPayload {
const BootloaderDfuStartPayload({ const BootloaderDfuStartPayload({
@ -37,143 +36,14 @@ class BootloaderDfuDataFrame {
final Uint8List bytes; final Uint8List bytes;
} }
class DfuStartPayload { class BootloaderDfuProtocol {
const DfuStartPayload({ const BootloaderDfuProtocol._();
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);
}
static const int crc32Initial = 0xFFFFFFFF; static const int crc32Initial = 0xFFFFFFFF;
static const int _crc32PolynomialReflected = 0xEDB88320; 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) { static Uint8List encodeStartPayload(BootloaderDfuStartPayload payload) {
final data = ByteData(_bootloaderStartPayloadLength); final data = ByteData(_startPayloadLength);
data.setUint8(0, universalShifterDfuOpcodeStart); data.setUint8(0, universalShifterDfuOpcodeStart);
data.setUint32(1, payload.totalLength, Endian.little); data.setUint32(1, payload.totalLength, Endian.little);
data.setUint32(5, payload.imageCrc32, 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) { 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) { static int crc32Finalize(int crc) {
return DfuProtocol.crc32Finalize(crc); return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
} }
static int crc32(List<int> bytes) { 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 { class ShifterService {
ShifterService({ ShifterService({
BluetoothController? bluetooth, required BluetoothController bluetooth,
required this.buttonDeviceId, required this.buttonDeviceId,
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth, }) : _bluetooth = bluetooth;
}) : _bluetooth = bluetooth,
_dfuPreflightBluetooth =
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
if (bluetooth == null && dfuPreflightBluetooth == null) {
throw ArgumentError(
'Either bluetooth or dfuPreflightBluetooth must be provided.',
);
}
}
final BluetoothController? _bluetooth; final BluetoothController _bluetooth;
final String buttonDeviceId; final String buttonDeviceId;
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
BluetoothController get _requireBluetooth { BluetoothController get _requireBluetooth {
final bluetooth = _bluetooth; return _bluetooth;
if (bluetooth == null) {
throw StateError('Bluetooth controller is not available.');
}
return bluetooth;
} }
final StreamController<CentralStatus> _statusController = 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() { void startStatusNotifications() {
if (_statusSubscription != null) { if (_statusSubscription != null) {
return; 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 { class GearRatiosData {
const GearRatiosData({ const GearRatiosData({
required this.ratios, required this.ratios,

View File

@ -1,137 +0,0 @@
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:anyhow/anyhow.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ShifterService.runDfuPreflight', () {
test('fails when no active button connection exists', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.disconnected, null),
mtuResult: Ok(128),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(preflight.failureReason,
DfuPreflightFailureReason.deviceNotConnected);
expect(adapter.requestMtuCallCount, 0);
});
test('fails when connected to a different button', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'wrong-device'),
mtuResult: Ok(128),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(preflight.failureReason,
DfuPreflightFailureReason.wrongConnectedDevice);
expect(adapter.requestMtuCallCount, 0);
});
test('fails when MTU negotiation fails', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
mtuResult: bail('adapter rejected mtu request'),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight(requestedMtu: 247);
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(
preflight.failureReason, DfuPreflightFailureReason.mtuRequestFailed);
expect(preflight.message, contains('adapter rejected mtu request'));
expect(adapter.requestedMtuValues, [247]);
});
test('fails when negotiated MTU is too low for 64-byte frame writes',
() async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
mtuResult: Ok(universalShifterDfuMinimumMtu - 1),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(preflight.failureReason, DfuPreflightFailureReason.mtuTooLow);
expect(preflight.negotiatedMtu, universalShifterDfuMinimumMtu - 1);
expect(preflight.requiredMtu, universalShifterDfuMinimumMtu);
});
test('passes when connected to target and MTU is sufficient', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
mtuResult: Ok(128),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isTrue);
expect(preflight.failureReason, isNull);
expect(preflight.negotiatedMtu, 128);
expect(preflight.requestedMtu, universalShifterDfuPreferredMtu);
});
});
}
class _FakeDfuPreflightBluetoothAdapter
implements DfuPreflightBluetoothAdapter {
_FakeDfuPreflightBluetoothAdapter({
required this.currentConnectionState,
required Result<int> mtuResult,
}) : _mtuResult = mtuResult;
@override
final (ConnectionStatus, String?) currentConnectionState;
final Result<int> _mtuResult;
int requestMtuCallCount = 0;
final List<int> requestedMtuValues = <int>[];
@override
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) async {
requestMtuCallCount += 1;
requestedMtuValues.add(mtu);
return _mtuResult;
}
}

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/firmware_update_service.dart'; import 'package:abawo_bt_app/service/firmware_update_service.dart';
@ -6,413 +7,304 @@ import 'package:anyhow/anyhow.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('FirmwareUpdateService', () { group('FirmwareUpdateService bootloader flow', () {
test('completes happy path with START, data frames, and FINISH', () async { test('completes happy path with START, offset data, FINISH, and verify',
final transport = _FakeFirmwareUpdateTransport(); () async {
final image = _validImage(130);
final transport = _FakeFirmwareUpdateTransport(totalBytes: image.length);
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 4, defaultStatusTimeout: const Duration(milliseconds: 100),
defaultAckTimeout: const Duration(milliseconds: 100),
); );
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate( final result = await service.startUpdate(
imageBytes: image, imageBytes: image,
sessionId: 7, sessionId: 7,
); );
expect(result.isOk(), isTrue); expect(result.isOk(), isTrue);
expect(transport.controlWrites.length, 2); expect(transport.steps, [
'enterBootloader',
'waitForAppDisconnect',
'connectToBootloader',
'negotiateMtu',
'readStatus',
'waitForBootloaderDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
]);
expect( expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart); transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
expect( expect(
transport.postFinishSteps, transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 7]);
[ expect(transport.dataWrites, isNotEmpty);
'waitForExpectedResetDisconnect', expect(transport.dataWrites.first[0], 7);
'reconnectForVerification', expect(transport.dataWrites.first.sublist(1, 5), [0, 0, 0, 0]);
'verifyDeviceReachable',
],
);
expect(service.currentProgress.state, DfuUpdateState.completed); expect(service.currentProgress.state, DfuUpdateState.completed);
expect(service.currentProgress.sentBytes, image.length); expect(service.currentProgress.sentBytes, image.length);
expect(service.currentProgress.expectedOffset, image.length);
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('rewinds to ack+1 and retransmits after ACK stall', () async { test('backs off on queue-full status and resumes from GET_STATUS',
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1); () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
queueFullOnFirstData: true,
);
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 3, defaultStatusTimeout: const Duration(milliseconds: 100),
defaultAckTimeout: const Duration(milliseconds: 100),
maxNoProgressRetries: 4,
); );
final image = List<int>.generate(190, (index) => index & 0xFF);
final result = await service.startUpdate( final result = await service.startUpdate(
imageBytes: image, imageBytes: image,
sessionId: 9, sessionId: 9,
); );
expect(result.isOk(), isTrue); expect(result.isOk(), isTrue);
expect(transport.dataWrites.length, greaterThan(4)); expect(
expect(transport.sequenceWriteCount(1), greaterThan(1)); transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
.length,
1,
);
expect(
transport.dataWriteOffsets.where((offset) => offset == 0).length, 2);
expect(service.currentProgress.state, DfuUpdateState.completed); expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('fails after bounded retries when ACK progress times out', () async { test('fails with bootloader status error on rejected START', () async {
final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true); final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
startStatusCode: DfuBootloaderStatusCode.vectorError,
);
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 1, defaultStatusTimeout: const Duration(milliseconds: 100),
defaultAckTimeout: const Duration(milliseconds: 40),
maxNoProgressRetries: 2,
); );
final image = List<int>.generate(90, (index) => index & 0xFF);
final result = await service.startUpdate( final result = await service.startUpdate(
imageBytes: image, imageBytes: image,
sessionId: 10, sessionId: 10,
); );
expect(result.isErr(), isTrue); expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('Upload stalled')); expect(result.unwrapErr().toString(), contains('vector table error'));
expect(result.unwrapErr().toString(), contains('after 3 retries'));
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
expect(transport.sequenceWriteCount(0), 3);
expect(service.currentProgress.state, DfuUpdateState.failed); expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.controlWrites.last.first, universalShifterDfuOpcodeStart);
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('cancel sends ABORT and reports aborted state', () async { test('cancel after START sends session-scoped ABORT', () async {
final image = _validImage(80);
final firstFrameSent = Completer<void>(); final firstFrameSent = Completer<void>();
final transport = _FakeFirmwareUpdateTransport( final transport = _FakeFirmwareUpdateTransport(
onDataWrite: (frame) { totalBytes: image.length,
suppressFirstDataStatus: true,
onDataWrite: () {
if (!firstFrameSent.isCompleted) { if (!firstFrameSent.isCompleted) {
firstFrameSent.complete(); firstFrameSent.complete();
} }
}, },
suppressDataAcks: true,
); );
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 1, defaultStatusTimeout: const Duration(seconds: 1),
defaultAckTimeout: const Duration(milliseconds: 500),
); );
final future = service.startUpdate( final future = service.startUpdate(
imageBytes: List<int>.generate(90, (index) => index & 0xFF), imageBytes: image,
sessionId: 11, sessionId: 11,
); );
await firstFrameSent.future.timeout(const Duration(seconds: 1)); await firstFrameSent.future.timeout(const Duration(seconds: 1));
await service.cancelUpdate(); await service.cancelUpdate();
final result = await future; final result = await future;
expect(result.isErr(), isTrue); expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('canceled')); expect(result.unwrapErr().toString(), contains('canceled'));
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]); expect(
transport.controlWrites.last, [universalShifterDfuOpcodeAbort, 11]);
expect(service.currentProgress.state, DfuUpdateState.aborted); expect(service.currentProgress.state, DfuUpdateState.aborted);
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('fails when reconnect does not succeed after expected reset',
() async {
final transport = _FakeFirmwareUpdateTransport(
reconnectError: 'simulated reconnect timeout',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 13,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('did not reconnect'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
],
);
await service.dispose();
await transport.dispose();
});
test('fails when expected reset disconnect is not observed', () async {
final transport = _FakeFirmwareUpdateTransport(
resetDisconnectError: 'simulated missing disconnect',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 15,
);
expect(result.isErr(), isTrue);
expect(
result.unwrapErr().toString(),
contains('expected post-FINISH reset disconnect'),
);
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
['waitForExpectedResetDisconnect'],
);
await service.dispose();
await transport.dispose();
});
test('fails when post-update status verification read fails', () async {
final transport = _FakeFirmwareUpdateTransport(
verificationError: 'simulated status read failure',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 14,
);
expect(result.isErr(), isTrue);
expect(
result.unwrapErr().toString(),
contains('post-update verification failed'),
);
expect(
result.unwrapErr().toString(),
contains('does not expose a version characteristic'),
);
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
],
);
await service.dispose();
await transport.dispose();
});
test('handles deterministic ACK sequence wrap-around across 0xFF->0x00',
() async {
const frameCount = 260;
final transport = _FakeFirmwareUpdateTransport();
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 16,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(
frameCount * universalShifterDfuFramePayloadSizeBytes,
(index) => index & 0xFF,
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 16,
);
expect(result.isOk(), isTrue);
var ffToZeroTransitions = 0;
for (var i = 1; i < transport.ackNotifications.length; i++) {
if (transport.ackNotifications[i - 1] == 0xFF &&
transport.ackNotifications[i] == 0x00) {
ffToZeroTransitions += 1;
}
}
expect(ffToZeroTransitions, greaterThanOrEqualTo(2));
expect(service.currentProgress.lastAckedSequence, 0x03);
expect(service.currentProgress.sentBytes, image.length);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
}); });
} }
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_FakeFirmwareUpdateTransport({ _FakeFirmwareUpdateTransport({
this.dropFirstSequence, required this.totalBytes,
this.startStatusCode = DfuBootloaderStatusCode.ok,
this.queueFullOnFirstData = false,
this.suppressFirstDataStatus = false,
this.onDataWrite, this.onDataWrite,
this.suppressDataAcks = false,
this.resetDisconnectError,
this.reconnectError,
this.verificationError,
}); });
final int? dropFirstSequence; final int totalBytes;
final void Function(List<int> frame)? onDataWrite; final DfuBootloaderStatusCode startStatusCode;
final bool suppressDataAcks; final bool queueFullOnFirstData;
final String? resetDisconnectError; final bool suppressFirstDataStatus;
final String? reconnectError; final void Function()? onDataWrite;
final String? verificationError;
final StreamController<List<int>> _ackController = final StreamController<List<int>> _statusController =
StreamController<List<int>>.broadcast(); StreamController<List<int>>.broadcast();
final List<String> steps = <String>[];
final List<List<int>> controlWrites = <List<int>>[]; final List<List<int>> controlWrites = <List<int>>[];
final List<List<int>> dataWrites = <List<int>>[]; final List<List<int>> dataWrites = <List<int>>[];
final List<int> ackNotifications = <int>[]; final List<int> dataWriteOffsets = <int>[];
final List<String> postFinishSteps = <String>[];
final Set<int> _droppedOnce = <int>{}; int _sessionId = 0;
int _lastAck = 0xFF; int _expectedOffset = 0;
int _expectedSequence = 0; bool _sentQueueFull = false;
bool _suppressedDataStatus = false;
@override @override
Future<Result<DfuPreflightResult>> runPreflight({ Future<Result<void>> enterBootloader() async {
required int requestedMtu, steps.add('enterBootloader');
}) async { return Ok(null);
return Ok(
DfuPreflightResult.ready(
requestedMtu: requestedMtu,
negotiatedMtu: 128,
),
);
} }
@override @override
Stream<List<int>> subscribeToAck() => _ackController.stream; Future<Result<void>> waitForAppDisconnect({required Duration timeout}) async {
steps.add('waitForAppDisconnect');
return Ok(null);
}
@override
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
steps.add('connectToBootloader');
return Ok(null);
}
@override
Future<Result<int>> negotiateMtu({required int requestedMtu}) async {
steps.add('negotiateMtu');
return Ok(128);
}
@override
Stream<List<int>> subscribeToStatus() => _statusController.stream;
@override
Future<Result<List<int>>> readStatus() async {
steps.add('readStatus');
return Ok(_status(DfuBootloaderStatusCode.ok, 0, 0));
}
@override @override
Future<Result<void>> writeControl(List<int> payload) async { Future<Result<void>> writeControl(List<int> payload) async {
controlWrites.add(List<int>.from(payload, growable: false)); controlWrites.add(List<int>.from(payload, growable: false));
final opcode = payload.first;
final opcode = payload.isEmpty ? -1 : payload.first;
if (opcode == universalShifterDfuOpcodeStart) { if (opcode == universalShifterDfuOpcodeStart) {
_lastAck = 0xFF; _sessionId = payload[17];
_expectedSequence = 0; _expectedOffset = 0;
_scheduleAck(0xFF); _scheduleStatus(startStatusCode, _sessionId, 0);
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
} else if (opcode == universalShifterDfuOpcodeFinish) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
} else if (opcode == universalShifterDfuOpcodeAbort) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
} }
if (opcode == universalShifterDfuOpcodeAbort) {
_lastAck = 0xFF;
_expectedSequence = 0;
}
return Ok(null); return Ok(null);
} }
@override @override
Future<Result<void>> writeDataFrame(List<int> frame) async { Future<Result<void>> writeDataFrame(List<int> frame) async {
dataWrites.add(List<int>.from(frame, growable: false)); dataWrites.add(List<int>.from(frame, growable: false));
onDataWrite?.call(frame); onDataWrite?.call();
if (suppressDataAcks) { final offset = _readLeU32(frame, 1);
dataWriteOffsets.add(offset);
if (queueFullOnFirstData && !_sentQueueFull) {
_sentQueueFull = true;
_scheduleStatus(
DfuBootloaderStatusCode.queueFull, _sessionId, _expectedOffset);
return Ok(null); return Ok(null);
} }
final sequence = frame.first; if (suppressFirstDataStatus && !_suppressedDataStatus) {
final shouldDrop = dropFirstSequence != null && _suppressedDataStatus = true;
sequence == dropFirstSequence &&
!_droppedOnce.contains(sequence);
if (shouldDrop) {
_droppedOnce.add(sequence);
_scheduleAck(_lastAck);
return Ok(null); return Ok(null);
} }
if (sequence == _expectedSequence) { final payloadLength =
_lastAck = sequence; frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes;
_expectedSequence = (_expectedSequence + 1) & 0xFF; _expectedOffset = offset + payloadLength;
} _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
_scheduleAck(_lastAck);
return Ok(null); return Ok(null);
} }
void _scheduleAck(int sequence) { @override
final ack = sequence & 0xFF; Future<Result<void>> waitForBootloaderDisconnect(
ackNotifications.add(ack); {required Duration timeout}) async {
steps.add('waitForBootloaderDisconnect');
return Ok(null);
}
@override
Future<Result<void>> reconnectForVerification(
{required Duration timeout}) async {
steps.add('reconnectForVerification');
return Ok(null);
}
@override
Future<Result<void>> verifyDeviceReachable(
{required Duration timeout}) async {
steps.add('verifyDeviceReachable');
return Ok(null);
}
void _scheduleStatus(
DfuBootloaderStatusCode code, int sessionId, int offset) {
final status = _status(code, sessionId, offset);
scheduleMicrotask(() { scheduleMicrotask(() {
_ackController.add([ack]); _statusController.add(status);
}); });
} }
@override List<int> _status(DfuBootloaderStatusCode code, int sessionId, int offset) {
Future<Result<void>> waitForExpectedResetDisconnect({ return [
required Duration timeout, code.value,
}) async { sessionId & 0xFF,
postFinishSteps.add('waitForExpectedResetDisconnect'); offset & 0xFF,
if (resetDisconnectError != null) { (offset >> 8) & 0xFF,
return bail(resetDisconnectError!); (offset >> 16) & 0xFF,
} (offset >> 24) & 0xFF,
return Ok(null); ];
} }
@override int _readLeU32(List<int> bytes, int offset) {
Future<Result<void>> reconnectForVerification({ final data = ByteData.sublistView(Uint8List.fromList(bytes));
required Duration timeout, return data.getUint32(offset, Endian.little);
}) async {
postFinishSteps.add('reconnectForVerification');
if (reconnectError != null) {
return bail(reconnectError!);
}
return Ok(null);
}
@override
Future<Result<void>> verifyDeviceReachable({
required Duration timeout,
}) async {
postFinishSteps.add('verifyDeviceReachable');
if (verificationError != null) {
return bail(verificationError!);
}
return Ok(null);
}
int sequenceWriteCount(int sequence) {
var count = 0;
for (final frame in dataWrites) {
if (frame.first == sequence) {
count += 1;
}
}
return count;
} }
Future<void> dispose() async { Future<void> dispose() async {
await _ackController.close(); await _statusController.close();
} }
} }
List<int> _validImage(int length) {
final image = Uint8List(length);
final data = ByteData.sublistView(image);
data.setUint32(0, 0x20001000, Endian.little);
data.setUint32(4, 0x00030009, Endian.little);
for (var index = 8; index < image.length; index++) {
image[index] = index & 0xFF;
}
return image;
}