diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 317a668..354253a 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,12 @@ import Foundation import file_picker import flutter_archive +import path_provider_foundation import universal_ble func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) } diff --git a/example/pubspec.lock b/example/pubspec.lock index dc169aa..65e0a14 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + complex: + dependency: transitive + description: + name: complex + sha256: dba084899c0a4bd2fcba9a36760409171d7bee7c35a749cc4451348270361325 + url: "https://pub.dev" + source: hosted + version: "0.7.2" convert: dependency: transitive description: @@ -186,10 +194,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 url: "https://pub.dev" source: hosted - version: "2.0.32" + version: "2.0.31" flutter_svg: dependency: "direct main" description: @@ -232,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + iirjdart: + dependency: transitive + description: + name: iirjdart + sha256: "5bd8aa6af6ee67473fbf7fbbfc7b992d0bb95768a16aaa62e70613389e564df8" + url: "https://pub.dev" + source: hosted + version: "0.1.0" json_annotation: dependency: transitive description: @@ -244,26 +260,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -316,10 +332,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" nested: dependency: transitive description: @@ -328,14 +344,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64" - url: "https://pub.dev" - source: hosted - version: "9.1.0" open_earable_flutter: dependency: "direct main" description: @@ -371,18 +379,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "95c68a74d3cab950fd0ed8073d9fab15c1c06eb1f3eec68676e87aabc9ecee5a" + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" url: "https://pub.dev" source: hosted - version: "2.2.21" + version: "2.2.19" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba" + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -560,10 +568,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.4" tuple: dependency: transitive description: @@ -624,18 +632,18 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.0.0" web: dependency: transitive description: @@ -669,5 +677,5 @@ packages: source: hosted version: "6.6.1" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 48bb10f..bfffa26 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:logger/logger.dart'; import 'package:meta/meta.dart'; +import 'package:open_earable_flutter/src/managers/exg/exg_factory.dart'; import 'package:open_earable_flutter/src/models/devices/cosinuss_one_factory.dart'; import 'package:open_earable_flutter/src/models/devices/esense_factory.dart'; import 'package:open_earable_flutter/src/models/devices/open_earable_factory.dart'; @@ -106,6 +107,7 @@ class WearableManager { StreamSubscription? _autoconnectScanSubscription; final List _wearableFactories = [ + ExGFactory(), OpenEarableFactory(), CosinussOneFactory(), PolarFactory(), diff --git a/lib/src/managers/exg/exg_factory.dart b/lib/src/managers/exg/exg_factory.dart new file mode 100644 index 0000000..5e0588e --- /dev/null +++ b/lib/src/managers/exg/exg_factory.dart @@ -0,0 +1,40 @@ +import 'package:open_earable_flutter/src/models/devices/cosinuss_one.dart'; +import 'package:open_earable_flutter/src/models/devices/discovered_device.dart'; +import 'package:open_earable_flutter/src/models/devices/wearable.dart'; +import 'package:open_earable_flutter/src/models/wearable_factory.dart'; +import 'package:universal_ble/universal_ble.dart'; +import 'exg_wearable.dart'; + + +class ExGFactory extends WearableFactory { + // todo ExG Devices are still named OpenEarable- fixit or keep it index 0 when creating the _wearableFactories + static final RegExp _nameRegex = RegExp(r'^OpenEarable(?:[-_].*)?$'); + + @override + Future matches(DiscoveredDevice device, List services) async { + final name = (device.name ?? '').trim(); + return _nameRegex.hasMatch(name); + } + + @override + Future createFromDevice(DiscoveredDevice device, { Set options = const {} }) async { + if (bleManager == null) { + throw Exception("bleManager needs to be set before using the factory"); + } + if (disconnectNotifier == null) { + throw Exception("disconnectNotifier needs to be set before using the factory"); + } + + final name = (device.name ?? '').trim(); + if (!_nameRegex.hasMatch(name)) { + throw Exception("device is not an exg device"); + } + + return ExGWearable( + name: device.name, + disconnectNotifier: disconnectNotifier!, + bleManager: bleManager!, + discoveredDevice: device, + ); + } +} diff --git a/lib/src/managers/exg/exg_filter_options.dart b/lib/src/managers/exg/exg_filter_options.dart new file mode 100644 index 0000000..c931aba --- /dev/null +++ b/lib/src/managers/exg/exg_filter_options.dart @@ -0,0 +1,6 @@ +class ExGFilterOptions { + static const List lowerCutoffs = [0.5, 1, 5, 10, 15]; + static const List higherCutoffs = [20, 30, 40, 50, 60]; + static const List samplingFrequencies = [100, 200, 250, 300, 400]; + static const List filterOrders = [1, 2, 3, 4]; +} diff --git a/lib/src/managers/exg/exg_preset.dart b/lib/src/managers/exg/exg_preset.dart new file mode 100644 index 0000000..12897a4 --- /dev/null +++ b/lib/src/managers/exg/exg_preset.dart @@ -0,0 +1,31 @@ +class ExGPreset { + final String name; + final double lowerCutoff; + final double higherCutoff; + final int samplingFrequency; + final int filterOrder; + + ExGPreset({ + required this.name, + required this.lowerCutoff, + required this.higherCutoff, + required this.samplingFrequency, + required this.filterOrder, + }); + + Map toJson() => { + 'name': name, + 'lowerCutoff': lowerCutoff, + 'higherCutoff': higherCutoff, + 'samplingFrequency': samplingFrequency, + 'filterOrder': filterOrder, + }; + + factory ExGPreset.fromJson(Map json) => ExGPreset( + name: json['name'], + lowerCutoff: (json['lowerCutoff'] as num).toDouble(), + higherCutoff: (json['higherCutoff'] as num).toDouble(), + samplingFrequency: json['samplingFrequency'], + filterOrder: json['filterOrder'], + ); +} diff --git a/lib/src/managers/exg/exg_wearable.dart b/lib/src/managers/exg/exg_wearable.dart new file mode 100644 index 0000000..9efd8f6 --- /dev/null +++ b/lib/src/managers/exg/exg_wearable.dart @@ -0,0 +1,446 @@ +import 'dart:async'; +import 'package:iirjdart/butterworth.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/managers/open_earable_sensor_manager.dart'; +import 'dart:typed_data'; +import 'exg_filter_options.dart'; +import 'exg_preset.dart'; + +class ExGWearable extends Wearable implements + SensorManager, + SensorConfigurationManager, + BatteryLevelStatus, + EdgeRecorderManager + { + static const batteryServiceUuid = "180f"; + static const _batteryLevelCharacteristicUuid = "02a19"; + + final List _sensorConfigurations; + final List _sensors; + final BleGattManager _bleManager; + final DiscoveredDevice _discoveredDevice; + + final _configCtrl = StreamController< + Map, SensorConfigurationValue> + >.broadcast(); + + final Map, SensorConfigurationValue> + _currentConfigValues = {}; + + ExGWearable({ + required super.name, + required super.disconnectNotifier, + required BleGattManager bleManager, + required DiscoveredDevice discoveredDevice, + }) : _sensors = [], + _sensorConfigurations = [], + _bleManager = bleManager, + _discoveredDevice = discoveredDevice { + _initSensors(); + } + + void _initSensors() { + final sensorManager = OpenEarableSensorHandler( + bleManager: _bleManager, + deviceId: _discoveredDevice.id, + ); + + final exgLowerCutoff = _ExGLowerCutoffSensorConfiguration(wearable: this); + final exgHigherCutoff = _ExGHigherCutoffSensorConfiguration(wearable: this); + final exgFs = _ExGFsSensorConfiguration(wearable: this); + final exgOrder = _ExGOrderSensorConfiguration(wearable: this); + + _sensorConfigurations.addAll([exgLowerCutoff, exgHigherCutoff, exgFs, exgOrder]); + + _sensors.add(_ExGSensor( + bleManager: _bleManager, + discoveredDevice: _discoveredDevice, + sensorManager: sensorManager, + )); + + _seedInitialConfigValues(); // <— important + } + + // optional: call this when you're done with the wearable + Future disposeWearable() async { + await _configCtrl.close(); + } + + void applyPreset(ExGPreset p) { + if (p.lowerCutoff >= p.higherCutoff) { + throw ArgumentError('Lower cutoff must be < higher cutoff'); + } + + for (final cfg in _sensorConfigurations) { + if (cfg is _ExGLowerCutoffSensorConfiguration) { + cfg.setConfiguration(CutoffConfigurationValue(value: p.lowerCutoff.toString())); + } else if (cfg is _ExGHigherCutoffSensorConfiguration) { + cfg.setConfiguration(CutoffConfigurationValue(value: p.higherCutoff.toString())); + } else if (cfg is _ExGFsSensorConfiguration) { + cfg.setConfiguration(CutoffConfigurationValue(value: p.samplingFrequency.toString())); + } else if (cfg is _ExGOrderSensorConfiguration) { + cfg.setConfiguration(CutoffConfigurationValue(value: p.filterOrder.toString())); + } + } + } + + @override + String? getWearableIconPath({bool darkmode = false}) { + // todo add Icon here + return null; + } + + @override + String get deviceId => _discoveredDevice.id; + + @override + Future disconnect() { + return _bleManager.disconnect(_discoveredDevice.id); + } + + + @override + Stream get batteryPercentageStream { + StreamController streamController = StreamController(); + + StreamSubscription subscription = _bleManager + .subscribe( + deviceId: _discoveredDevice.id, + serviceId: batteryServiceUuid, + characteristicId: _batteryLevelCharacteristicUuid, + ) + .listen((data) { + streamController.add(data[0]); + }); + + readBatteryPercentage().then((percentage) { + streamController.add(percentage); + streamController.close(); + }).catchError((error) { + streamController.addError(error); + streamController.close(); + }); + + // Cancel BLE subscription when canceling stream + streamController.onCancel = () { + subscription.cancel(); + }; + + return streamController.stream; + } + + @override + Future readBatteryPercentage() async { + List batteryLevelList = await _bleManager.read( + deviceId: _discoveredDevice.id, + serviceId: batteryServiceUuid, + characteristicId: _batteryLevelCharacteristicUuid, + ); + + logger.t("Battery level bytes: $batteryLevelList"); + + if (batteryLevelList.length != 1) { + throw StateError( + 'Battery level characteristic expected 1 value, but got ${batteryLevelList.length}', + ); + } + + return batteryLevelList[0]; + } + + @override + Stream, SensorConfigurationValue>> + get sensorConfigurationStream => _configCtrl.stream; + + @override + List get sensorConfigurations => + List.unmodifiable(_sensorConfigurations); + + @override + List get sensors => List.unmodifiable(_sensors); + + void _notifyConfigChanged( + SensorConfiguration cfg, + SensorConfigurationValue val, + ) { + _currentConfigValues[cfg] = val; + // emit a snapshot for listeners (UI etc.) + _configCtrl.add(Map.unmodifiable(_currentConfigValues)); + } + + // Call this once after creating the configs to seed initial values + void _seedInitialConfigValues() { + for (final cfg in _sensorConfigurations) { + _currentConfigValues[cfg] = cfg.offValue!; + } + _configCtrl.add(Map.unmodifiable(_currentConfigValues)); + } + + @override + Future get filePrefix async { + return ""; + } + + @override + Future setFilePrefix(String prefix) async { + + } +} + +class _ExGSensor extends Sensor { + static const String exgServiceUuid = "0029d054-23d0-4c58-a199-c6bdc16c4975"; + static const String exgCharacteristicUuid = "20a4a273-c214-4c18-b433-329f30ef7275"; + final BleGattManager _bleManager; + final DiscoveredDevice _discoveredDevice; + + final List _axisNames = ['EOG']; + final List _axisUnits = ['µV']; + final double inampGain = 50.0; + final bool enableFilters = true; + + late double Function(double) _biopotentialFilter; + int filterOrder; + List filterCutoff; + String filterBtype; + double filterFs; + bool filterNotch; + + _ExGSensor({ + required BleGattManager bleManager, + required DiscoveredDevice discoveredDevice, + required OpenEarableSensorHandler sensorManager, + + this.filterOrder = 4, + this.filterCutoff = const [0.5, 50], + this.filterBtype = "bandpass", + this.filterFs = 250, + this.filterNotch = true, + + }) : _bleManager = bleManager, + _discoveredDevice = discoveredDevice, + _biopotentialFilter = _getBiopotentialFilter( + order: filterOrder, + cutoff: filterCutoff, + btype: filterBtype, + fs: filterFs, + notch: filterNotch, + ), + super( + sensorName: 'exg Filters', + chartTitle: 'exg', + shortChartTitle: 'exg', + ) { + _updateFilter(); + } + + void _updateFilter() { + _biopotentialFilter = _getBiopotentialFilter( + order: filterOrder, + cutoff: filterCutoff, + btype: filterBtype, + fs: filterFs, + notch: filterNotch, + ); + + // Print current filter settings for verification + print( + '[BioFilter Update] ' + 'order: $filterOrder, ' + 'cutoff: $filterCutoff, ' + 'btype: $filterBtype, ' + 'fs: $filterFs, ' + 'notch: $filterNotch' + ); + } + + void updateFilterSettings({ + int? filterOrder, + List? filterCutoff, // [low, high] + String? filterBtype, + double? filterFs, + bool? filterNotch, + }) { + if (filterOrder != null) this.filterOrder = filterOrder; + if (filterCutoff != null) this.filterCutoff = filterCutoff; + if (filterBtype != null) this.filterBtype = filterBtype; + if (filterFs != null) this.filterFs = filterFs; + if (filterNotch != null) this.filterNotch = filterNotch; + + _updateFilter(); + } + + @override + List get axisNames => _axisNames; + @override + List get axisUnits => _axisUnits; + + @override + Stream get sensorStream { + final controller = StreamController(); + + final subscription = _bleManager.subscribe( + deviceId: _discoveredDevice.id, + serviceId: exgServiceUuid, + characteristicId: exgCharacteristicUuid, + ).listen((data) { + if (data.length < 20) return; + + final byteData = ByteData.sublistView(Uint8List.fromList(data)); + // 5 values of 4 bytes each (Float32), adjust if needed + for (int i = 0; i < 4; i++) { + final rawValue = byteData.getFloat32(i * 4, Endian.little); + double processedEog; + + if (enableFilters) { + // Call the pre-configured filter + processedEog = (_biopotentialFilter(rawValue) / inampGain) * 1e6; + } else { + final rawUv = (rawValue / inampGain) * 1e6; + processedEog = rawUv; + } + + final values = [processedEog]; + final timestamp = DateTime.now(); + + controller.add( + SensorDoubleValue( + values: values, + timestamp: timestamp.millisecondsSinceEpoch, + ), + ); + } + }); + + controller.onCancel = () { + subscription.cancel(); + }; + return controller.stream; + } + + /// function to create and configure a biopotential filter. + static double Function(double) _getBiopotentialFilter({ + int order = 4, + List cutoff = const [0.5, 50], + String btype = "bandpass", + double fs = 30, + bool notch = true, + }) { + print(cutoff); + + if (btype == "bandpass" && cutoff.length == 2) { + double centerFrequency = (cutoff[0] + cutoff[1]) / 2.0; + double widthFrequency = cutoff[1] - cutoff[0]; + + final biopotentialFilter = Butterworth(); + biopotentialFilter.bandPass(order, fs, centerFrequency, widthFrequency); + + if (notch) { + final notchFilter = Butterworth(); + final double notchWidth = 50.0 / 30.0; + notchFilter.bandStop(2, fs, 50.0, notchWidth); + + return (double x) { + return biopotentialFilter.filter(notchFilter.filter(x)); + }; + } else { + return (double x) { + return biopotentialFilter.filter(x); + }; + } + } else { + throw UnimplementedError("Filter type '$btype' or cutoff configuration is not supported."); + } + } +} + +class CutoffConfigurationValue extends SensorConfigurationValue { + CutoffConfigurationValue({required String value}) + : super(key: value); + + double get cutoff => double.parse(key); +} +class _ExGLowerCutoffSensorConfiguration extends SensorConfiguration { + final ExGWearable wearable; + _ExGLowerCutoffSensorConfiguration({required this.wearable}) + : super( + name: 'Lower Cutoff', + values: ExGFilterOptions.lowerCutoffs + .map((v) => CutoffConfigurationValue(value: v.toString())) + .toList(), + offValue: CutoffConfigurationValue(value: ExGFilterOptions.lowerCutoffs.first.toString()), + ); + + @override + void setConfiguration(SensorConfigurationValue configuration) { + final sensor = wearable.sensors.first as _ExGSensor; + final newLow = double.parse(configuration.key); + sensor.updateFilterSettings( + filterCutoff: [newLow, sensor.filterCutoff[1]], + ); + wearable._notifyConfigChanged(this, configuration); + } +} + +class _ExGHigherCutoffSensorConfiguration extends SensorConfiguration { + final ExGWearable wearable; + _ExGHigherCutoffSensorConfiguration({required this.wearable}) + : super( + name: 'Higher Cutoff', + values: ExGFilterOptions.higherCutoffs + .map((v) => CutoffConfigurationValue(value: v.toString())) + .toList(), + offValue: CutoffConfigurationValue(value: ExGFilterOptions.higherCutoffs.first.toString()), + ); + + @override + void setConfiguration(SensorConfigurationValue configuration) { + final sensor = wearable.sensors.first as _ExGSensor; + final newHigh = double.parse(configuration.key); + sensor.updateFilterSettings( + filterCutoff: [sensor.filterCutoff[0], newHigh], + ); + wearable._notifyConfigChanged(this, configuration); + + } +} + +class _ExGFsSensorConfiguration extends SensorConfiguration { + final ExGWearable wearable; + _ExGFsSensorConfiguration({required this.wearable}) + : super( + name: 'Sampling Frequency (fs)', + values: ExGFilterOptions.samplingFrequencies + .map((v) => CutoffConfigurationValue(value: v.toString())) + .toList(), + offValue: CutoffConfigurationValue(value: ExGFilterOptions.samplingFrequencies.first.toString()), + ); + + @override + void setConfiguration(SensorConfigurationValue configuration) { + final sensor = wearable.sensors.first as _ExGSensor; + sensor.updateFilterSettings(filterFs: double.parse(configuration.key)); + wearable._notifyConfigChanged(this, configuration); + } +} + +class _ExGOrderSensorConfiguration extends SensorConfiguration { + final ExGWearable wearable; + _ExGOrderSensorConfiguration({required this.wearable}) + : super( + name: 'Filter Order', + values: ExGFilterOptions.filterOrders + .map((v) => CutoffConfigurationValue(value: v.toString())) + .toList(), + offValue: CutoffConfigurationValue(value: ExGFilterOptions.filterOrders.first.toString()), + ); + + + @override + void setConfiguration(SensorConfigurationValue configuration) { + final sensor = wearable.sensors.first as _ExGSensor; + sensor.updateFilterSettings(filterOrder: int.parse(configuration.key)); + wearable._notifyConfigChanged(this, configuration); + } +} + + diff --git a/pubspec.yaml b/pubspec.yaml index b57dcb3..5f82a61 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 + iirjdart: ^0.1.0 + dev_dependencies: flutter_test: