diff --git a/example/pubspec.lock b/example/pubspec.lock index fc44f0e..d3af3b6 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -342,7 +342,7 @@ packages: path: ".." relative: true source: path - version: "2.2.2" + version: "2.2.3" path: dependency: transitive description: diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 673bb53..0527fe5 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -19,6 +19,7 @@ import 'src/managers/wearable_disconnect_notifier.dart'; import 'src/models/capabilities/stereo_device.dart'; import 'src/models/capabilities/system_device.dart'; import 'src/models/devices/discovered_device.dart'; +import 'src/models/devices/tau_ring_factory.dart'; import 'src/models/devices/wearable.dart'; export 'src/models/devices/discovered_device.dart'; @@ -106,6 +107,7 @@ class WearableManager { CosinussOneFactory(), PolarFactory(), DevKitFactory(), + TauRingFactory(), ]; factory WearableManager() { diff --git a/lib/src/managers/tau_sensor_handler.dart b/lib/src/managers/tau_sensor_handler.dart new file mode 100644 index 0000000..93d0c01 --- /dev/null +++ b/lib/src/managers/tau_sensor_handler.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:open_earable_flutter/src/models/devices/tau_ring.dart'; + +import '../../open_earable_flutter.dart'; +import 'sensor_handler.dart'; +import '../utils/sensor_value_parser/sensor_value_parser.dart'; + +class TauSensorHandler extends SensorHandler { + final DiscoveredDevice _discoveredDevice; + final BleGattManager _bleManager; + + final SensorValueParser _sensorValueParser; + + TauSensorHandler({ + required DiscoveredDevice discoveredDevice, + required BleGattManager bleManager, + required SensorValueParser sensorValueParser, + }) : _discoveredDevice = discoveredDevice, + _bleManager = bleManager, + _sensorValueParser = sensorValueParser; + + @override + Stream> subscribeToSensorData(int sensorId) { + if (!_bleManager.isConnected(_discoveredDevice.id)) { + throw Exception("Can't subscribe to sensor data. Earable not connected"); + } + + StreamController> streamController = + StreamController(); + _bleManager + .subscribe( + deviceId: _discoveredDevice.id, + serviceId: TauRingGatt.service, + characteristicId: TauRingGatt.rxChar, + ).listen( + (data) async { + List> parsedData = await _parseData(data); + for (var d in parsedData) { + streamController.add(d); + } + }, + onError: (error) { + logger.e("Error while subscribing to sensor data: $error"); + }, + ); + + return streamController.stream; + } + + @override + Future writeSensorConfig(TauSensorConfig sensorConfig) async { + if (!_bleManager.isConnected(_discoveredDevice.id)) { + Exception("Can't write sensor config. Earable not connected"); + } + + Uint8List sensorConfigBytes = sensorConfig.toBytes(); + + await _bleManager.write( + deviceId: _discoveredDevice.id, + serviceId: TauRingGatt.service, + characteristicId: TauRingGatt.txChar, + byteData: sensorConfigBytes, + ); + } + + /// Parses raw sensor data bytes into a [Map] of sensor values. + Future>> _parseData(List data) async { + ByteData byteData = ByteData.sublistView(Uint8List.fromList(data)); + + return _sensorValueParser.parse(byteData, []); + } +} + +class TauSensorConfig extends SensorConfig { + int cmd; + int subOpcode; + + TauSensorConfig({ + required this.cmd, + required this.subOpcode, + }); + + Uint8List toBytes() { + int randomByte = DateTime.now().microsecondsSinceEpoch & 0xFF; + + return Uint8List.fromList([ + 0x00, + randomByte, + cmd, + subOpcode, + ]); + } +} diff --git a/lib/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart b/lib/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart new file mode 100644 index 0000000..4867e56 --- /dev/null +++ b/lib/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart @@ -0,0 +1,37 @@ +import 'package:open_earable_flutter/src/managers/tau_sensor_handler.dart'; + +import '../sensor_configuration.dart'; + +class TauRingSensorConfiguration extends SensorConfiguration { + + final TauSensorHandler _sensorHandler; + + TauRingSensorConfiguration({required super.name, required super.values, required TauSensorHandler sensorHandler}) + : _sensorHandler = sensorHandler; + + @override + void setConfiguration(TauRingSensorConfigurationValue value) { + TauSensorConfig config = TauSensorConfig( + cmd: value.cmd, + subOpcode: value.subOpcode, + ); + + _sensorHandler.writeSensorConfig(config); + } +} + +class TauRingSensorConfigurationValue extends SensorConfigurationValue { + final int cmd; + final int subOpcode; + + TauRingSensorConfigurationValue({ + required super.key, + required this.cmd, + required this.subOpcode, + }); + + @override + String toString() { + return key; + } +} diff --git a/lib/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart b/lib/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart new file mode 100644 index 0000000..7f918a4 --- /dev/null +++ b/lib/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import '../../../../managers/sensor_handler.dart'; +import '../../sensor.dart'; + +class TauRingSensor extends Sensor { + const TauRingSensor({ + required this.sensorId, + required super.sensorName, + required super.chartTitle, + required super.shortChartTitle, + required List axisNames, + required List axisUnits, + required this.sensorHandler, + super.relatedConfigurations = const [], + }) : _axisNames = axisNames, _axisUnits = axisUnits; + + final int sensorId; + final List _axisNames; + final List _axisUnits; + + final SensorHandler sensorHandler; + + @override + List get axisNames => _axisNames; + + @override + List get axisUnits => _axisUnits; + + @override + int get axisCount => _axisNames.length; + + @override + Stream get sensorStream { + StreamController streamController = StreamController(); + sensorHandler.subscribeToSensorData(sensorId).listen( + (data) { + int timestamp = data["timestamp"]; + + List values = []; + for (var entry in (data[sensorName] as Map).entries) { + if (entry.key == 'units') { + continue; + } + + values.add(entry.value); + } + + SensorIntValue sensorValue = SensorIntValue( + values: values, + timestamp: timestamp, + ); + + streamController.add(sensorValue); + }, + ); + return streamController.stream; + } +} diff --git a/lib/src/models/devices/tau_ring.dart b/lib/src/models/devices/tau_ring.dart new file mode 100644 index 0000000..0823ac7 --- /dev/null +++ b/lib/src/models/devices/tau_ring.dart @@ -0,0 +1,68 @@ +import '../../../open_earable_flutter.dart'; + + +/// τ-Ring integration for OpenEarable. +/// Implements Wearable (mandatory) + SensorManager (exposes sensors). +class TauRing extends Wearable implements SensorManager, SensorConfigurationManager { + TauRing({ + required DiscoveredDevice discoveredDevice, + required this.deviceId, + required super.name, + List sensors = const [], + List sensorConfigs = const [], + required BleGattManager bleManager, + required super.disconnectNotifier, + }) : _sensors = sensors, + _sensorConfigs = sensorConfigs, + _bleManager = bleManager, + _discoveredDevice = discoveredDevice; + + final DiscoveredDevice _discoveredDevice; + + final List _sensors; + final List _sensorConfigs; + final BleGattManager _bleManager; + + @override + final String deviceId; + + @override + List> get sensorConfigurations => _sensorConfigs; + @override + List> get sensors => _sensors; + + @override + Future disconnect() { + return _bleManager.disconnect(_discoveredDevice.id); + } + + @override + Stream, SensorConfigurationValue>> get sensorConfigurationStream => const Stream.empty(); +} + +// τ-Ring GATT constants (from the vendor AAR) +class TauRingGatt { + static const String service = 'bae80001-4f05-4503-8e65-3af1f7329d1f'; + static const String txChar = 'bae80010-4f05-4503-8e65-3af1f7329d1f'; // write + static const String rxChar = 'bae80011-4f05-4503-8e65-3af1f7329d1f'; // notify + + // opcodes (subset) + static const int cmdApp = 0xA0; // APP_* handshake + static const int cmdVers = 0x11; // version + static const int cmdBatt = 0x12; // battery + static const int cmdSys = 0x37; // system (reset etc.) + static const int cmdPPGQ2 = 0x32; // start/stop PPG Q2 + + // build a framed command: [0x00, rnd, cmdId, payload...] + static List frame(int cmd, {List payload = const [], int? rnd}) { + final r = rnd ?? DateTime.now().microsecondsSinceEpoch & 0xFF; + return [0x00, r & 0xFF, cmd, ...payload]; + } + + static List le64(int ms) { + final b = List.filled(8, 0); + var v = ms; + for (var i = 0; i < 8; i++) { b[i] = v & 0xFF; v >>= 8; } + return b; + } +} diff --git a/lib/src/models/devices/tau_ring_factory.dart b/lib/src/models/devices/tau_ring_factory.dart new file mode 100644 index 0000000..9b12719 --- /dev/null +++ b/lib/src/models/devices/tau_ring_factory.dart @@ -0,0 +1,77 @@ +import 'package:open_earable_flutter/src/models/capabilities/sensor_configuration_specializations/tau_ring_sensor_configuration.dart'; +import 'package:open_earable_flutter/src/models/capabilities/sensor_specializations/tau_ring/tau_ring_sensor.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../../managers/tau_sensor_handler.dart'; +import '../../utils/sensor_value_parser/tau_ring_value_parser.dart'; +import '../capabilities/sensor.dart'; +import '../capabilities/sensor_configuration.dart'; +import '../wearable_factory.dart'; +import 'discovered_device.dart'; +import 'tau_ring.dart'; +import 'wearable.dart'; + +class TauRingFactory extends WearableFactory { + @override + Future createFromDevice(DiscoveredDevice device, {Set options = const {}}) { + if (bleManager == null) { + throw Exception("Can't create τ-Ring instance: bleManager not set in factory"); + } + if (disconnectNotifier == null) { + throw Exception("Can't create τ-Ring instance: disconnectNotifier not set in factory"); + } + + final sensorHandler = TauSensorHandler( + discoveredDevice: device, + bleManager: bleManager!, + sensorValueParser: TauRingValueParser(), + ); + + List sensorConfigs = [ + TauRingSensorConfiguration( + name: "6-Axis IMU", + values: [ + TauRingSensorConfigurationValue(key: "On", cmd: 0x40, subOpcode: 0x06), + TauRingSensorConfigurationValue(key: "Off", cmd: 0x40, subOpcode: 0x00), + ], + sensorHandler: sensorHandler, + ), + ]; + List sensors = [ + TauRingSensor( + sensorId: 0x40, + sensorName: "Accelerometer", + chartTitle: "Accelerometer", + shortChartTitle: "Accel", + axisNames: ["X", "Y", "Z"], + axisUnits: ["g", "g", "g"], + sensorHandler: sensorHandler, + ), + TauRingSensor( + sensorId: 0x40, + sensorName: "Gyroscope", + chartTitle: "Gyroscope", + shortChartTitle: "Gyro", + axisNames: ["X", "Y", "Z"], + axisUnits: ["dps", "dps", "dps"], + sensorHandler: sensorHandler, + ), + ]; + + final w = TauRing( + discoveredDevice: device, + deviceId: device.id, + name: device.name, + sensors: sensors, + sensorConfigs: sensorConfigs, + disconnectNotifier: disconnectNotifier!, + bleManager: bleManager!, + ); + return Future.value(w); + } + + @override + Future matches(DiscoveredDevice device, List services) async { + return services.any((s) => s.uuid.toLowerCase() == TauRingGatt.service); + } +} diff --git a/lib/src/utils/sensor_value_parser/tau_ring_value_parser.dart b/lib/src/utils/sensor_value_parser/tau_ring_value_parser.dart new file mode 100644 index 0000000..f5588c8 --- /dev/null +++ b/lib/src/utils/sensor_value_parser/tau_ring_value_parser.dart @@ -0,0 +1,154 @@ +import 'dart:typed_data'; + +import '../../../open_earable_flutter.dart' show logger; +import '../sensor_scheme_parser/sensor_scheme_reader.dart'; +import 'sensor_value_parser.dart'; + +class TauRingValueParser extends SensorValueParser { + // 100 Hz → 10 ms per sample + static const int _samplePeriodMs = 10; + + int _lastSeq = -1; + int _lastTs = 0; + + @override + List> parse( + ByteData data, + List sensorSchemes, + ) { + + + logger.t("Received Tau Ring sensor data: size: ${data.lengthInBytes} ${data.buffer.asUint8List()}"); + + + final int framePrefix = data.getUint8(0); + if (framePrefix != 0x00) { + throw Exception("Invalid frame prefix: $framePrefix"); // TODO: specific exception + } + + if (data.lengthInBytes < 5) { + throw Exception("Data too short to parse"); // TODO: specific exception + } + + final int sequenceNum = data.getUint8(1); + final int cmd = data.getUint8(2); + final int subOpcode = data.getUint8(3); + final int status = data.getUint8(4); + final ByteData payload = ByteData.sublistView(data, 5); + + logger.t("last sequenceNum: $_lastSeq, current sequenceNum: $sequenceNum"); + if (sequenceNum != _lastSeq) { + _lastSeq = sequenceNum; + _lastTs = 0; + logger.d("Sequence number changed. Resetting last timestamp."); + } + + // These header fields should go into every sample map + final Map baseHeader = { + "sequenceNum": sequenceNum, + "cmd": cmd, + "subOpcode": subOpcode, + "status": status, + }; + + List> result; + switch (cmd) { + case 0x40: // IMU + switch (subOpcode) { + case 0x01: // Accel only (6 bytes per sample) + result = _parseAccel( + data: payload, + receiveTs: _lastTs, + baseHeader: baseHeader, + ); + case 0x06: // Accel + Gyro (12 bytes per sample) + result = _parseAccelGyro( + data: payload, + receiveTs: _lastTs, + baseHeader: baseHeader, + ); + default: + throw Exception("Unknown sub-opcode for sensor data: $subOpcode"); + } + + default: + throw Exception("Unknown command: $cmd"); + } + if (result.isNotEmpty) { + _lastTs = result.last["timestamp"] as int; + logger.t("Updated last timestamp to $_lastTs"); + } + return result; + } + + List> _parseAccel({ + required ByteData data, + required int receiveTs, + required Map baseHeader, + }) { + if (data.lengthInBytes % 6 != 0) { + throw Exception("Invalid data length for Accel: ${data.lengthInBytes}"); + } + + final int nSamples = data.lengthInBytes ~/ 6; + if (nSamples == 0) return const []; + + final List> parsedData = []; + for (int i = 0; i < data.lengthInBytes; i += 6) { + final int sampleIndex = i ~/ 6; + final int ts = receiveTs + sampleIndex * _samplePeriodMs; + + final ByteData sample = ByteData.sublistView(data, i, i + 6); + final Map accelData = _parseImuComp(sample); + + parsedData.add({ + ...baseHeader, + "timestamp": ts, + "Accelerometer": accelData, + }); + } + return parsedData; + } + + List> _parseAccelGyro({ + required ByteData data, + required int receiveTs, + required Map baseHeader, + }) { + if (data.lengthInBytes % 12 != 0) { + throw Exception("Invalid data length for Accel+Gyro: ${data.lengthInBytes}"); + } + + final int nSamples = data.lengthInBytes ~/ 12; + if (nSamples == 0) return const []; + + final List> parsedData = []; + for (int i = 0; i < data.lengthInBytes; i += 12) { + final int sampleIndex = i ~/ 12; + final int ts = receiveTs + sampleIndex * _samplePeriodMs; + + final ByteData sample = ByteData.sublistView(data, i, i + 12); + final ByteData accBytes = ByteData.sublistView(sample, 0, 6); + final ByteData gyroBytes = ByteData.sublistView(sample, 6); + + final Map accelData = _parseImuComp(accBytes); + final Map gyroData = _parseImuComp(gyroBytes); + + parsedData.add({ + ...baseHeader, + "timestamp": ts, + "Accelerometer": accelData, + "Gyroscope": gyroData, + }); + } + return parsedData; + } + + Map _parseImuComp(ByteData data) { + return { + 'X': data.getInt16(0, Endian.little), + 'Y': data.getInt16(2, Endian.little), + 'Z': data.getInt16(4, Endian.little), + }; + } +}