diff --git a/lib/service/firmware_update_service.dart b/lib/service/firmware_update_service.dart index ff6e1d3..399d891 100644 --- a/lib/service/firmware_update_service.dart +++ b/lib/service/firmware_update_service.dart @@ -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 _progressController = StreamController.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> writeControl(List payload); Future> writeDataFrame(List frame); + + Future> waitForExpectedResetDisconnect({ + required Duration timeout, + }); + + Future> 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> verifyDeviceReachable({ + required Duration timeout, + }); } class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { @@ -535,6 +598,82 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { withResponse: false, ); } + + @override + Future> 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> 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> 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 { diff --git a/test/service/firmware_update_service_test.dart b/test/service/firmware_update_service_test.dart index 8cb795c..885c360 100644 --- a/test/service/firmware_update_service_test.dart +++ b/test/service/firmware_update_service_test.dart @@ -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.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.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.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 frame)? onDataWrite; final bool suppressDataAcks; + final String? resetDisconnectError; + final String? reconnectError; + final String? verificationError; final StreamController> _ackController = StreamController>.broadcast(); final List> controlWrites = >[]; final List> dataWrites = >[]; + final List postFinishSteps = []; final Set _droppedOnce = {}; int _lastAck = 0xFF; int _expectedSequence = 0; @@ -184,6 +301,39 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { return Ok(null); } + @override + Future> waitForExpectedResetDisconnect({ + required Duration timeout, + }) async { + postFinishSteps.add('waitForExpectedResetDisconnect'); + if (resetDisconnectError != null) { + return bail(resetDisconnectError!); + } + return Ok(null); + } + + @override + Future> reconnectForVerification({ + required Duration timeout, + }) async { + postFinishSteps.add('reconnectForVerification'); + if (reconnectError != null) { + return bail(reconnectError!); + } + return Ok(null); + } + + @override + Future> 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) {