feat(dfu): verify reconnect before reporting update success

This commit is contained in:
2026-03-03 17:11:47 +01:00
parent aafa9928ac
commit c581b4d92c
2 changed files with 289 additions and 0 deletions

View File

@ -14,12 +14,18 @@ class FirmwareUpdateService {
this.defaultWindowSize = 8,
this.maxNoProgressRetries = 5,
this.defaultAckTimeout = const Duration(milliseconds: 800),
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
this.defaultReconnectTimeout = const Duration(seconds: 12),
this.defaultVerificationTimeout = const Duration(seconds: 5),
}) : _transport = transport;
final FirmwareUpdateTransport _transport;
final int defaultWindowSize;
final int maxNoProgressRetries;
final Duration defaultAckTimeout;
final Duration defaultPostFinishResetTimeout;
final Duration defaultReconnectTimeout;
final Duration defaultVerificationTimeout;
final StreamController<DfuUpdateProgress> _progressController =
StreamController<DfuUpdateProgress>.broadcast();
@ -59,6 +65,9 @@ class FirmwareUpdateService {
int? windowSize,
Duration? ackTimeout,
int? noProgressRetries,
Duration? postFinishResetTimeout,
Duration? reconnectTimeout,
Duration? verificationTimeout,
}) async {
if (_isRunning) {
return bail(
@ -73,6 +82,12 @@ class FirmwareUpdateService {
final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout;
final effectiveNoProgressRetries =
noProgressRetries ?? maxNoProgressRetries;
final effectivePostFinishResetTimeout =
postFinishResetTimeout ?? defaultPostFinishResetTimeout;
final effectiveReconnectTimeout =
reconnectTimeout ?? defaultReconnectTimeout;
final effectiveVerificationTimeout =
verificationTimeout ?? defaultVerificationTimeout;
if (effectiveWindowSize <= 0) {
return bail(
@ -231,6 +246,38 @@ class FirmwareUpdateService {
);
}
await _ackSubscription?.cancel();
_ackSubscription = null;
final resetDisconnectResult =
await _transport.waitForExpectedResetDisconnect(
timeout: effectivePostFinishResetTimeout,
);
if (resetDisconnectResult.isErr()) {
throw _DfuFailure(
'Device did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
);
}
final reconnectResult = await _transport.reconnectForVerification(
timeout: effectiveReconnectTimeout,
);
if (reconnectResult.isErr()) {
throw _DfuFailure(
'Device did not reconnect after DFU reset: ${reconnectResult.unwrapErr()}',
);
}
final verificationResult = await _transport.verifyDeviceReachable(
timeout: effectiveVerificationTimeout,
);
if (verificationResult.isErr()) {
throw _DfuFailure(
'Device reconnected but post-update verification failed: ${verificationResult.unwrapErr()} '
'Firmware version cannot be compared yet because the device does not expose a version characteristic.',
);
}
shouldAbortForCleanup = false;
_emitProgress(
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
@ -486,6 +533,22 @@ abstract interface class FirmwareUpdateTransport {
Future<Result<void>> writeControl(List<int> payload);
Future<Result<void>> writeDataFrame(List<int> frame);
Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout,
});
Future<Result<void>> reconnectForVerification({
required Duration timeout,
});
/// Verifies that the device is reachable after reconnect.
///
/// Current limitation: strict firmware version comparison is not possible
/// yet because no firmware version characteristic is exposed by the device.
Future<Result<void>> verifyDeviceReachable({
required Duration timeout,
});
}
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
@ -535,6 +598,82 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
withResponse: false,
);
}
@override
Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout,
}) async {
final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 == ConnectionStatus.disconnected) {
return Ok(null);
}
try {
await bluetoothController.connectionStateStream
.firstWhere((state) => state.$1 == ConnectionStatus.disconnected)
.timeout(timeout);
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for the expected reset disconnect.',
);
} catch (error) {
return bail('Failed while waiting for expected reset disconnect: $error');
}
}
@override
Future<Result<void>> reconnectForVerification({
required Duration timeout,
}) async {
final connectResult =
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
if (connectResult.isErr()) {
return bail(connectResult.unwrapErr());
}
final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 == ConnectionStatus.connected &&
currentState.$2 == buttonDeviceId) {
return Ok(null);
}
try {
await bluetoothController.connectionStateStream
.firstWhere(
(state) =>
state.$1 == ConnectionStatus.connected &&
state.$2 == buttonDeviceId,
)
.timeout(timeout);
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for reconnect.',
);
} catch (error) {
return bail('Reconnect wait failed: $error');
}
}
@override
Future<Result<void>> verifyDeviceReachable({
required Duration timeout,
}) async {
try {
final statusResult = await shifterService.readStatus().timeout(timeout);
if (statusResult.isErr()) {
return bail(statusResult.unwrapErr());
}
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.',
);
} catch (error) {
return bail('Post-update verification failed: $error');
}
}
}
class _DfuFailure implements Exception {