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 {

View File

@ -27,6 +27,14 @@ void main() {
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
],
);
expect(service.currentProgress.state, DfuUpdateState.completed);
expect(service.currentProgress.sentBytes, image.length);
@ -91,6 +99,108 @@ void main() {
await service.dispose();
await transport.dispose();
});
test('fails when reconnect does not succeed after expected reset',
() async {
final transport = _FakeFirmwareUpdateTransport(
reconnectError: 'simulated reconnect timeout',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 13,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('did not reconnect'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
],
);
await service.dispose();
await transport.dispose();
});
test('fails when expected reset disconnect is not observed', () async {
final transport = _FakeFirmwareUpdateTransport(
resetDisconnectError: 'simulated missing disconnect',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 15,
);
expect(result.isErr(), isTrue);
expect(
result.unwrapErr().toString(),
contains('expected post-FINISH reset disconnect'),
);
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
['waitForExpectedResetDisconnect'],
);
await service.dispose();
await transport.dispose();
});
test('fails when post-update status verification read fails', () async {
final transport = _FakeFirmwareUpdateTransport(
verificationError: 'simulated status read failure',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 14,
);
expect(result.isErr(), isTrue);
expect(
result.unwrapErr().toString(),
contains('post-update verification failed'),
);
expect(
result.unwrapErr().toString(),
contains('does not expose a version characteristic'),
);
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
],
);
await service.dispose();
await transport.dispose();
});
});
}
@ -99,17 +209,24 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
this.dropFirstSequence,
this.onDataWrite,
this.suppressDataAcks = false,
this.resetDisconnectError,
this.reconnectError,
this.verificationError,
});
final int? dropFirstSequence;
final void Function(List<int> frame)? onDataWrite;
final bool suppressDataAcks;
final String? resetDisconnectError;
final String? reconnectError;
final String? verificationError;
final StreamController<List<int>> _ackController =
StreamController<List<int>>.broadcast();
final List<List<int>> controlWrites = <List<int>>[];
final List<List<int>> dataWrites = <List<int>>[];
final List<String> postFinishSteps = <String>[];
final Set<int> _droppedOnce = <int>{};
int _lastAck = 0xFF;
int _expectedSequence = 0;
@ -184,6 +301,39 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
return Ok(null);
}
@override
Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout,
}) async {
postFinishSteps.add('waitForExpectedResetDisconnect');
if (resetDisconnectError != null) {
return bail(resetDisconnectError!);
}
return Ok(null);
}
@override
Future<Result<void>> reconnectForVerification({
required Duration timeout,
}) async {
postFinishSteps.add('reconnectForVerification');
if (reconnectError != null) {
return bail(reconnectError!);
}
return Ok(null);
}
@override
Future<Result<void>> verifyDeviceReachable({
required Duration timeout,
}) async {
postFinishSteps.add('verifyDeviceReachable');
if (verificationError != null) {
return bail(verificationError!);
}
return Ok(null);
}
int sequenceWriteCount(int sequence) {
var count = 0;
for (final frame in dataWrites) {