import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:universal_ble/src/utils/ble_command_queue.dart'; import 'package:universal_ble/src/universal_ble_linux/universal_ble_linux.dart'; import 'package:universal_ble/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart'; import 'package:universal_ble/src/universal_ble_web/universal_ble_web.dart'; import 'package:universal_ble/src/utils/universal_logger.dart'; import 'package:universal_ble/universal_ble.dart'; class UniversalBle { /// Get platform specific implementation. static UniversalBlePlatform _platform = _defaultPlatform(); static final BleCommandQueue _bleCommandQueue = BleCommandQueue(); /// Set custom platform specific implementation (e.g. for testing). static void setInstance(UniversalBlePlatform instance) => _platform = instance; /// Set global timeout for all commands. /// Default timeout is 10 seconds. /// Set to null to disable. static set timeout(Duration? duration) { _bleCommandQueue.timeout = duration; } /// Set how commands will be executed. By default, all commands are executed in a global queue (`QueueType.global`), /// with each command waiting for the previous one to finish. /// /// [QueueType.global] will execute commands of all devices in a single queue. /// [QueueType.perDevice] will execute command of each device in separate queues. /// [QueueType.none] will execute all commands in parallel. static set queueType(QueueType queueType) { _bleCommandQueue.queueType = queueType; UniversalLogger.logInfo('Queue ${queueType.name}'); } /// Scan Stream static Stream get scanStream => _platform.scanStream; /// Bluetooth availability state stream static Stream get availabilityStream => _platform.availabilityStream; /// Connection stream of a device static Stream connectionStream(String deviceId) => _platform.connectionStream(deviceId); /// Characteristic value stream static Stream characteristicValueStream( String deviceId, String characteristicId) => _platform.characteristicValueStream(deviceId, characteristicId); /// Pairing state stream static Stream pairingStateStream(String deviceId) => _platform.pairingStateStream(deviceId); /// Get Bluetooth availability state. /// To be notified of updates, set [onAvailabilityChange] listener. static Future getBluetoothAvailabilityState() async { return await _bleCommandQueue.queueCommand( () => _platform.getBluetoothAvailabilityState(), ); } /// Start scan. /// Scan results will arrive in [onScanResult] listener. /// It might throw errors if Bluetooth is not available. /// `webRequestOptions` is supported on Web only. static Future startScan({ ScanFilter? scanFilter, PlatformConfig? platformConfig, }) async { return await _bleCommandQueue.queueCommandWithoutTimeout( () => _platform.startScan( scanFilter: scanFilter, platformConfig: platformConfig, ), ); } /// Stop scan. /// Set [onScanResult] listener to `null` if you don't need it anymore. /// It might throw errors if Bluetooth is not available. static Future stopScan() async { return await _bleCommandQueue.queueCommandWithoutTimeout( () => _platform.stopScan(), ); } /// Connect to a device. /// It is advised to stop scanning before connecting. /// It throws error if device connection fails. /// Default connection timeout is 60 sec. /// Can throw `ConnectionException` or `PlatformException`. static Future connect( String deviceId, { Duration? connectionTimeout, }) async { connectionTimeout ??= const Duration(seconds: 60); StreamSubscription? connectionSubscription; Completer completer = Completer(); void handleError(dynamic error) { if (completer.isCompleted) return; connectionSubscription?.cancel(); completer.completeError(ConnectionException(error)); } try { connectionSubscription = _platform .bleConnectionUpdateStreamController.stream .where((e) => e.deviceId == deviceId) .listen( (e) { if (e.error != null) { handleError(e.error); } else { if (!completer.isCompleted) { completer.complete(e.isConnected); } } }, onError: handleError, cancelOnError: true, ); _platform .connect(deviceId, connectionTimeout: connectionTimeout) .catchError(handleError); if (!await completer.future.timeout(connectionTimeout)) { throw ConnectionException("Failed to connect"); } } finally { connectionSubscription?.cancel(); } } /// Disconnect from a device. /// Get notified of connection state changes in [onConnectionChange] listener. static Future disconnect(String deviceId) async { return await _bleCommandQueue.queueCommand( () => _platform.disconnect(deviceId), deviceId: deviceId, ); } /// Discover services of a device. static Future> discoverServices(String deviceId) async { return await _bleCommandQueue.queueCommand( () => _platform.discoverServices(deviceId), deviceId: deviceId, ); } /// Set a characteristic notifiable. /// Updates will arrive in [onValueChange] listener and [characteristicValueStream] /// call [unsubscribe] to stop updates static Future subscribeNotifications( String deviceId, String service, String characteristic, ) async { return _sendBleInputPropertyCommand( deviceId, service, characteristic, BleInputProperty.notification, ); } /// Set a characteristic notifiable. /// Updates will arrive in [onValueChange] listener and [characteristicValueStream] /// call [unsubscribe] to stop updates static Future subscribeIndications( String deviceId, String service, String characteristic, ) async { return _sendBleInputPropertyCommand( deviceId, service, characteristic, BleInputProperty.indication, ); } /// Stop characteristic notifications/indication updates static Future unsubscribe( String deviceId, String service, String characteristic, ) async { return _sendBleInputPropertyCommand( deviceId, service, characteristic, BleInputProperty.disabled, ); } /// Read a characteristic value. /// On iOS and MacOS this command will also trigger [onValueChange] listener. static Future read( String deviceId, String service, String characteristic, { final Duration? timeout, }) async { return await _bleCommandQueue.queueCommand( () => _platform.readValue( deviceId, BleUuidParser.string(service), BleUuidParser.string(characteristic), timeout: timeout ?? _bleCommandQueue.timeout, ), timeout: timeout, deviceId: deviceId, ); } /// Write a characteristic value. /// To write a characteristic value without response, set [withoutResponse] to [true]. static Future write( String deviceId, String service, String characteristic, Uint8List value, { bool withoutResponse = false, }) async { await _bleCommandQueue.queueCommand( () => _platform.writeValue( deviceId, BleUuidParser.string(service), BleUuidParser.string(characteristic), value, withoutResponse ? BleOutputProperty.withoutResponse : BleOutputProperty.withResponse, ), deviceId: deviceId, ); } /// Request MTU value. /// It will **attempt** to set the MTU (Maximum Transmission Unit) but it is not guaranteed to succeed due to platform limitations. /// It will always return the current MTU. static Future requestMtu(String deviceId, int expectedMtu) async { return await _bleCommandQueue.queueCommand( () => _platform.requestMtu(deviceId, expectedMtu), deviceId: deviceId, ); } /// Check if a device is paired. /// /// For `Apple` and `Web`, you have to pass a "pairingCommand" with an encrypted read or write characteristic. /// Returns true/false if it manages to execute the command. /// Returns null when no `pairingCommand` is passed. /// Note that it will trigger pairing if the device is not already paired. static Future isPaired( String deviceId, { BleCommand? pairingCommand, Duration? connectionTimeout, }) async { if (BleCapabilities.hasSystemPairingApi) { return _bleCommandQueue.queueCommand( () => _platform.isPaired(deviceId), deviceId: deviceId, ); } if (pairingCommand == null) { UniversalLogger.logWarning("PairingCommand required to get result"); return null; } try { await _connectAndExecuteBleCommand( deviceId, pairingCommand, connectionTimeout: connectionTimeout, updateCallbackValue: false, ); // Because pairingCommand will be never null, so we wont get Unknown result here return true; } catch (e) { UniversalLogger.logError("ExecuteBleCommandFailed: $e"); return false; } } /// Pair a device. /// /// It throws error if pairing fails. /// /// On `Apple` and `Web`, it only works on devices with encrypted characteristics. /// It is advised to pass a pairingCommand with an encrypted read or write characteristic. /// When not passing a pairingCommand, you should afterwards use [isPaired] with a pairingCommand /// to verify the pairing state. /// /// On `Web/Windows` and `Web/Linux`, it does not work for devices that use `ConfirmOnly` pairing. /// Can throw `PairingException`, `ConnectionException` or `PlatformException`. static Future pair( String deviceId, { BleCommand? pairingCommand, Duration? connectionTimeout, }) async { if (BleCapabilities.hasSystemPairingApi) { bool paired = await _platform.pair(deviceId); if (!paired) throw PairingException(); } else { if (pairingCommand == null) { UniversalLogger.logWarning("PairingCommand required to get result"); } await _connectAndExecuteBleCommand( deviceId, pairingCommand, connectionTimeout: connectionTimeout, ); } } /// Unpair a device. /// It might throw an error if device is not paired. static Future unpair(String deviceId) async { return await _bleCommandQueue.queueCommand( () => _platform.unpair(deviceId), deviceId: deviceId, ); } /// Get connected devices to the system (connected by any app). /// Use [withServices] to filter devices by services. /// On `Apple`, [withServices] is required to get any connected devices. If not passed, several [18XX] generic services will be set by default. /// On `Android`, `Linux` and `Windows`, if [withServices] is used, then internally all services will be discovered for each device first (either by connecting or by using cached services). /// Not supported on `Web`. static Future> getSystemDevices({ List? withServices, }) async { return await _bleCommandQueue.queueCommand( () => _platform.getSystemDevices(withServices?.toValidUUIDList()), ); } /// Returns connection state of the device. /// All platforms will return `Connected/Disconnected` states. /// `Android` and `Apple` can also return `Connecting/Disconnecting` states. static Future getConnectionState(String deviceId) async { return await _bleCommandQueue.queueCommand( () => _platform.getConnectionState(deviceId), ); } /// Enable Bluetooth. /// It might throw errors if Bluetooth is not available. /// Not supported on `Web` and `Apple`. static Future enableBluetooth() async { return await _bleCommandQueue.queueCommand( () => _platform.enableBluetooth(), ); } /// Disable Bluetooth. /// It might throw errors if Bluetooth is not available. /// Not supported on `Web` and `Apple`. static Future disableBluetooth() async { return await _bleCommandQueue.queueCommand( () => _platform.disableBluetooth(), ); } /// [receivesAdvertisements] returns true on web if the browser supports receiving advertisements from a certain `deviceId`. /// The rest of the platforms will always return true. /// If true, then you will be getting scanResult updates for this device. /// /// For this feature to work, you need to enable the `chrome://flags/#enable-experimental-web-platform-features` flag. /// Not every browser supports this API yet. /// Even if the browser supports it, sometimes it won't fire any advertisement events even though the device may be sending them. static bool receivesAdvertisements(String deviceId) => _platform.receivesAdvertisements(deviceId); /// Get Bluetooth state availability. static set onAvailabilityChange(OnAvailabilityChange? onAvailabilityChange) { _platform.onAvailabilityChange = onAvailabilityChange; if (onAvailabilityChange != null) { getBluetoothAvailabilityState().then((value) { onAvailabilityChange(value); }).onError((error, stackTrace) => null); } } @Deprecated( "Use [subscribeNotifications] or [subscribeIndications] or [unsubscribe] instead") static Future setNotifiable( String deviceId, String service, String characteristic, BleInputProperty bleInputProperty, ) async { return _sendBleInputPropertyCommand( deviceId, service, characteristic, bleInputProperty, ); } @Deprecated("Use [write] instead") static Future writeValue( String deviceId, String service, String characteristic, Uint8List value, BleOutputProperty bleOutputProperty, ) async { await write( deviceId, service, characteristic, value, withoutResponse: bleOutputProperty == BleOutputProperty.withoutResponse, ); } @Deprecated("Use [read] instead") static Future readValue( String deviceId, String service, String characteristic, { final Duration? timeout, }) { return read(deviceId, service, characteristic, timeout: timeout); } static Future _sendBleInputPropertyCommand( String deviceId, String service, String characteristic, BleInputProperty bleInputProperty, ) async { return await _bleCommandQueue.queueCommand( () => _platform.setNotifiable( deviceId, BleUuidParser.string(service), BleUuidParser.string(characteristic), bleInputProperty, ), deviceId: deviceId, ); } static Future _connectAndExecuteBleCommand( String deviceId, BleCommand? bleCommand, { Duration? connectionTimeout, bool updateCallbackValue = false, }) async { // Try to connect first if (await getConnectionState(deviceId) != BleConnectionState.connected) { UniversalLogger.logInfo("Connecting to $deviceId"); await connect( deviceId, connectionTimeout: connectionTimeout, ); } List services = await discoverServices(deviceId); UniversalLogger.logInfo("Discovered services: ${services.length}"); if (bleCommand == null) { // Just attempt pairing await _attemptPairingReadingAll(deviceId, services); return; } await _executeBleCommand(deviceId, services, bleCommand); if (updateCallbackValue) _platform.updatePairingState(deviceId, true); } // Fire and forget, and do not rely on result static Future _attemptPairingReadingAll( String deviceId, List services, ) async { bool containsReadCharacteristics = false; try { // If BleCommand not given, fallback to reading all characteristics for (BleService service in services) { for (BleCharacteristic characteristic in service.characteristics) { if (characteristic.properties.contains(CharacteristicProperty.read)) { containsReadCharacteristics = true; await read( deviceId, service.uuid, characteristic.uuid, timeout: const Duration(seconds: 30), ); } } } } catch (_) {} if (!containsReadCharacteristics) { throw PairingException("No readable characteristic found"); } } static Future _executeBleCommand( String deviceId, List services, BleCommand bleCommand, ) async { // First find BleCommand's characteristic BleCharacteristic? characteristic; for (BleService service in services) { if (BleUuidParser.compareStrings(service.uuid, bleCommand.service)) { for (BleCharacteristic char in service.characteristics) { if (BleUuidParser.compareStrings( char.uuid, bleCommand.characteristic)) { characteristic = char; break; } } } } if (characteristic == null) { throw PairingException("BleCommand not found in discovered services"); } // Check if BleCommand Supports Read or Write bool? withoutResponse; if (characteristic.properties.contains(CharacteristicProperty.write)) { withoutResponse = false; } else if (characteristic.properties .contains(CharacteristicProperty.writeWithoutResponse)) { withoutResponse = true; } else if (!characteristic.properties .contains(CharacteristicProperty.read)) { throw PairingException( "BleCommand does not support read or write operation", ); } Uint8List? value = bleCommand.writeValue; try { if (value != null && withoutResponse != null) { await write( deviceId, bleCommand.service, bleCommand.characteristic, value, withoutResponse: withoutResponse, ); } else { // Fallback to read if supported await read( deviceId, bleCommand.service, bleCommand.characteristic, timeout: const Duration(seconds: 30), ); } } catch (e) { throw PairingException(e.toString()); } } /// Get updates of remaining items of a queue. static set onQueueUpdate(OnQueueUpdate? onQueueUpdate) => _bleCommandQueue.onQueueUpdate = onQueueUpdate; /// Get scan results. static set onScanResult(OnScanResult? bleDevice) => _platform.onScanResult = bleDevice; /// Get connection state changes. static set onConnectionChange(OnConnectionChange? onConnectionChange) => _platform.onConnectionChange = onConnectionChange; /// Get characteristic value updates, after calling [subscribeNotifications] or [subscribeIndications] static set onValueChange(OnValueChange? onValueChange) => _platform.onValueChange = onValueChange; /// Get pair state changes. static set onPairingStateChange(OnPairingStateChange pairingStateChange) => _platform.onPairingStateChange = pairingStateChange; static UniversalBlePlatform _defaultPlatform() { if (kIsWeb) return UniversalBleWeb.instance; if (defaultTargetPlatform == TargetPlatform.linux) { return UniversalBleLinux.instance; } return UniversalBlePigeonChannel.instance; } }