feat: smarter firmware confirm via reconnect
This commit is contained in:
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user