606 lines
19 KiB
Dart
606 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_web_bluetooth/flutter_web_bluetooth.dart';
|
|
import 'package:universal_ble/src/utils/universal_logger.dart';
|
|
import 'package:universal_ble/src/universal_ble_pigeon/universal_ble.g.dart';
|
|
import 'package:universal_ble/universal_ble.dart';
|
|
|
|
class UniversalBleWeb extends UniversalBlePlatform {
|
|
static UniversalBleWeb? _instance;
|
|
static UniversalBleWeb get instance => _instance ??= UniversalBleWeb._();
|
|
|
|
UniversalBleWeb._() {
|
|
_setupListeners();
|
|
}
|
|
|
|
final Map<String, BluetoothDevice> _bluetoothDeviceList = {};
|
|
final Map<String, StreamSubscription> _deviceAdvertisementStreamList = {};
|
|
final Map<String, StreamSubscription> _connectedDeviceStreamList = {};
|
|
final Map<String, StreamSubscription> _characteristicStreamList = {};
|
|
final Map<String, List<_UniversalWebBluetoothService>> _serviceCache = {};
|
|
bool _isScanning = false;
|
|
|
|
@override
|
|
Future<BleConnectionState> getConnectionState(String deviceId) async {
|
|
BluetoothDevice? device = _getDeviceById(deviceId);
|
|
bool connected = await device?.connected.first ?? false;
|
|
return connected
|
|
? BleConnectionState.connected
|
|
: BleConnectionState.disconnected;
|
|
}
|
|
|
|
@override
|
|
Future<void> connect(
|
|
String deviceId, {
|
|
Duration? connectionTimeout = const Duration(seconds: 10),
|
|
}) async {
|
|
var device = _getDeviceById(deviceId);
|
|
if (device == null) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.deviceNotFound,
|
|
message: "$deviceId Not Found",
|
|
);
|
|
}
|
|
await device.connect(timeout: connectionTimeout);
|
|
|
|
// Subscribe to Connection Stream
|
|
if (_connectedDeviceStreamList[deviceId] != null) {
|
|
_connectedDeviceStreamList[deviceId]?.cancel();
|
|
}
|
|
|
|
_connectedDeviceStreamList[deviceId] = device.connected.listen((event) {
|
|
if (!event) _cleanConnection(deviceId);
|
|
updateConnection(deviceId, event);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> disconnect(String deviceId) async {
|
|
_getDeviceById(deviceId)?.disconnect();
|
|
}
|
|
|
|
@override
|
|
Future<List<BleService>> discoverServices(
|
|
String deviceId, bool withDescriptors) async {
|
|
List<BleService> services = [];
|
|
for (var service in await _getServices(deviceId)) {
|
|
services.add(await service._toBleService(deviceId, withDescriptors));
|
|
}
|
|
return services;
|
|
}
|
|
|
|
@override
|
|
Future<AvailabilityState> getBluetoothAvailabilityState() async {
|
|
bool isSupported = FlutterWebBluetooth.instance.isBluetoothApiSupported;
|
|
if (!isSupported) return AvailabilityState.unsupported;
|
|
bool isAvailable = await FlutterWebBluetooth.instance.getAvailability();
|
|
if (isAvailable) return AvailabilityState.poweredOn;
|
|
return AvailabilityState.unknown;
|
|
}
|
|
|
|
@override
|
|
Future<void> startScan({
|
|
ScanFilter? scanFilter,
|
|
PlatformConfig? platformConfig,
|
|
}) async {
|
|
try {
|
|
_isScanning = true;
|
|
FlutterWebBluetooth.instance.isAvailable;
|
|
BluetoothDevice device = await FlutterWebBluetooth.instance.requestDevice(
|
|
_getRequestOptionBuilder(scanFilter, platformConfig?.web),
|
|
);
|
|
|
|
// Update local device list
|
|
_bluetoothDeviceList[device.id] = device;
|
|
|
|
// Update Scan Result
|
|
updateScanResult(device.toBleScanResult());
|
|
|
|
_watchDeviceAdvertisements(device);
|
|
} catch (e) {
|
|
String error = e.toString().replaceAll("DeviceNotFoundError:", "").trim();
|
|
if (error.toLowerCase().contains("api globally disabled")) {
|
|
throw WebBluetoothGloballyDisabled(message: error);
|
|
}
|
|
rethrow;
|
|
} finally {
|
|
_isScanning = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool receivesAdvertisements(String deviceId) {
|
|
// Advertisements do not work on Linux/Web even with the "Experimental Web Platform features" flag enabled. Verified with Chrome Version 128.0.6613.138
|
|
if (kIsWeb && defaultTargetPlatform == TargetPlatform.linux) {
|
|
return false;
|
|
}
|
|
|
|
return _getDeviceById(deviceId)?.hasWatchAdvertisements() ?? false;
|
|
}
|
|
|
|
/// 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.toSet().toList(),
|
|
),
|
|
);
|
|
});
|
|
device.advertisementsUseMemory = true;
|
|
await device.watchAdvertisements();
|
|
} catch (e) {
|
|
UniversalLogger.logError("WebWatchAdvertisementError: $e");
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> stopScan() async {
|
|
_disposeAdvertisementWatcher();
|
|
}
|
|
|
|
@override
|
|
Future<bool> isScanning() async => _isScanning;
|
|
|
|
@override
|
|
Future<void> setNotifiable(
|
|
String deviceId,
|
|
String service,
|
|
String characteristic,
|
|
BleInputProperty bleInputProperty,
|
|
) async {
|
|
UniversalLogger.logDebug(
|
|
"SET_NOTIFY -> $deviceId $service $characteristic input=${bleInputProperty.name}",
|
|
withTimestamp: true,
|
|
);
|
|
final bleCharacteristic = await _getBleCharacteristic(
|
|
deviceId: deviceId,
|
|
serviceId: service,
|
|
characteristicId: characteristic,
|
|
);
|
|
|
|
if (bleCharacteristic == null) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.characteristicNotFound,
|
|
message:
|
|
'Characteristic $characteristic for service $service not found',
|
|
);
|
|
}
|
|
|
|
String characteristicKey = "${deviceId}_${service}_$characteristic";
|
|
|
|
if (bleInputProperty != BleInputProperty.disabled) {
|
|
if (_characteristicStreamList[characteristicKey] != null) {
|
|
_characteristicStreamList[characteristicKey]?.cancel();
|
|
}
|
|
await bleCharacteristic.startNotifications();
|
|
_characteristicStreamList[characteristicKey] =
|
|
bleCharacteristic.value.listen((ByteData event) {
|
|
final preview = event.buffer
|
|
.asUint8List()
|
|
.take(8)
|
|
.map((e) => e.toRadixString(16).padLeft(2, '0'))
|
|
.join();
|
|
UniversalLogger.logVerbose(
|
|
"NOTIFY <- $deviceId $characteristic len=${event.lengthInBytes} data=$preview",
|
|
withTimestamp: true,
|
|
);
|
|
updateCharacteristicValue(
|
|
deviceId,
|
|
characteristic,
|
|
event.buffer.asUint8List(),
|
|
DateTime.now().millisecondsSinceEpoch,
|
|
);
|
|
});
|
|
} else {
|
|
await bleCharacteristic.stopNotifications();
|
|
_characteristicStreamList.remove(characteristicKey)?.cancel();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> writeValue(
|
|
String deviceId,
|
|
String service,
|
|
String characteristic,
|
|
Uint8List value,
|
|
BleOutputProperty bleOutputProperty,
|
|
) async {
|
|
UniversalLogger.logDebug(
|
|
"WRITE -> $deviceId $service $characteristic len=${value.length} property=${bleOutputProperty.name}",
|
|
withTimestamp: true,
|
|
);
|
|
final bleCharacteristic = await _getBleCharacteristic(
|
|
deviceId: deviceId,
|
|
serviceId: service,
|
|
characteristicId: characteristic,
|
|
);
|
|
|
|
if (bleCharacteristic == null) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.characteristicNotFound,
|
|
message:
|
|
'Characteristic $characteristic for service $service not found',
|
|
);
|
|
}
|
|
|
|
if (bleOutputProperty == BleOutputProperty.withResponse) {
|
|
await bleCharacteristic.writeValueWithResponse(Uint8List.fromList(value));
|
|
} else {
|
|
await bleCharacteristic
|
|
.writeValueWithoutResponse(Uint8List.fromList(value));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Uint8List> readValue(
|
|
String deviceId,
|
|
String service,
|
|
String characteristic, {
|
|
final Duration? timeout,
|
|
}) async {
|
|
UniversalLogger.logDebug(
|
|
"READ -> $deviceId $service $characteristic",
|
|
withTimestamp: true,
|
|
);
|
|
var bleCharacteristic = await _getBleCharacteristic(
|
|
deviceId: deviceId,
|
|
serviceId: service,
|
|
characteristicId: characteristic,
|
|
);
|
|
if (bleCharacteristic == null) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.characteristicNotFound,
|
|
message:
|
|
'Characteristic $characteristic for service $service not found',
|
|
);
|
|
}
|
|
var data = timeout != null
|
|
? bleCharacteristic.readValue(timeout: timeout)
|
|
: bleCharacteristic.readValue();
|
|
return (await data).buffer.asUint8List();
|
|
}
|
|
|
|
/// `Unimplemented`
|
|
@override
|
|
Future<int> requestMtu(String deviceId, int expectedMtu) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "requestMtu is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
/// `Unimplemented`
|
|
@override
|
|
Future<int> readRssi(String deviceId) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "readRssi is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<bool> isPaired(String deviceId) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "isPaired is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<bool> pair(String deviceId) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "pair is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> unpair(String deviceId) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "unpair is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<List<BleDevice>> getSystemDevices(
|
|
List<String>? withServices,
|
|
) {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "getSystemDevices is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
/// Helpers
|
|
void _setupListeners() {
|
|
FlutterWebBluetooth.instance.isAvailable.listen(
|
|
(bool isAvailable) {
|
|
AvailabilityState newState = AvailabilityState.unknown;
|
|
if (!FlutterWebBluetooth.instance.isBluetoothApiSupported) {
|
|
newState = AvailabilityState.unsupported;
|
|
} else if (FlutterWebBluetooth.instance.isBluetoothApiSupported &&
|
|
!isAvailable) {
|
|
newState = AvailabilityState.poweredOff;
|
|
} else if (isAvailable) {
|
|
newState = AvailabilityState.poweredOn;
|
|
}
|
|
updateAvailability(newState);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _cleanConnection(String deviceId) {
|
|
_connectedDeviceStreamList.removeWhere((key, value) {
|
|
if (key == deviceId) value.cancel();
|
|
return key == deviceId;
|
|
});
|
|
_characteristicStreamList.removeWhere((key, value) {
|
|
if (key.contains(deviceId)) value.cancel();
|
|
return key.contains(deviceId);
|
|
});
|
|
_disposeAdvertisementWatcher(deviceId);
|
|
_serviceCache.remove(deviceId);
|
|
// _bluetoothDeviceList.removeWhere((element) => element.id == deviceId);
|
|
}
|
|
|
|
Future<BluetoothCharacteristic?> _getBleCharacteristic({
|
|
required String deviceId,
|
|
required String serviceId,
|
|
required String characteristicId,
|
|
}) async {
|
|
for (var service in await _getServices(deviceId)) {
|
|
if (BleUuidParser.compareStrings(service.uuid, serviceId)) {
|
|
return service.getCharacteristic(characteristicId);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
BluetoothDevice? _getDeviceById(String id) => _bluetoothDeviceList[id];
|
|
|
|
/// Get services and their characteristics.
|
|
/// Services and characteristics are cached.
|
|
/// Clears cache on disconnection.
|
|
Future<List<_UniversalWebBluetoothService>> _getServices(
|
|
String deviceId,
|
|
) async {
|
|
BluetoothDevice? device = _getDeviceById(deviceId);
|
|
if (device == null) return [];
|
|
var services = _serviceCache[deviceId] ?? [];
|
|
if (services.isNotEmpty) return services;
|
|
for (var service in await device.discoverServices()) {
|
|
services.add(await _UniversalWebBluetoothService.fromService(service));
|
|
}
|
|
_serviceCache[deviceId] = services;
|
|
return services;
|
|
}
|
|
|
|
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
|
|
Future<bool> enableBluetooth() {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "enableBluetooth is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<bool> disableBluetooth() {
|
|
throw UniversalBleException(
|
|
code: UniversalBleErrorCode.notImplemented,
|
|
message: "disableBluetooth is not implemented on Web platform",
|
|
);
|
|
}
|
|
|
|
RequestOptionsBuilder _getRequestOptionBuilder(
|
|
ScanFilter? scanFilter,
|
|
WebOptions? webOptions,
|
|
) {
|
|
List<RequestFilterBuilder> filters = [];
|
|
List<RequestFilterBuilder> exclusionFilters = [];
|
|
List<int> optionalManufacturerData = [];
|
|
List<String> optionalServices = [];
|
|
|
|
if (webOptions != null) {
|
|
optionalServices.addAll(webOptions.optionalServices.toValidUUIDList());
|
|
optionalManufacturerData.addAll(webOptions.optionalManufacturerData);
|
|
}
|
|
|
|
if (scanFilter != null) {
|
|
// Add services filter
|
|
for (var service in scanFilter.withServices.toValidUUIDList()) {
|
|
filters.add(RequestFilterBuilder(services: [service]));
|
|
if (webOptions == null || webOptions.optionalServices.isEmpty) {
|
|
optionalServices.add(service);
|
|
}
|
|
}
|
|
|
|
// Add manufacturer data filter
|
|
for (var manufacturerData in scanFilter.withManufacturerData) {
|
|
filters.add(
|
|
RequestFilterBuilder(
|
|
manufacturerData: [
|
|
ManufacturerDataFilterBuilder(
|
|
companyIdentifier: manufacturerData.companyIdentifier,
|
|
dataPrefix: manufacturerData.payloadPrefix,
|
|
mask: manufacturerData.payloadMask,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Add optionalManufacturerData from scanFilter if webOptions is not provided
|
|
if (webOptions == null || webOptions.optionalManufacturerData.isEmpty) {
|
|
optionalManufacturerData.add(manufacturerData.companyIdentifier);
|
|
}
|
|
}
|
|
|
|
// Add name filter
|
|
for (var name in scanFilter.withNamePrefix) {
|
|
filters.add(RequestFilterBuilder(namePrefix: name));
|
|
}
|
|
|
|
// Add exclusion filters
|
|
for (var exclusionFilter in scanFilter.exclusionFilters) {
|
|
exclusionFilters.add(RequestFilterBuilder(
|
|
services: exclusionFilter.services.isEmpty
|
|
? null
|
|
: exclusionFilter.services.toValidUUIDList(),
|
|
namePrefix: exclusionFilter.namePrefix,
|
|
manufacturerData: exclusionFilter.manufacturerDataFilter.isEmpty
|
|
? null
|
|
: exclusionFilter.manufacturerDataFilter.map((e) {
|
|
return ManufacturerDataFilterBuilder(
|
|
companyIdentifier: e.companyIdentifier,
|
|
dataPrefix: e.payloadPrefix,
|
|
mask: e.payloadMask,
|
|
);
|
|
}).toList(),
|
|
));
|
|
}
|
|
}
|
|
|
|
if (optionalServices.isEmpty) {
|
|
UniversalLogger.logError(
|
|
"OptionalServices list is empty on web, you have to specify services in the ScanFilter in order to be able to access those after connecting",
|
|
);
|
|
}
|
|
if (filters.isEmpty && exclusionFilters.isNotEmpty) {
|
|
UniversalLogger.logError(
|
|
"Web platform requires inclusion filters when using exclusion filters. Please add withServices, withNamePrefix, or withManufacturerData filters.",
|
|
);
|
|
}
|
|
|
|
if (filters.isEmpty && exclusionFilters.isEmpty) {
|
|
return RequestOptionsBuilder.acceptAllDevices(
|
|
optionalServices: optionalServices,
|
|
optionalManufacturerData: optionalManufacturerData,
|
|
);
|
|
} else {
|
|
return RequestOptionsBuilder(
|
|
filters,
|
|
optionalServices: optionalServices,
|
|
optionalManufacturerData: optionalManufacturerData,
|
|
exclusionFilters: exclusionFilters.isEmpty ? null : exclusionFilters,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
extension _BluetoothDeviceExtension on BluetoothDevice {
|
|
BleDevice toBleScanResult({
|
|
int? rssi,
|
|
UnmodifiableMapView<int, ByteData>? manufacturerDataMap,
|
|
List<String> services = const [],
|
|
}) {
|
|
return BleDevice(
|
|
name: name,
|
|
deviceId: id,
|
|
manufacturerDataList: manufacturerDataMap?.toManufacturerDataList() ?? [],
|
|
rssi: rssi,
|
|
services: services,
|
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
|
);
|
|
}
|
|
}
|
|
|
|
extension _UnmodifiableMapViewExtension on UnmodifiableMapView<int, ByteData> {
|
|
List<ManufacturerData>? toManufacturerDataList() => entries
|
|
.map((MapEntry<int, ByteData> data) =>
|
|
ManufacturerData(data.key, data.value.buffer.asUint8List()))
|
|
.toList();
|
|
}
|
|
|
|
class _UniversalWebBluetoothService {
|
|
late String uuid;
|
|
BluetoothService service;
|
|
List<BluetoothCharacteristic> characteristics;
|
|
|
|
_UniversalWebBluetoothService({
|
|
required this.service,
|
|
required this.characteristics,
|
|
}) {
|
|
uuid = service.uuid;
|
|
}
|
|
|
|
static Future<_UniversalWebBluetoothService> fromService(
|
|
BluetoothService service,
|
|
) async {
|
|
return _UniversalWebBluetoothService(
|
|
service: service,
|
|
characteristics: await service.getCharacteristics(),
|
|
);
|
|
}
|
|
|
|
BluetoothCharacteristic? getCharacteristic(String characteristicId) {
|
|
for (var characteristic in characteristics) {
|
|
if (BleUuidParser.compareStrings(characteristic.uuid, characteristicId)) {
|
|
return characteristic;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<BleService> _toBleService(
|
|
String deviceId,
|
|
bool withDescriptors,
|
|
) async {
|
|
List<BleCharacteristic> bleCharacteristics = [];
|
|
for (var characteristic in characteristics) {
|
|
List<BleDescriptor> descriptors = [];
|
|
if (withDescriptors) {
|
|
try {
|
|
var bluetoothDescriptors = await characteristic.getDescriptors();
|
|
descriptors =
|
|
bluetoothDescriptors.map((e) => BleDescriptor(e.uuid)).toList();
|
|
} catch (_) {}
|
|
}
|
|
bleCharacteristics.add(BleCharacteristic.withMetaData(
|
|
deviceId: deviceId,
|
|
serviceId: service.uuid,
|
|
uuid: characteristic.uuid,
|
|
properties: [
|
|
if (characteristic.properties.broadcast)
|
|
CharacteristicProperty.broadcast,
|
|
if (characteristic.properties.read) CharacteristicProperty.read,
|
|
if (characteristic.properties.write) CharacteristicProperty.write,
|
|
if (characteristic.properties.writeWithoutResponse)
|
|
CharacteristicProperty.writeWithoutResponse,
|
|
if (characteristic.properties.notify) CharacteristicProperty.notify,
|
|
if (characteristic.properties.indicate)
|
|
CharacteristicProperty.indicate,
|
|
if (characteristic.properties.authenticatedSignedWrites)
|
|
CharacteristicProperty.authenticatedSignedWrites,
|
|
],
|
|
descriptors: descriptors,
|
|
));
|
|
}
|
|
return BleService(service.uuid, bleCharacteristics);
|
|
}
|
|
}
|