feat: recover bootloader OTA transfers

This commit is contained in:
2026-04-29 19:59:11 +02:00
parent 16365e1d04
commit dc1f53b6e1
3 changed files with 445 additions and 111 deletions

View File

@ -24,6 +24,7 @@ void main() {
expect(result.isOk(), isTrue);
expect(transport.steps, [
'isConnectedToBootloader',
'enterBootloader',
'waitForAppDisconnect',
'connectToBootloader',
@ -48,6 +49,63 @@ void main() {
await transport.dispose();
});
test('starts directly when already connected to bootloader', () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
alreadyInBootloader: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 8,
);
expect(result.isOk(), isTrue);
expect(transport.steps, [
'isConnectedToBootloader',
'negotiateMtu',
'readStatus',
'waitForBootloaderDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
]);
expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
await service.dispose();
await transport.dispose();
});
test('tolerates enter bootloader write error when app disconnects',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
failEnterBootloader: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 12,
);
expect(result.isOk(), isTrue);
expect(transport.steps, contains('waitForAppDisconnect'));
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('backs off on queue-full status and resumes from GET_STATUS',
() async {
final image = _validImage(80);
@ -80,6 +138,84 @@ void main() {
await transport.dispose();
});
test('reconnects and resumes from status after transient data failure',
() async {
final image = _validImage(130);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
failDataWriteAtOffsetOnce:
universalShifterBootloaderDfuMaxPayloadSizeBytes,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 13,
);
expect(result.isOk(), isTrue);
expect(
transport.steps.where((step) => step == 'connectToBootloader').length,
2,
);
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
.length,
1,
);
expect(
transport.dataWriteOffsets
.where(
(offset) =>
offset == universalShifterBootloaderDfuMaxPayloadSizeBytes,
)
.length,
2,
);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('restarts START when reconnect status has no active session',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
failDataWriteAtOffsetOnce:
universalShifterBootloaderDfuMaxPayloadSizeBytes,
resetSessionOnRecoveryStatus: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 14,
);
expect(result.isOk(), isTrue);
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeStart)
.length,
2,
);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails with bootloader status error on rejected START', () async {
final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport(
@ -147,15 +283,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_FakeFirmwareUpdateTransport({
required this.totalBytes,
this.startStatusCode = DfuBootloaderStatusCode.ok,
this.alreadyInBootloader = false,
this.failEnterBootloader = false,
this.queueFullOnFirstData = false,
this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false,
this.onDataWrite,
});
final int totalBytes;
final DfuBootloaderStatusCode startStatusCode;
final bool alreadyInBootloader;
final bool failEnterBootloader;
final bool queueFullOnFirstData;
final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus;
final void Function()? onDataWrite;
final StreamController<List<int>> _statusController =
@ -167,12 +311,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
int _sessionId = 0;
int _expectedOffset = 0;
int _connectCount = 0;
bool _sentDataFailure = false;
bool _sentQueueFull = false;
bool _suppressedDataStatus = false;
@override
Future<Result<bool>> isConnectedToBootloader() async {
steps.add('isConnectedToBootloader');
return Ok(alreadyInBootloader);
}
@override
Future<Result<void>> enterBootloader() async {
steps.add('enterBootloader');
if (failEnterBootloader) {
return bail('app disconnected before write response');
}
return Ok(null);
}
@ -185,6 +340,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
steps.add('connectToBootloader');
_connectCount += 1;
return Ok(null);
}
@ -212,6 +368,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_expectedOffset = 0;
_scheduleStatus(startStatusCode, _sessionId, 0);
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
_sessionId = 0;
_expectedOffset = 0;
}
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
} else if (opcode == universalShifterDfuOpcodeFinish) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
@ -229,6 +389,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
final offset = _readLeU32(frame, 1);
dataWriteOffsets.add(offset);
if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) {
_sentDataFailure = true;
return bail('simulated BLE write failure');
}
if (queueFullOnFirstData && !_sentQueueFull) {
_sentQueueFull = true;
_scheduleStatus(