feat: smarter firmware confirm via reconnect

This commit is contained in:
2026-05-04 14:40:13 +02:00
parent 16690dc216
commit bcccd03ecc
2 changed files with 172 additions and 17 deletions

View File

@ -196,15 +196,11 @@ class FirmwareUpdateService {
); );
_emitProgress(state: DfuUpdateState.finishing); _emitProgress(state: DfuUpdateState.finishing);
final finishStatus = await _writeControlAndWaitForStatus( await _writeFinishAndWaitForReset(
BootloaderDfuProtocol.encodeFinishPayload(normalizedSessionId),
timeout: effectiveStatusTimeout,
);
_requireOkStatus(
finishStatus,
sessionId: normalizedSessionId, sessionId: normalizedSessionId,
expectedOffset: imageBytes.length, expectedOffset: imageBytes.length,
operation: 'FINISH', statusTimeout: effectiveStatusTimeout,
resetTimeout: effectivePostFinishResetTimeout,
); );
await _statusSubscription?.cancel(); await _statusSubscription?.cancel();
@ -212,15 +208,6 @@ class FirmwareUpdateService {
_emitProgress( _emitProgress(
state: DfuUpdateState.rebooting, sentBytes: imageBytes.length); state: DfuUpdateState.rebooting, sentBytes: imageBytes.length);
final resetDisconnectResult =
await _transport.waitForBootloaderDisconnect(
timeout: effectivePostFinishResetTimeout,
);
if (resetDisconnectResult.isErr()) {
throw _DfuFailure(
'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
);
}
if (!verifyAfterFinish) { if (!verifyAfterFinish) {
_emitProgress( _emitProgress(
@ -554,6 +541,76 @@ class FirmwareUpdateService {
); );
} }
Future<void> _writeFinishAndWaitForReset({
required int sessionId,
required int expectedOffset,
required Duration statusTimeout,
required Duration resetTimeout,
}) async {
final eventCount = _statusEventCount;
final result = await _transport.writeControl(
BootloaderDfuProtocol.encodeFinishPayload(sessionId),
);
if (result.isErr()) {
throw _DfuFailure(
'Failed to write bootloader control command: ${result.unwrapErr()}',
);
}
final deadline = DateTime.now().add(resetTimeout);
var observedEvents = eventCount;
while (true) {
_throwIfCancelled();
_throwIfStatusStreamErrored();
if (_statusEventCount > observedEvents && _latestStatus != null) {
final status = _latestStatus!;
_requireOkStatus(
status,
sessionId: sessionId,
expectedOffset: expectedOffset,
operation: 'FINISH',
);
final remaining = deadline.difference(DateTime.now());
final resetDisconnectResult =
await _transport.waitForBootloaderDisconnect(
timeout: remaining > Duration.zero ? remaining : Duration.zero,
);
if (resetDisconnectResult.isErr()) {
throw _DfuFailure(
'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
);
}
return;
}
final disconnectResult = await _transport.waitForBootloaderDisconnect(
timeout: Duration.zero,
);
if (disconnectResult.isOk()) {
return;
}
final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) {
throw _DfuFailure(
'Bootloader did not perform the expected post-FINISH reset disconnect within ${resetTimeout.inMilliseconds}ms.',
);
}
final waitDuration =
remaining < statusTimeout ? remaining : statusTimeout;
final gotEvent = await _waitForNextStatusEvent(
afterEventCount: observedEvents,
timeout: waitDuration,
recoverable: false,
);
if (gotEvent) {
observedEvents = _statusEventCount - 1;
}
}
}
Future<DfuBootloaderStatus> _waitForStatus({ Future<DfuBootloaderStatus> _waitForStatus({
required int afterEventCount, required int afterEventCount,
required Duration timeout, required Duration timeout,

View File

@ -140,6 +140,89 @@ void main() {
await transport.dispose(); await transport.dispose();
}); });
test('completes when FINISH status is lost but bootloader disconnects',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
suppressFinishStatus: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 20),
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 15,
);
expect(result.isOk(), isTrue);
expect(
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 15]);
expect(transport.steps, contains('reconnectForVerification'));
expect(transport.steps, contains('verifyDeviceReachable'));
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails when FINISH status is lost and bootloader stays connected',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
suppressFinishStatus: true,
disconnectAfterFinish: false,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 10),
defaultPostFinishResetTimeout: const Duration(milliseconds: 30),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 16,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('post-FINISH reset'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(transport.steps, isNot(contains('reconnectForVerification')));
await service.dispose();
await transport.dispose();
});
test('fails when FINISH returns explicit bootloader error', () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
finishStatusCode: DfuBootloaderStatusCode.flashError,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 20),
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 17,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('flash error'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(transport.steps, isNot(contains('reconnectForVerification')));
await service.dispose();
await transport.dispose();
});
test('reconnects and resumes from status after transient data failure', test('reconnects and resumes from status after transient data failure',
() async { () async {
final image = _validImage(130); final image = _validImage(130);
@ -297,6 +380,9 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
this.suppressFirstDataStatus = false, this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce, this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false, this.resetSessionOnRecoveryStatus = false,
this.suppressFinishStatus = false,
this.disconnectAfterFinish = true,
this.finishStatusCode = DfuBootloaderStatusCode.ok,
this.onDataWrite, this.onDataWrite,
}); });
@ -308,6 +394,9 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
final bool suppressFirstDataStatus; final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce; final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus; final bool resetSessionOnRecoveryStatus;
final bool suppressFinishStatus;
final bool disconnectAfterFinish;
final DfuBootloaderStatusCode finishStatusCode;
final void Function()? onDataWrite; final void Function()? onDataWrite;
final StreamController<List<int>> _statusController = final StreamController<List<int>> _statusController =
@ -323,6 +412,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
bool _sentDataFailure = false; bool _sentDataFailure = false;
bool _sentQueueFull = false; bool _sentQueueFull = false;
bool _suppressedDataStatus = false; bool _suppressedDataStatus = false;
bool _finishDisconnectAvailable = false;
@override @override
Future<Result<bool>> isConnectedToBootloader() async { Future<Result<bool>> isConnectedToBootloader() async {
@ -389,7 +479,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
} }
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
} else if (opcode == universalShifterDfuOpcodeFinish) { } else if (opcode == universalShifterDfuOpcodeFinish) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes); if (suppressFinishStatus) {
_finishDisconnectAvailable = disconnectAfterFinish;
} else {
_scheduleStatus(finishStatusCode, payload[1], totalBytes);
}
} else if (opcode == universalShifterDfuOpcodeAbort) { } else if (opcode == universalShifterDfuOpcodeAbort) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0); _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
} }
@ -431,7 +525,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Future<Result<void>> waitForBootloaderDisconnect( Future<Result<void>> waitForBootloaderDisconnect(
{required Duration timeout}) async { {required Duration timeout}) async {
if (timeout == Duration.zero && !_finishDisconnectAvailable) {
return bail('still connected');
}
steps.add('waitForBootloaderDisconnect'); steps.add('waitForBootloaderDisconnect');
_finishDisconnectAvailable = true;
return Ok(null); return Ok(null);
} }