import 'dart:async'; import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/dfu_protocol.dart'; import 'package:abawo_bt_app/service/shifter_service.dart'; import 'package:anyhow/anyhow.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' show DiscoveredDevice, Uuid; class FirmwareUpdateService { FirmwareUpdateService({ required FirmwareUpdateTransport transport, this.verifyAfterFinish = true, this.defaultStatusTimeout = const Duration(seconds: 2), this.defaultBootloaderConnectTimeout = const Duration(seconds: 60), this.defaultPostFinishResetTimeout = const Duration(seconds: 8), this.defaultReconnectTimeout = const Duration(seconds: 12), this.defaultVerificationTimeout = const Duration(seconds: 5), this.queueFullBackoff = const Duration(milliseconds: 200), this.maxNoProgressRetries = 5, this.maxReconnectResumeAttempts = 3, }) : _transport = transport; final FirmwareUpdateTransport _transport; final bool verifyAfterFinish; final Duration defaultStatusTimeout; final Duration defaultBootloaderConnectTimeout; final Duration defaultPostFinishResetTimeout; final Duration defaultReconnectTimeout; final Duration defaultVerificationTimeout; final Duration queueFullBackoff; final int maxNoProgressRetries; final int maxReconnectResumeAttempts; final StreamController _progressController = StreamController.broadcast(); DfuUpdateProgress _currentProgress = const DfuUpdateProgress( state: DfuUpdateState.idle, totalBytes: 0, sentBytes: 0, expectedOffset: 0, sessionId: 0, flags: DfuUpdateFlags(), ); StreamSubscription>? _statusSubscription; Completer? _statusSignal; Completer? _cancelSignal; DfuBootloaderStatus? _latestStatus; String? _statusStreamError; int _statusEventCount = 0; bool _isRunning = false; bool _cancelRequested = false; int _totalBytes = 0; Stream get progressStream => _progressController.stream; DfuUpdateProgress get currentProgress => _currentProgress; bool get isUpdating => _isRunning; Future> startUpdate({ required List imageBytes, required int sessionId, int appStart = universalShifterDfuAppStart, int imageVersion = 0, DfuUpdateFlags flags = const DfuUpdateFlags(), int requestedMtu = universalShifterDfuPreferredMtu, Duration? statusTimeout, Duration? bootloaderConnectTimeout, Duration? postFinishResetTimeout, Duration? reconnectTimeout, Duration? verificationTimeout, }) async { if (_isRunning) { return bail( 'Firmware update is already running. Cancel or wait for completion before starting a new upload.', ); } if (imageBytes.isEmpty) { return bail( 'Firmware image is empty. Select a valid .bin file and retry.'); } final effectiveStatusTimeout = statusTimeout ?? defaultStatusTimeout; final effectiveBootloaderConnectTimeout = bootloaderConnectTimeout ?? defaultBootloaderConnectTimeout; final effectivePostFinishResetTimeout = postFinishResetTimeout ?? defaultPostFinishResetTimeout; final effectiveReconnectTimeout = reconnectTimeout ?? defaultReconnectTimeout; final effectiveVerificationTimeout = verificationTimeout ?? defaultVerificationTimeout; final rawSessionId = sessionId & 0xFF; final normalizedSessionId = rawSessionId == 0 ? 1 : rawSessionId; final imageCrc32 = BootloaderDfuProtocol.crc32(imageBytes); final startPayload = BootloaderDfuStartPayload( totalLength: imageBytes.length, imageCrc32: imageCrc32, appStart: appStart, imageVersion: imageVersion, sessionId: normalizedSessionId, flags: flags.rawValue, ); _isRunning = true; _cancelRequested = false; _cancelSignal = Completer(); _statusSignal = null; _latestStatus = null; _statusStreamError = null; _statusEventCount = 0; _totalBytes = imageBytes.length; var startAccepted = false; _emitProgress( state: DfuUpdateState.starting, totalBytes: imageBytes.length, sentBytes: 0, expectedOffset: 0, sessionId: normalizedSessionId, flags: flags, bootloaderStatus: null, ); try { _throwIfCancelled(); _emitProgress(state: DfuUpdateState.enteringBootloader); final alreadyInBootloader = await _isConnectedToBootloader(); if (!alreadyInBootloader) { final enterResult = await _transport.enterBootloader(); final appDisconnectResult = await _transport.waitForAppDisconnect( timeout: effectiveBootloaderConnectTimeout, ); if (appDisconnectResult.isErr()) { if (enterResult.isErr()) { throw _DfuFailure( 'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}', ); } throw _DfuFailure( 'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}', ); } _emitProgress(state: DfuUpdateState.connectingBootloader); await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout); } await _optimizeBootloaderConnection(); final mtuResult = await _transport.negotiateMtu(requestedMtu: requestedMtu); if (mtuResult.isErr()) { throw _DfuFailure( 'Could not negotiate bootloader DFU MTU: ${mtuResult.unwrapErr()}', ); } final payloadSize = BootloaderDfuProtocol.maxPayloadSizeForMtu( mtuResult.unwrap(), ); if (payloadSize <= 0) { throw _DfuFailure( 'Negotiated MTU ${mtuResult.unwrap()} is too small for bootloader DFU frames.', ); } await _subscribeToStatus(); _emitProgress(state: DfuUpdateState.waitingForStatus); await _readInitialStatus(); _emitProgress(state: DfuUpdateState.erasing); final startStatus = await _sendStartAndWaitForStatus( startPayload, timeout: effectiveStatusTimeout, ); _requireOkStatus( startStatus, sessionId: normalizedSessionId, expectedOffset: 0, operation: 'START', ); startAccepted = true; _emitProgress(state: DfuUpdateState.transferring); await _transferImage( imageBytes: imageBytes, sessionId: normalizedSessionId, payloadSize: payloadSize, startPayload: startPayload, statusTimeout: effectiveStatusTimeout, bootloaderConnectTimeout: effectiveBootloaderConnectTimeout, ); _emitProgress(state: DfuUpdateState.finishing); await _writeFinishAndWaitForReset( sessionId: normalizedSessionId, expectedOffset: imageBytes.length, statusTimeout: effectiveStatusTimeout, resetTimeout: effectivePostFinishResetTimeout, ); await _statusSubscription?.cancel(); _statusSubscription = null; _emitProgress( state: DfuUpdateState.rebooting, sentBytes: imageBytes.length); if (!verifyAfterFinish) { _emitProgress( state: DfuUpdateState.completed, sentBytes: imageBytes.length, expectedOffset: imageBytes.length, ); return Ok(null); } _emitProgress(state: DfuUpdateState.verifying); final reconnectResult = await _transport.reconnectForVerification( timeout: effectiveReconnectTimeout, ); if (reconnectResult.isErr()) { throw _DfuFailure( 'Updated app did not reconnect after bootloader reset: ${reconnectResult.unwrapErr()}', ); } final verificationResult = await _transport.verifyDeviceReachable( timeout: effectiveVerificationTimeout, ); if (verificationResult.isErr()) { throw _DfuFailure( 'Updated app reconnected but verification failed: ${verificationResult.unwrapErr()}', ); } _emitProgress( state: DfuUpdateState.completed, sentBytes: imageBytes.length, expectedOffset: imageBytes.length, ); return Ok(null); } on _DfuCancelled { if (startAccepted) { await _sendAbortForCancel(normalizedSessionId); } _emitProgress(state: DfuUpdateState.aborted); return bail('Firmware update canceled by user.'); } on _DfuFailure catch (failure) { _emitProgress( state: DfuUpdateState.failed, errorMessage: failure.message); return bail(failure.message); } catch (error) { final message = 'Firmware update failed unexpectedly: $error. Reconnect to the button or bootloader and retry.'; _emitProgress(state: DfuUpdateState.failed, errorMessage: message); return bail(message); } finally { await _statusSubscription?.cancel(); _statusSubscription = null; _isRunning = false; _cancelRequested = false; _cancelSignal = null; _statusSignal = null; _statusStreamError = null; _latestStatus = null; _statusEventCount = 0; _totalBytes = 0; } } Future cancelUpdate() async { if (!_isRunning || _cancelRequested) { return; } _cancelRequested = true; _cancelSignal?.complete(); _signalStatusWaiters(); } Future dispose() async { await cancelUpdate(); await _statusSubscription?.cancel(); _statusSubscription = null; await _progressController.close(); } Future _subscribeToStatus() async { await _statusSubscription?.cancel(); _statusStreamError = null; _statusSubscription = _transport.subscribeToStatus().listen( _handleStatusPayload, onError: (Object error) { _statusStreamError = 'Bootloader status indication stream failed: $error. Reconnect and retry the update.'; _signalStatusWaiters(); }, ); } Future _readInitialStatus() async { final statusResult = await _transport.readStatus(); if (statusResult.isErr()) { throw _DfuFailure( 'Could not read initial bootloader DFU status: ${statusResult.unwrapErr()}', ); } _handleStatusPayload(statusResult.unwrap()); } Future _transferImage({ required List imageBytes, required int sessionId, required int payloadSize, required BootloaderDfuStartPayload startPayload, required Duration statusTimeout, required Duration bootloaderConnectTimeout, }) async { var expectedOffset = _currentProgress.expectedOffset; var retriesWithoutProgress = 0; var reconnectResumeAttempts = 0; while (expectedOffset < imageBytes.length) { _throwIfCancelled(); _throwIfStatusStreamErrored(recoverable: true); try { final frame = BootloaderDfuProtocol.buildDataFrame( imageBytes: imageBytes, sessionId: sessionId, offset: expectedOffset, payloadSize: payloadSize, ); final status = await _writeDataFrameAndWaitForStatus( frame, timeout: statusTimeout, ); if (status.code == DfuBootloaderStatusCode.queueFull || status.code == DfuBootloaderStatusCode.stateError) { if (status.code == DfuBootloaderStatusCode.queueFull) { await _delayWithCancel(queueFullBackoff); } final recoveredStatus = await _requestStatus(timeout: statusTimeout); _requireOkStatus( recoveredStatus, sessionId: sessionId, operation: 'GET_STATUS after ${_statusLabel(status)}', ); expectedOffset = recoveredStatus.expectedOffset .clamp(0, imageBytes.length) .toInt(); _emitTransferProgress(recoveredStatus, imageBytes.length); reconnectResumeAttempts = 0; continue; } _requireOkStatus(status, sessionId: sessionId, operation: 'DATA'); final nextOffset = status.expectedOffset.clamp(0, imageBytes.length).toInt(); if (nextOffset <= expectedOffset) { retriesWithoutProgress += 1; if (retriesWithoutProgress > maxNoProgressRetries) { throw _DfuFailure( 'Bootloader DFU stalled at offset $expectedOffset after $retriesWithoutProgress status checks without progress.', ); } final recoveredStatus = await _requestStatus(timeout: statusTimeout); _requireOkStatus( recoveredStatus, sessionId: sessionId, operation: 'GET_STATUS after no progress', ); expectedOffset = recoveredStatus.expectedOffset .clamp(0, imageBytes.length) .toInt(); _emitTransferProgress(recoveredStatus, imageBytes.length); continue; } retriesWithoutProgress = 0; reconnectResumeAttempts = 0; expectedOffset = nextOffset; _emitTransferProgress(status, imageBytes.length); } on _DfuFailure catch (failure) { if (!failure.recoverable || reconnectResumeAttempts >= maxReconnectResumeAttempts) { rethrow; } reconnectResumeAttempts += 1; final recoveredStatus = await _recoverTransferStatus( timeout: statusTimeout, bootloaderConnectTimeout: bootloaderConnectTimeout, failure: failure, ); if (recoveredStatus.isOk && recoveredStatus.sessionId == 0 && recoveredStatus.expectedOffset == 0) { _emitProgress( state: DfuUpdateState.erasing, sentBytes: 0, expectedOffset: 0, ); final startStatus = await _sendStartAndWaitForStatus( startPayload, timeout: statusTimeout, ); _requireOkStatus( startStatus, sessionId: sessionId, expectedOffset: 0, operation: 'START after reconnect', ); expectedOffset = 0; retriesWithoutProgress = 0; _emitProgress(state: DfuUpdateState.transferring); continue; } _requireOkStatus( recoveredStatus, sessionId: sessionId, operation: 'GET_STATUS after reconnect', ); expectedOffset = recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt(); _emitTransferProgress(recoveredStatus, imageBytes.length); _emitProgress(state: DfuUpdateState.transferring); continue; } } } Future _isConnectedToBootloader() async { final result = await _transport.isConnectedToBootloader(); if (result.isErr()) { throw _DfuFailure( 'Could not check bootloader DFU connection: ${result.unwrapErr()}', ); } return result.unwrap(); } Future _connectToBootloader({required Duration timeout}) async { final bootloaderConnectResult = await _transport.connectToBootloader( timeout: timeout, ); if (bootloaderConnectResult.isErr()) { throw _DfuFailure( 'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}', ); } } Future _optimizeBootloaderConnection() async { final result = await _transport.optimizeBootloaderConnection(); if (result.isErr()) { _emitProgress(errorMessage: result.unwrapErr().toString()); } } Future _sendStartAndWaitForStatus( BootloaderDfuStartPayload payload, { required Duration timeout, }) { return _writeControlAndWaitForStatus( BootloaderDfuProtocol.encodeStartPayload(payload), timeout: timeout, ); } Future _recoverTransferStatus({ required Duration timeout, required Duration bootloaderConnectTimeout, required _DfuFailure failure, }) async { await _statusSubscription?.cancel(); _statusSubscription = null; _statusSignal = null; _latestStatus = null; _statusStreamError = null; _statusEventCount = 0; _emitProgress( state: DfuUpdateState.connectingBootloader, errorMessage: failure.message, ); await _connectToBootloader(timeout: bootloaderConnectTimeout); await _optimizeBootloaderConnection(); await _subscribeToStatus(); _emitProgress(state: DfuUpdateState.waitingForStatus); return _requestStatus(timeout: timeout); } Future _writeControlAndWaitForStatus( List payload, { required Duration timeout, bool recoverable = false, }) async { final eventCount = _statusEventCount; final result = await _transport.writeControl(payload); if (result.isErr()) { throw _DfuFailure( 'Failed to write bootloader control command: ${result.unwrapErr()}', recoverable: recoverable, ); } return _waitForStatus( afterEventCount: eventCount, timeout: timeout, recoverable: recoverable, ); } Future _writeDataFrameAndWaitForStatus( BootloaderDfuDataFrame frame, { required Duration timeout, }) async { final eventCount = _statusEventCount; final result = await _transport.writeDataFrame(frame.bytes); if (result.isErr()) { throw _DfuFailure( 'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}', recoverable: true, ); } return _waitForStatus( afterEventCount: eventCount, timeout: timeout, recoverable: true, ); } Future _requestStatus({required Duration timeout}) { return _writeControlAndWaitForStatus( BootloaderDfuProtocol.encodeGetStatusPayload(), timeout: timeout, recoverable: true, ); } Future _writeFinishAndWaitForReset({ required int sessionId, required int expectedOffset, required Duration statusTimeout, required Duration resetTimeout, }) async { final eventCount = _statusEventCount; final result = await _transport.writeControl( BootloaderDfuProtocol.encodeFinishPayload(sessionId), ); if (result.isErr()) { throw _DfuFailure( 'Failed to write bootloader control command: ${result.unwrapErr()}', ); } final deadline = DateTime.now().add(resetTimeout); var observedEvents = eventCount; while (true) { _throwIfCancelled(); _throwIfStatusStreamErrored(); if (_statusEventCount > observedEvents && _latestStatus != null) { final status = _latestStatus!; _requireOkStatus( status, sessionId: sessionId, expectedOffset: expectedOffset, operation: 'FINISH', ); final remaining = deadline.difference(DateTime.now()); final resetDisconnectResult = await _transport.waitForBootloaderDisconnect( timeout: remaining > Duration.zero ? remaining : Duration.zero, ); if (resetDisconnectResult.isErr()) { throw _DfuFailure( 'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}', ); } return; } final disconnectResult = await _transport.waitForBootloaderDisconnect( timeout: Duration.zero, ); if (disconnectResult.isOk()) { return; } final remaining = deadline.difference(DateTime.now()); if (remaining <= Duration.zero) { throw _DfuFailure( 'Bootloader did not perform the expected post-FINISH reset disconnect within ${resetTimeout.inMilliseconds}ms.', ); } final waitDuration = remaining < statusTimeout ? remaining : statusTimeout; final gotEvent = await _waitForNextStatusEvent( afterEventCount: observedEvents, timeout: waitDuration, recoverable: false, ); if (gotEvent) { observedEvents = _statusEventCount - 1; } } } Future _waitForStatus({ required int afterEventCount, required Duration timeout, bool recoverable = false, }) async { final deadline = DateTime.now().add(timeout); var observedEvents = afterEventCount; while (true) { _throwIfCancelled(); _throwIfStatusStreamErrored(recoverable: recoverable); if (_statusEventCount > observedEvents && _latestStatus != null) { return _latestStatus!; } final remaining = deadline.difference(DateTime.now()); if (remaining <= Duration.zero) { throw _DfuFailure( 'Timed out waiting for bootloader DFU status. Reconnect and retry the update.', recoverable: recoverable, ); } final gotEvent = await _waitForNextStatusEvent( afterEventCount: observedEvents, timeout: remaining, recoverable: recoverable, ); if (gotEvent) { observedEvents = _statusEventCount - 1; } } } Future _waitForNextStatusEvent({ required int afterEventCount, required Duration timeout, required bool recoverable, }) async { if (_statusEventCount > afterEventCount) { return true; } _statusSignal ??= Completer(); final signal = _statusSignal!; try { await Future.any([ signal.future, _cancelSignal?.future ?? Future.value(), ]).timeout(timeout); } on TimeoutException { return false; } if (identical(_statusSignal, signal)) { _statusSignal = null; } _throwIfCancelled(); _throwIfStatusStreamErrored(recoverable: recoverable); return _statusEventCount > afterEventCount; } void _handleStatusPayload(List payload) { try { final status = BootloaderDfuProtocol.parseStatusPayload(payload); _latestStatus = status; final sentBytes = status.expectedOffset.clamp(0, _totalBytes).toInt(); _emitProgress( bootloaderStatus: status, expectedOffset: status.expectedOffset, sentBytes: sentBytes, ); } on FormatException catch (error) { _statusStreamError = 'Received malformed bootloader DFU status: $error. Reconnect and retry.'; } finally { _statusEventCount += 1; _signalStatusWaiters(); } } void _emitTransferProgress(DfuBootloaderStatus status, int totalBytes) { final offset = status.expectedOffset.clamp(0, totalBytes).toInt(); _emitProgress( bootloaderStatus: status, expectedOffset: offset, sentBytes: offset, ); } void _emitProgress({ DfuUpdateState? state, int? totalBytes, int? sentBytes, int? expectedOffset, int? sessionId, DfuUpdateFlags? flags, DfuBootloaderStatus? bootloaderStatus, String? errorMessage, }) { final next = DfuUpdateProgress( state: state ?? _currentProgress.state, totalBytes: totalBytes ?? _currentProgress.totalBytes, sentBytes: sentBytes ?? _currentProgress.sentBytes, expectedOffset: expectedOffset ?? _currentProgress.expectedOffset, sessionId: sessionId ?? _currentProgress.sessionId, flags: flags ?? _currentProgress.flags, bootloaderStatus: bootloaderStatus ?? _currentProgress.bootloaderStatus, errorMessage: errorMessage, ); _currentProgress = next; _progressController.add(next); } void _requireOkStatus( DfuBootloaderStatus status, { required int sessionId, int? expectedOffset, required String operation, }) { if (!status.isOk) { throw _DfuFailure(_statusFailureMessage(status, operation)); } if (status.sessionId != sessionId) { throw _DfuFailure( '$operation returned status for session ${status.sessionId}, expected $sessionId.', ); } if (expectedOffset != null && status.expectedOffset != expectedOffset) { throw _DfuFailure( '$operation returned expected offset ${status.expectedOffset}, expected $expectedOffset.', ); } } String _statusFailureMessage(DfuBootloaderStatus status, String operation) { return '$operation failed with bootloader status ${_statusLabel(status)} ' '(session ${status.sessionId}, expected offset ${status.expectedOffset}).'; } String _statusLabel(DfuBootloaderStatus status) { return switch (status.code) { DfuBootloaderStatusCode.ok => 'OK', DfuBootloaderStatusCode.parseError => 'parse error', DfuBootloaderStatusCode.stateError => 'state error', DfuBootloaderStatusCode.boundsError => 'bounds error', DfuBootloaderStatusCode.crcError => 'CRC error', DfuBootloaderStatusCode.flashError => 'flash error', DfuBootloaderStatusCode.unsupportedError => 'unsupported flags', DfuBootloaderStatusCode.vectorError => 'vector table error', DfuBootloaderStatusCode.queueFull => 'queue full', DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error', DfuBootloaderStatusCode.unknown => 'unknown status 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}', }; } Future _sendAbortForCancel(int sessionId) async { final result = await _transport.writeControl( BootloaderDfuProtocol.encodeAbortPayload(sessionId), ); if (result.isErr()) { _emitProgress( errorMessage: 'Could not send bootloader DFU ABORT during cancel: ${result.unwrapErr()}', ); } } void _throwIfCancelled() { if (_cancelRequested) { throw const _DfuCancelled(); } } Future _delayWithCancel(Duration duration) async { if (duration <= Duration.zero) { return; } await Future.any([ Future.delayed(duration), _cancelSignal?.future ?? Future.value(), ]); _throwIfCancelled(); } void _throwIfStatusStreamErrored({bool recoverable = false}) { final error = _statusStreamError; if (error != null) { throw _DfuFailure(error, recoverable: recoverable); } } void _signalStatusWaiters() { final signal = _statusSignal; if (signal != null && !signal.isCompleted) { signal.complete(); } } } abstract interface class FirmwareUpdateTransport { Future> isConnectedToBootloader(); Future> enterBootloader(); Future> waitForAppDisconnect({required Duration timeout}); Future> connectToBootloader({required Duration timeout}); Future> optimizeBootloaderConnection(); Future> negotiateMtu({required int requestedMtu}); Stream> subscribeToStatus(); Future>> readStatus(); Future> writeControl(List payload); Future> writeDataFrame(List frame); Future> waitForBootloaderDisconnect({required Duration timeout}); Future> reconnectForVerification({required Duration timeout}); Future> verifyDeviceReachable({required Duration timeout}); } class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { ShifterFirmwareUpdateTransport({ required this.shifterService, required this.bluetoothController, required this.buttonDeviceId, }); final ShifterService? shifterService; final BluetoothController bluetoothController; final String buttonDeviceId; String? _bootloaderDeviceId; @override Future> isConnectedToBootloader() async { final currentState = bluetoothController.currentConnectionState; if (currentState.$1 != ConnectionStatus.connected || currentState.$2 == null) { return Ok(false); } final statusResult = await bluetoothController.readCharacteristic( currentState.$2!, universalShifterControlServiceUuid, universalShifterDfuStatusCharacteristicUuid, ); if (statusResult.isErr()) { return Ok(false); } _bootloaderDeviceId = currentState.$2; return Ok(true); } @override Future> enterBootloader() { final shifter = shifterService; if (shifter == null) { return Future.value(bail('Normal app control service is not available.')); } return shifter.writeCommand(UniversalShifterCommand.enterDfu); } @override Future> waitForAppDisconnect({required Duration timeout}) { return _waitForDisconnect(timeout: timeout, label: 'app reset'); } @override Future> connectToBootloader({required Duration timeout}) async { final currentState = bluetoothController.currentConnectionState; if (currentState.$1 == ConnectionStatus.connected && currentState.$2 == _bootloaderDeviceId && _bootloaderDeviceId != null) { return Ok(null); } if (shifterService == null) { _bootloaderDeviceId = buttonDeviceId; return bluetoothController.connectById( buttonDeviceId, timeout: timeout, ); } final scanResult = await _scanForBootloader(timeout: timeout); if (scanResult.isErr()) { return Err(scanResult.unwrapErr()); } final bootloaderDevice = scanResult.unwrap(); _bootloaderDeviceId = bootloaderDevice.id; return bluetoothController.connectById( bootloaderDevice.id, timeout: timeout, ); } @override Future> optimizeBootloaderConnection() { final deviceId = _requireBootloaderDeviceId(); if (deviceId.isErr()) { return Future.value(Err(deviceId.unwrapErr())); } return bluetoothController.requestHighPerformanceConnection( deviceId.unwrap(), ); } @override Future> negotiateMtu({required int requestedMtu}) { final deviceId = _requireBootloaderDeviceId(); if (deviceId.isErr()) { return Future.value(Err(deviceId.unwrapErr())); } return bluetoothController.requestMtuAndGetValue( deviceId.unwrap(), mtu: requestedMtu, ); } @override Stream> subscribeToStatus() { final deviceId = _requireBootloaderDeviceId().unwrap(); return bluetoothController.subscribeToCharacteristic( deviceId, universalShifterControlServiceUuid, universalShifterDfuStatusCharacteristicUuid, ); } @override Future>> readStatus() { final deviceId = _requireBootloaderDeviceId(); if (deviceId.isErr()) { return Future.value(Err(deviceId.unwrapErr())); } return bluetoothController.readCharacteristic( deviceId.unwrap(), universalShifterControlServiceUuid, universalShifterDfuStatusCharacteristicUuid, ); } @override Future> writeControl(List payload) { final deviceId = _requireBootloaderDeviceId(); if (deviceId.isErr()) { return Future.value(Err(deviceId.unwrapErr())); } return bluetoothController.writeCharacteristic( deviceId.unwrap(), universalShifterControlServiceUuid, universalShifterDfuControlCharacteristicUuid, payload, ); } @override Future> writeDataFrame(List frame) { final deviceId = _requireBootloaderDeviceId(); if (deviceId.isErr()) { return Future.value(Err(deviceId.unwrapErr())); } return bluetoothController.writeCharacteristic( deviceId.unwrap(), universalShifterControlServiceUuid, universalShifterDfuDataCharacteristicUuid, frame, ); } @override Future> waitForBootloaderDisconnect( {required Duration timeout}) { return _waitForDisconnect(timeout: timeout, label: 'bootloader reset'); } @override Future> reconnectForVerification( {required Duration timeout}) async { final connectResult = await bluetoothController.connectById(buttonDeviceId, timeout: timeout); if (connectResult.isErr()) { return Err(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 updated app reconnect.', ); } catch (error) { return bail('Updated app reconnect wait failed: $error'); } } @override Future> verifyDeviceReachable( {required Duration timeout}) async { try { final statusResult = await bluetoothController .readCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterStatusCharacteristicUuid, ) .timeout(timeout); if (statusResult.isErr()) { return Err(statusResult.unwrapErr()); } CentralStatus.fromBytes(statusResult.unwrap()); 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'); } } Future> _waitForDisconnect({ required Duration timeout, required String label, }) 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 $label disconnect.', ); } catch (error) { return bail('Failed while waiting for $label disconnect: $error'); } } Future> _scanForBootloader({ required Duration timeout, }) async { final serviceUuid = Uuid.parse(universalShifterControlServiceUuid); final scanResult = await bluetoothController.startScan( withServices: [serviceUuid], timeout: timeout, ); if (scanResult.isErr()) { return Err(scanResult.unwrapErr()); } try { DiscoveredDevice? immediate; for (final device in bluetoothController.scanResults) { if (_isBootloaderAdvertisement(device)) { immediate = device; break; } } if (immediate != null) { return Ok(immediate); } final device = await bluetoothController.scanResultsStream .expand((devices) => devices) .firstWhere(_isBootloaderAdvertisement) .timeout(timeout); return Ok(device); } on TimeoutException { return bail( 'Timed out after ${timeout.inMilliseconds}ms scanning for US-DFU bootloader.', ); } catch (error) { return bail('Bootloader scan failed: $error'); } finally { await bluetoothController.stopScan(); } } bool _isBootloaderAdvertisement(DiscoveredDevice device) { final name = device.name.trim(); if (name == 'US-DFU' || name == 'UniversalShifters DFU') { return true; } return name.toLowerCase().contains('dfu') && device.serviceUuids.any( (uuid) => uuid.expanded == Uuid.parse(universalShifterControlServiceUuid).expanded, ); } Result _requireBootloaderDeviceId() { final deviceId = _bootloaderDeviceId; if (deviceId == null || deviceId.trim().isEmpty) { return bail('Bootloader device is not connected yet.'); } return Ok(deviceId); } } class _DfuFailure implements Exception { const _DfuFailure(this.message, {this.recoverable = false}); final String message; final bool recoverable; @override String toString() => message; } class _DfuCancelled implements Exception { const _DfuCancelled(); }