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

@ -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:typed_data';
import 'package:abawo_bt_app/model/shifter_types.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';
void main() {
group('FirmwareUpdateService', () {
test('completes happy path with START, data frames, and FINISH', () async {
final transport = _FakeFirmwareUpdateTransport();
group('FirmwareUpdateService bootloader flow', () {
test('completes happy path with START, offset data, FINISH, and verify',
() async {
final image = _validImage(130);
final transport = _FakeFirmwareUpdateTransport(totalBytes: image.length);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 7,
);
expect(result.isOk(), isTrue);
expect(transport.controlWrites.length, 2);
expect(transport.steps, [
'enterBootloader',
'waitForAppDisconnect',
'connectToBootloader',
'negotiateMtu',
'readStatus',
'waitForBootloaderDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
]);
expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
],
);
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 7]);
expect(transport.dataWrites, isNotEmpty);
expect(transport.dataWrites.first[0], 7);
expect(transport.dataWrites.first.sublist(1, 5), [0, 0, 0, 0]);
expect(service.currentProgress.state, DfuUpdateState.completed);
expect(service.currentProgress.sentBytes, image.length);
expect(service.currentProgress.expectedOffset, image.length);
await service.dispose();
await transport.dispose();
});
test('rewinds to ack+1 and retransmits after ACK stall', () async {
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
test('backs off on queue-full status and resumes from GET_STATUS',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
queueFullOnFirstData: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 3,
defaultAckTimeout: const Duration(milliseconds: 100),
maxNoProgressRetries: 4,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(190, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 9,
);
expect(result.isOk(), isTrue);
expect(transport.dataWrites.length, greaterThan(4));
expect(transport.sequenceWriteCount(1), greaterThan(1));
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
.length,
1,
);
expect(
transport.dataWriteOffsets.where((offset) => offset == 0).length, 2);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails after bounded retries when ACK progress times out', () async {
final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true);
test('fails with bootloader status error on rejected START', () async {
final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
startStatusCode: DfuBootloaderStatusCode.vectorError,
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 1,
defaultAckTimeout: const Duration(milliseconds: 40),
maxNoProgressRetries: 2,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(90, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 10,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('Upload stalled'));
expect(result.unwrapErr().toString(), contains('after 3 retries'));
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
expect(transport.sequenceWriteCount(0), 3);
expect(result.unwrapErr().toString(), contains('vector table error'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.controlWrites.last.first, universalShifterDfuOpcodeStart);
await service.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 transport = _FakeFirmwareUpdateTransport(
onDataWrite: (frame) {
totalBytes: image.length,
suppressFirstDataStatus: true,
onDataWrite: () {
if (!firstFrameSent.isCompleted) {
firstFrameSent.complete();
}
},
suppressDataAcks: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 1,
defaultAckTimeout: const Duration(milliseconds: 500),
defaultStatusTimeout: const Duration(seconds: 1),
);
final future = service.startUpdate(
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
imageBytes: image,
sessionId: 11,
);
await firstFrameSent.future.timeout(const Duration(seconds: 1));
await service.cancelUpdate();
final result = await future;
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('canceled'));
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
expect(
transport.controlWrites.last, [universalShifterDfuOpcodeAbort, 11]);
expect(service.currentProgress.state, DfuUpdateState.aborted);
await service.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 {
_FakeFirmwareUpdateTransport({
this.dropFirstSequence,
required this.totalBytes,
this.startStatusCode = DfuBootloaderStatusCode.ok,
this.queueFullOnFirstData = false,
this.suppressFirstDataStatus = false,
this.onDataWrite,
this.suppressDataAcks = false,
this.resetDisconnectError,
this.reconnectError,
this.verificationError,
});
final int? dropFirstSequence;
final void Function(List<int> frame)? onDataWrite;
final bool suppressDataAcks;
final String? resetDisconnectError;
final String? reconnectError;
final String? verificationError;
final int totalBytes;
final DfuBootloaderStatusCode startStatusCode;
final bool queueFullOnFirstData;
final bool suppressFirstDataStatus;
final void Function()? onDataWrite;
final StreamController<List<int>> _ackController =
final StreamController<List<int>> _statusController =
StreamController<List<int>>.broadcast();
final List<String> steps = <String>[];
final List<List<int>> controlWrites = <List<int>>[];
final List<List<int>> dataWrites = <List<int>>[];
final List<int> ackNotifications = <int>[];
final List<String> postFinishSteps = <String>[];
final Set<int> _droppedOnce = <int>{};
int _lastAck = 0xFF;
int _expectedSequence = 0;
final List<int> dataWriteOffsets = <int>[];
int _sessionId = 0;
int _expectedOffset = 0;
bool _sentQueueFull = false;
bool _suppressedDataStatus = false;
@override
Future<Result<DfuPreflightResult>> runPreflight({
required int requestedMtu,
}) async {
return Ok(
DfuPreflightResult.ready(
requestedMtu: requestedMtu,
negotiatedMtu: 128,
),
);
Future<Result<void>> enterBootloader() async {
steps.add('enterBootloader');
return Ok(null);
}
@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
Future<Result<void>> writeControl(List<int> payload) async {
controlWrites.add(List<int>.from(payload, growable: false));
final opcode = payload.isEmpty ? -1 : payload.first;
final opcode = payload.first;
if (opcode == universalShifterDfuOpcodeStart) {
_lastAck = 0xFF;
_expectedSequence = 0;
_scheduleAck(0xFF);
_sessionId = payload[17];
_expectedOffset = 0;
_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);
}
@override
Future<Result<void>> writeDataFrame(List<int> frame) async {
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);
}
final sequence = frame.first;
final shouldDrop = dropFirstSequence != null &&
sequence == dropFirstSequence &&
!_droppedOnce.contains(sequence);
if (shouldDrop) {
_droppedOnce.add(sequence);
_scheduleAck(_lastAck);
if (suppressFirstDataStatus && !_suppressedDataStatus) {
_suppressedDataStatus = true;
return Ok(null);
}
if (sequence == _expectedSequence) {
_lastAck = sequence;
_expectedSequence = (_expectedSequence + 1) & 0xFF;
}
_scheduleAck(_lastAck);
final payloadLength =
frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes;
_expectedOffset = offset + payloadLength;
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
return Ok(null);
}
void _scheduleAck(int sequence) {
final ack = sequence & 0xFF;
ackNotifications.add(ack);
@override
Future<Result<void>> waitForBootloaderDisconnect(
{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(() {
_ackController.add([ack]);
_statusController.add(status);
});
}
@override
Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout,
}) async {
postFinishSteps.add('waitForExpectedResetDisconnect');
if (resetDisconnectError != null) {
return bail(resetDisconnectError!);
}
return Ok(null);
List<int> _status(DfuBootloaderStatusCode code, int sessionId, int offset) {
return [
code.value,
sessionId & 0xFF,
offset & 0xFF,
(offset >> 8) & 0xFF,
(offset >> 16) & 0xFF,
(offset >> 24) & 0xFF,
];
}
@override
Future<Result<void>> reconnectForVerification({
required Duration timeout,
}) 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;
int _readLeU32(List<int> bytes, int offset) {
final data = ByteData.sublistView(Uint8List.fromList(bytes));
return data.getUint32(offset, Endian.little);
}
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;
}