diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f9d6e..a52e545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ -## 0.20 -* Fix `getBluetoothAvailabilityState` reporting wrong Bluetooth status on browsers where Web Bluetooth can be globally disabled -* `startScan` API on Web platforms now throws a `WebBluetoothGloballyDisabled` exception if scanning cannot proceed due to Web Bluetooth being globally disabled +## 0.20.0 +* Add new high level API. Services are auto-discovered. the BleDevice class offers convenient member methods and properties. +* BREAKING CHANGE: `bleDevice.isPaired` is now `bleDevice.paired` +* Deprecate `readValue` in favor of `read` +* Deprecate `writeValue` in favor of `write` +* Deprecate `setNotifiable` in favor of `subscribeNotifications`, `subscribeIndications`, `unsubscribe` +* Fix `getBluetoothAvailabilityState` reporting wrong Bluetooth status on browsers where Web Bluetooth can globally be disabled +* `startScan` on web now throws a `WebBluetoothGloballyDisabled` exception if scanning cannot proceed due to Web Bluetooth being globally disabled +* EXAMPLE APP: Migrate to the new high level API ## 0.19.0 * Get and prefer advertised name of scanned devices on Apple diff --git a/README.low_level.md b/README.low_level.md new file mode 100644 index 0000000..e475bd7 --- /dev/null +++ b/README.low_level.md @@ -0,0 +1,133 @@ +# Low Level API + +### Connecting + +```dart +// Connect to a device using the `deviceId` of the BleDevice received from `UniversalBle.onScanResult` +String deviceId = bleDevice.deviceId; +UniversalBle.connect(deviceId); + +// Disconnect from a device +UniversalBle.disconnect(deviceId); + +// Get connection/disconnection updates using stream +UniversalBle.connectionStream(deviceId).listen((bool isConnected) { + debugPrint('Is device $deviceId connected?: $isConnected'); +}); + +// Or set a handler to get updates of all devices +UniversalBle.onConnectionChange = (String deviceId, bool isConnected, String? error) { + debugPrint('Is device $deviceId connected?: $isConnected. Error: $error'); +} +``` + +### Discovering Services + +After establishing a connection, you need to discover services. This method will discover all services and their characteristics. + +```dart +// Discover services of a specific device +UniversalBle.discoverServices(deviceId); +``` + +### Reading & Writing data + +You need to first [discover services](#discovering-services) before you are able to read and write to characteristics. + +```dart +// Read data from a characteristic +UniversalBle.read(deviceId, serviceId, characteristicId); + +// Write data to a characteristic +UniversalBle.write(deviceId, serviceId, characteristicId, value); + +// Subscribe to a characteristic notifications +UniversalBle.subscribeNotifications(deviceId, serviceId, characteristicId); + +// Subscribe to a characteristic indications +UniversalBle.subscribeIndications(deviceId, serviceId, characteristicId); + +// Get characteristic notifications/indications updates using stream +UniversalBle.characteristicValueStream(deviceId, characteristicId).listen((Uint8List value) { + debugPrint('OnValueChange $deviceId, $characteristicId, ${hex.encode(value)}'); +}); + +// Or set a handler to get updates of all characteristics +UniversalBle.onValueChange = (String deviceId, String characteristicId, Uint8List value) { + debugPrint('onValueChange $deviceId, $characteristicId, ${hex.encode(value)}'); +} + +// Unsubscribe from notifications/indications +UniversalBle.unsubscribe(deviceId, serviceId, characteristicId); +``` + +### Pairing + +#### Trigger pairing + +##### Pair on Android, Windows, Linux + +```dart +await UniversalBle.pair(deviceId); +``` + +##### Pair on Apple and web + +For Apple and Web, pairing support depends on the device. Pairing is triggered automatically by the OS when you try to read/write from/to an encrypted characteristic. + +Calling `UniversalBle.pair(deviceId)` will only trigger pairing if the device has an _encrypted read characteristic_. + +If your device only has encrypted write characteristics or you happen to know which encrypted read characteristic you want to use, you can pass it with a `pairingCommand`. + +```dart +UniversalBle.pair(deviceId, pairingCommand: BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")); +``` + +After pairing you can check the pairing status. + +#### Pairing status + +##### Pair on Android, Windows, Linux + +```dart +// Check current pairing state +bool? isPaired = UniversalBle.isPaired(deviceId); +``` + +##### Pair on Apple and web + +For `Apple` and `Web`, you have to pass a "pairingCommand" with an encrypted read or write characteristic. If you don't pass it then it will return `null`. + +```dart +bool? isPaired = await UniversalBle.isPaired(deviceId, pairingCommand: BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")); +``` + +##### Discovering encrypted characteristic + +To discover encrypted characteristics, make sure your device is not paired and use the example app to read/write to all discovered characteristics one by one. If one of them triggers pairing, that means it is encrypted and you can use it to construct `BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")`. + +#### Pairing state changes + +```dart +// Get pairing state updates using stream +UniversalBle.pairingStateStream(deviceId).listen((bool paired) { + // Handle pairing state change +}); + +// Or set a handler to get pairing state updates of all devices +UniversalBle.onPairingStateChange = (String deviceId, bool paired) {} +``` + +#### Unpair + +```dart +UniversalBle.unpair(deviceId); +``` + +### Request MTU + +This method 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. + +```dart +int mtu = await UniversalBle.requestMtu(widget.deviceId, 247); +``` diff --git a/README.md b/README.md index 501332a..a1cae44 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE - [Reading & Writing data](#reading--writing-data) - [Pairing](#pairing) - [Bluetooth Availability](#bluetooth-availability) +- [Requesting MTU](#requesting-mtu) - [Command Queue](#command-queue) - [Timeout](#timeout) - [UUID Format Agnostic](#uuid-format-agnostic) @@ -26,12 +27,12 @@ A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE | connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | | discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| readValue | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| writeValue | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| setNotifiable | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | | unpair | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | -| isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | onPairingStateChange | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | | enable/disable Bluetooth | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | @@ -57,7 +58,7 @@ import 'package:universal_ble/universal_ble.dart'; ```dart // Get scan updates from stream -UniversalBle.scanStream.listen((bleDevice) { +UniversalBle.scanStream.listen((BleDevice bleDevice) { // e.g. Use BleDevice ID to connect }); @@ -155,64 +156,139 @@ List withNamePrefix; ### Connecting +#### Connect + +Connects to the BLE device. This method initiates a connection to the Bluetooth device. + ```dart -// Connect to a device using the `deviceId` of the BleDevice received from `UniversalBle.onScanResult` -String deviceId = bleDevice.deviceId; -UniversalBle.connect(deviceId); +await bleDevice.connect(); +``` -// Disconnect from a device -UniversalBle.disconnect(deviceId); +#### Disconnect -// Get connection/disconnection updates using stream -UniversalBle.connectionStream(deviceId).listen((bool isConnected) { - debugPrint('OnConnectionChange $deviceId, $isConnected'); +Disconnects from the BLE device. This method terminates the connection to the Bluetooth device. + +```dart +await bleDevice.disconnect(); +``` + +#### Connection Stream + +```dart +bleDevice.connectionStream.listen((isConnected) { + debugPrint('Is device connected?: $isConnected'); }); +``` -// Or set a handler to get updates of all devices -UniversalBle.onConnectionChange = (String deviceId, bool isConnected, String? error) { - debugPrint('OnConnectionChange $deviceId, $isConnected Error: $error'); -} +#### IsConnected -// Get current connection state +```dart +bool isConnected = await bleDevice.isConnected; +``` + +#### Connection state + +```dart // Can be connected, disconnected, connecting or disconnecting BleConnectionState connectionState = await bleDevice.connectionState; ``` ### Discovering Services -After establishing a connection, you need to discover services. This method will discover all services and their characteristics. +After establishing a connection, services need to be discovered. This method will discover all services and their characteristics. + +If you don't call this method then it will be automatically called when you try to get any service or characteristic. + +#### DiscoverServices + +Discovers the services offered by the device. Returns a `Future>`. After discovery services are cached and each call of this method updates the cache. ```dart -// Discover services of a specific device -UniversalBle.discoverServices(deviceId); +List services = await bleDevice.discoverServices(); +for (var service in services) { + debugPrint('Service UUID: ${service.uuid}'); +} ``` -### Reading & Writing data +#### GetService + +Retrieves a specific service. Returns a `Future`. + +- `service`: The UUID of the service. +- `preferCached`: If `true` (default), cached services are used. If cache is empty, `discoverServices()` will be called. + +```dart +BleService service = await bleDevice.getService('180a'); +``` + +#### GetCharacteristic + +Retrieves a specific characteristic from a service. Returns a `Future`. + +- `service`: The UUID of the service. +- `characteristic`: The UUID of the characteristic. +- `preferCached`: If `true` (default), cached services are used. If cache is empty, `discoverServices()` will be called + +```dart +BleCharacteristic characteristic = await bleDevice.getCharacteristic('180a','2a56'); +``` + +Or retrieve from `BleService` + +```dart +BleCharacteristic characteristic = await service.getCharacteristic('2a56'); +``` + +## Reading & Writing data You need to first [discover services](#discovering-services) before you are able to read and write to characteristics. ```dart -// Read data from a characteristic -UniversalBle.readValue(deviceId, serviceId, characteristicId); +Uint8List value = await characteristic.read(); +``` -// Write data to a characteristic -UniversalBle.writeValue(deviceId, serviceId, characteristicId, value); +```dart +await characteristic.write([0x01, 0x02, 0x03]); -// Subscribe to a characteristic -UniversalBle.setNotifiable(deviceId, serviceId, characteristicId, BleInputProperty.notification); +await characteristic.write([0x01, 0x02, 0x03], withResponse: false); +``` -// Get characteristic updates using stream -UniversalBle.characteristicValueStream(deviceId, characteristicId).listen((Uint8List value) { - debugPrint('OnValueChange $deviceId, $characteristicId, ${hex.encode(value)}'); +## Subscriptions + +Get `BleCharacteristic` using `bleDevice.getCharacteristic` + +### OnValueReceived + +A stream of `Uint8List` that emits values received from the characteristic. Listen to this stream to receive updates whenever the characteristic's value changes. + +```dart +characteristic.onValueReceived.listen((value) { + debugPrint('Received value: ${value.toString()}'); }); +``` -// Or set a handler to get updates of all characteristics -UniversalBle.onValueChange = (String deviceId, String characteristicId, Uint8List value) { - debugPrint('onValueChange $deviceId, $characteristicId, ${hex.encode(value)}'); -} +### Notifications -// Unsubscribe from a characteristic -UniversalBle.setNotifiable(deviceId, serviceId, characteristicId, BleInputProperty.disabled); +Subscribe to notifications for this characteristic. Throws an exception if the characteristic does not support notifications. + +```dart +await characteristic.notifications.subscribe(); +``` + +### Indications + +Subscribe to indications for this characteristic. Throws an exception if the characteristic does not support indications. + +```dart +await characteristic.indications.subscribe(); +``` + +### Unsubscribe + +Unsubscribe from notifications and indications of this characteristic. + +```dart +await characteristic.unsubscribe(); ``` ### Pairing @@ -222,18 +298,18 @@ UniversalBle.setNotifiable(deviceId, serviceId, characteristicId, BleInputProper ##### Pair on Android, Windows, Linux ```dart -await UniversalBle.pair(deviceId); +await bleDevice.pair(); ``` ##### Pair on Apple and web For Apple and Web, pairing support depends on the device. Pairing is triggered automatically by the OS when you try to read/write from/to an encrypted characteristic. -Calling `UniversalBle.pair(deviceId)` will only trigger pairing if the device has an *encrypted read characteristic*. +Calling `bleDevice.pair()` will only trigger pairing if the device has an *encrypted read characteristic*. If your device only has encrypted write characteristics or you happen to know which encrypted read characteristic you want to use, you can pass it with a `pairingCommand`. ```dart -UniversalBle.pair(deviceId, pairingCommand: BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")); +await bleDevice.pair(pairingCommand: BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")); ``` After pairing you can check the pairing status. @@ -243,7 +319,7 @@ After pairing you can check the pairing status. ```dart // Check current pairing state -bool? isPaired = UniversalBle.isPaired(deviceId); +bool? isPaired = bleDevice.isPaired(); ``` ##### Pair on Apple and web @@ -251,7 +327,7 @@ bool? isPaired = UniversalBle.isPaired(deviceId); For `Apple` and `Web`, you have to pass a "pairingCommand" with an encrypted read or write characteristic. If you don't pass it then it will return `null`. ```dart -bool? isPaired = await UniversalBle.isPaired(deviceId, pairingCommand: BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")); +bool? isPaired = await bleDevice.isPaired(pairingCommand: BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")); ``` ##### Discovering encrypted characteristic @@ -261,17 +337,14 @@ To discover encrypted characteristics, make sure your device is not paired and u ```dart // Get pairing state updates using stream -UniversalBle.pairingStateStream(deviceId).listen((bool isPaired) { +bleDevice.pairingStateStream.listen((bool paired) { // Handle pairing state change }); - -// Or set a handler to get pairing state updates of all devices -UniversalBle.onPairingStateChange = (String deviceId, bool isPaired) {} ``` #### Unpair ```dart -UniversalBle.unpair(deviceId); +bleDevice.unpair(); ``` ### Bluetooth Availability @@ -292,12 +365,10 @@ UniversalBle.enableBluetooth(); UniversalBle.disableBluetooth(); ``` -### Request MTU - -This method 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. +### Requesting MTU ```dart -int mtu = await UniversalBle.requestMtu(widget.deviceId, 247); +int mtu = await bleDevice.requestMtu(256); ``` #### Platform Limitations @@ -469,6 +540,9 @@ class UniversalBleMock extends UniversalBlePlatform { UniversalBle.setInstance(UniversalBleMock()); ``` +## Low level API + +For more granular control, you can use the [Low-Level API](README.low_level.md). This API is "Device ID"-based, offering greater flexibility by enabling direct calls without the need for object instances. ## 🧩 Apps using Universal BLE diff --git a/example/lib/home/home.dart b/example/lib/home/home.dart index 4c52c94..03bba47 100644 --- a/example/lib/home/home.dart +++ b/example/lib/home/home.dart @@ -41,13 +41,13 @@ class _MyAppState extends State { UniversalBle.queueType = _queueType; UniversalBle.timeout = const Duration(seconds: 10); - UniversalBle.onAvailabilityChange = (state) { + UniversalBle.availabilityStream.listen((state) { setState(() { bleAvailabilityState = state; }); - }; + }); - UniversalBle.onScanResult = (result) { + UniversalBle.scanStream.listen((result) { // log(result.toString()); int index = _bleDevices.indexWhere((e) => e.deviceId == result.deviceId); if (index == -1) { @@ -59,7 +59,7 @@ class _MyAppState extends State { _bleDevices[index] = result; } setState(() {}); - }; + }); // UniversalBle.onQueueUpdate = (String id, int remainingItems) { // debugPrint("Queue: $id RemainingItems: $remainingItems"); @@ -259,13 +259,11 @@ class _MyAppState extends State { bleDevice: device, onTap: () { Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PeripheralDetailPage( - device.deviceId, - device.name ?? "Unknown Peripheral", - ), - )); + context, + MaterialPageRoute( + builder: (_) => PeripheralDetailPage(device), + ), + ); UniversalBle.stopScan(); setState(() { _isScanning = false; diff --git a/example/lib/home/widgets/scanned_item_widget.dart b/example/lib/home/widgets/scanned_item_widget.dart index 0ce70c6..7608f30 100644 --- a/example/lib/home/widgets/scanned_item_widget.dart +++ b/example/lib/home/widgets/scanned_item_widget.dart @@ -30,7 +30,7 @@ class ScannedItemWidget extends StatelessWidget { visible: manufacturerData != null, child: Text(manufacturerData.toString()), ), - bleDevice.isPaired == true + bleDevice.paired == true ? const Text( "Paired", style: TextStyle(color: Colors.green), diff --git a/example/lib/peripheral_details/peripheral_detail_page.dart b/example/lib/peripheral_details/peripheral_detail_page.dart index 37821b4..9c93781 100644 --- a/example/lib/peripheral_details/peripheral_detail_page.dart +++ b/example/lib/peripheral_details/peripheral_detail_page.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_print, depend_on_referenced_packages - import 'dart:async'; import 'package:convert/convert.dart'; @@ -13,9 +11,8 @@ import 'package:universal_ble_example/widgets/responsive_buttons_grid.dart'; import 'package:universal_ble_example/widgets/responsive_view.dart'; class PeripheralDetailPage extends StatefulWidget { - final String deviceId; - final String deviceName; - const PeripheralDetailPage(this.deviceId, this.deviceName, {super.key}); + final BleDevice bleDevice; + const PeripheralDetailPage(this.bleDevice, {super.key}); @override State createState() { @@ -24,30 +21,32 @@ class PeripheralDetailPage extends StatefulWidget { } class _PeripheralDetailPageState extends State { + late final bleDevice = widget.bleDevice; bool isConnected = false; GlobalKey valueFormKey = GlobalKey(); List discoveredServices = []; final List _logs = []; final binaryCode = TextEditingController(); - ({ - BleService service, - BleCharacteristic characteristic - })? selectedCharacteristic; + StreamSubscription? connectionStreamSubscription; + StreamSubscription? pairingStateSubscription; + BleService? selectedService; + BleCharacteristic? selectedCharacteristic; @override void initState() { super.initState(); - UniversalBle.onConnectionChange = _handleConnectionChange; + + connectionStreamSubscription = + bleDevice.connectionStream.listen(_handleConnectionChange); + pairingStateSubscription = + bleDevice.pairingStateStream.listen(_handlePairingStateChange); UniversalBle.onValueChange = _handleValueChange; - UniversalBle.onPairingStateChange = _handlePairingStateChange; _asyncInits(); } void _asyncInits() { - UniversalBle.getConnectionState( - widget.deviceId, - ).then((state) { + bleDevice.connectionState.then((state) { if (state == BleConnectionState.connected) { setState(() { isConnected = true; @@ -59,7 +58,8 @@ class _PeripheralDetailPageState extends State { @override void dispose() { super.dispose(); - UniversalBle.onConnectionChange = null; + connectionStreamSubscription?.cancel(); + pairingStateSubscription?.cancel(); UniversalBle.onValueChange = null; } @@ -69,18 +69,10 @@ class _PeripheralDetailPageState extends State { }); } - void _handleConnectionChange( - String deviceId, - bool isConnected, - String? error, - ) { - print( - '_handleConnectionChange $deviceId, $isConnected ${error != null ? 'Error: $error' : ''}', - ); + void _handleConnectionChange(bool isConnected) { + debugPrint('_handleConnectionChange $isConnected'); setState(() { - if (deviceId == widget.deviceId) { - this.isConnected = isConnected; - } + this.isConnected = isConnected; }); _addLog('Connection', isConnected ? "Connected" : "Disconnected"); // Auto Discover Services @@ -93,12 +85,12 @@ class _PeripheralDetailPageState extends State { String deviceId, String characteristicId, Uint8List value) { String s = String.fromCharCodes(value); String data = '$s\nraw : ${value.toString()}'; - print('_handleValueChange $deviceId, $characteristicId, $s'); + debugPrint('_handleValueChange $characteristicId, $s'); _addLog("Value", data); } - void _handlePairingStateChange(String deviceId, bool isPaired) { - print('isPaired $deviceId, $isPaired'); + void _handlePairingStateChange(bool isPaired) { + debugPrint('isPaired $isPaired'); _addLog("PairingStateChange - isPaired", isPaired); } @@ -106,10 +98,9 @@ class _PeripheralDetailPageState extends State { const webWarning = "Note: Only services added in ScanFilter or WebOptions will be discovered"; try { - var services = await UniversalBle.discoverServices(widget.deviceId); - print('${services.length} services discovered'); - print(services); - discoveredServices.clear(); + var services = await bleDevice.discoverServices(); + debugPrint('${services.length} services discovered'); + debugPrint(services.toString()); setState(() { discoveredServices = services; }); @@ -126,13 +117,10 @@ class _PeripheralDetailPageState extends State { } Future _readValue() async { + BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic; if (selectedCharacteristic == null) return; try { - Uint8List value = await UniversalBle.readValue( - widget.deviceId, - selectedCharacteristic!.service.uuid, - selectedCharacteristic!.characteristic.uuid, - ); + Uint8List value = await selectedCharacteristic.read(); String s = String.fromCharCodes(value); String data = '$s\nraw : ${value.toString()}'; _addLog('Read', data); @@ -142,6 +130,7 @@ class _PeripheralDetailPageState extends State { } Future _writeValue() async { + BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic; if (selectedCharacteristic == null || !valueFormKey.currentState!.validate() || binaryCode.text.isEmpty) { @@ -157,54 +146,59 @@ class _PeripheralDetailPageState extends State { } try { - await UniversalBle.writeValue( - widget.deviceId, - selectedCharacteristic!.service.uuid, - selectedCharacteristic!.characteristic.uuid, + await selectedCharacteristic.write( value, - _hasSelectedCharacteristicProperty( - [CharacteristicProperty.writeWithoutResponse]) - ? BleOutputProperty.withoutResponse - : BleOutputProperty.withResponse, + withResponse: _hasSelectedCharacteristicProperty( + [CharacteristicProperty.writeWithoutResponse], + ), ); _addLog('Write', value); } catch (e) { - print(e); + debugPrint(e.toString()); _addLog('WriteError', e); } } - Future _setBleInputProperty(BleInputProperty inputProperty) async { + Future _subscribeChar() async { + BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic; if (selectedCharacteristic == null) return; try { - if (inputProperty != BleInputProperty.disabled) { - List properties = - selectedCharacteristic!.characteristic.properties; - if (properties.contains(CharacteristicProperty.notify)) { - inputProperty = BleInputProperty.notification; - } else if (properties.contains(CharacteristicProperty.indicate)) { - inputProperty = BleInputProperty.indication; - } else { - throw 'No notify or indicate property'; - } - } - await UniversalBle.setNotifiable( - widget.deviceId, - selectedCharacteristic!.service.uuid, - selectedCharacteristic!.characteristic.uuid, - inputProperty, - ); - _addLog('BleInputProperty', inputProperty); + var subscription = _getCharacteristicSubscription(selectedCharacteristic); + if (subscription == null) throw 'No notify or indicate property'; + await subscription.subscribe(); + _addLog('BleCharSubscription', 'Subscribed'); + // Updates can also be handled by + // subscription.listen((data) {}); } catch (e) { _addLog('NotifyError', e); } } + Future _unsubscribeChar() async { + try { + await selectedCharacteristic?.unsubscribe(); + _addLog('BleCharSubscription', 'UnSubscribed'); + } catch (e) { + _addLog('NotifyError', e); + } + } + + CharacteristicSubscription? _getCharacteristicSubscription( + BleCharacteristic characteristic) { + var properties = characteristic.properties; + if (properties.contains(CharacteristicProperty.notify)) { + return characteristic.notifications; + } else if (properties.contains(CharacteristicProperty.indicate)) { + return characteristic.indications; + } + return null; + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text("${widget.deviceName} - ${widget.deviceId}"), + title: Text("${bleDevice.name ?? "Unknown"} - ${bleDevice.deviceId}"), elevation: 4, actions: [ Padding( @@ -234,13 +228,10 @@ class _PeripheralDetailPageState extends State { : ServicesListWidget( discoveredServices: discoveredServices, scrollable: true, - onTap: (BleService service, - BleCharacteristic characteristic) { + onTap: (service, characteristic) { setState(() { - selectedCharacteristic = ( - service: service, - characteristic: characteristic - ); + selectedService = service; + selectedCharacteristic = characteristic; }); }, ), @@ -264,9 +255,7 @@ class _PeripheralDetailPageState extends State { enabled: !isConnected, onPressed: () async { try { - await UniversalBle.connect( - widget.deviceId, - ); + await bleDevice.connect(); _addLog("ConnectionResult", true); } catch (e) { _addLog('ConnectError (${e.runtimeType})', e); @@ -277,7 +266,7 @@ class _PeripheralDetailPageState extends State { text: 'Disconnect', enabled: isConnected, onPressed: () { - UniversalBle.disconnect(widget.deviceId); + bleDevice.disconnect(); }, ), ], @@ -294,17 +283,17 @@ class _PeripheralDetailPageState extends State { child: Card( child: ListTile( title: SelectableText( - "Characteristic: ${selectedCharacteristic!.characteristic.uuid}", + "Characteristic: ${selectedCharacteristic?.uuid}", ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( - "Service: ${selectedCharacteristic!.service.uuid}", + "Service: ${selectedService?.uuid}", ), Text( - "Properties: ${selectedCharacteristic!.characteristic.properties.map((e) => e.name)}", + "Properties: ${selectedCharacteristic?.properties.map((e) => e.name)}", ), ], ), @@ -360,9 +349,7 @@ class _PeripheralDetailPageState extends State { onPressed: () async { _addLog( 'ConnectionState', - await UniversalBle.getConnectionState( - widget.deviceId, - ), + await bleDevice.connectionState, ); }, text: 'Connection State', @@ -371,8 +358,7 @@ class _PeripheralDetailPageState extends State { PlatformButton( enabled: isConnected, onPressed: () async { - int mtu = await UniversalBle.requestMtu( - widget.deviceId, 247); + int mtu = await bleDevice.requestMtu(247); _addLog('MTU', mtu); }, text: 'Request Mtu', @@ -403,8 +389,7 @@ class _PeripheralDetailPageState extends State { CharacteristicProperty.notify, CharacteristicProperty.indicate ]), - onPressed: () => _setBleInputProperty( - BleInputProperty.notification), + onPressed: _subscribeChar, text: 'Subscribe', ), PlatformButton( @@ -414,21 +399,19 @@ class _PeripheralDetailPageState extends State { CharacteristicProperty.notify, CharacteristicProperty.indicate ]), - onPressed: () => _setBleInputProperty( - BleInputProperty.disabled), + onPressed: _unsubscribeChar, text: 'Unsubscribe', ), PlatformButton( enabled: BleCapabilities.supportsAllPairingKinds, onPressed: () async { try { - await UniversalBle.pair( - widget.deviceId, - // pairingCommand: BleCommand( - // service: "", - // characteristic: "", - // ), - ); + await bleDevice.pair( + // pairingCommand: BleCommand( + // service: "", + // characteristic: "", + // ), + ); _addLog("Pairing Result", true); } catch (e) { _addLog('PairError (${e.runtimeType})', e); @@ -438,20 +421,19 @@ class _PeripheralDetailPageState extends State { ), PlatformButton( onPressed: () async { - bool? isPaired = await UniversalBle.isPaired( - widget.deviceId, - // pairingCommand: BleCommand( - // service: "", - // characteristic: "", - // ), - ); - _addLog('IsPaired', isPaired); + bool? isPaired = await bleDevice.isPaired( + // pairingCommand: BleCommand( + // service: "", + // characteristic: "", + // ), + ); + _addLog('isPaired', isPaired); }, - text: 'IsPaired', + text: 'isPaired', ), PlatformButton( onPressed: () async { - await UniversalBle.unpair(widget.deviceId); + await bleDevice.unpair(); }, text: 'Unpair', ), @@ -462,13 +444,10 @@ class _PeripheralDetailPageState extends State { if (deviceType != DeviceType.desktop) ServicesListWidget( discoveredServices: discoveredServices, - onTap: (BleService service, - BleCharacteristic characteristic) { + onTap: (service, characteristic) { setState(() { - selectedCharacteristic = ( - service: service, - characteristic: characteristic - ); + selectedService = service; + selectedCharacteristic = characteristic; }); }, ), @@ -497,9 +476,8 @@ class _PeripheralDetailPageState extends State { } bool _hasSelectedCharacteristicProperty( - List properties) => - properties.any((property) => - selectedCharacteristic?.characteristic.properties - .contains(property) ?? - false); + List properties) { + return properties.any((property) => + selectedCharacteristic?.properties.contains(property) ?? false); + } } diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 7ec7ff3..e0cabd0 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -20,9 +20,9 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin SPEC CHECKSUMS: - device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f + device_info_plus: b0fafc687fb901e2af612763340f1b0d4352f8e5 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 + universal_ble: ff19787898040d721109c6324472e5dd4bc86adc PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 diff --git a/example/pubspec.lock b/example/pubspec.lock index 0488018..8755957 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -402,7 +402,7 @@ packages: path: ".." relative: true source: path - version: "0.19.1" + version: "0.20.0" vector_math: dependency: transitive description: diff --git a/lib/src/extensions/ble_characteristic_extension.dart b/lib/src/extensions/ble_characteristic_extension.dart new file mode 100644 index 0000000..307e526 --- /dev/null +++ b/lib/src/extensions/ble_characteristic_extension.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:universal_ble/universal_ble.dart'; + +/// Extension methods for [BleCharacteristic] to simplify common operations. +extension BleCharacteristicExtension on BleCharacteristic { + /// A stream of [Uint8List] that emits values received from the characteristic. + Stream get onValueReceived => + UniversalBle.characteristicValueStream(_deviceId, uuid); + + /// Subscribes to notifications for this characteristic. + /// + /// Throws an exception if the characteristic does not support notifications. + CharacteristicSubscription get notifications => + CharacteristicSubscription(this, CharacteristicProperty.notify); + + /// Subscribes to indications for this characteristic. + /// + /// Throws an exception if the characteristic does not support indications. + CharacteristicSubscription get indications => + CharacteristicSubscription(this, CharacteristicProperty.indicate); + + /// Unsubscribes notifications/indications from this characteristic. + Future unsubscribe() => + UniversalBle.unsubscribe(_deviceId, _serviceId, uuid); + + /// Reads the current value of the characteristic. + Future read() => UniversalBle.read( + _deviceId, + _serviceId, + uuid, + ); + + /// Writes a value to the characteristic. + /// + /// [value] is the list of bytes to write. + /// [withResponse] indicates whether the write should be performed with a response from the device. + /// Default is true, meaning the device will acknowledge the write operation. + /// If set to false, the write operation will be performed without waiting for a response. + Future write(List value, {bool withResponse = true}) async { + await UniversalBle.write( + _deviceId, + _serviceId, + uuid, + Uint8List.fromList(value), + withoutResponse: !withResponse, + ); + } + + String get _deviceId { + String? deviceId = metaData?.deviceId; + if (deviceId == null) { + throw "DeviceId is not preset in characteristic metaData"; + } + return deviceId; + } + + String get _serviceId { + String? serviceId = metaData?.serviceId; + if (serviceId == null) { + throw "ServiceId is not preset in characteristic metaData"; + } + return serviceId; + } +} + +/// Manages subscription to a characteristic's notifications or indications. +/// +/// Instances are typically obtained via the `notifications` or `indications` +/// getters on `BleCharacteristic`. +/// +/// call [subscribe] to instruct the device to start sending data. +/// call [unsubscribe] To stop receiving data and instruct the device to cease sending, +/// call [listen] to register a callback to receive this data.. +/// use [isSupported] to check if this operation is supported by the characteristic +/// +class CharacteristicSubscription { + final BleCharacteristic _characteristic; + final CharacteristicProperty _property; + + /// Indicates whether the characteristic supports the requested subscription type + /// (notifications or indications). + final bool isSupported; + + CharacteristicSubscription( + this._characteristic, + this._property, + ) : isSupported = _characteristic.properties.contains(_property); + + /// Registers a listener for incoming data from the characteristic. + StreamSubscription listen( + void Function(Uint8List event) onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _characteristic.onValueReceived.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + /// Subscribes to this characteristic. + Future subscribe() { + if (!isSupported) throw Exception('Operation not supported'); + + if (_property == CharacteristicProperty.indicate) { + return UniversalBle.subscribeIndications( + _characteristic._deviceId, + _characteristic._serviceId, + _characteristic.uuid, + ); + } + + return UniversalBle.subscribeNotifications( + _characteristic._deviceId, + _characteristic._serviceId, + _characteristic.uuid, + ); + } + + /// Unsubscribes from this characteristic. + Future unsubscribe() { + if (!isSupported) throw Exception('Operation not supported'); + return UniversalBle.unsubscribe( + _characteristic._deviceId, + _characteristic._serviceId, + _characteristic.uuid, + ); + } + + @override + String toString() => + "CharacteristicSubscription(property: ${_property.name}, isSupported: $isSupported, characteristic: ${_characteristic.uuid})"; +} diff --git a/lib/src/extensions/ble_device_extension.dart b/lib/src/extensions/ble_device_extension.dart new file mode 100644 index 0000000..dd46c59 --- /dev/null +++ b/lib/src/extensions/ble_device_extension.dart @@ -0,0 +1,126 @@ +import 'package:universal_ble/src/utils/cache_handler.dart'; +import 'package:universal_ble/universal_ble.dart'; + +/// Extension methods for [BleDevice] to simplify common operations. +extension BleDeviceExtension on BleDevice { + /// A stream of [bool] that emits connection status changes for the device. + Stream get connectionStream => UniversalBle.connectionStream(deviceId); + + /// A stream of [bool] that emits pairing status changes for the device. + Stream get pairingStateStream => + UniversalBle.pairingStateStream(deviceId); + + /// Checks if the device is currently connected. + Future get isConnected async => + await UniversalBle.getConnectionState(deviceId) == + BleConnectionState.connected; + + /// Connects to the device. + Future connect() => UniversalBle.connect(deviceId); + + /// Disconnects from the device. + Future disconnect() => UniversalBle.disconnect(deviceId); + + /// Requests a specific MTU (Maximum Transmission Unit) size for the connection. + Future requestMtu(int expectedMtu) => + UniversalBle.requestMtu(deviceId, expectedMtu); + + /// 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. + Future isPaired({ + BleCommand? pairingCommand, + Duration? connectionTimeout, + }) { + return UniversalBle.isPaired( + deviceId, + pairingCommand: pairingCommand, + connectionTimeout: connectionTimeout, + ); + } + + /// 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`. + Future pair({ + BleCommand? pairingCommand, + Duration? connectionTimeout, + }) { + return UniversalBle.pair( + deviceId, + pairingCommand: pairingCommand, + connectionTimeout: connectionTimeout, + ); + } + + /// Unpair a device. + /// + /// It might throw an error if device is not paired. + Future unpair() => UniversalBle.unpair(deviceId); + + /// Discovers the services offered by the device. + /// + /// Returns cached services if already discovered after connection. + Future> discoverServices() async { + List servicesCache = + await UniversalBle.discoverServices(deviceId); + CacheHandler.instance.saveServices(deviceId, servicesCache); + return servicesCache; + } + + /// Retrieves a specific service. + /// + /// [service] is the UUID of the service. + /// [preferCached] indicates whether to use cached services. If cache is empty, discoverServices() will be called. + /// might throw [NotFoundException] + Future getService( + String service, { + bool preferCached = true, + }) async { + List discoveredServices = []; + if (preferCached) { + discoveredServices = CacheHandler.instance.getServices(deviceId) ?? []; + } + if (discoveredServices.isEmpty) { + discoveredServices = await discoverServices(); + } + + if (discoveredServices.isEmpty) { + throw ServiceNotFoundException('No services found'); + } + + return discoveredServices.firstWhere( + (s) => BleUuidParser.compareStrings(s.uuid, service), + orElse: () => throw ServiceNotFoundException( + 'Service "$service" not available', + ), + ); + } + + /// Retrieves a specific characteristic from a service. + /// + /// [service] is the UUID of the service. + /// [characteristic] is the UUID of the characteristic. + /// [preferCached] indicates whether to use cached services. If cache is empty, discoverServices() will be called. + /// might throw [NotFoundException] + Future getCharacteristic( + String characteristic, { + required String service, + bool preferCached = true, + }) async { + BleService bluetoothService = + await getService(service, preferCached: preferCached); + return bluetoothService.getCharacteristic(characteristic); + } +} diff --git a/lib/src/extensions/ble_service_extension.dart b/lib/src/extensions/ble_service_extension.dart new file mode 100644 index 0000000..ed9ca0a --- /dev/null +++ b/lib/src/extensions/ble_service_extension.dart @@ -0,0 +1,20 @@ +import 'package:universal_ble/universal_ble.dart'; + +/// Extension methods for [BleService] objects. +extension BleServiceExtension on BleService { + /// Retrieves a [BleCharacteristic] from the service by its UUID. + /// + /// Throws an error if no characteristics are found or if the characteristic + /// with the given UUID is not available. + BleCharacteristic getCharacteristic(String characteristicId) { + if (characteristics.isEmpty) { + throw CharacteristicNotFoundException('No characteristics found'); + } + return characteristics.firstWhere( + (c) => BleUuidParser.compareStrings(c.uuid, characteristicId), + orElse: () => throw CharacteristicNotFoundException( + 'Characteristic "$characteristicId" not available', + ), + ); + } +} diff --git a/lib/src/extensions/exports.dart b/lib/src/extensions/exports.dart new file mode 100644 index 0000000..8a9e561 --- /dev/null +++ b/lib/src/extensions/exports.dart @@ -0,0 +1,3 @@ +export 'package:universal_ble/src/extensions/ble_characteristic_extension.dart'; +export 'package:universal_ble/src/extensions/ble_service_extension.dart'; +export 'package:universal_ble/src/extensions/ble_device_extension.dart'; diff --git a/lib/src/models/ble_capabilities.dart b/lib/src/models/ble_capabilities.dart index 9b37657..7ffe8c4 100644 --- a/lib/src/models/ble_capabilities.dart +++ b/lib/src/models/ble_capabilities.dart @@ -8,8 +8,8 @@ class BleCapabilities { /// because they only support "Numeric Comparison" and "Passkey Entry". /// /// For more fine-grained control it is recommended to use `triggersConfirmOnlyPairing` - /// in conjunction with the pairing method of your peripheral, - /// e.g. if (!BleCapabilities.triggersConfirmOnlyPairing && peripheralUsesConfirmOnlyPairing) throw "In-app pairing not supported"; + /// in conjunction with the pairing method of your device, + /// e.g. if (!BleCapabilities.triggersConfirmOnlyPairing && deviceUsesConfirmOnlyPairing) throw "In-app pairing not supported"; static final bool supportsAllPairingKinds = triggersConfirmOnlyPairing || hasSystemPairingApi; diff --git a/lib/src/models/ble_device.dart b/lib/src/models/ble_device.dart index b471861..2d34cff 100644 --- a/lib/src/models/ble_device.dart +++ b/lib/src/models/ble_device.dart @@ -7,7 +7,9 @@ class BleDevice { String? name; String? rawName; int? rssi; - bool? isPaired; + bool? paired; + + /// List of services advertised by the device. List services; bool? isSystemDevice; List manufacturerDataList; @@ -32,7 +34,7 @@ class BleDevice { required this.deviceId, required String? name, this.rssi, - this.isPaired, + this.paired, this.services = const [], this.isSystemDevice, this.manufacturerDataList = const [], @@ -47,7 +49,7 @@ class BleDevice { 'deviceId: $deviceId, ' 'name: $name, ' 'rssi: $rssi, ' - 'isPaired: $isPaired, ' + 'paired: $paired, ' 'services: $services, ' 'isSystemDevice: $isSystemDevice, ' 'manufacturerDataList: $manufacturerDataList'; diff --git a/lib/src/models/ble_property.dart b/lib/src/models/ble_property.dart index df94343..d76ca32 100644 --- a/lib/src/models/ble_property.dart +++ b/lib/src/models/ble_property.dart @@ -2,17 +2,9 @@ enum BleInputProperty { disabled, notification, indication; - - const BleInputProperty(); - - factory BleInputProperty.parse(int index) => BleInputProperty.values[index]; } enum BleOutputProperty { withResponse, withoutResponse; - - const BleOutputProperty(); - - factory BleOutputProperty.parse(int index) => BleOutputProperty.values[index]; } diff --git a/lib/src/models/ble_service.dart b/lib/src/models/ble_service.dart index f844820..fd0fc3a 100644 --- a/lib/src/models/ble_service.dart +++ b/lib/src/models/ble_service.dart @@ -1,12 +1,13 @@ import 'package:universal_ble/universal_ble.dart'; class BleService { - late String uuid; + String uuid; List characteristics; - BleService(String uuid, this.characteristics) { - this.uuid = BleUuidParser.string(uuid); - } + BleService( + String uuid, + this.characteristics, + ) : uuid = BleUuidParser.string(uuid); @override String toString() { @@ -15,17 +16,43 @@ class BleService { } class BleCharacteristic { - late String uuid; + String uuid; List properties; + ({String deviceId, String serviceId})? metaData; - BleCharacteristic(String uuid, this.properties) { - this.uuid = BleUuidParser.string(uuid); - } + BleCharacteristic( + String uuid, + this.properties, + ) : uuid = BleUuidParser.string(uuid); + + BleCharacteristic.withMetaData({ + required String deviceId, + required String serviceId, + required String uuid, + required this.properties, + }) : uuid = BleUuidParser.string(uuid), + metaData = ( + deviceId: deviceId, + serviceId: BleUuidParser.string(serviceId), + ); @override String toString() { return 'BleCharacteristic{uuid: $uuid, properties: $properties}'; } + + @override + bool operator ==(Object other) { + if (other is! BleCharacteristic) return false; + if (other.uuid != uuid) return false; + if (other.properties != properties) return false; + if (other.metaData?.deviceId != metaData?.deviceId) return false; + if (other.metaData?.serviceId != metaData?.serviceId) return false; + return true; + } + + @override + int get hashCode => uuid.hashCode ^ properties.hashCode ^ metaData.hashCode; } enum CharacteristicProperty { diff --git a/lib/src/models/manufacturer_data_filter.dart b/lib/src/models/manufacturer_data_filter.dart index e1d68f0..9cff9ba 100644 --- a/lib/src/models/manufacturer_data_filter.dart +++ b/lib/src/models/manufacturer_data_filter.dart @@ -4,7 +4,7 @@ class ManufacturerDataFilter { /// Must be of integer type, in hex or decimal form (e.g. 0x004c or 76). int companyIdentifier; - /// Matches as prefix the peripheral's advertised data. + /// Matches as prefix the device's advertised data. Uint8List? payloadPrefix; /// For each bit in the mask, set it to 1 if it needs to match diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index bdf2145..895021f 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:universal_ble/src/ble_command_queue.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/universal_logger.dart'; +import 'package:universal_ble/src/utils/universal_logger.dart'; import 'package:universal_ble/universal_ble.dart'; class UniversalBle { @@ -71,12 +71,11 @@ class UniversalBle { ScanFilter? scanFilter, PlatformConfig? platformConfig, }) async { - return await _bleCommandQueue.queueCommand( + return await _bleCommandQueue.queueCommandWithoutTimeout( () => _platform.startScan( scanFilter: scanFilter, platformConfig: platformConfig, ), - withTimeout: false, ); } @@ -84,9 +83,8 @@ class UniversalBle { /// 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.queueCommand( + return await _bleCommandQueue.queueCommandWithoutTimeout( () => _platform.stopScan(), - withTimeout: false, ); } @@ -157,29 +155,54 @@ class UniversalBle { } /// Set a characteristic notifiable. - /// Set `bleInputProperty` to [BleInputProperty.notification] or [BleInputProperty.indication]. - /// Updates will arrive in [onValueChange] listener. - /// To stop listening to a characteristic, set `bleInputProperty` to [BleInputProperty.disabled]. - static Future setNotifiable( + /// Updates will arrive in [onValueChange] listener and [characteristicValueStream] + /// call [unsubscribe] to stop updates + static Future subscribeNotifications( 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, + 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 readValue( + static Future read( String deviceId, String service, String characteristic, { @@ -198,21 +221,23 @@ class UniversalBle { } /// Write a characteristic value. - /// To write a characteristic value with response, set `bleOutputProperty` to [BleOutputProperty.withResponse]. - static Future writeValue( + /// To write a characteristic value without response, set [withoutResponse] to [true]. + static Future write( String deviceId, String service, String characteristic, - Uint8List value, - BleOutputProperty bleOutputProperty, - ) async { + Uint8List value, { + bool withoutResponse = false, + }) async { await _bleCommandQueue.queueCommand( () => _platform.writeValue( deviceId, BleUuidParser.string(service), BleUuidParser.string(characteristic), value, - bleOutputProperty, + withoutResponse + ? BleOutputProperty.withoutResponse + : BleOutputProperty.withResponse, ), deviceId: deviceId, ); @@ -367,6 +392,66 @@ class UniversalBle { } } + @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, { @@ -407,7 +492,7 @@ class UniversalBle { for (BleCharacteristic characteristic in service.characteristics) { if (characteristic.properties.contains(CharacteristicProperty.read)) { containsReadCharacteristics = true; - await readValue( + await read( deviceId, service.uuid, characteristic.uuid, @@ -447,12 +532,12 @@ class UniversalBle { } // Check if BleCommand Supports Read or Write - BleOutputProperty? bleOutputProperty; + bool? withoutResponse; if (characteristic.properties.contains(CharacteristicProperty.write)) { - bleOutputProperty = BleOutputProperty.withResponse; + withoutResponse = false; } else if (characteristic.properties .contains(CharacteristicProperty.writeWithoutResponse)) { - bleOutputProperty = BleOutputProperty.withoutResponse; + withoutResponse = true; } else if (!characteristic.properties .contains(CharacteristicProperty.read)) { throw PairingException( @@ -463,17 +548,17 @@ class UniversalBle { Uint8List? value = bleCommand.writeValue; try { - if (value != null && bleOutputProperty != null) { - await writeValue( + if (value != null && withoutResponse != null) { + await write( deviceId, bleCommand.service, bleCommand.characteristic, value, - bleOutputProperty, + withoutResponse: withoutResponse, ); } else { // Fallback to read if supported - await readValue( + await read( deviceId, bleCommand.service, bleCommand.characteristic, @@ -497,7 +582,7 @@ class UniversalBle { static set onConnectionChange(OnConnectionChange? onConnectionChange) => _platform.onConnectionChange = onConnectionChange; - /// Get characteristic value updates, set `bleInputProperty` in [setNotifiable] to [BleInputProperty.notification] or [BleInputProperty.indication]. + /// Get characteristic value updates, after calling [subscribeNotifications] or [subscribeIndications] static set onValueChange(OnValueChange? onValueChange) => _platform.onValueChange = onValueChange; diff --git a/lib/src/universal_ble_exceptions.dart b/lib/src/universal_ble_exceptions.dart index dc3fd3c..9a360d7 100644 --- a/lib/src/universal_ble_exceptions.dart +++ b/lib/src/universal_ble_exceptions.dart @@ -30,6 +30,24 @@ class WebBluetoothGloballyDisabled implements Exception { String toString() => message; } +class NotFoundException implements Exception {} + +class ServiceNotFoundException implements NotFoundException { + String message; + ServiceNotFoundException(this.message); + + @override + String toString() => message; +} + +class CharacteristicNotFoundException implements NotFoundException { + String message; + CharacteristicNotFoundException(this.message); + + @override + String toString() => message; +} + String _errorParser(dynamic error) { if (error == null) { return "Failed"; diff --git a/lib/src/universal_ble_linux/universal_ble_linux.dart b/lib/src/universal_ble_linux/universal_ble_linux.dart index 7a88c39..e854b60 100644 --- a/lib/src/universal_ble_linux/universal_ble_linux.dart +++ b/lib/src/universal_ble_linux/universal_ble_linux.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:bluez/bluez.dart'; import 'package:flutter/services.dart'; import 'package:universal_ble/src/models/model_exports.dart'; -import 'package:universal_ble/src/universal_ble_filter_util.dart'; +import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; import 'package:universal_ble/src/universal_ble_platform_interface.dart'; -import 'package:universal_ble/src/universal_logger.dart'; +import 'package:universal_ble/src/utils/universal_logger.dart'; class UniversalBleLinux extends UniversalBlePlatform { UniversalBleLinux._(); @@ -185,14 +185,23 @@ class UniversalBleLinux extends UniversalBlePlatform { List services = []; for (final service in device.gattServices) { + String serviceId = service.uuid.toString(); + final characteristics = service.characteristics.map((e) { final properties = List.from(e.flags .map((e) => e.toCharacteristicProperty()) .where((element) => element != null) .toList()); - return BleCharacteristic(e.uuid.toString(), properties); + return BleCharacteristic.withMetaData( + deviceId: deviceId, + serviceId: serviceId, + uuid: e.uuid.toString(), + properties: properties, + ); }).toList(); - services.add(BleService(service.uuid.toString(), characteristics)); + services.add( + BleService(serviceId, characteristics), + ); } return services; } @@ -604,7 +613,7 @@ extension BlueZDeviceExtension on BlueZDevice { return BleDevice( name: name, deviceId: address, - isPaired: paired, + paired: paired, rssi: rssi, isSystemDevice: isSystemDevice, services: uuids.map((e) => e.toString()).toList(), diff --git a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart index 9071a18..1a5fd86 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart @@ -69,7 +69,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform { await _channel.discoverServices(deviceId); return List.from(universalBleServices .where((e) => e != null) - .map((e) => e!.toBleService()) + .map((e) => e!.toBleService(deviceId)) .toList()); } @@ -169,14 +169,16 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform { } extension _BleServiceExtension on UniversalBleService { - BleService toBleService() { + BleService toBleService(String deviceId) { List bleCharacteristics = []; for (UniversalBleCharacteristic? characteristic in characteristics ?? []) { if (characteristic == null) continue; List? properties = List.from(characteristic.properties); - bleCharacteristics.add(BleCharacteristic( - characteristic.uuid, - List.from( + bleCharacteristics.add(BleCharacteristic.withMetaData( + deviceId: deviceId, + serviceId: uuid, + uuid: characteristic.uuid, + properties: List.from( properties.map((e) => CharacteristicProperty.parse(e ?? 1)), ), )); @@ -228,7 +230,7 @@ extension _UniversalBleScanResultExtension on UniversalBleScanResult { name: name, deviceId: deviceId, rssi: rssi, - isPaired: isPaired, + paired: isPaired, isSystemDevice: isSystemDevice, services: services?.map(BleUuidParser.string).toList() ?? [], manufacturerDataList: manufacturerDataList diff --git a/lib/src/universal_ble_platform_interface.dart b/lib/src/universal_ble_platform_interface.dart index 91017ab..532ebfd 100644 --- a/lib/src/universal_ble_platform_interface.dart +++ b/lib/src/universal_ble_platform_interface.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:universal_ble/src/universal_ble_stream_controller.dart'; +import 'package:universal_ble/src/utils/cache_handler.dart'; +import 'package:universal_ble/src/utils/universal_ble_stream_controller.dart'; import 'package:universal_ble/universal_ble.dart'; abstract class UniversalBlePlatform { @@ -122,6 +123,10 @@ abstract class UniversalBlePlatform { try { onConnectionChange?.call(deviceId, isConnected, error); } catch (_) {} + + if (!isConnected) { + CacheHandler.instance.resetDeviceCache(deviceId); + } } void updateCharacteristicValue( diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index 4749cf3..ec5e1e5 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -3,7 +3,7 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:flutter_web_bluetooth/flutter_web_bluetooth.dart'; -import 'package:universal_ble/src/universal_logger.dart'; +import 'package:universal_ble/src/utils/universal_logger.dart'; import 'package:universal_ble/universal_ble.dart'; class UniversalBleWeb extends UniversalBlePlatform { @@ -58,7 +58,9 @@ class UniversalBleWeb extends UniversalBlePlatform { @override Future> discoverServices(String deviceId) async => - (await _getServices(deviceId)).map((e) => e._bleService).toList(); + (await _getServices(deviceId)) + .map((e) => e._bleService(deviceId)) + .toList(); @override Future getBluetoothAvailabilityState() async { @@ -463,20 +465,24 @@ class _UniversalWebBluetoothService { return null; } - BleService get _bleService => BleService( + BleService _bleService(String deviceId) => BleService( service.uuid, characteristics.map((e) { - return BleCharacteristic(e.uuid, [ - if (e.properties.broadcast) CharacteristicProperty.broadcast, - if (e.properties.read) CharacteristicProperty.read, - if (e.properties.write) CharacteristicProperty.write, - if (e.properties.writeWithoutResponse) - CharacteristicProperty.writeWithoutResponse, - if (e.properties.notify) CharacteristicProperty.notify, - if (e.properties.indicate) CharacteristicProperty.indicate, - if (e.properties.authenticatedSignedWrites) - CharacteristicProperty.authenticatedSignedWrites, - ]); + return BleCharacteristic.withMetaData( + deviceId: deviceId, + serviceId: service.uuid, + uuid: e.uuid, + properties: [ + if (e.properties.broadcast) CharacteristicProperty.broadcast, + if (e.properties.read) CharacteristicProperty.read, + if (e.properties.write) CharacteristicProperty.write, + if (e.properties.writeWithoutResponse) + CharacteristicProperty.writeWithoutResponse, + if (e.properties.notify) CharacteristicProperty.notify, + if (e.properties.indicate) CharacteristicProperty.indicate, + if (e.properties.authenticatedSignedWrites) + CharacteristicProperty.authenticatedSignedWrites, + ]); }).toList(), ); } diff --git a/lib/src/ble_command_queue.dart b/lib/src/utils/ble_command_queue.dart similarity index 57% rename from lib/src/ble_command_queue.dart rename to lib/src/utils/ble_command_queue.dart index 2d5449b..2fad9a1 100644 --- a/lib/src/ble_command_queue.dart +++ b/lib/src/utils/ble_command_queue.dart @@ -13,16 +13,28 @@ class BleCommandQueue { Future queueCommand( Future Function() command, { - bool withTimeout = true, String? deviceId, Duration? timeout, }) { - Duration? duration = timeout ?? (withTimeout ? this.timeout : null); + Duration? timeoutDuration = timeout ?? this.timeout; + if (timeoutDuration == null) { + return queueCommandWithoutTimeout(command, deviceId: deviceId); + } return switch (queueType) { - QueueType.global => _queue().add(command, duration), - QueueType.perDevice => _queue(deviceId).add(command, duration), - QueueType.none => - duration != null ? command().timeout(duration) : command(), + QueueType.global => _queue().add(command, timeoutDuration), + QueueType.perDevice => _queue(deviceId).add(command, timeoutDuration), + QueueType.none => command().timeout(timeoutDuration), + }; + } + + Future queueCommandWithoutTimeout( + Future Function() command, { + String? deviceId, + }) { + return switch (queueType) { + QueueType.global => _queue().add(command), + QueueType.perDevice => _queue(deviceId).add(command), + QueueType.none => command(), }; } diff --git a/lib/src/utils/cache_handler.dart b/lib/src/utils/cache_handler.dart new file mode 100644 index 0000000..8cef839 --- /dev/null +++ b/lib/src/utils/cache_handler.dart @@ -0,0 +1,28 @@ +import 'package:universal_ble/src/models/model_exports.dart'; + +/// Manages an in-memory cache for Bluetooth devices +class CacheHandler { + static CacheHandler? _instance; + static CacheHandler get instance => _instance ??= CacheHandler._(); + CacheHandler._(); + + /// Internal cache to store discovered services for each device. + final Map> _servicesCache = {}; + + /// Saves the discovered Bluetooth services for a specific device in the cache. + void saveServices(String deviceId, List? services) { + if (services == null) { + _servicesCache.remove(deviceId); + } else { + _servicesCache[deviceId] = services; + } + } + + /// Retrieves the cached Bluetooth services for a specific device. + List? getServices(String deviceId) => _servicesCache[deviceId]; + + /// Resets the cache for a specific device, removing all stored services. + void resetDeviceCache(String deviceId) { + _servicesCache.remove(deviceId); + } +} diff --git a/lib/src/universal_ble_filter_util.dart b/lib/src/utils/universal_ble_filter_util.dart similarity index 100% rename from lib/src/universal_ble_filter_util.dart rename to lib/src/utils/universal_ble_filter_util.dart diff --git a/lib/src/universal_ble_stream_controller.dart b/lib/src/utils/universal_ble_stream_controller.dart similarity index 100% rename from lib/src/universal_ble_stream_controller.dart rename to lib/src/utils/universal_ble_stream_controller.dart diff --git a/lib/src/universal_logger.dart b/lib/src/utils/universal_logger.dart similarity index 100% rename from lib/src/universal_logger.dart rename to lib/src/utils/universal_logger.dart diff --git a/lib/universal_ble.dart b/lib/universal_ble.dart index b49e23b..3c3d40c 100644 --- a/lib/universal_ble.dart +++ b/lib/universal_ble.dart @@ -4,3 +4,4 @@ export 'package:universal_ble/src/universal_ble_exceptions.dart'; export 'package:universal_ble/src/universal_ble_platform_interface.dart'; export 'package:universal_ble/src/universal_ble.dart'; export 'package:universal_ble/src/models/model_exports.dart'; +export 'package:universal_ble/src/extensions/exports.dart'; diff --git a/test/manufacturer_data_filter_test.dart b/test/manufacturer_data_filter_test.dart index a5265dc..a84cd44 100644 --- a/test/manufacturer_data_filter_test.dart +++ b/test/manufacturer_data_filter_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:universal_ble/src/universal_ble_filter_util.dart'; +import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; import 'dart:typed_data'; import 'package:universal_ble/universal_ble.dart'; diff --git a/test/scan_filter_test.dart b/test/scan_filter_test.dart index de27b07..754d64d 100644 --- a/test/scan_filter_test.dart +++ b/test/scan_filter_test.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; -import 'package:universal_ble/src/universal_ble_filter_util.dart'; +import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; import 'package:universal_ble/universal_ble.dart'; void main() { diff --git a/test/universal_ble_stream_controller_test.dart b/test/universal_ble_stream_controller_test.dart index c667338..f710df8 100644 --- a/test/universal_ble_stream_controller_test.dart +++ b/test/universal_ble_stream_controller_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:universal_ble/src/universal_ble_stream_controller.dart'; +import 'package:universal_ble/src/utils/universal_ble_stream_controller.dart'; void main() { group("Test UniversalBleStreamController", () {