feat: recover bootloader OTA transfers
This commit is contained in:
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user