import 'dart:async'; import 'package:anyhow/anyhow.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' hide ConnectionStatus, Result, Logger; import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'bluetooth.g.dart'; final log = Logger('BluetoothController'); @Riverpod(keepAlive: true) FlutterReactiveBle reactiveBle(Ref ref) { ref.keepAlive(); return FlutterReactiveBle(); } @Riverpod(keepAlive: true) Future bluetooth(Ref ref) async { ref.keepAlive(); final controller = BluetoothController(ref.read(reactiveBleProvider)); await controller.init(); return controller; } @Riverpod(keepAlive: true) Stream<(ConnectionStatus, String?)> connectionStatus(Ref ref) { final asyncController = ref.watch(bluetoothProvider); return asyncController.when( data: (controller) => controller.connectionStateStream, loading: () => Stream.value((ConnectionStatus.disconnected, null)), error: (_, __) => Stream.value((ConnectionStatus.disconnected, null)), ); } /// Represents the connection status of the Bluetooth device. enum ConnectionStatus { disconnected, connecting, connected, disconnecting } class BluetoothController { BluetoothController(this._ble); static const int defaultMtu = 64; final FlutterReactiveBle _ble; StreamSubscription? _bleStatusSubscription; StreamSubscription? _scanResultsSubscription; Timer? _scanTimeout; final Map _scanResultsById = {}; final _scanResultsSubject = BehaviorSubject>.seeded(const []); final _isScanningSubject = BehaviorSubject.seeded(false); String? _connectedDeviceId; StreamSubscription? _connectionStateSubscription; final _connectionStateSubject = BehaviorSubject<(ConnectionStatus, String?)>.seeded( (ConnectionStatus.disconnected, null)); Stream<(ConnectionStatus, String?)> get connectionStateStream => _connectionStateSubject.stream; (ConnectionStatus, String?) get currentConnectionState => _connectionStateSubject.value; Stream> get scanResultsStream => _scanResultsSubject.stream; Stream get isScanningStream => _isScanningSubject.stream; List get scanResults => _scanResultsSubject.value; Future> init() async { _bleStatusSubscription ??= _ble.statusStream.listen((status) { log.info('BLE status: $status'); }); return Ok(null); } Future> startScan({ List? withServices, Duration? timeout, ScanMode scanMode = ScanMode.lowLatency, bool requireLocationServicesEnabled = true, }) async { if (_isScanningSubject.value) { return Ok(null); } try { final status = _ble.status; if (status != BleStatus.ready) { await _ble.statusStream .where((value) => value == BleStatus.ready) .first; } _scanTimeout?.cancel(); _scanResultsById.clear(); _scanResultsSubject.add(const []); _isScanningSubject.add(true); _scanResultsSubscription = _ble .scanForDevices( withServices: withServices ?? const [], scanMode: scanMode, requireLocationServicesEnabled: requireLocationServicesEnabled, ) .listen((device) { _scanResultsById[device.id] = device; _scanResultsSubject .add(_scanResultsById.values.toList(growable: false)); }, onError: (Object error, StackTrace st) { log.severe('Scan error: $error', error, st); _isScanningSubject.add(false); }); if (timeout != null) { _scanTimeout = Timer(timeout, () { unawaited(stopScan()); }); } return Ok(null); } catch (e) { _isScanningSubject.add(false); return bail('Failed to start Bluetooth scan: $e'); } } Future> stopScan() async { try { _scanTimeout?.cancel(); _scanTimeout = null; await _scanResultsSubscription?.cancel(); _scanResultsSubscription = null; _isScanningSubject.add(false); return Ok(null); } catch (e) { _isScanningSubject.add(false); return bail('Failed to stop Bluetooth scan: $e'); } } Future> waitForScanToComplete() async { try { await isScanningStream.where((val) => val == false).first; return Ok(null); } catch (e) { return bail('Error waiting for scan to complete: $e'); } } Future get isScanning async => isScanningStream.first; Future> connect(DiscoveredDevice device, {Duration? timeout}) async { return connectById(device.id, timeout: timeout ?? Duration(seconds: 10)); } Future> connectById( String deviceId, { Duration timeout = const Duration(seconds: 10), Map>? servicesWithCharacteristicsToDiscover, }) async { final currentState = currentConnectionState; final currentDeviceId = currentState.$2; if (deviceId == currentDeviceId && (currentState.$1 == ConnectionStatus.connected || currentState.$1 == ConnectionStatus.connecting)) { log.info('Already connected or connecting to $deviceId.'); if (currentState.$1 == ConnectionStatus.connected) { unawaited(_requestMtuOnConnect(deviceId)); } return Ok(null); } if (currentDeviceId != null && deviceId != currentDeviceId) { final disconnectResult = await disconnect(); if (disconnectResult.isErr()) { return disconnectResult .context('Failed to disconnect from previous device'); } await Future.delayed(const Duration(milliseconds: 300)); } try { await _connectionStateSubscription?.cancel(); _updateConnectionState(ConnectionStatus.connecting, deviceId); _connectionStateSubscription = _ble .connectToDevice( id: deviceId, connectionTimeout: timeout, servicesWithCharacteristicsToDiscover: servicesWithCharacteristicsToDiscover, ) .listen((update) { switch (update.connectionState) { case DeviceConnectionState.connected: _connectedDeviceId = deviceId; _updateConnectionState(ConnectionStatus.connected, deviceId); unawaited(_requestMtuOnConnect(deviceId)); break; case DeviceConnectionState.connecting: _updateConnectionState(ConnectionStatus.connecting, deviceId); break; case DeviceConnectionState.disconnecting: _updateConnectionState(ConnectionStatus.disconnecting, deviceId); break; case DeviceConnectionState.disconnected: _cleanUpConnection(); break; } }, onError: (Object error, StackTrace st) { log.severe('Failed to connect to $deviceId: $error', error, st); _cleanUpConnection(); }); return Ok(null); } catch (e) { _cleanUpConnection(); return bail('Failed to connect to $deviceId: $e'); } } Future> disconnect() async { final deviceIdToDisconnect = _connectedDeviceId ?? _connectionStateSubject.value.$2; if (deviceIdToDisconnect == null) { _cleanUpConnection(); return Ok(null); } _updateConnectionState( ConnectionStatus.disconnecting, deviceIdToDisconnect); try { await _connectionStateSubscription?.cancel(); _connectionStateSubscription = null; _cleanUpConnection(); return Ok(null); } catch (e) { _cleanUpConnection(); return bail('Failed to disconnect from $deviceIdToDisconnect: $e'); } } Future>> readCharacteristic( String deviceId, String serviceUuid, String characteristicUuid, ) async { try { final characteristic = QualifiedCharacteristic( serviceId: Uuid.parse(serviceUuid), characteristicId: Uuid.parse(characteristicUuid), deviceId: deviceId, ); final value = await _ble.readCharacteristic(characteristic); return Ok(value); } catch (e) { return bail('Error reading characteristic: $e'); } } Future> writeCharacteristic( String deviceId, String serviceUuid, String characteristicUuid, List value, { bool withResponse = true, }) async { try { final characteristic = QualifiedCharacteristic( serviceId: Uuid.parse(serviceUuid), characteristicId: Uuid.parse(characteristicUuid), deviceId: deviceId, ); if (withResponse) { await _ble.writeCharacteristicWithResponse( characteristic, value: value, ); } else { await _ble.writeCharacteristicWithoutResponse( characteristic, value: value, ); } return Ok(null); } catch (e) { return bail('Error writing characteristic: $e'); } } Future> requestMtu(String deviceId, {int mtu = defaultMtu}) async { try { final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu); log.info( 'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu'); return Ok(null); } catch (e) { return bail('Error requesting MTU $mtu for $deviceId: $e'); } } Future _requestMtuOnConnect(String deviceId) async { final mtuResult = await requestMtu(deviceId, mtu: defaultMtu); if (mtuResult.isErr()) { log.warning( 'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}'); } } Stream> subscribeToCharacteristic( String deviceId, String serviceUuid, String characteristicUuid, ) { final characteristic = QualifiedCharacteristic( serviceId: Uuid.parse(serviceUuid), characteristicId: Uuid.parse(characteristicUuid), deviceId: deviceId, ); return _ble.subscribeToCharacteristic(characteristic); } void _updateConnectionState(ConnectionStatus status, String? deviceId) { if (_connectionStateSubject.value.$1 == status && _connectionStateSubject.value.$2 == deviceId) { return; } _connectionStateSubject.add((status, deviceId)); log.fine( 'Connection state updated: $status, device: ${deviceId ?? 'none'}'); } void _cleanUpConnection() { _connectedDeviceId = null; _updateConnectionState(ConnectionStatus.disconnected, null); } Future> dispose() async { _scanTimeout?.cancel(); await _scanResultsSubscription?.cancel(); await _bleStatusSubscription?.cancel(); await disconnect(); await _scanResultsSubject.close(); await _isScanningSubject.close(); await _connectionStateSubject.close(); return Ok(null); } }