From 5b9dfe25da49d9e954524cabefaf7c7ba79a0b08 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Thu, 21 May 2026 10:41:32 +0300 Subject: [PATCH 1/5] feat(web): add browser support --- lib/src/managers/ble_manager.dart | 38 +++++++++- .../models/devices/open_earable_factory.dart | 37 +++++++--- lib/src/models/devices/open_earable_v2.dart | 8 +++ .../v2_sensor_scheme_reader.dart | 72 +++++++++++++++---- .../v2_sensor_value_parser.dart | 8 ++- 5 files changed, 136 insertions(+), 27 deletions(-) diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index de50c86b..9e59a91f 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:open_earable_flutter/src/constants.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -10,6 +11,29 @@ import '../../open_earable_flutter.dart'; /// A class that establishes and manages Bluetooth Low Energy (BLE) /// communication with OpenEarable devices. class BleManager extends BleGattManager { + // Web Bluetooth requires services to be declared at requestDevice time. + static const Set _webOptionalServiceUuids = { + sensorServiceUuid, + deviceInfoServiceUuid, + parseInfoServiceUuid, + audioPlayerServiceUuid, + batteryServiceUuid, + buttonServiceUuid, + ledServiceUuid, + // OpenEarable V2 + "1410df95-5f68-4ebb-a7c7-5e0fb9ae7557", // audio config service + "2e04cbf7-939d-4be5-823e-271838b75259", // time sync service + "8d53dc1d-1db7-4cd3-868b-8a527460aa84", // mcumgr SMP service + // OpenRing + "bae80001-4f05-4503-8e65-3af1f7329d1f", + // Other supported devices + "0000a000-1212-efde-1523-785feabcd123", // Cosinuss PPG+ACC + "00001809-0000-1000-8000-00805f9b34fb", // temperature + "0000180d-0000-1000-8000-00805f9b34fb", // heart rate + "0000180a-0000-1000-8000-00805f9b34fb", // device information + "ff06", // eSense + }; + static const int _desiredMtu = 60; int _mtu = _desiredMtu; // Largest Byte package sent is 42 bytes for IMU int get mtu => _mtu; @@ -163,7 +187,19 @@ class BleManager extends BleGattManager { _scanStreamController?.add(device); } } - await UniversalBle.startScan(); + await UniversalBle.startScan( + scanFilter: ScanFilter( + withNamePrefix: ["OpenEarable", "OpenRing", "Cosinuss", "eSense"], + ), + platformConfig: kIsWeb + ? PlatformConfig( + web: WebOptions( + optionalServices: + _webOptionalServiceUuids.toList(growable: false), + ), + ) + : null, + ); } _firstScan = false; } diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index 11dadebd..bfde824f 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:open_earable_flutter/src/managers/sensor_handler.dart'; import 'package:open_earable_flutter/src/models/wearable_factory.dart'; import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_scheme_reader.dart'; @@ -52,7 +53,8 @@ class OpenEarableFactory extends WearableFactory { } @override - Future createFromDevice(DiscoveredDevice device, { Set options = const {} }) async { + Future createFromDevice(DiscoveredDevice device, + {Set options = const {}}) async { if (bleManager == null) { throw Exception("bleManager needs to be set before using the factory"); } @@ -91,7 +93,8 @@ class OpenEarableFactory extends WearableFactory { }, isConnectedViaSystem: options.contains(const ConnectedViaSystem()), ); - if (await bleManager!.hasService(deviceId: device.id, serviceId: timeSynchronizationServiceUuid)) { + if (await bleManager!.hasService( + deviceId: device.id, serviceId: timeSynchronizationServiceUuid)) { wearable.registerCapability( OpenEarableV2TimeSyncImp( bleManager: bleManager!, @@ -151,15 +154,22 @@ class OpenEarableFactory extends WearableFactory { List sensorSchemes = await schemeParser.readSensorSchemes(); + logger.d("Platform: ${kIsWeb ? 'WEB/Chrome' : 'NATIVE'}"); + logger.d( + "Sensor schemes provided by device: ${sensorSchemes.map((s) => 'ID:${s.sensorId}(${s.sensorName})').join(', ')}"); + for (SensorScheme scheme in sensorSchemes) { - List sensorConfigurationValues = []; + List sensorConfigurationValues = + []; final features = scheme.options?.features ?? []; final hasStreaming = features.contains(SensorConfigFeatures.streaming); final hasRecording = features.contains(SensorConfigFeatures.recording); - final hasFrequencies = features.contains(SensorConfigFeatures.frequencyDefinition); + final hasFrequencies = + features.contains(SensorConfigFeatures.frequencyDefinition); final frequencies = scheme.options?.frequencies?.frequencies ?? []; - final maxStreamingIndex = scheme.options?.frequencies?.maxStreamingFreqIndex ?? -1; + final maxStreamingIndex = + scheme.options?.frequencies?.maxStreamingFreqIndex ?? -1; //TODO: handle case where no frequencies are defined if (hasFrequencies && frequencies.isNotEmpty) { @@ -211,7 +221,9 @@ class OpenEarableFactory extends WearableFactory { .firstOrNull; if (sensorConfigurationValues.isEmpty) { - logger.w("No configuration values generated for sensor: ${scheme.sensorName}"); + logger.w( + "No configuration values generated for sensor: ${scheme.sensorName}", + ); } final sensorConfiguration = SensorConfigurationOpenEarableV2( @@ -229,16 +241,21 @@ class OpenEarableFactory extends WearableFactory { sensorConfigurations.add(sensorConfiguration); - if (scheme.options?.features.contains(SensorConfigFeatures.streaming) ?? false) { + if (scheme.options?.features.contains(SensorConfigFeatures.streaming) ?? + false) { // Group components by group name final sensorGroups = >{}; for (final component in scheme.components) { - sensorGroups.putIfAbsent(component.groupName, () => []).add(component); + sensorGroups + .putIfAbsent(component.groupName, () => []) + .add(component); } for (final groupName in sensorGroups.keys) { - final axisNames = sensorGroups[groupName]!.map((c) => c.componentName).toList(); - final axisUnits = sensorGroups[groupName]!.map((c) => c.unitName).toList(); + final axisNames = + sensorGroups[groupName]!.map((c) => c.componentName).toList(); + final axisUnits = + sensorGroups[groupName]!.map((c) => c.unitName).toList(); final sensor = _OpenEarableSensorV2( sensorId: scheme.sensorId, diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index e2b617d6..f6fb4633 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:open_earable_flutter/src/constants.dart'; import 'package:open_earable_flutter/src/models/devices/bluetooth_wearable.dart'; import 'package:pub_semver/pub_semver.dart'; @@ -688,6 +689,13 @@ class OpenEarableV2TimeSyncImp implements TimeSynchronizable { @override Future synchronizeTime() async { + if (kIsWeb) { + logger.i( + 'Skipping OpenEarable V2 time synchronization on web because the packet format uses Uint64 serialization, which is unsupported by dart2js.', + ); + return; + } + logger.i("Synchronizing time with OpenEarable V2 device..."); // Will complete when we have enough samples and wrote the final offset. diff --git a/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart b/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart index 78f8b9be..07b03df8 100644 --- a/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart +++ b/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:open_earable_flutter/open_earable_flutter.dart' show logger; import 'package:open_earable_flutter/src/constants.dart'; @@ -29,10 +30,20 @@ class V2SensorSchemeReader extends SensorSchemeReader { } int sensorIdCount = sensorIdBuffer[0]; - List sensorIds = sensorIdBuffer.sublist(1, sensorIdCount + 1); + if (sensorIdBuffer.length < 1 + sensorIdCount) { + logger.w( + "Sensor id buffer shorter than expected (count=$sensorIdCount, len=${sensorIdBuffer.length}).", + ); + } + + List sensorIds = sensorIdBuffer.length >= 1 + sensorIdCount + ? sensorIdBuffer.sublist(1, sensorIdCount + 1) + : sensorIdBuffer.sublist(1); _sensorIds.clear(); _sensorIds.addAll(sensorIds); + + logger.d("Parsed sensor ids: $sensorIds (count: $sensorIdCount)"); } @override @@ -55,10 +66,8 @@ class V2SensorSchemeReader extends SensorSchemeReader { characteristicId: sensorSchemeCharacteristicUuid, ); - final Future> responseFuture = stream - .cast>() - .first - .timeout(const Duration(seconds: 5)); + final Future> responseFuture = + stream.cast>().first.timeout(const Duration(seconds: 5)); // Request sensor value only after the listener/future is set up await _bleManager.write( @@ -70,16 +79,23 @@ class V2SensorSchemeReader extends SensorSchemeReader { try { final value = await responseFuture; - logger.d("Received notification for sensor scheme of sensor $sensorId: $value"); + logger.d( + "Received notification for sensor scheme of sensor $sensorId: $value", + ); final scheme = _parseSensorScheme(value); - if (scheme.sensorId != sensorId) { - throw Exception( - "Sensor id mismatch. Expected: $sensorId, got: ${scheme.sensorId}", + if (scheme.sensorId == 0 && sensorId != 0) { + logger.w( + "Sensor scheme response for sensor $sensorId omitted the sensor id. Using the requested id.", + ); + scheme.sensorId = sensorId; + } else if (scheme.sensorId != sensorId) { + logger.w( + "Sensor scheme response for sensor $sensorId reported sensor id ${scheme.sensorId}. Using the returned scheme.", ); } - _sensorSchemes[sensorId] = scheme; + _sensorSchemes[scheme.sensorId] = scheme; return scheme; } on TimeoutException catch (e) { throw TimeoutException("Timeout while waiting for sensor scheme: $e"); @@ -94,11 +110,29 @@ class V2SensorSchemeReader extends SensorSchemeReader { for (int sensorId in _sensorIds) { if (!_sensorSchemes.containsKey(sensorId) || forceRead) { - SensorScheme scheme = await getSchemeForSensor(sensorId); - _sensorSchemes[sensorId] = scheme; + try { + SensorScheme scheme = await getSchemeForSensor(sensorId); + _sensorSchemes[scheme.sensorId] = scheme; + } catch (e) { + logger.e( + "Failed to read sensor scheme for sensor $sensorId: $e${kIsWeb ? ' (on web platform)' : ''}", + ); + if (kIsWeb) { + logger.d( + "Skipping sensor $sensorId due to read failure on web. " + "This may be a BLE notification timeout or subscription issue.", + ); + } + // Continue with next sensor instead of failing entirely + continue; + } } } + logger.d( + "Successfully read ${_sensorSchemes.length} sensor scheme(s): " + "${_sensorSchemes.keys.join(', ')}", + ); return _sensorSchemes.values.toList(); } @@ -144,8 +178,12 @@ class V2SensorSchemeReader extends SensorSchemeReader { String unitName = utf8.decode(unitNameBytes); currentIndex += unitNameLength; - Component component = - Component(ParseType.fromInt(componentType), groupName, componentName, unitName); + Component component = Component( + ParseType.fromInt(componentType), + groupName, + componentName, + unitName, + ); sensorScheme.components.add(component); } @@ -175,7 +213,11 @@ class V2SensorSchemeReader extends SensorSchemeReader { freqs.add(byteData.getFloat32(0, Endian.little)); } currentIndex += frequencyCount * 4; - frequencies = SensorConfigFrequencies(maxStreamingFreqIndex, defaultFreqIndex, freqs); + frequencies = SensorConfigFrequencies( + maxStreamingFreqIndex, + defaultFreqIndex, + freqs, + ); } sensorScheme.options = SensorConfigOptions(features, frequencies); diff --git a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart index 9b4e7c27..4a9b2d0c 100644 --- a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart +++ b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart @@ -25,7 +25,7 @@ class V2SensorValueParser extends SensorValueParser { ); _requireBytes(data, i, 8, 'timestamp'); - final baseTimestamp = data.getUint64(i, Endian.little); + final baseTimestamp = _readUint64(data, i); i += 8; // Precompute size of one component payload for efficiency. @@ -137,6 +137,12 @@ int _getTimeDiff(ByteData data) { return data.getUint16(data.lengthInBytes - 2, Endian.little); } +int _readUint64(ByteData data, int index) { + final low = data.getUint32(index, Endian.little); + final high = data.getUint32(index + 4, Endian.little); + return high * 0x100000000 + low; +} + _ParsedSample _parseSample({ required ByteData data, required int startIndex, From f88b988fe2c591e79c041c41eafd0f08751d8820 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Thu, 21 May 2026 10:53:07 +0300 Subject: [PATCH 2/5] style: add missing trailing commas --- lib/src/models/devices/open_earable_factory.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index bfde824f..1b394ba4 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -53,8 +53,10 @@ class OpenEarableFactory extends WearableFactory { } @override - Future createFromDevice(DiscoveredDevice device, - {Set options = const {}}) async { + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async { if (bleManager == null) { throw Exception("bleManager needs to be set before using the factory"); } @@ -94,7 +96,9 @@ class OpenEarableFactory extends WearableFactory { isConnectedViaSystem: options.contains(const ConnectedViaSystem()), ); if (await bleManager!.hasService( - deviceId: device.id, serviceId: timeSynchronizationServiceUuid)) { + deviceId: device.id, + serviceId: timeSynchronizationServiceUuid, + )) { wearable.registerCapability( OpenEarableV2TimeSyncImp( bleManager: bleManager!, @@ -156,7 +160,8 @@ class OpenEarableFactory extends WearableFactory { logger.d("Platform: ${kIsWeb ? 'WEB/Chrome' : 'NATIVE'}"); logger.d( - "Sensor schemes provided by device: ${sensorSchemes.map((s) => 'ID:${s.sensorId}(${s.sensorName})').join(', ')}"); + "Sensor schemes provided by device: ${sensorSchemes.map((s) => 'ID:${s.sensorId}(${s.sensorName})').join(', ')}", + ); for (SensorScheme scheme in sensorSchemes) { List sensorConfigurationValues = From 3326c0b2cfa9b625f381d38433a19f25ba9531c0 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 21 May 2026 12:33:55 +0200 Subject: [PATCH 3/5] feat(wearable-factories): get list of ble services from factories in order to make services available from web --- lib/open_earable_flutter.dart | 3 ++ lib/src/managers/ble_manager.dart | 27 +--------- .../models/devices/cosinuss_one_factory.dart | 36 +++++++++---- lib/src/models/devices/esense_factory.dart | 50 +++++++++++++------ .../models/devices/open_earable_factory.dart | 8 +++ lib/src/models/devices/open_earable_v1.dart | 9 ++++ lib/src/models/devices/open_earable_v2.dart | 40 +++++++++------ lib/src/models/devices/open_ring_factory.dart | 5 ++ lib/src/models/devices/polar_factory.dart | 11 +++- lib/src/models/wearable_factory.dart | 12 ++++- 10 files changed, 134 insertions(+), 67 deletions(-) diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index c3262467..da52dc0b 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -200,6 +200,9 @@ class WearableManager { }) { return _bleManager.startScan( checkAndRequestPermissions: checkAndRequestPermissions, + webOptionalServiceUuids: { + for (final factory in _wearableFactories) ...factory.usedServiceUuids, + }, ); } diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index 68b4fab0..2f6b54e4 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:open_earable_flutter/src/constants.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -11,29 +10,6 @@ import '../../open_earable_flutter.dart'; /// A class that establishes and manages Bluetooth Low Energy (BLE) /// communication with OpenEarable devices. class BleManager extends BleGattManager { - // Web Bluetooth requires services to be declared at requestDevice time. - static const Set _webOptionalServiceUuids = { - sensorServiceUuid, - deviceInfoServiceUuid, - parseInfoServiceUuid, - audioPlayerServiceUuid, - batteryServiceUuid, - buttonServiceUuid, - ledServiceUuid, - // OpenEarable V2 - "1410df95-5f68-4ebb-a7c7-5e0fb9ae7557", // audio config service - "2e04cbf7-939d-4be5-823e-271838b75259", // time sync service - "8d53dc1d-1db7-4cd3-868b-8a527460aa84", // mcumgr SMP service - // OpenRing - "bae80001-4f05-4503-8e65-3af1f7329d1f", - // Other supported devices - "0000a000-1212-efde-1523-785feabcd123", // Cosinuss PPG+ACC - "00001809-0000-1000-8000-00805f9b34fb", // temperature - "0000180d-0000-1000-8000-00805f9b34fb", // heart rate - "0000180a-0000-1000-8000-00805f9b34fb", // device information - "ff06", // eSense - }; - static const int _desiredMtu = 60; int _mtu = _desiredMtu; // Largest Byte package sent is 42 bytes for IMU int get mtu => _mtu; @@ -150,6 +126,7 @@ class BleManager extends BleGattManager { /// Initiates the BLE device scan to discover nearby Bluetooth devices. Future startScan({ bool checkAndRequestPermissions = true, + Set webOptionalServiceUuids = const {}, }) async { bool? permGranted; @@ -196,7 +173,7 @@ class BleManager extends BleGattManager { ? PlatformConfig( web: WebOptions( optionalServices: - _webOptionalServiceUuids.toList(growable: false), + webOptionalServiceUuids.toList(growable: false), ), ) : null, diff --git a/lib/src/models/devices/cosinuss_one_factory.dart b/lib/src/models/devices/cosinuss_one_factory.dart index 5eab8e2f..68d47397 100644 --- a/lib/src/models/devices/cosinuss_one_factory.dart +++ b/lib/src/models/devices/cosinuss_one_factory.dart @@ -8,28 +8,44 @@ class CosinussOneFactory extends WearableFactory { static const String _name = "earconnect"; @override - Future matches(DiscoveredDevice device, List services) async { + Set get usedServiceUuids => const { + CosinussOne.ppgAndAccServiceUuid, + CosinussOne.temperatureServiceUuid, + CosinussOne.heartRateServiceUuid, + CosinussOne.batteryServiceUuid, + }; + + @override + Future matches( + DiscoveredDevice device, + List services, + ) async { return device.name == _name; } @override - Future createFromDevice(DiscoveredDevice device, { Set options = const {} }) async { + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async { if (bleManager == null) { - throw Exception("bleManager needs to be set before using the factory"); + throw StateError("bleManager needs to be set before using the factory"); } if (disconnectNotifier == null) { - throw Exception("disconnectNotifier needs to be set before using the factory"); + throw StateError( + "disconnectNotifier needs to be set before using the factory", + ); } if (device.name != _name) { - throw Exception("device is not a cosinuss one"); + throw ArgumentError.value(device.name, 'device.name', 'Expected $_name'); } return CosinussOne( - name: device.name, - disconnectNotifier: disconnectNotifier!, - bleManager: bleManager!, - discoveredDevice: device, - ); + name: device.name, + disconnectNotifier: disconnectNotifier!, + bleManager: bleManager!, + discoveredDevice: device, + ); } } diff --git a/lib/src/models/devices/esense_factory.dart b/lib/src/models/devices/esense_factory.dart index 0e948891..1f798ad2 100644 --- a/lib/src/models/devices/esense_factory.dart +++ b/lib/src/models/devices/esense_factory.dart @@ -15,9 +15,15 @@ import 'wearable.dart'; class EsenseFactory extends WearableFactory { @override - Future createFromDevice(DiscoveredDevice device, - {Set options = const {},}) async { + Set get usedServiceUuids => const { + esenseServiceUuid, + }; + @override + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async { EsenseSensorHandler sensorHandler = EsenseSensorHandler( bleGattManager: bleManager!, discoveredDevice: device, @@ -29,18 +35,25 @@ class EsenseFactory extends WearableFactory { EsenseSensorConfigurationValue(frequencyHz: 50.0), EsenseSensorConfigurationValue(frequencyHz: 100.0), EsenseSensorConfigurationValue(frequencyHz: 200.0), - ].expand((v) => [v, v.copyWith(options: {StreamSensorConfigOption()})]).toList(); - + ] + .expand( + (v) => [ + v, + v.copyWith(options: {StreamSensorConfigOption()}), + ], + ) + .toList(); + final imuConfig = EsenseSensorConfiguration( - name: "6-axis IMU", - values: imuConfigValues, - sensorCommand: 0x53, - sensorHandler: sensorHandler, - availableOptions: { - StreamSensorConfigOption(), - }, - offValue: imuConfigValues.firstWhere((v) => v.options.isEmpty), - ); + name: "6-axis IMU", + values: imuConfigValues, + sensorCommand: 0x53, + sensorHandler: sensorHandler, + availableOptions: { + StreamSensorConfigOption(), + }, + offValue: imuConfigValues.firstWhere((v) => v.options.isEmpty), + ); Esense esense = Esense( name: device.name, @@ -71,12 +84,15 @@ class EsenseFactory extends WearableFactory { ), ], ); - + return esense; } @override - Future matches(DiscoveredDevice device, List services) async { + Future matches( + DiscoveredDevice device, + List services, + ) async { return RegExp(r'^eSense-\d{4}$').hasMatch(device.name); } } @@ -127,7 +143,9 @@ class EsenseSensor extends Sensor { } else if (entry.value is double) { values.add(entry.value as double); } else { - throw Exception("Unsupported sensor value type: ${entry.value.runtimeType}"); + throw UnsupportedError( + "Unsupported sensor value type: ${entry.value.runtimeType}", + ); } } diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index 1b394ba4..a15c0c70 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -34,6 +34,14 @@ class OpenEarableFactory extends WearableFactory { final _v1Regex = RegExp(r'^1\.\d+\.\d+$'); final _v2Regex = RegExp(r'^2\.\d+\.\d+$'); + @override + Set get usedServiceUuids => { + ...OpenEarableV1.serviceUuids, + ...OpenEarableV2.serviceUuids, + mcuMgrSmpServiceUuid, + timeSynchronizationServiceUuid, + }; + @override Future matches( DiscoveredDevice device, diff --git a/lib/src/models/devices/open_earable_v1.dart b/lib/src/models/devices/open_earable_v1.dart index 26cf4069..6366c52e 100644 --- a/lib/src/models/devices/open_earable_v1.dart +++ b/lib/src/models/devices/open_earable_v1.dart @@ -50,6 +50,15 @@ class OpenEarableV1 extends Wearable static const String buttonServiceUuid = "29c10bdc-4773-11ee-be56-0242ac120002"; static const String batteryServiceUuid = "180F"; + static const Set serviceUuids = { + sensorServiceUuid, + parseInfoServiceUuid, + deviceInfoServiceUuid, + ledServiceUuid, + audioPlayerServiceUuid, + buttonServiceUuid, + batteryServiceUuid, + }; final List _sensors; final List _sensorConfigurations; diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index f6fb4633..9ebb78b2 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -36,7 +36,8 @@ const String _audioModeCharacteristicUuid = const String _buttonServiceUuid = "29c10bdc-4773-11ee-be56-0242ac120002"; const String _buttonCharacteristicUuid = "29c10f38-4773-11ee-be56-0242ac120002"; -const String timeSynchronizationServiceUuid = "2e04cbf7-939d-4be5-823e-271838b75259"; +const String timeSynchronizationServiceUuid = + "2e04cbf7-939d-4be5-823e-271838b75259"; const String _timeSyncTimeMappingCharacteristicUuid = "2e04cbf8-939d-4be5-823e-271838b75259"; const String _timeSyncRttCharacteristicUuid = @@ -59,11 +60,11 @@ final VersionConstraint _versionConstraint = /// as well as health and energy status. class OpenEarableV2 extends BluetoothWearable with - DeviceFirmwareVersionNumberExt, - BatteryLevelStatusGattReader, - BatteryLevelStatusServiceGattReader, - BatteryHealthStatusGattReader, - BatteryEnergyStatusGattReader + DeviceFirmwareVersionNumberExt, + BatteryLevelStatusGattReader, + BatteryLevelStatusServiceGattReader, + BatteryHealthStatusGattReader, + BatteryEnergyStatusGattReader implements SensorManager, SensorConfigurationManager, @@ -83,6 +84,15 @@ class OpenEarableV2 extends BluetoothWearable "45622510-6468-465a-b141-0b9b0f96b468"; static const String ledServiceUuid = "81040a2e-4819-11ee-be56-0242ac120002"; static const String batteryServiceUuid = "180F"; + static const Set serviceUuids = { + sensorServiceUuid, + parseInfoServiceUuid, + deviceInfoServiceUuid, + ledServiceUuid, + batteryServiceUuid, + _buttonServiceUuid, + _audioConfigServiceUuid, + }; final List _sensors; final List _sensorConfigurations; @@ -192,7 +202,6 @@ class OpenEarableV2 extends BluetoothWearable StreamSubscription? _sensorConfigSubscription; StreamSubscription? _buttonSubscription; - @override final Set availableMicrophones; @override @@ -708,10 +717,10 @@ class OpenEarableV2TimeSyncImp implements TimeSynchronizable { late final StreamSubscription> rttSub; rttSub = bleManager .subscribe( - deviceId: deviceId, - serviceId: timeSynchronizationServiceUuid, - characteristicId: _timeSyncRttCharacteristicUuid, - ) + deviceId: deviceId, + serviceId: timeSynchronizationServiceUuid, + characteristicId: _timeSyncRttCharacteristicUuid, + ) .listen( (data) async { final t4 = DateTime.now().microsecondsSinceEpoch; @@ -723,8 +732,9 @@ class OpenEarableV2TimeSyncImp implements TimeSynchronizable { logger.d("Received time sync response packet: $pkt"); - final t1 = pkt.timePhoneSend; // phone send timestamp (µs) - final t3 = pkt.timeDeviceSend; // device send timestamp (µs, device clock) + final t1 = pkt.timePhoneSend; // phone send timestamp (µs) + final t3 = + pkt.timeDeviceSend; // device send timestamp (µs, device clock) // Estimate Unix time at the moment the device sent the response. // Use midpoint between T1 and T4 as an estimate of when the device was "in the middle". @@ -762,7 +772,9 @@ class OpenEarableV2TimeSyncImp implements TimeSynchronizable { } }, onError: (error, stack) async { - logger.e("Error during time sync subscription $error, $stack",); + logger.e( + "Error during time sync subscription $error, $stack", + ); if (!completer.isCompleted) { completer.completeError(error, stack); } diff --git a/lib/src/models/devices/open_ring_factory.dart b/lib/src/models/devices/open_ring_factory.dart index 9fd1bb5d..c40f2ff0 100644 --- a/lib/src/models/devices/open_ring_factory.dart +++ b/lib/src/models/devices/open_ring_factory.dart @@ -18,6 +18,11 @@ import 'open_ring.dart'; import 'wearable.dart'; class OpenRingFactory extends WearableFactory { + @override + Set get usedServiceUuids => const { + OpenRingGatt.service, + }; + @override Future createFromDevice( DiscoveredDevice device, { diff --git a/lib/src/models/devices/polar_factory.dart b/lib/src/models/devices/polar_factory.dart index dbbd6392..c0ab8c6f 100644 --- a/lib/src/models/devices/polar_factory.dart +++ b/lib/src/models/devices/polar_factory.dart @@ -15,7 +15,16 @@ class PolarFactory extends WearableFactory { static const String _namePrefix = "Polar"; @override - Future createFromDevice(DiscoveredDevice device, { Set options = const {} }) async { + Set get usedServiceUuids => const { + Polar.disServiceUuid, + Polar.heartRateServiceUuid, + }; + + @override + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async { if (bleManager == null) { throw Exception("bleManager needs to be set before using the factory"); } diff --git a/lib/src/models/wearable_factory.dart b/lib/src/models/wearable_factory.dart index db4e6820..e0fc465f 100644 --- a/lib/src/models/wearable_factory.dart +++ b/lib/src/models/wearable_factory.dart @@ -16,8 +16,18 @@ abstract class WearableFactory { /// It is provided by the [WearableManager] and should not be set directly. WearableDisconnectNotifier? disconnectNotifier; + /// BLE service UUIDs that this factory may need after selecting a device. + /// + /// Web Bluetooth requires services to be requested before a wearable instance + /// can be created, so factories declare the service set up front. + Set get usedServiceUuids => const {}; + /// Checks if the factory can create a wearable from the given device and services. Future matches(DiscoveredDevice device, List services); + /// Creates a wearable from the given device. - Future createFromDevice(DiscoveredDevice device, { Set options = const {} }); + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }); } From 652fa09ba0a8ab62e961479541bac69a4a93b3f9 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 21 May 2026 12:42:16 +0200 Subject: [PATCH 4/5] chore(version): bumped version to 2.3.9 --- CHANGELOG.md | 4 ++++ example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d95288..d4b67d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.3.9 + +* fixed permissions on web, where web was not able to access the devices BLE services + ## 2.3.8 * updated dependencies to latest versions diff --git a/example/pubspec.lock b/example/pubspec.lock index 30301d6c..eb7392d2 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -414,7 +414,7 @@ packages: path: ".." relative: true source: path - version: "2.3.7" + version: "2.3.9" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index eaeb84f2..96bd3d8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: open_earable_flutter description: This package provides functionality for interacting with OpenEarable devices. Control LED colors, control audio, and access raw sensor data. -version: 2.3.8 +version: 2.3.9 repository: https://github.com/OpenEarable/open_earable_flutter/tree/main platforms: From defb5b0073b7a94d8605c19e2b87f9063ef5d1b4 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 21 May 2026 13:35:04 +0200 Subject: [PATCH 5/5] docs(documentation): enhance custom wearable factory guide with service UUIDs --- doc/ADD_CUSTOM_WEARABLE.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/doc/ADD_CUSTOM_WEARABLE.md b/doc/ADD_CUSTOM_WEARABLE.md index e161d886..30a7eb0b 100644 --- a/doc/ADD_CUSTOM_WEARABLE.md +++ b/doc/ADD_CUSTOM_WEARABLE.md @@ -20,10 +20,20 @@ class MyCustomWearable extends Wearable { ## 2. Implement a Custom Wearable Factory -Create a factory that determines when your custom wearable should be used. This factory is responsible for recognizing a device and constructing the corresponding wearable object. If you need to perform ble gatt operations, you can use the `BleGattManager` in `bleManager` of the `WearableFactory` class. The `BleGattManager` provides methods for interacting with BLE devices, such as reading and writing characteristics. It is provided by the `WearableManager` and should not be set manually. +Create a factory that determines when your custom wearable should be used. This factory is responsible for recognizing a device and constructing the corresponding wearable object. If you need to perform BLE GATT operations, you can use the `BleGattManager` in `bleManager` of the `WearableFactory` class. The `BleGattManager` provides methods for interacting with BLE devices, such as reading and writing characteristics. It is provided by the `WearableManager` and should not be set manually. + +If your wearable uses BLE services, declare them in `usedServiceUuids`. Web Bluetooth requires all services to be requested before a device is selected, so the `WearableManager` collects this list from all registered factories when scanning starts. ```dart class MyCustomWearableFactory extends WearableFactory { + static const String _customServiceUuid = + "00000000-0000-1000-8000-000000000000"; + + @override + Set get usedServiceUuids => const { + _customServiceUuid, + }; + @override Future matches(DiscoveredDevice device, List services) async { // Define logic to check if the device matches your custom wearable @@ -45,6 +55,8 @@ class MyCustomWearableFactory extends WearableFactory { } ``` +Include every service your factory may need while matching, creating, or using the wearable. This includes services used by capabilities that are registered during `createFromDevice`, because those capabilities are still created after Web Bluetooth has already requested access. + --- ## 3. Register the Custom Factory