Receive advertisement events on web (#63)

* Fix web manufacturer discovery

* Minor Fixes

* Cleanups

* Add canWatchAdvertisements api

* Improve Cleanups

* Improve code level documentation

* Improve documentation

---------

Co-authored-by: Foti Dim <fdimanidis@gmail.com>
This commit is contained in:
Rohit Sangwan
2024-07-19 14:09:49 +05:30
committed by GitHub
parent c0c678c735
commit 0d6b612497
11 changed files with 136 additions and 67 deletions
+30 -1
View File
@@ -19,6 +19,35 @@
"--dart-define", "--dart-define",
"MOCK=true", "MOCK=true",
] ]
},
{
"name": "example_web",
"cwd": "example",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"-d",
"web-server",
"--web-hostname=127.0.0.1",
"--web-port=8080"
],
"preLaunchTask": "open_chrome"
},
],
"tasks": [
{
"label": "open_chrome",
"type": "shell",
"command": "open",
"args": [
"-na",
"Google Chrome",
"--args",
"--user-data-dir=/tmp/temporary-chrome-profile-dir",
"--disable-web-security",
"--disable-site-isolation-trials"
],
} }
] ],
} }
+2
View File
@@ -1,5 +1,7 @@
## 0.11.1 ## 0.11.1
* Trim spaces in UUIDs * Trim spaces in UUIDs
* Receive advertisement events on web
* Improve cleanup after disconnection on web
## 0.11.0 ## 0.11.0
* Unify UUID format across all platforms, 128-bit lowercase * Unify UUID format across all platforms, 128-bit lowercase
+2 -2
View File
@@ -115,7 +115,7 @@ You can optionally set filters when scanning.
##### With Services ##### With Services
When setting this parameter, the scan results will only include devices that advertize any of the specified services. This is the primary filter. All devices are first filtered by services, then further filtered by other criteria. This parameter is mandatory on [web](#web) if you want to access those services. When setting this parameter, the scan results will only include devices that advertise any of the specified services. This is the primary filter. All devices are first filtered by services, then further filtered by other criteria. This parameter is mandatory on [web](#web) if you want to access those services.
```dart ```dart
List<String> withServices; List<String> withServices;
@@ -328,7 +328,7 @@ When publishing on Windows you need to declare the following [capabilities](http
### Web ### Web
On web, the `withServices` parameter in the ScanFilter is used as [optional_services](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice#optionalservices) as well as a services filter. On web you have to set this parameter to ensure that you can access the specified services after connecting to the device. You can leave it empty for the rest of the platforms if your device does not advertize services. On web, the `withServices` parameter in the ScanFilter is used as [optional_services](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice#optionalservices) as well as a services filter. On web you have to set this parameter to ensure that you can access the specified services after connecting to the device. You can leave it empty for the rest of the platforms if your device does not advertise services.
```dart ```dart
ScanFilter( ScanFilter(
+3 -2
View File
@@ -1,5 +1,7 @@
// ignore_for_file: use_build_context_synchronously // ignore_for_file: use_build_context_synchronously
import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart'; import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/capabilities.dart'; import 'package:universal_ble_example/data/capabilities.dart';
@@ -50,8 +52,7 @@ class _MyAppState extends State<MyApp> {
}; };
UniversalBle.onScanResult = (result) { UniversalBle.onScanResult = (result) {
// debugPrint("BleDevice: ${result.name} ${result.services}"); log(result.toString());
// debugPrint("${result.name} ${result.manufacturerData}");
int index = _bleDevices.indexWhere((e) => e.deviceId == result.deviceId); int index = _bleDevices.indexWhere((e) => e.deviceId == result.deviceId);
if (index == -1) { if (index == -1) {
_bleDevices.add(result); _bleDevices.add(result);
@@ -74,6 +74,7 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
ScanFilter( ScanFilter(
withServices: serviceUUids, withServices: serviceUUids,
withNamePrefix: namePrefixes, withNamePrefix: namePrefixes,
withManufacturerData: manufacturerDataFilters,
), ),
); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -93,16 +93,20 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
} }
Future<void> _discoverServices() async { Future<void> _discoverServices() async {
var services = await UniversalBle.discoverServices(widget.deviceId); try {
print('${services.length} services discovered'); var services = await UniversalBle.discoverServices(widget.deviceId);
discoveredServices.clear(); print('${services.length} services discovered');
setState(() { discoveredServices.clear();
discoveredServices = services; setState(() {
}); discoveredServices = services;
});
if (kIsWeb) { if (kIsWeb) {
_addLog("DiscoverServices", _addLog("DiscoverServices",
'${services.length} services discovered,\nNote: Only services added in ScanFilter will be discovered'); '${services.length} services discovered,\nNote: Only services added in ScanFilter will be discovered');
}
} catch (e) {
_addLog("DiscoverServicesError", e);
} }
} }
+1 -1
View File
@@ -402,7 +402,7 @@ packages:
path: ".." path: ".."
relative: true relative: true
source: path source: path
version: "0.11.0" version: "0.11.1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
+8 -3
View File
@@ -12,12 +12,17 @@ class BleDevice {
Uint8List? manufacturerDataHead; Uint8List? manufacturerDataHead;
Uint8List? manufacturerData; Uint8List? manufacturerData;
/// Returns connection state of device, /// Returns connection state of the device.
/// All platforms will return `Connected/Disconnected` states /// All platforms will return `Connected/Disconnected` states.
/// `Android` and `Apple` can also return `Connecting/Disconnecting` states /// `Android` and `Apple` can also return `Connecting/Disconnecting` states.
Future<BleConnectionState> get connectionState => Future<BleConnectionState> get connectionState =>
UniversalBle.getConnectionState(deviceId); UniversalBle.getConnectionState(deviceId);
/// On web, it returns true if the web browser supports receiving advertisements from this device.
/// The rest of the platforms will always return true.
bool get receivesAdvertisements =>
UniversalBle.receivesAdvertisements(deviceId);
BleDevice({ BleDevice({
required this.deviceId, required this.deviceId,
required this.name, required this.name,
+13 -3
View File
@@ -207,9 +207,9 @@ class UniversalBle {
); );
} }
/// Returns connection state of device, /// Returns connection state of the device.
/// All platforms will return `Connected/Disconnected` states /// All platforms will return `Connected/Disconnected` states.
/// `Android` and `Apple` can also return `Connecting/Disconnecting` states /// `Android` and `Apple` can also return `Connecting/Disconnecting` states.
static Future<BleConnectionState> getConnectionState(String deviceId) async { static Future<BleConnectionState> getConnectionState(String deviceId) async {
return await _bleCommandQueue.queueCommand( return await _bleCommandQueue.queueCommand(
() => _platform.getConnectionState(deviceId), () => _platform.getConnectionState(deviceId),
@@ -225,6 +225,16 @@ class UniversalBle {
); );
} }
/// [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 /// Get Bluetooth state availability
static set onAvailabilityChange(OnAvailabilityChange? onAvailabilityChange) { static set onAvailabilityChange(OnAvailabilityChange? onAvailabilityChange) {
_platform.onAvailabilityChange = onAvailabilityChange; _platform.onAvailabilityChange = onAvailabilityChange;
@@ -50,7 +50,6 @@ abstract class UniversalBlePlatform {
List<String>? withServices, List<String>? withServices,
); );
/// `onScanResult` interceptor to filter by name
void updateScanResult(BleDevice bleDevice) { void updateScanResult(BleDevice bleDevice) {
// Filter by name // Filter by name
ScanFilter? scanFilter = _scanFilter; ScanFilter? scanFilter = _scanFilter;
@@ -62,7 +61,8 @@ abstract class UniversalBlePlatform {
onScanResult?.call(bleDevice); onScanResult?.call(bleDevice);
} }
/// `onValueChange` interceptor to parse the native uuids to 128 bit uuid, to keep consistency bool receivesAdvertisements(String deviceId) => true;
void updateCharacteristicValue( void updateCharacteristicValue(
String deviceId, String characteristicId, Uint8List value) { String deviceId, String characteristicId, Uint8List value) {
onValueChange?.call( onValueChange?.call(
@@ -135,46 +135,46 @@ class UniversalBleWeb extends UniversalBlePlatform {
// Update Scan Result // Update Scan Result
updateScanResult(device.toBleScanResult()); updateScanResult(device.toBleScanResult());
/// This will work only if `chrome://flags/#enable-experimental-web-platform-features` is enabled _watchDeviceAdvertisements(device);
if (FlutterWebBluetooth.instance.hasRequestLEScan) { }
// Check if platform can watch advertisements
if (device.hasWatchAdvertisements()) {
if (_deviceAdvertisementStreamList[device.id] == null) {
_deviceAdvertisementStreamList[device.id]?.cancel();
await device.unwatchAdvertisements();
}
_deviceAdvertisementStreamList[device.id] = @override
device.advertisements.listen((event) { bool receivesAdvertisements(String deviceId) =>
updateScanResult( _getDeviceById(deviceId)?.hasWatchAdvertisements() ?? false;
device.toBleScanResult(
rssi: event.rssi,
manufacturerDataMap: event.manufacturerData,
services: event.uuids,
),
);
});
await device.watchAdvertisements(); /// This will work only if `chrome://flags/#enable-experimental-web-platform-features` is enabled
Future<void> _watchDeviceAdvertisements(BluetoothDevice device) async {
try {
if (!device.hasWatchAdvertisements()) return;
if (_deviceAdvertisementStreamList[device.id] != null) {
_deviceAdvertisementStreamList[device.id]?.cancel();
await device.unwatchAdvertisements();
} }
_deviceAdvertisementStreamList[device.id] =
device.advertisements.listen((event) {
updateScanResult(
device.toBleScanResult(
rssi: event.rssi,
manufacturerDataMap: event.manufacturerData,
services: event.uuids,
),
);
});
device.advertisementsUseMemory = true;
await device.watchAdvertisements();
} catch (e) {
UniversalBlePlatform.logInfo(
"WebWatchAdvertisementError: $e",
isError: true,
);
} }
} }
@override @override
Future<void> stopScan() async { Future<void> stopScan() async {
// Cancel advertisement streams _disposeAdvertisementWatcher();
if (FlutterWebBluetooth.instance.hasRequestLEScan) {
_deviceAdvertisementStreamList.removeWhere((key, value) {
value.cancel();
return true;
});
for (var element in _bluetoothDeviceList.entries) {
if (element.value.hasWatchAdvertisements()) {
element.value.unwatchAdvertisements();
}
}
}
} }
@override @override
@@ -328,6 +328,7 @@ class UniversalBleWeb extends UniversalBlePlatform {
if (key.contains(deviceId)) value.cancel(); if (key.contains(deviceId)) value.cancel();
return key.contains(deviceId); return key.contains(deviceId);
}); });
_disposeAdvertisementWatcher(deviceId);
// _bluetoothDeviceList.removeWhere((element) => element.id == deviceId); // _bluetoothDeviceList.removeWhere((element) => element.id == deviceId);
} }
@@ -350,6 +351,17 @@ class UniversalBleWeb extends UniversalBlePlatform {
BluetoothDevice? _getDeviceById(String id) => _bluetoothDeviceList[id]; BluetoothDevice? _getDeviceById(String id) => _bluetoothDeviceList[id];
void _disposeAdvertisementWatcher([String? deviceId]) {
_deviceAdvertisementStreamList.removeWhere((key, value) {
if (deviceId != null && key != deviceId) return false;
value.cancel();
_getDeviceById(deviceId ?? key)
?.unwatchAdvertisements()
.onError((_, __) {});
return true;
});
}
@override @override
Future<bool> enableBluetooth() { Future<bool> enableBluetooth() {
throw UnimplementedError(); throw UnimplementedError();
@@ -374,24 +386,24 @@ extension _BluetoothDeviceExtension on BluetoothDevice {
} }
extension _UnmodifiableMapViewExtension on UnmodifiableMapView<int, ByteData> { extension _UnmodifiableMapViewExtension on UnmodifiableMapView<int, ByteData> {
Uint8List toUint8List() { Uint8List? toUint8List() {
int totalLength = List<MapEntry<int, ByteData>> sorted = entries.toList()
values.fold<int>(0, (prev, element) => prev + element.lengthInBytes); ..sort((a, b) => a.key - b.key);
Uint8List result = Uint8List(totalLength); if (sorted.isEmpty) return null;
int offset = 0; int companyId = sorted.first.key;
for (var entry in entries) { List<int> manufacturerDataValue = sorted.first.value.buffer.asUint8List();
var byteData = entry.value; final byteData = ByteData(2);
var sublist = byteData.buffer.asUint8List(); byteData.setInt16(0, companyId, Endian.host);
result.setRange(offset, offset + sublist.length, sublist); List<int> bytes = byteData.buffer.asUint8List();
offset += sublist.length; return Uint8List.fromList(bytes + manufacturerDataValue);
}
return result;
} }
} }
extension ScanFilterExtension on ScanFilter { extension ScanFilterExtension on ScanFilter {
RequestOptionsBuilder toRequestOptionsBuilder() { RequestOptionsBuilder toRequestOptionsBuilder() {
List<RequestFilterBuilder> filters = []; List<RequestFilterBuilder> filters = [];
List<int> manufacturerCompanyIdentifiers = [];
// Add services filter // Add services filter
for (var service in withServices.toValidUUIDList()) { for (var service in withServices.toValidUUIDList()) {
filters.add( filters.add(
@@ -414,6 +426,10 @@ extension ScanFilterExtension on ScanFilter {
], ],
), ),
); );
int? companyId = manufacturerData.companyIdentifier;
if (companyId != null) {
manufacturerCompanyIdentifiers.add(companyId);
}
} }
// Add name filter // Add name filter
@@ -430,6 +446,7 @@ extension ScanFilterExtension on ScanFilter {
return RequestOptionsBuilder( return RequestOptionsBuilder(
filters, filters,
optionalServices: withServices.toValidUUIDList(), optionalServices: withServices.toValidUUIDList(),
optionalManufacturerData: manufacturerCompanyIdentifiers,
); );
} }
} }