feat(dfu): verify reconnect before reporting update success
This commit is contained in:
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user