diff --git a/lib/src/managers/open_ring_sensor_handler.dart b/lib/src/managers/open_ring_sensor_handler.dart index e1ece90..87a4be8 100644 --- a/lib/src/managers/open_ring_sensor_handler.dart +++ b/lib/src/managers/open_ring_sensor_handler.dart @@ -4,8 +4,8 @@ import 'dart:typed_data'; import 'package:open_earable_flutter/src/models/devices/open_ring.dart'; import '../../open_earable_flutter.dart'; -import 'sensor_handler.dart'; import '../utils/sensor_value_parser/sensor_value_parser.dart'; +import 'sensor_handler.dart'; class OpenRingSensorHandler extends SensorHandler { final DiscoveredDevice _discoveredDevice; @@ -17,9 +17,9 @@ class OpenRingSensorHandler extends SensorHandler { required DiscoveredDevice discoveredDevice, required BleGattManager bleManager, required SensorValueParser sensorValueParser, - }) : _discoveredDevice = discoveredDevice, - _bleManager = bleManager, - _sensorValueParser = sensorValueParser; + }) : _discoveredDevice = discoveredDevice, + _bleManager = bleManager, + _sensorValueParser = sensorValueParser; @override Stream> subscribeToSensorData(int sensorId) { @@ -31,20 +31,25 @@ class OpenRingSensorHandler extends SensorHandler { StreamController(); _bleManager .subscribe( - deviceId: _discoveredDevice.id, - serviceId: OpenRingGatt.service, - characteristicId: OpenRingGatt.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"); - }, - ); + deviceId: _discoveredDevice.id, + serviceId: OpenRingGatt.service, + characteristicId: OpenRingGatt.rxChar, + ) + .listen( + (data) async { + try { + List> parsedData = await _parseData(data); + for (var d in parsedData) { + streamController.add(d); + } + } catch (error) { + logger.e("Error while parsing OpenRing sensor packet: $error"); + } + }, + onError: (error) { + logger.e("Error while subscribing to sensor data: $error"); + }, + ); return streamController.stream; } @@ -65,31 +70,30 @@ class OpenRingSensorHandler extends SensorHandler { ); } - /// Parses raw sensor data bytes into a [Map] of sensor values. + /// 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, []); + + final parsed = _sensorValueParser.parse(byteData, []); + if (parsed.isNotEmpty) { + logger.t( + "OpenRingSensorHandler parsed ${parsed.length} sample(s), first=${parsed.first}, last=${parsed.last}", + ); + } + + return parsed; } } class OpenRingSensorConfig extends SensorConfig { int cmd; - int subOpcode; + List payload; - OpenRingSensorConfig({ - required this.cmd, - required this.subOpcode, - }); + OpenRingSensorConfig({required this.cmd, required this.payload}); Uint8List toBytes() { int randomByte = DateTime.now().microsecondsSinceEpoch & 0xFF; - return Uint8List.fromList([ - 0x00, - randomByte, - cmd, - subOpcode, - ]); + return Uint8List.fromList([0x00, randomByte, cmd, ...payload]); } } diff --git a/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart b/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart index d33b6ef..b2ab5ae 100644 --- a/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart +++ b/lib/src/models/capabilities/sensor_configuration_specializations/open_ring_sensor_configuration.dart @@ -2,18 +2,21 @@ import 'package:open_earable_flutter/src/managers/open_ring_sensor_handler.dart' import '../sensor_configuration.dart'; -class OpenRingSensorConfiguration extends SensorConfiguration { - +class OpenRingSensorConfiguration + extends SensorConfiguration { final OpenRingSensorHandler _sensorHandler; - OpenRingSensorConfiguration({required super.name, required super.values, required OpenRingSensorHandler sensorHandler}) - : _sensorHandler = sensorHandler; + OpenRingSensorConfiguration({ + required super.name, + required super.values, + required OpenRingSensorHandler sensorHandler, + }) : _sensorHandler = sensorHandler; @override void setConfiguration(OpenRingSensorConfigurationValue value) { OpenRingSensorConfig config = OpenRingSensorConfig( cmd: value.cmd, - subOpcode: value.subOpcode, + payload: value.payload, ); _sensorHandler.writeSensorConfig(config); @@ -22,12 +25,12 @@ class OpenRingSensorConfiguration extends SensorConfiguration payload; OpenRingSensorConfigurationValue({ required super.key, required this.cmd, - required this.subOpcode, + required this.payload, }); @override diff --git a/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart b/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart index 608a565..0f6342d 100644 --- a/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart +++ b/lib/src/models/capabilities/sensor_specializations/open_ring/open_ring_sensor.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../../../../open_earable_flutter.dart' show logger; import '../../../../managers/sensor_handler.dart'; import '../../sensor.dart'; @@ -33,27 +34,52 @@ class OpenRingSensor extends Sensor { @override Stream get sensorStream { StreamController streamController = StreamController(); - sensorHandler.subscribeToSensorData(sensorId).listen( - (data) { - int timestamp = data["timestamp"]; + sensorHandler.subscribeToSensorData(sensorId).listen((data) { + if (!data.containsKey(sensorName)) { + return; + } - List values = []; - for (var entry in (data[sensorName] as Map).entries) { + final sensorData = data[sensorName]; + final timestamp = data["timestamp"]; + if (sensorData is! Map || timestamp is! int) { + return; + } + + final Map sensorDataMap = sensorData; + List values = []; + for (final axisName in _axisNames) { + final dynamic axisValue = sensorDataMap[axisName]; + if (axisValue is int) { + values.add(axisValue); + } + } + + if (values.isEmpty) { + for (var entry in sensorDataMap.entries) { if (entry.key == 'units') { continue; } - - values.add(entry.value); + if (entry.value is int) { + values.add(entry.value as int); + } } + } + + if (values.isEmpty) { + return; + } + + logger.t( + "OpenRingSensor[$sensorName] emit timestamp=$timestamp values=$values raw=$sensorDataMap", + ); - SensorIntValue sensorValue = SensorIntValue( - values: values, - timestamp: timestamp, - ); + SensorIntValue sensorValue = SensorIntValue( + values: values, + timestamp: timestamp, + ); - streamController.add(sensorValue); - }, - ); + streamController.add(sensorValue); + }); return streamController.stream; } } diff --git a/lib/src/models/devices/open_ring_factory.dart b/lib/src/models/devices/open_ring_factory.dart index 2fb9f0d..3029b11 100644 --- a/lib/src/models/devices/open_ring_factory.dart +++ b/lib/src/models/devices/open_ring_factory.dart @@ -13,14 +13,19 @@ import 'wearable.dart'; class OpenRingFactory extends WearableFactory { @override - Future createFromDevice(DiscoveredDevice device, {Set options = const {}}) { + 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"); + throw Exception( + "Can't create τ-Ring instance: disconnectNotifier not set in factory", + ); } - + final sensorHandler = OpenRingSensorHandler( discoveredDevice: device, bleManager: bleManager!, @@ -31,16 +36,44 @@ class OpenRingFactory extends WearableFactory { OpenRingSensorConfiguration( name: "6-Axis IMU", values: [ - OpenRingSensorConfigurationValue(key: "On", cmd: 0x40, subOpcode: 0x06), - OpenRingSensorConfigurationValue(key: "Off", cmd: 0x40, subOpcode: 0x00), + OpenRingSensorConfigurationValue( + key: "On", + cmd: 0x40, + payload: [0x06], + ), + OpenRingSensorConfigurationValue( + key: "Off", + cmd: 0x40, + payload: [0x00], + ), ], sensorHandler: sensorHandler, ), OpenRingSensorConfiguration( name: "PPG", values: [ - OpenRingSensorConfigurationValue(key: "On", cmd: OpenRingGatt.cmdPPGQ2, subOpcode: 0x01), - OpenRingSensorConfigurationValue(key: "Off", cmd: OpenRingGatt.cmdPPGQ2, subOpcode: 0x00), + OpenRingSensorConfigurationValue( + key: "On", + cmd: OpenRingGatt.cmdPPGQ2, + payload: [ + 0x01, // start + 0x00, // collectionTime (continuous) + 0x19, // acquisition parameter (firmware-fixed) + 0x01, // enable waveform streaming + 0x01, // enable progress packets + ], + ), + OpenRingSensorConfigurationValue( + key: "Off", + cmd: OpenRingGatt.cmdPPGQ2, + payload: [ + 0x00, // stop + 0x00, // collectionTime + 0x19, // acquisition parameter + 0x00, // disable waveform streaming + 0x00, // disable progress packets + ], + ), ], sensorHandler: sensorHandler, ), @@ -69,8 +102,8 @@ class OpenRingFactory extends WearableFactory { sensorName: "PPG", chartTitle: "PPG", shortChartTitle: "PPG", - axisNames: ["Green", "Red", "Infrared"], - axisUnits: ["raw", "raw", "raw"], + axisNames: ["Red", "Infrared", "AccX", "AccY", "AccZ"], + axisUnits: ["raw", "raw", "raw", "raw", "raw"], sensorHandler: sensorHandler, ), ]; @@ -86,7 +119,7 @@ class OpenRingFactory extends WearableFactory { ); return Future.value(w); } - + @override Future matches(DiscoveredDevice device, List services) async { return services.any((s) => s.uuid.toLowerCase() == OpenRingGatt.service); diff --git a/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart b/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart index b6b393f..5ddd596 100644 --- a/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart +++ b/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart @@ -8,137 +8,242 @@ class OpenRingValueParser extends SensorValueParser { // 100 Hz → 10 ms per sample static const int _samplePeriodMs = 10; - int _lastSeq = -1; - int _lastTs = 0; + final Map _lastSeqByCmd = {}; + final Map _lastTsByCmd = {}; @override List> parse( ByteData data, List sensorSchemes, ) { - - - logger.t("Received Open Ring sensor data: size: ${data.lengthInBytes} ${data.buffer.asUint8List()}"); + logger.t( + "Received Open Ring sensor data: size: ${data.lengthInBytes} ${data.buffer.asUint8List()}", + ); + if (data.lengthInBytes < 4) { + throw Exception("Data too short to parse"); + } 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 + throw Exception("Invalid frame prefix: $framePrefix"); } 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."); + final int lastSeq = _lastSeqByCmd[cmd] ?? -1; + final int receiveTs = _lastTsByCmd[cmd] ?? 0; + logger.t( + "cmd=$cmd last sequenceNum: $lastSeq, current sequenceNum: $sequenceNum, receiveTs: $receiveTs", + ); + _lastSeqByCmd[cmd] = sequenceNum; + + List> result; + switch (cmd) { + case 0x40: // IMU + result = _parseImuFrame(data, sequenceNum, cmd, receiveTs); + break; + case 0x32: // PPG Q2 + result = _parsePpgFrame(data, sequenceNum, cmd, receiveTs); + break; + default: + logger.t("Ignoring unsupported OpenRing command: $cmd"); + return const []; + } + + if (result.isNotEmpty) { + final int updatedTs = result.last["timestamp"] as int; + _lastTsByCmd[cmd] = updatedTs; + logger.t("cmd=$cmd Updated last timestamp to $updatedTs"); + + final Map first = result.first; + final Map last = result.last; + logger.t( + "cmd=$cmd parsed ${result.length} sample(s) ts ${first['timestamp']}..${last['timestamp']} firstPayload=${_extractSensorPayload(first)}", + ); + } + + return result; + } + + List> _parseImuFrame( + ByteData frame, + int sequenceNum, + int cmd, + int receiveTs, + ) { + if (frame.lengthInBytes < 4) { + throw Exception("IMU frame too short: ${frame.lengthInBytes}"); } - // These header fields should go into every sample map + final int subOpcode = frame.getUint8(3); + if (frame.lengthInBytes < 5) { + if (subOpcode == 0x00) { + return const []; + } + throw Exception("IMU frame missing status byte: ${frame.lengthInBytes}"); + } + + final int status = frame.getUint8(4); + final ByteData payload = + frame.lengthInBytes > 5 + ? ByteData.sublistView(frame, 5) + : ByteData.sublistView(frame, 5, 5); + 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"); + + logger.t("IMU using fixed sample period=${_samplePeriodMs}ms"); + + switch (subOpcode) { + case 0x01: // Accel-only stream (ignored by design) + case 0x04: // Accel-only stream (ignored by design) + if (status == 0x01) { + logger.t("IMU device busy for sub-opcode: $subOpcode"); + return const []; } - case 0x32: // PPG Q2 - switch (subOpcode) { - case 0x00: - result = const []; - case 0x01: - result = _parsePpg( - data: payload, - receiveTs: _lastTs, - baseHeader: baseHeader, - ); - default: - throw Exception("Unknown sub-opcode for PPG data: $subOpcode"); + logger.t( + "Ignoring IMU accel-only sub-opcode $subOpcode; expecting accel+gyro (0x06)", + ); + return const []; + case 0x06: // Accel + Gyro (12 bytes per sample) + if (status == 0x01) { + logger.t("IMU device busy for sub-opcode: $subOpcode"); + return const []; } - + return _parseAccelGyro( + data: payload, + receiveTs: receiveTs, + baseHeader: baseHeader, + samplePeriodMs: _samplePeriodMs, + ); + case 0x00: + // Common non-streaming/control response. + return const []; default: - throw Exception("Unknown command: $cmd"); + logger.t("Ignoring unsupported IMU sub-opcode: $subOpcode"); + return const []; } - 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}"); + List> _parsePpgFrame( + ByteData frame, + int sequenceNum, + int cmd, + int receiveTs, + ) { + if (frame.lengthInBytes < 5) { + throw Exception("PPG frame too short: ${frame.lengthInBytes}"); } - final int nSamples = data.lengthInBytes ~/ 6; - if (nSamples == 0) return const []; + final int type = frame.getUint8(3); + final int value = frame.getUint8(4); - final List> parsedData = []; - for (int i = 0; i < data.lengthInBytes; i += 6) { - final int sampleIndex = i ~/ 6; - final int ts = receiveTs + sampleIndex * _samplePeriodMs; + final Map baseHeader = { + "sequenceNum": sequenceNum, + "cmd": cmd, + "type": type, + "value": value, + }; - final ByteData sample = ByteData.sublistView(data, i, i + 6); - final Map accelData = _parseImuComp(sample); + if (type == 0xFF) { + logger.d("OpenRing PPG progress: $value%"); + if (value >= 100) { + logger.d("OpenRing PPG progress complete"); + } + return const []; + } - parsedData.add({ - ...baseHeader, - "timestamp": ts, - "Accelerometer": accelData, - }); + if (type == 0x00) { + if (value == 0 || value == 2 || value == 4) { + logger.w("OpenRing PPG error packet received: code=$value"); + return const []; + } + + if (value == 3) { + if (frame.lengthInBytes < 9) { + throw Exception("Invalid final PPG result length: ${frame.lengthInBytes}"); + } + + final int heart = frame.getUint8(5); + final int q2 = frame.getUint8(6); + final int temp = frame.getInt16(7, Endian.little); + + logger.d("OpenRing PPG result received: heart=$heart q2=$q2 temp=$temp"); + return const []; + } + + logger.w("OpenRing PPG result packet with unknown value=$value"); + return const []; } - return parsedData; + + if (type == 0x01) { + if (frame.lengthInBytes < 6) { + throw Exception("PPG waveform frame too short: ${frame.lengthInBytes}"); + } + + final int nSamples = frame.getUint8(5); + final ByteData waveformPayload = ByteData.sublistView(frame, 6); + + return _parsePpgWaveform( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + } + + if (type == 0x02) { + if (frame.lengthInBytes < 6) { + throw Exception("PPG extended waveform frame too short: ${frame.lengthInBytes}"); + } + + final int nSamples = frame.getUint8(5); + final ByteData waveformPayload = ByteData.sublistView(frame, 6); + + return _parsePpgWaveformType2( + data: waveformPayload, + nSamples: nSamples, + receiveTs: receiveTs, + baseHeader: baseHeader, + ); + } + + logger.t( + "Ignoring unsupported PPG packet type: $type, frame=${frame.buffer.asUint8List()}" + ); + return const []; } + List> _parseAccelGyro({ required ByteData data, required int receiveTs, required Map baseHeader, + required int samplePeriodMs, }) { - if (data.lengthInBytes % 12 != 0) { - throw Exception("Invalid data length for Accel+Gyro: ${data.lengthInBytes}"); + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 12); + if (usableBytes == 0) { + if (data.lengthInBytes != 0) { + logger.t("Ignoring short Accel+Gyro payload: len=${data.lengthInBytes}"); + } + return const []; + } + if (usableBytes != data.lengthInBytes) { + logger.t( + "Truncating Accel+Gyro payload from ${data.lengthInBytes} to $usableBytes bytes", + ); } - - final int nSamples = data.lengthInBytes ~/ 12; - if (nSamples == 0) return const []; final List> parsedData = []; - for (int i = 0; i < data.lengthInBytes; i += 12) { + for (int i = 0; i < usableBytes; i += 12) { final int sampleIndex = i ~/ 12; - final int ts = receiveTs + sampleIndex * _samplePeriodMs; + final int ts = receiveTs + (sampleIndex + 1) * samplePeriodMs; final ByteData sample = ByteData.sublistView(data, i, i + 12); final ByteData accBytes = ByteData.sublistView(sample, 0, 6); @@ -165,36 +270,115 @@ class OpenRingValueParser extends SensorValueParser { }; } - List> _parsePpg({ + List> _parsePpgWaveform({ required ByteData data, + required int nSamples, required int receiveTs, required Map baseHeader, }) { - if (data.lengthInBytes % 12 != 0) { - throw Exception("Invalid data length for PPG: ${data.lengthInBytes}"); + final int expectedBytes = nSamples * 14; + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 14); + if (usableBytes == 0 || nSamples == 0) { + return const []; } - final int nSamples = data.lengthInBytes ~/ 12; - if (nSamples == 0) return const []; + int usableSamples = usableBytes ~/ 14; + if (usableSamples > nSamples) { + usableSamples = nSamples; + } + + if (data.lengthInBytes != expectedBytes && nSamples > usableSamples) { + logger.t( + "PPG waveform length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)", + ); + } final List> parsedData = []; - for (int i = 0; i < data.lengthInBytes; i += 12) { - final int sampleIndex = i ~/ 12; - final int ts = receiveTs + sampleIndex * _samplePeriodMs; + for (int i = 0; i < usableSamples; i++) { + final int offset = i * 14; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; - final ByteData sample = ByteData.sublistView(data, i, i + 12); + parsedData.add({ + ...baseHeader, + "timestamp": ts, + "PPG": { + "Red": data.getInt32(offset, Endian.little), + "Infrared": data.getInt32(offset + 4, Endian.little), + "AccX": data.getInt16(offset + 8, Endian.little), + "AccY": data.getInt16(offset + 10, Endian.little), + "AccZ": data.getInt16(offset + 12, Endian.little), + }, + }); + } + + return parsedData; + } + + List> _parsePpgWaveformType2({ + required ByteData data, + required int nSamples, + required int receiveTs, + required Map baseHeader, + }) { + // Observed packet type 0x02 layout: + // [sampleCount][n * 34-byte samples] + // sample bytes (LE): + // 0..3 unknown int32 + // 4..7 red int32 + // 8..11 infrared int32 + // 12..19 unknown int32 x2 + // 20..25 accX/accY/accZ int16 + // 26..33 unknown tail (4x int16/uint16) + const int sampleSize = 34; + + final int expectedBytes = nSamples * sampleSize; + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % sampleSize); + if (usableBytes == 0 || nSamples == 0) { + return const []; + } + + int usableSamples = usableBytes ~/ sampleSize; + if (usableSamples > nSamples) { + usableSamples = nSamples; + } + + if (data.lengthInBytes != expectedBytes && nSamples > usableSamples) { + logger.t( + "PPG type2 length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)", + ); + } + + final List> parsedData = []; + for (int i = 0; i < usableSamples; i++) { + final int offset = i * sampleSize; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; parsedData.add({ ...baseHeader, "timestamp": ts, "PPG": { - "Green": sample.getInt32(0, Endian.little), - "Red": sample.getInt32(4, Endian.little), - "Infrared": sample.getInt32(8, Endian.little), + "Red": data.getInt32(offset + 4, Endian.little), + "Infrared": data.getInt32(offset + 8, Endian.little), + "AccX": data.getInt16(offset + 20, Endian.little), + "AccY": data.getInt16(offset + 22, Endian.little), + "AccZ": data.getInt16(offset + 24, Endian.little), }, }); } return parsedData; } + + + + Map? _extractSensorPayload(Map sample) { + if (sample['Accelerometer'] is Map) { + return sample['Accelerometer'] as Map; + } + if (sample['PPG'] is Map) { + return sample['PPG'] as Map; + } + return null; + } + }