feat(dfu): verify reconnect before reporting update success
This commit is contained in:
@ -14,12 +14,18 @@ class FirmwareUpdateService {
|
|||||||
this.defaultWindowSize = 8,
|
this.defaultWindowSize = 8,
|
||||||
this.maxNoProgressRetries = 5,
|
this.maxNoProgressRetries = 5,
|
||||||
this.defaultAckTimeout = const Duration(milliseconds: 800),
|
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;
|
}) : _transport = transport;
|
||||||
|
|
||||||
final FirmwareUpdateTransport _transport;
|
final FirmwareUpdateTransport _transport;
|
||||||
final int defaultWindowSize;
|
final int defaultWindowSize;
|
||||||
final int maxNoProgressRetries;
|
final int maxNoProgressRetries;
|
||||||
final Duration defaultAckTimeout;
|
final Duration defaultAckTimeout;
|
||||||
|
final Duration defaultPostFinishResetTimeout;
|
||||||
|
final Duration defaultReconnectTimeout;
|
||||||
|
final Duration defaultVerificationTimeout;
|
||||||
|
|
||||||
final StreamController<DfuUpdateProgress> _progressController =
|
final StreamController<DfuUpdateProgress> _progressController =
|
||||||
StreamController<DfuUpdateProgress>.broadcast();
|
StreamController<DfuUpdateProgress>.broadcast();
|
||||||
@ -59,6 +65,9 @@ class FirmwareUpdateService {
|
|||||||
int? windowSize,
|
int? windowSize,
|
||||||
Duration? ackTimeout,
|
Duration? ackTimeout,
|
||||||
int? noProgressRetries,
|
int? noProgressRetries,
|
||||||
|
Duration? postFinishResetTimeout,
|
||||||
|
Duration? reconnectTimeout,
|
||||||
|
Duration? verificationTimeout,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isRunning) {
|
if (_isRunning) {
|
||||||
return bail(
|
return bail(
|
||||||
@ -73,6 +82,12 @@ class FirmwareUpdateService {
|
|||||||
final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout;
|
final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout;
|
||||||
final effectiveNoProgressRetries =
|
final effectiveNoProgressRetries =
|
||||||
noProgressRetries ?? maxNoProgressRetries;
|
noProgressRetries ?? maxNoProgressRetries;
|
||||||
|
final effectivePostFinishResetTimeout =
|
||||||
|
postFinishResetTimeout ?? defaultPostFinishResetTimeout;
|
||||||
|
final effectiveReconnectTimeout =
|
||||||
|
reconnectTimeout ?? defaultReconnectTimeout;
|
||||||
|
final effectiveVerificationTimeout =
|
||||||
|
verificationTimeout ?? defaultVerificationTimeout;
|
||||||
|
|
||||||
if (effectiveWindowSize <= 0) {
|
if (effectiveWindowSize <= 0) {
|
||||||
return bail(
|
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;
|
shouldAbortForCleanup = false;
|
||||||
_emitProgress(
|
_emitProgress(
|
||||||
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
|
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
|
||||||
@ -486,6 +533,22 @@ abstract interface class FirmwareUpdateTransport {
|
|||||||
Future<Result<void>> writeControl(List<int> payload);
|
Future<Result<void>> writeControl(List<int> payload);
|
||||||
|
|
||||||
Future<Result<void>> writeDataFrame(List<int> frame);
|
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 {
|
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||||
@ -535,6 +598,82 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
withResponse: false,
|
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 {
|
class _DfuFailure implements Exception {
|
||||||
|
|||||||
@ -27,6 +27,14 @@ void main() {
|
|||||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
||||||
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
||||||
|
expect(
|
||||||
|
transport.postFinishSteps,
|
||||||
|
[
|
||||||
|
'waitForExpectedResetDisconnect',
|
||||||
|
'reconnectForVerification',
|
||||||
|
'verifyDeviceReachable',
|
||||||
|
],
|
||||||
|
);
|
||||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||||
expect(service.currentProgress.sentBytes, image.length);
|
expect(service.currentProgress.sentBytes, image.length);
|
||||||
|
|
||||||
@ -91,6 +99,108 @@ void main() {
|
|||||||
await service.dispose();
|
await service.dispose();
|
||||||
await transport.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.dropFirstSequence,
|
||||||
this.onDataWrite,
|
this.onDataWrite,
|
||||||
this.suppressDataAcks = false,
|
this.suppressDataAcks = false,
|
||||||
|
this.resetDisconnectError,
|
||||||
|
this.reconnectError,
|
||||||
|
this.verificationError,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int? dropFirstSequence;
|
final int? dropFirstSequence;
|
||||||
final void Function(List<int> frame)? onDataWrite;
|
final void Function(List<int> frame)? onDataWrite;
|
||||||
final bool suppressDataAcks;
|
final bool suppressDataAcks;
|
||||||
|
final String? resetDisconnectError;
|
||||||
|
final String? reconnectError;
|
||||||
|
final String? verificationError;
|
||||||
|
|
||||||
final StreamController<List<int>> _ackController =
|
final StreamController<List<int>> _ackController =
|
||||||
StreamController<List<int>>.broadcast();
|
StreamController<List<int>>.broadcast();
|
||||||
|
|
||||||
final List<List<int>> controlWrites = <List<int>>[];
|
final List<List<int>> controlWrites = <List<int>>[];
|
||||||
final List<List<int>> dataWrites = <List<int>>[];
|
final List<List<int>> dataWrites = <List<int>>[];
|
||||||
|
final List<String> postFinishSteps = <String>[];
|
||||||
final Set<int> _droppedOnce = <int>{};
|
final Set<int> _droppedOnce = <int>{};
|
||||||
int _lastAck = 0xFF;
|
int _lastAck = 0xFF;
|
||||||
int _expectedSequence = 0;
|
int _expectedSequence = 0;
|
||||||
@ -184,6 +301,39 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
return Ok(null);
|
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) {
|
int sequenceWriteCount(int sequence) {
|
||||||
var count = 0;
|
var count = 0;
|
||||||
for (final frame in dataWrites) {
|
for (final frame in dataWrites) {
|
||||||
|
|||||||
Reference in New Issue
Block a user