import 'dart:async'; import 'dart:io'; import 'package:anyhow/anyhow.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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) Future bluetooth(Ref ref) async { ref.keepAlive(); final controller = BluetoothController(); log.info(await controller.init()); return controller; } @Riverpod(keepAlive: true) Stream<(ConnectionStatus, BluetoothDevice?)> connectionStatus(Ref ref) { // Get the (potentially still loading) BluetoothController final asyncController = ref.watch(bluetoothProvider); // If the controller is ready, return its stream. Otherwise, return an empty stream. // The provider will automatically update when the controller becomes ready. 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 { StreamSubscription? _btStateSubscription; StreamSubscription>? _scanResultsSubscription; List _latestScanResults = []; StreamSubscription? _servicesResetSubscription; final Map> _servicesByDevice = {}; final Map> _characteristicsByDevice = {}; // Connection State BluetoothDevice? _connectedDevice; StreamSubscription? _connectionStateSubscription; final _connectionStateSubject = BehaviorSubject<(ConnectionStatus, BluetoothDevice?)>.seeded( (ConnectionStatus.disconnected, null)); /// Stream providing the current connection status and the connected device (if any). Stream<(ConnectionStatus, BluetoothDevice?)> get connectionStateStream => _connectionStateSubject.stream; /// Gets the latest connection status and device. (ConnectionStatus, BluetoothDevice?) get currentConnectionState => _connectionStateSubject.value; Future> init() async { log.severe("CALLED FBPON!"); if (await FlutterBluePlus.isSupported == false) { log.severe("Bluetooth is not supported on this device!"); return bail("Bluetooth is not supported on this device!"); } _btStateSubscription = FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) { if (state == BluetoothAdapterState.on) { log.info("Bluetooth is on!"); // usually start scanning, connecting, etc } else { log.info("Bluetooth is off!"); // show an error to the user, etc } }); if (!kIsWeb && Platform.isAndroid) { await FlutterBluePlus.turnOn(); } connectionStateStream.listen((state) { log.info('Connection state changed: $state'); }); return Ok(null); } /// Start scanning for Bluetooth devices /// /// [withServices] - Optional list of service UUIDs to filter devices by /// [withNames] - Optional list of device names to filter by /// [timeout] - Optional duration after which scanning will automatically stop Future> startScan({ List? withServices, List? withNames, Duration? timeout, }) async { try { // Wait for Bluetooth to be enabled await FlutterBluePlus.adapterState .where((val) => val == BluetoothAdapterState.on) .first; // Set up scan results listener _scanResultsSubscription = FlutterBluePlus.onScanResults.listen( (results) { if (results.isNotEmpty) { _latestScanResults = results; ScanResult latestResult = results.last; log.info( '${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!'); } }, onError: (e) { log.severe('Scan error: $e'); }, ); // Clean up subscription when scanning completes FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!); // Start scanning with optional parameters await FlutterBluePlus.startScan( withServices: withServices ?? [], withNames: withNames ?? [], timeout: timeout, ); return Ok(null); } catch (e) { return bail('Failed to start Bluetooth scan: $e'); } } /// Stop an ongoing Bluetooth scan Future> stopScan() async { try { await FlutterBluePlus.stopScan(); return Ok(null); } catch (e) { return bail('Failed to stop Bluetooth scan: $e'); } } /// Get the latest scan results List get scanResults => _latestScanResults; /// Wait for the current scan to complete Future> waitForScanToComplete() async { try { await FlutterBluePlus.isScanning.where((val) => val == false).first; return Ok(null); } catch (e) { return bail('Error waiting for scan to complete: $e'); } } /// Check if currently scanning Future get isScanning async { return await FlutterBluePlus.isScanning.first; } /// Connects to a specific Bluetooth device. /// /// Ensures that only one device is connected at a time. If another device /// is already connected or connecting, it will be disconnected first. Future> connect(BluetoothDevice device, {Duration? timeout}) async { final currentState = currentConnectionState; final currentDevice = currentState.$2; // Prevent connecting if already connected/connecting to the *same* device if (device.remoteId == currentDevice?.remoteId && (currentState.$1 == ConnectionStatus.connected || currentState.$1 == ConnectionStatus.connecting)) { log.info('Currently connected device: ${currentState.$2}'); log.info('Already connected or connecting to ${device.remoteId}.'); return Ok(null); // Or potentially an error/different status? } log.info('Attempting to connect to ${device.remoteId}...'); // If connecting or connected to a *different* device, disconnect it first. if (currentDevice != null && device.remoteId != currentDevice.remoteId) { log.info( 'Disconnecting from previous device ${currentDevice.remoteId} first.'); final disconnectResult = await disconnect(); if (disconnectResult.isErr()) { return disconnectResult .context('Failed to disconnect from previous device'); } // Wait a moment for the disconnection to fully process await Future.delayed(const Duration(milliseconds: 500)); } try { // Cancel any previous connection state listener before starting a new one await _connectionStateSubscription?.cancel(); _connectionStateSubscription = device.connectionState.listen((BluetoothConnectionState state) async { log.info('[${device.remoteId}] Connection state changed: $state'); switch (state) { case BluetoothConnectionState.connected: _connectedDevice = device; _updateConnectionState(ConnectionStatus.connected, device); // IMPORTANT: Discover services after connecting try { _attachServicesResetListener(device); final servicesResult = await _discoverAndCacheServices(device, force: true); if (servicesResult.isErr()) { throw servicesResult.unwrapErr(); } log.info( '[${device.remoteId}] Services discovered: \n${servicesResult.unwrap().map((e) => e.uuid.toString()).join('\n')}'); } catch (e) { log.severe( '[${device.remoteId}] Error discovering services: $e. Disconnecting.'); // Disconnect if service discovery fails, as the connection might be unusable await disconnect(); } break; case BluetoothConnectionState.disconnected: if (_connectionStateSubject.value.$1 != ConnectionStatus.connected) { log.warning( '[${device.remoteId}] Disconnected WITHOUT being connected! Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}\nDoing nothing'); break; } else { log.warning( '[${device.remoteId}] Disconnected. Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}'); // Only clean up if this is the device we were connected/connecting to if (_connectionStateSubject.value.$2?.remoteId == device.remoteId) { // Clean up connection state, handling disconnection. // In general, reconnection is better, but this is how it's handled here. // App behavior would be to go back to the homepage on disconnection _cleanUpConnection(); } else { log.info( '[${device.remoteId}] Received disconnect for a device we were not tracking.'); } break; } case BluetoothConnectionState.connecting: case BluetoothConnectionState.disconnecting: // deprecated states log.warning( 'Received unexpected connection state: ${device.connectionState}. This should not happen.'); break; } }); await device.connect( license: License.free, timeout: timeout ?? const Duration(seconds: 15), mtu: 512, ); // Note: Success is primarily handled by the connectionState listener log.info( 'Connection initiated for ${device.remoteId}. Waiting for state change.'); _connectionStateSubject.add((ConnectionStatus.connected, device)); return Ok(null); } catch (e) { log.severe('Failed to connect to ${device.remoteId}: $e'); _cleanUpConnection(); // Clean up state on connection failure return bail('Failed to connect to ${device.remoteId}: $e'); } } /// Connects to a device using its remote ID string with a specific timeout. Future> connectById(String remoteId, {Duration timeout = const Duration(seconds: 10)}) async { log.info('Attempting to connect by ID: $remoteId with timeout: $timeout'); try { // Get the BluetoothDevice object from the ID final device = BluetoothDevice.fromId(remoteId); // Call the existing connect method, passing the device and timeout // Assumes the 'connect' method below is modified to accept the timeout. return await connect(device, timeout: timeout); // Pass timeout here } catch (e, st) { // Catch potential errors from fromId or during connection setup before connect() is called log.severe('Error connecting by ID $remoteId: $e'); _cleanUpConnection(); // Ensure state is cleaned up return bail('Failed to initiate connection for ID $remoteId: $e', st); } } /// Disconnects from the currently connected device. Future> disconnect() async { final deviceToDisconnect = _connectedDevice ?? _connectionStateSubject.value.$2; if (deviceToDisconnect == null) { log.info('No device is currently connected or connecting.'); // Ensure state is definitely disconnected if called unnecessarily _cleanUpConnection(); return Ok(null); } log.info('Disconnecting from ${deviceToDisconnect.remoteId}...'); _updateConnectionState(ConnectionStatus.disconnecting, deviceToDisconnect); try { await deviceToDisconnect.disconnect(); log.info('Disconnect command sent to ${deviceToDisconnect.remoteId}.'); // State update to disconnected is handled by the connectionState listener // but we call cleanup here as a safety measure in case the listener fails _cleanUpConnection(); return Ok(null); } catch (e) { log.severe( 'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e'); // Even on error, try to clean up the state _cleanUpConnection(); return bail( 'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e'); } } void _updateConnectionState( ConnectionStatus status, BluetoothDevice? device) { // Avoid emitting redundant states if (_connectionStateSubject.value.$1 == status && _connectionStateSubject.value.$2?.remoteId == device?.remoteId) { return; } _connectionStateSubject.add((status, device)); log.fine( 'Connection state updated: $status, Device: ${device?.remoteId ?? 'none'}'); } Future>> discoverServices( BluetoothDevice device, { bool force = false, }) async { return _discoverAndCacheServices(device, force: force); } Future> writeCharacteristic( BluetoothDevice device, String serviceUuid, String characteristicUuid, List value, { bool withoutResponse = false, bool allowLongWrite = false, int timeout = 15, }) async { final serviceGuid = Guid(serviceUuid); final characteristicGuid = Guid(characteristicUuid); final chrResult = await _getCharacteristic(device, serviceGuid, characteristicGuid); if (chrResult.isErr()) { return chrResult.context('Failed to resolve characteristic for write'); } try { await chrResult.unwrap().write( value, withoutResponse: withoutResponse, allowLongWrite: allowLongWrite, timeout: timeout, ); return Ok(null); } catch (e) { return bail('Error writing characteristic $characteristicUuid: $e'); } } Future>>> subscribeToNotifications( BluetoothDevice device, String serviceUuid, String characteristicUuid, { void Function(List)? onValue, bool useLastValueStream = false, int timeout = 15, }) async { return _subscribeToCharacteristic( device, serviceUuid, characteristicUuid, useLastValueStream: useLastValueStream, timeout: timeout, forceIndications: false, onValue: onValue, ); } Future>>> subscribeToIndications( BluetoothDevice device, String serviceUuid, String characteristicUuid, { void Function(List)? onValue, bool useLastValueStream = false, int timeout = 15, }) async { return _subscribeToCharacteristic( device, serviceUuid, characteristicUuid, useLastValueStream: useLastValueStream, timeout: timeout, forceIndications: true, onValue: onValue, ); } Future> unsubscribeFromCharacteristic( BluetoothDevice device, String serviceUuid, String characteristicUuid, { int timeout = 15, }) async { final serviceGuid = Guid(serviceUuid); final characteristicGuid = Guid(characteristicUuid); final chrResult = await _getCharacteristic(device, serviceGuid, characteristicGuid); if (chrResult.isErr()) { return chrResult .context('Failed to resolve characteristic to unsubscribe'); } try { await chrResult.unwrap().setNotifyValue(false, timeout: timeout); return Ok(null); } catch (e) { return bail('Error disabling notifications for $characteristicUuid: $e'); } } /// Helper function to clean up connection resources and state. Future _cleanUpConnection() async { log.fine('Cleaning up connection state and subscriptions.'); _connectedDevice = null; await _servicesResetSubscription?.cancel(); _servicesResetSubscription = null; _servicesByDevice.clear(); _characteristicsByDevice.clear(); await _connectionStateSubscription?.cancel(); _connectionStateSubscription = null; _updateConnectionState(ConnectionStatus.disconnected, null); } Future> dispose() async { await _scanResultsSubscription?.cancel(); await _btStateSubscription?.cancel(); await disconnect(); // Ensure disconnection on dispose await _connectionStateSubject.close(); return Ok(null); } Future>> readCharacteristic( BluetoothDevice device, String svcUuid, String characteristic) async { // Implement reading characteristic logic here // This is a placeholder implementation log.info( 'Reading characteristic from device: $device, characteristic: $characteristic'); final serviceUUID = Guid(svcUuid); final characteristicUUID = Guid(characteristic); if (!device.servicesList.map((e) => e.uuid).contains(serviceUUID)) { return bail('Service $svcUuid not found on device ${device.remoteId}'); } final BluetoothService service = (device.servicesList).firstWhere((s) => s.uuid == serviceUUID); if (service.characteristics.isEmpty || !service.characteristics .map((c) => c.uuid) .contains(characteristicUUID)) { return bail( 'Characteristic $characteristic not found on device ${device.remoteId}'); } try { final val = await service.characteristics .firstWhere((c) => c.uuid == characteristicUUID) .read(); return Ok(val); } catch (e) { return bail('Error reading characteristic: $e'); } } String _deviceKey(BluetoothDevice device) => device.remoteId.str; String _characteristicKey(Guid serviceUuid, Guid characteristicUuid) => '${serviceUuid.toString()}|${characteristicUuid.toString()}'; void _cacheServices(BluetoothDevice device, List services) { final serviceMap = {}; final characteristicMap = {}; for (final service in services) { serviceMap[service.uuid] = service; for (final chr in service.characteristics) { characteristicMap[_characteristicKey(service.uuid, chr.uuid)] = chr; } } _servicesByDevice[_deviceKey(device)] = serviceMap; _characteristicsByDevice[_deviceKey(device)] = characteristicMap; } void _attachServicesResetListener(BluetoothDevice device) { _servicesResetSubscription?.cancel(); _servicesResetSubscription = device.onServicesReset.listen((_) async { log.info('[${device.remoteId}] Services reset. Re-discovering.'); final res = await _discoverAndCacheServices(device, force: true); if (res.isErr()) { log.severe( '[${device.remoteId}] Failed to re-discover services: ${res.unwrapErr()}'); } }); device.cancelWhenDisconnected(_servicesResetSubscription!); } Future>> _discoverAndCacheServices( BluetoothDevice device, { bool force = false, }) async { try { if (!force) { final cached = _servicesByDevice[_deviceKey(device)]; if (cached != null && cached.isNotEmpty) { return Ok(cached.values.toList()); } } if (!force && device.servicesList.isNotEmpty) { _cacheServices(device, device.servicesList); return Ok(device.servicesList); } final services = await device.discoverServices(); _cacheServices(device, services); return Ok(services); } catch (e) { return bail('Failed to discover services for ${device.remoteId}: $e'); } } Future> _getCharacteristic( BluetoothDevice device, Guid serviceUuid, Guid characteristicUuid, ) async { final deviceKey = _deviceKey(device); final cached = _characteristicsByDevice[deviceKey] ?[_characteristicKey(serviceUuid, characteristicUuid)]; if (cached != null) { return Ok(cached); } final discoverResult = await _discoverAndCacheServices(device); if (discoverResult.isErr()) { return bail(discoverResult.unwrapErr().toString()); } final refreshed = _characteristicsByDevice[deviceKey] ?[_characteristicKey(serviceUuid, characteristicUuid)]; if (refreshed == null) { return bail( 'Characteristic $characteristicUuid not found on service $serviceUuid for device ${device.remoteId}'); } return Ok(refreshed); } Future>>> _subscribeToCharacteristic( BluetoothDevice device, String serviceUuid, String characteristicUuid, { required bool forceIndications, required bool useLastValueStream, required int timeout, void Function(List)? onValue, }) async { final serviceGuid = Guid(serviceUuid); final characteristicGuid = Guid(characteristicUuid); final chrResult = await _getCharacteristic(device, serviceGuid, characteristicGuid); if (chrResult.isErr()) { return bail('Failed to resolve characteristic subscription: ' '${chrResult.unwrapErr()}'); } final characteristic = chrResult.unwrap(); final properties = characteristic.properties; if (forceIndications && !properties.indicate) { return bail( 'Characteristic $characteristicUuid does not support indications'); } if (!forceIndications && !properties.notify && !properties.indicate) { return bail( 'Characteristic $characteristicUuid does not support notifications'); } if (forceIndications && !kIsWeb && !Platform.isAndroid) { return bail('Indications can only be forced on Android.'); } try { final stream = useLastValueStream ? characteristic.lastValueStream : characteristic.onValueReceived; final subscription = stream.listen(onValue ?? (_) {}); device.cancelWhenDisconnected(subscription); await characteristic.setNotifyValue( true, timeout: timeout, forceIndications: forceIndications, ); return Ok(subscription); } catch (e) { return bail( 'Error subscribing to characteristic $characteristicUuid: $e'); } } }