From ba000bc7c86971f38c041746bf016d247b9f3422 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:00:17 +0100 Subject: [PATCH 01/13] Add OpenRing payload-based config and PPG streaming command --- .../managers/open_ring_sensor_handler.dart | 55 ++++++++----------- .../open_ring_sensor_configuration.dart | 17 +++--- lib/src/models/devices/open_ring_factory.dart | 49 ++++++++++++++--- 3 files changed, 75 insertions(+), 46 deletions(-) diff --git a/lib/src/managers/open_ring_sensor_handler.dart b/lib/src/managers/open_ring_sensor_handler.dart index e1ece90..8446473 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,21 @@ 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 { + 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; } @@ -65,31 +66,23 @@ 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, []); } } 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/devices/open_ring_factory.dart b/lib/src/models/devices/open_ring_factory.dart index 2fb9f0d..e3254cb 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: [ + 0x00, // start + 0x00, // collectionTime (continuous) + 0x19, // acquisition parameter (firmware-fixed) + 0x01, // enable waveform streaming + 0x01, // enable progress packets + ], + ), + OpenRingSensorConfigurationValue( + key: "Off", + cmd: OpenRingGatt.cmdPPGQ2, + payload: [ + 0x01, // stop + 0x00, // collectionTime + 0x19, // acquisition parameter + 0x00, // disable waveform streaming + 0x00, // disable progress packets + ], + ), ], 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); From eb470c2619c7cb1bf60273afa4ca026559935cc2 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:15:20 +0100 Subject: [PATCH 02/13] Fix OpenRing IMU frame parsing and add PPG packet decoding --- lib/src/models/devices/open_ring_factory.dart | 4 +- .../open_ring_value_parser.dart | 194 ++++++++++++------ 2 files changed, 132 insertions(+), 66 deletions(-) diff --git a/lib/src/models/devices/open_ring_factory.dart b/lib/src/models/devices/open_ring_factory.dart index e3254cb..d7241d9 100644 --- a/lib/src/models/devices/open_ring_factory.dart +++ b/lib/src/models/devices/open_ring_factory.dart @@ -102,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, ), ]; 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..22d34f4 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 @@ -16,25 +16,21 @@ class OpenRingValueParser extends SensorValueParser { 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) { @@ -43,57 +39,127 @@ class OpenRingValueParser extends SensorValueParser { 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"); - } + result = _parseImuFrame(data, sequenceNum, cmd); + break; 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"); - } - + result = _parsePpgFrame(data, sequenceNum, cmd); + break; 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> _parseImuFrame( + ByteData frame, + int sequenceNum, + int cmd, + ) { + final int subOpcode = frame.getUint8(3); + final ByteData payload = ByteData.sublistView(frame, 4); + + final Map baseHeader = { + "sequenceNum": sequenceNum, + "cmd": cmd, + "subOpcode": subOpcode, + }; + + switch (subOpcode) { + case 0x01: // Accel only (6 bytes per sample) + return _parseAccel( + data: payload, + receiveTs: _lastTs, + baseHeader: baseHeader, + ); + case 0x06: // Accel + Gyro (12 bytes per sample) + return _parseAccelGyro( + data: payload, + receiveTs: _lastTs, + baseHeader: baseHeader, + ); + default: + throw Exception("Unknown sub-opcode for IMU data: $subOpcode"); + } + } + + List> _parsePpgFrame( + ByteData frame, + int sequenceNum, + int cmd, + ) { + if (frame.lengthInBytes < 5) { + throw Exception("PPG frame too short: ${frame.lengthInBytes}"); + } + + final int type = frame.getUint8(3); + final int value = frame.getUint8(4); + + final Map baseHeader = { + "sequenceNum": sequenceNum, + "cmd": cmd, + "type": type, + "value": value, + }; + + if (type == 0xFF) { + logger.d("OpenRing PPG progress: $value%"); + if (value >= 100) { + logger.d("OpenRing PPG progress complete"); + } + return const []; + } + + 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 []; + } + + 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: _lastTs, + baseHeader: baseHeader, + ); + } + + throw Exception("Unknown PPG packet type: $type"); + } + List> _parseAccel({ required ByteData data, required int receiveTs, @@ -103,8 +169,7 @@ class OpenRingValueParser extends SensorValueParser { throw Exception("Invalid data length for Accel: ${data.lengthInBytes}"); } - final int nSamples = data.lengthInBytes ~/ 6; - if (nSamples == 0) return const []; + if (data.lengthInBytes == 0) return const []; final List> parsedData = []; for (int i = 0; i < data.lengthInBytes; i += 6) { @@ -132,8 +197,7 @@ class OpenRingValueParser extends SensorValueParser { throw Exception("Invalid data length for Accel+Gyro: ${data.lengthInBytes}"); } - final int nSamples = data.lengthInBytes ~/ 12; - if (nSamples == 0) return const []; + if (data.lengthInBytes == 0) return const []; final List> parsedData = []; for (int i = 0; i < data.lengthInBytes; i += 12) { @@ -165,32 +229,34 @@ 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}"); + if (data.lengthInBytes != nSamples * 14) { + throw Exception( + "Invalid data length for PPG waveform: ${data.lengthInBytes}, expected ${nSamples * 14}", + ); } - 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); + for (int i = 0; i < nSamples; i++) { + final int offset = i * 14; + final int ts = receiveTs + i * _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, 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), }, }); } From ab0a8e1bd5a5b3e32a55387b74258b3fe6f41f85 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:20:06 +0100 Subject: [PATCH 03/13] Swap OpenRing PPG start/stop control byte --- lib/src/models/devices/open_ring_factory.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/models/devices/open_ring_factory.dart b/lib/src/models/devices/open_ring_factory.dart index d7241d9..3029b11 100644 --- a/lib/src/models/devices/open_ring_factory.dart +++ b/lib/src/models/devices/open_ring_factory.dart @@ -56,7 +56,7 @@ class OpenRingFactory extends WearableFactory { key: "On", cmd: OpenRingGatt.cmdPPGQ2, payload: [ - 0x00, // start + 0x01, // start 0x00, // collectionTime (continuous) 0x19, // acquisition parameter (firmware-fixed) 0x01, // enable waveform streaming @@ -67,7 +67,7 @@ class OpenRingFactory extends WearableFactory { key: "Off", cmd: OpenRingGatt.cmdPPGQ2, payload: [ - 0x01, // stop + 0x00, // stop 0x00, // collectionTime 0x19, // acquisition parameter 0x00, // disable waveform streaming From 1ec726fb58ed868bdc41d63d33877a37f90d1f02 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:27:07 +0100 Subject: [PATCH 04/13] Harden OpenRing IMU stream handling for mixed packets --- .../open_ring/open_ring_sensor.dart | 43 ++++++++++++------- .../open_ring_value_parser.dart | 12 ++++-- 2 files changed, 36 insertions(+), 19 deletions(-) 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..0bdd69a 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 @@ -33,27 +33,38 @@ 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) { - if (entry.key == 'units') { - continue; - } + final sensorData = data[sensorName]; + final timestamp = data["timestamp"]; + if (sensorData is! Map || timestamp is! int) { + return; + } - values.add(entry.value); + List values = []; + for (var entry in sensorData.entries) { + if (entry.key == 'units') { + continue; } + if (entry.value is int) { + values.add(entry.value as int); + } + } + + if (values.isEmpty) { + return; + } - 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/utils/sensor_value_parser/open_ring_value_parser.dart b/lib/src/utils/sensor_value_parser/open_ring_value_parser.dart index 22d34f4..28fbf28 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 @@ -48,7 +48,8 @@ class OpenRingValueParser extends SensorValueParser { result = _parsePpgFrame(data, sequenceNum, cmd); break; default: - throw Exception("Unknown command: $cmd"); + logger.t("Ignoring unsupported OpenRing command: $cmd"); + return const []; } if (result.isNotEmpty) { @@ -86,8 +87,12 @@ class OpenRingValueParser extends SensorValueParser { receiveTs: _lastTs, baseHeader: baseHeader, ); + case 0x00: + // Common non-streaming/control response. + return const []; default: - throw Exception("Unknown sub-opcode for IMU data: $subOpcode"); + logger.t("Ignoring unsupported IMU sub-opcode: $subOpcode"); + return const []; } } @@ -157,7 +162,8 @@ class OpenRingValueParser extends SensorValueParser { ); } - throw Exception("Unknown PPG packet type: $type"); + logger.t("Ignoring unsupported PPG packet type: $type"); + return const []; } List> _parseAccel({ From 6b90fc18fe9c21f9369fef92a7e8bf0de4c4f1e6 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:31:49 +0100 Subject: [PATCH 05/13] Make OpenRing parser tolerant to partial/mixed packets --- .../managers/open_ring_sensor_handler.dart | 10 +++- .../open_ring_value_parser.dart | 59 +++++++++++++------ 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/lib/src/managers/open_ring_sensor_handler.dart b/lib/src/managers/open_ring_sensor_handler.dart index 8446473..087ec7c 100644 --- a/lib/src/managers/open_ring_sensor_handler.dart +++ b/lib/src/managers/open_ring_sensor_handler.dart @@ -37,9 +37,13 @@ class OpenRingSensorHandler extends SensorHandler { ) .listen( (data) async { - List> parsedData = await _parseData(data); - for (var d in parsedData) { - streamController.add(d); + 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) { 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 28fbf28..cb78bd4 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 @@ -162,7 +162,9 @@ class OpenRingValueParser extends SensorValueParser { ); } - logger.t("Ignoring unsupported PPG packet type: $type"); + logger.t( + "Ignoring unsupported PPG packet type: $type, frame=${frame.buffer.asUint8List()}" + ); return const []; } @@ -171,14 +173,21 @@ class OpenRingValueParser extends SensorValueParser { required int receiveTs, required Map baseHeader, }) { - if (data.lengthInBytes % 6 != 0) { - throw Exception("Invalid data length for Accel: ${data.lengthInBytes}"); + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 6); + if (usableBytes == 0) { + if (data.lengthInBytes != 0) { + logger.t("Ignoring short Accel payload: len=${data.lengthInBytes}"); + } + return const []; + } + if (usableBytes != data.lengthInBytes) { + logger.t( + "Truncating Accel payload from ${data.lengthInBytes} to $usableBytes bytes", + ); } - - if (data.lengthInBytes == 0) return const []; final List> parsedData = []; - for (int i = 0; i < data.lengthInBytes; i += 6) { + for (int i = 0; i < usableBytes; i += 6) { final int sampleIndex = i ~/ 6; final int ts = receiveTs + sampleIndex * _samplePeriodMs; @@ -199,14 +208,21 @@ class OpenRingValueParser extends SensorValueParser { required int receiveTs, required Map baseHeader, }) { - 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", + ); } - - if (data.lengthInBytes == 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; @@ -241,16 +257,25 @@ class OpenRingValueParser extends SensorValueParser { required int receiveTs, required Map baseHeader, }) { - if (data.lengthInBytes != nSamples * 14) { - throw Exception( - "Invalid data length for PPG waveform: ${data.lengthInBytes}, expected ${nSamples * 14}", - ); + final int expectedBytes = nSamples * 14; + final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 14); + if (usableBytes == 0 || nSamples == 0) { + return const []; } - if (nSamples == 0) return const []; + int usableSamples = usableBytes ~/ 14; + if (usableSamples > nSamples) { + usableSamples = nSamples; + } + + if (data.lengthInBytes != expectedBytes) { + logger.t( + "PPG waveform length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)", + ); + } final List> parsedData = []; - for (int i = 0; i < nSamples; i++) { + for (int i = 0; i < usableSamples; i++) { final int offset = i * 14; final int ts = receiveTs + i * _samplePeriodMs; From 2b1ecfc917af60f57946e3d8f2252cc22641a6cf Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:35:47 +0100 Subject: [PATCH 06/13] Decode OpenRing PPG packet type 0x02 frames --- .../open_ring_value_parser.dart | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) 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 cb78bd4..9328db3 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 @@ -162,6 +162,22 @@ class OpenRingValueParser extends SensorValueParser { ); } + 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: _lastTs, + baseHeader: baseHeader, + ); + } + logger.t( "Ignoring unsupported PPG packet type: $type, frame=${frame.buffer.asUint8List()}" ); @@ -294,4 +310,60 @@ class OpenRingValueParser extends SensorValueParser { 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) { + 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 * _samplePeriodMs; + + parsedData.add({ + ...baseHeader, + "timestamp": ts, + "PPG": { + "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; + } + } From 8e25cbaa2f21584ea57d31f1403f2317e4546148 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:55:25 +0100 Subject: [PATCH 07/13] Fix OpenRing IMU payload offset and flexible waveform logs --- .../open_ring_value_parser.dart | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) 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 9328db3..e119bc8 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 @@ -65,23 +65,48 @@ class OpenRingValueParser extends SensorValueParser { int sequenceNum, int cmd, ) { + if (frame.lengthInBytes < 4) { + throw Exception("IMU frame too short: ${frame.lengthInBytes}"); + } + final int subOpcode = frame.getUint8(3); - final ByteData payload = ByteData.sublistView(frame, 4); + 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, }; switch (subOpcode) { case 0x01: // Accel only (6 bytes per sample) + case 0x04: // Accel only (6 bytes per sample) + if (status == 0x01) { + logger.t("IMU device busy for sub-opcode: $subOpcode"); + return const []; + } return _parseAccel( data: payload, receiveTs: _lastTs, baseHeader: baseHeader, ); 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: _lastTs, @@ -284,7 +309,7 @@ class OpenRingValueParser extends SensorValueParser { usableSamples = nSamples; } - if (data.lengthInBytes != expectedBytes) { + if (data.lengthInBytes != expectedBytes && nSamples > usableSamples) { logger.t( "PPG waveform length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)", ); @@ -339,7 +364,7 @@ class OpenRingValueParser extends SensorValueParser { usableSamples = nSamples; } - if (data.lengthInBytes != expectedBytes) { + if (data.lengthInBytes != expectedBytes && nSamples > usableSamples) { logger.t( "PPG type2 length mismatch len=${data.lengthInBytes} expected=$expectedBytes; parsing $usableSamples sample(s)", ); From cb6aa71b7ebd88688abccf804f88cba03bab75a0 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 10:59:55 +0100 Subject: [PATCH 08/13] Fix OpenRing sample timestamps to be strictly monotonic --- .../utils/sensor_value_parser/open_ring_value_parser.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 e119bc8..9cd64e7 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 @@ -230,7 +230,7 @@ class OpenRingValueParser extends SensorValueParser { final List> parsedData = []; for (int i = 0; i < usableBytes; i += 6) { final int sampleIndex = i ~/ 6; - final int ts = receiveTs + sampleIndex * _samplePeriodMs; + final int ts = receiveTs + (sampleIndex + 1) * _samplePeriodMs; final ByteData sample = ByteData.sublistView(data, i, i + 6); final Map accelData = _parseImuComp(sample); @@ -265,7 +265,7 @@ class OpenRingValueParser extends SensorValueParser { final List> parsedData = []; 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); @@ -318,7 +318,7 @@ class OpenRingValueParser extends SensorValueParser { final List> parsedData = []; for (int i = 0; i < usableSamples; i++) { final int offset = i * 14; - final int ts = receiveTs + i * _samplePeriodMs; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; parsedData.add({ ...baseHeader, @@ -373,7 +373,7 @@ class OpenRingValueParser extends SensorValueParser { final List> parsedData = []; for (int i = 0; i < usableSamples; i++) { final int offset = i * sampleSize; - final int ts = receiveTs + i * _samplePeriodMs; + final int ts = receiveTs + (i + 1) * _samplePeriodMs; parsedData.add({ ...baseHeader, From 9758f6bb2f1a2fff44cc71e6cc1f232ad5976f57 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 11:06:17 +0100 Subject: [PATCH 09/13] Trace OpenRing parsed/emitted values and decouple timestamps by cmd --- .../managers/open_ring_sensor_handler.dart | 9 +++- .../open_ring/open_ring_sensor.dart | 25 +++++++-- .../open_ring_value_parser.dart | 51 +++++++++++++------ 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/lib/src/managers/open_ring_sensor_handler.dart b/lib/src/managers/open_ring_sensor_handler.dart index 087ec7c..87a4be8 100644 --- a/lib/src/managers/open_ring_sensor_handler.dart +++ b/lib/src/managers/open_ring_sensor_handler.dart @@ -74,7 +74,14 @@ class OpenRingSensorHandler extends SensorHandler { 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; } } 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 0bdd69a..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'; @@ -44,13 +45,23 @@ class OpenRingSensor extends Sensor { return; } + final Map sensorDataMap = sensorData; List values = []; - for (var entry in sensorData.entries) { - if (entry.key == 'units') { - continue; + for (final axisName in _axisNames) { + final dynamic axisValue = sensorDataMap[axisName]; + if (axisValue is int) { + values.add(axisValue); } - if (entry.value is int) { - values.add(entry.value as int); + } + + if (values.isEmpty) { + for (var entry in sensorDataMap.entries) { + if (entry.key == 'units') { + continue; + } + if (entry.value is int) { + values.add(entry.value as int); + } } } @@ -58,6 +69,10 @@ class OpenRingSensor extends Sensor { return; } + logger.t( + "OpenRingSensor[$sensorName] emit timestamp=$timestamp values=$values raw=$sensorDataMap", + ); + SensorIntValue sensorValue = SensorIntValue( values: values, timestamp: timestamp, 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 9cd64e7..434f8e4 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,8 +8,8 @@ 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( @@ -32,20 +32,20 @@ class OpenRingValueParser extends SensorValueParser { final int sequenceNum = data.getUint8(1); final int cmd = data.getUint8(2); - 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); + result = _parseImuFrame(data, sequenceNum, cmd, receiveTs); break; case 0x32: // PPG Q2 - result = _parsePpgFrame(data, sequenceNum, cmd); + result = _parsePpgFrame(data, sequenceNum, cmd, receiveTs); break; default: logger.t("Ignoring unsupported OpenRing command: $cmd"); @@ -53,8 +53,15 @@ class OpenRingValueParser extends SensorValueParser { } if (result.isNotEmpty) { - _lastTs = result.last["timestamp"] as int; - logger.t("Updated last timestamp to $_lastTs"); + 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; @@ -64,6 +71,7 @@ class OpenRingValueParser extends SensorValueParser { ByteData frame, int sequenceNum, int cmd, + int receiveTs, ) { if (frame.lengthInBytes < 4) { throw Exception("IMU frame too short: ${frame.lengthInBytes}"); @@ -99,7 +107,7 @@ class OpenRingValueParser extends SensorValueParser { } return _parseAccel( data: payload, - receiveTs: _lastTs, + receiveTs: receiveTs, baseHeader: baseHeader, ); case 0x06: // Accel + Gyro (12 bytes per sample) @@ -109,7 +117,7 @@ class OpenRingValueParser extends SensorValueParser { } return _parseAccelGyro( data: payload, - receiveTs: _lastTs, + receiveTs: receiveTs, baseHeader: baseHeader, ); case 0x00: @@ -125,6 +133,7 @@ class OpenRingValueParser extends SensorValueParser { ByteData frame, int sequenceNum, int cmd, + int receiveTs, ) { if (frame.lengthInBytes < 5) { throw Exception("PPG frame too short: ${frame.lengthInBytes}"); @@ -182,7 +191,7 @@ class OpenRingValueParser extends SensorValueParser { return _parsePpgWaveform( data: waveformPayload, nSamples: nSamples, - receiveTs: _lastTs, + receiveTs: receiveTs, baseHeader: baseHeader, ); } @@ -198,7 +207,7 @@ class OpenRingValueParser extends SensorValueParser { return _parsePpgWaveformType2( data: waveformPayload, nSamples: nSamples, - receiveTs: _lastTs, + receiveTs: receiveTs, baseHeader: baseHeader, ); } @@ -391,4 +400,14 @@ class OpenRingValueParser extends SensorValueParser { 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; + } + } From 907aa83def4e7573a6fb1f94e4e7c6d82ae311bf Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 11:14:08 +0100 Subject: [PATCH 10/13] Estimate IMU sample rate from packet timing for timestamp spacing --- .../open_ring_value_parser.dart | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) 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 434f8e4..9665653 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 @@ -10,6 +10,8 @@ class OpenRingValueParser extends SensorValueParser { final Map _lastSeqByCmd = {}; final Map _lastTsByCmd = {}; + final Map _lastPacketWallMsByImuStream = {}; + final Map _samplePeriodMsByImuStream = {}; @override List> parse( @@ -42,7 +44,13 @@ class OpenRingValueParser extends SensorValueParser { List> result; switch (cmd) { case 0x40: // IMU - result = _parseImuFrame(data, sequenceNum, cmd, receiveTs); + result = _parseImuFrame( + data, + sequenceNum, + cmd, + receiveTs, + DateTime.now().millisecondsSinceEpoch, + ); break; case 0x32: // PPG Q2 result = _parsePpgFrame(data, sequenceNum, cmd, receiveTs); @@ -72,6 +80,7 @@ class OpenRingValueParser extends SensorValueParser { int sequenceNum, int cmd, int receiveTs, + int packetWallMs, ) { if (frame.lengthInBytes < 4) { throw Exception("IMU frame too short: ${frame.lengthInBytes}"); @@ -109,6 +118,12 @@ class OpenRingValueParser extends SensorValueParser { data: payload, receiveTs: receiveTs, baseHeader: baseHeader, + samplePeriodMs: _estimateImuSamplePeriodMs( + streamKey: 'imu_$subOpcode', + packetWallMs: packetWallMs, + payloadLength: payload.lengthInBytes, + bytesPerSample: 6, + ), ); case 0x06: // Accel + Gyro (12 bytes per sample) if (status == 0x01) { @@ -119,6 +134,12 @@ class OpenRingValueParser extends SensorValueParser { data: payload, receiveTs: receiveTs, baseHeader: baseHeader, + samplePeriodMs: _estimateImuSamplePeriodMs( + streamKey: 'imu_$subOpcode', + packetWallMs: packetWallMs, + payloadLength: payload.lengthInBytes, + bytesPerSample: 12, + ), ); case 0x00: // Common non-streaming/control response. @@ -222,6 +243,7 @@ class OpenRingValueParser extends SensorValueParser { required ByteData data, required int receiveTs, required Map baseHeader, + required int samplePeriodMs, }) { final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 6); if (usableBytes == 0) { @@ -239,7 +261,7 @@ class OpenRingValueParser extends SensorValueParser { final List> parsedData = []; for (int i = 0; i < usableBytes; i += 6) { final int sampleIndex = i ~/ 6; - final int ts = receiveTs + (sampleIndex + 1) * _samplePeriodMs; + final int ts = receiveTs + (sampleIndex + 1) * samplePeriodMs; final ByteData sample = ByteData.sublistView(data, i, i + 6); final Map accelData = _parseImuComp(sample); @@ -257,6 +279,7 @@ class OpenRingValueParser extends SensorValueParser { required ByteData data, required int receiveTs, required Map baseHeader, + required int samplePeriodMs, }) { final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 12); if (usableBytes == 0) { @@ -274,7 +297,7 @@ class OpenRingValueParser extends SensorValueParser { final List> parsedData = []; for (int i = 0; i < usableBytes; i += 12) { final int sampleIndex = i ~/ 12; - final int ts = receiveTs + (sampleIndex + 1) * _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); @@ -400,6 +423,41 @@ class OpenRingValueParser extends SensorValueParser { return parsedData; } + + int _estimateImuSamplePeriodMs({ + required String streamKey, + required int packetWallMs, + required int payloadLength, + required int bytesPerSample, + }) { + final int sampleCount = payloadLength ~/ bytesPerSample; + if (sampleCount <= 0) { + return _samplePeriodMsByImuStream[streamKey] ?? _samplePeriodMs; + } + + final int? lastPacketWallMs = _lastPacketWallMsByImuStream[streamKey]; + final int fallbackPeriod = _samplePeriodMsByImuStream[streamKey] ?? _samplePeriodMs; + + int nextPeriod = fallbackPeriod; + if (lastPacketWallMs != null) { + final int elapsedMs = packetWallMs - lastPacketWallMs; + if (elapsedMs > 0) { + final int measuredPeriod = ((elapsedMs / sampleCount).round().clamp(1, 100)) as int; + nextPeriod = ((fallbackPeriod * 3) + measuredPeriod) ~/ 4; + } + } + + _lastPacketWallMsByImuStream[streamKey] = packetWallMs; + _samplePeriodMsByImuStream[streamKey] = nextPeriod; + + final double estHz = 1000.0 / nextPeriod; + logger.t( + "IMU $streamKey estimated samplePeriod=${nextPeriod}ms (~${estHz.toStringAsFixed(1)}Hz), sampleCount=$sampleCount", + ); + + return nextPeriod; + } + Map? _extractSensorPayload(Map sample) { if (sample['Accelerometer'] is Map) { return sample['Accelerometer'] as Map; From b60afdd5d8e1992339d2c9d35277ef6daf353a12 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 11:23:16 +0100 Subject: [PATCH 11/13] Use fixed IMU sample spacing for timestamps --- .../open_ring_value_parser.dart | 60 ++----------------- 1 file changed, 5 insertions(+), 55 deletions(-) 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 9665653..24d8b9c 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 @@ -10,8 +10,6 @@ class OpenRingValueParser extends SensorValueParser { final Map _lastSeqByCmd = {}; final Map _lastTsByCmd = {}; - final Map _lastPacketWallMsByImuStream = {}; - final Map _samplePeriodMsByImuStream = {}; @override List> parse( @@ -44,13 +42,7 @@ class OpenRingValueParser extends SensorValueParser { List> result; switch (cmd) { case 0x40: // IMU - result = _parseImuFrame( - data, - sequenceNum, - cmd, - receiveTs, - DateTime.now().millisecondsSinceEpoch, - ); + result = _parseImuFrame(data, sequenceNum, cmd, receiveTs); break; case 0x32: // PPG Q2 result = _parsePpgFrame(data, sequenceNum, cmd, receiveTs); @@ -80,7 +72,6 @@ class OpenRingValueParser extends SensorValueParser { int sequenceNum, int cmd, int receiveTs, - int packetWallMs, ) { if (frame.lengthInBytes < 4) { throw Exception("IMU frame too short: ${frame.lengthInBytes}"); @@ -107,6 +98,8 @@ class OpenRingValueParser extends SensorValueParser { "status": status, }; + logger.t("IMU using fixed sample period=${_samplePeriodMs}ms"); + switch (subOpcode) { case 0x01: // Accel only (6 bytes per sample) case 0x04: // Accel only (6 bytes per sample) @@ -118,12 +111,7 @@ class OpenRingValueParser extends SensorValueParser { data: payload, receiveTs: receiveTs, baseHeader: baseHeader, - samplePeriodMs: _estimateImuSamplePeriodMs( - streamKey: 'imu_$subOpcode', - packetWallMs: packetWallMs, - payloadLength: payload.lengthInBytes, - bytesPerSample: 6, - ), + samplePeriodMs: _samplePeriodMs, ); case 0x06: // Accel + Gyro (12 bytes per sample) if (status == 0x01) { @@ -134,12 +122,7 @@ class OpenRingValueParser extends SensorValueParser { data: payload, receiveTs: receiveTs, baseHeader: baseHeader, - samplePeriodMs: _estimateImuSamplePeriodMs( - streamKey: 'imu_$subOpcode', - packetWallMs: packetWallMs, - payloadLength: payload.lengthInBytes, - bytesPerSample: 12, - ), + samplePeriodMs: _samplePeriodMs, ); case 0x00: // Common non-streaming/control response. @@ -424,39 +407,6 @@ class OpenRingValueParser extends SensorValueParser { } - int _estimateImuSamplePeriodMs({ - required String streamKey, - required int packetWallMs, - required int payloadLength, - required int bytesPerSample, - }) { - final int sampleCount = payloadLength ~/ bytesPerSample; - if (sampleCount <= 0) { - return _samplePeriodMsByImuStream[streamKey] ?? _samplePeriodMs; - } - - final int? lastPacketWallMs = _lastPacketWallMsByImuStream[streamKey]; - final int fallbackPeriod = _samplePeriodMsByImuStream[streamKey] ?? _samplePeriodMs; - - int nextPeriod = fallbackPeriod; - if (lastPacketWallMs != null) { - final int elapsedMs = packetWallMs - lastPacketWallMs; - if (elapsedMs > 0) { - final int measuredPeriod = ((elapsedMs / sampleCount).round().clamp(1, 100)) as int; - nextPeriod = ((fallbackPeriod * 3) + measuredPeriod) ~/ 4; - } - } - - _lastPacketWallMsByImuStream[streamKey] = packetWallMs; - _samplePeriodMsByImuStream[streamKey] = nextPeriod; - - final double estHz = 1000.0 / nextPeriod; - logger.t( - "IMU $streamKey estimated samplePeriod=${nextPeriod}ms (~${estHz.toStringAsFixed(1)}Hz), sampleCount=$sampleCount", - ); - - return nextPeriod; - } Map? _extractSensorPayload(Map sample) { if (sample['Accelerometer'] is Map) { From d8df56265be1362ff21ff35e738c3761a12a6107 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 11:30:06 +0100 Subject: [PATCH 12/13] Align IMU accel parsing with SDK sub-opcode behavior --- .../open_ring_value_parser.dart | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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 24d8b9c..31863e8 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 @@ -107,7 +107,7 @@ class OpenRingValueParser extends SensorValueParser { logger.t("IMU device busy for sub-opcode: $subOpcode"); return const []; } - return _parseAccel( + return _parseAccelSingle( data: payload, receiveTs: receiveTs, baseHeader: baseHeader, @@ -258,6 +258,38 @@ class OpenRingValueParser extends SensorValueParser { return parsedData; } + List> _parseAccelSingle({ + required ByteData data, + required int receiveTs, + required Map baseHeader, + required int samplePeriodMs, + }) { + if (data.lengthInBytes < 6) { + if (data.lengthInBytes != 0) { + logger.t("Ignoring short Accel payload: len=${data.lengthInBytes}"); + } + return const []; + } + + if (data.lengthInBytes > 6) { + logger.t( + "Accel payload has ${data.lengthInBytes} bytes, decoding first 6 bytes to match SDK behavior", + ); + } + + final int ts = receiveTs + samplePeriodMs; + final ByteData sample = ByteData.sublistView(data, 0, 6); + final Map accelData = _parseImuComp(sample); + + return [ + { + ...baseHeader, + "timestamp": ts, + "Accelerometer": accelData, + }, + ]; + } + List> _parseAccelGyro({ required ByteData data, required int receiveTs, From 81d884258da3ed767c45973901353deb9ee997c1 Mon Sep 17 00:00:00 2001 From: Tobias Roeddiger Date: Wed, 11 Feb 2026 11:35:18 +0100 Subject: [PATCH 13/13] Ignore OpenRing accel-only IMU packets and parse accel+gyro only --- .../open_ring_value_parser.dart | 79 ++----------------- 1 file changed, 5 insertions(+), 74 deletions(-) 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 31863e8..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 @@ -101,18 +101,16 @@ class OpenRingValueParser extends SensorValueParser { logger.t("IMU using fixed sample period=${_samplePeriodMs}ms"); switch (subOpcode) { - case 0x01: // Accel only (6 bytes per sample) - case 0x04: // Accel only (6 bytes per sample) + 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 []; } - return _parseAccelSingle( - data: payload, - receiveTs: receiveTs, - baseHeader: baseHeader, - samplePeriodMs: _samplePeriodMs, + 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"); @@ -222,73 +220,6 @@ class OpenRingValueParser extends SensorValueParser { return const []; } - List> _parseAccel({ - required ByteData data, - required int receiveTs, - required Map baseHeader, - required int samplePeriodMs, - }) { - final int usableBytes = data.lengthInBytes - (data.lengthInBytes % 6); - if (usableBytes == 0) { - if (data.lengthInBytes != 0) { - logger.t("Ignoring short Accel payload: len=${data.lengthInBytes}"); - } - return const []; - } - if (usableBytes != data.lengthInBytes) { - logger.t( - "Truncating Accel payload from ${data.lengthInBytes} to $usableBytes bytes", - ); - } - - final List> parsedData = []; - for (int i = 0; i < usableBytes; i += 6) { - final int sampleIndex = i ~/ 6; - final int ts = receiveTs + (sampleIndex + 1) * 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> _parseAccelSingle({ - required ByteData data, - required int receiveTs, - required Map baseHeader, - required int samplePeriodMs, - }) { - if (data.lengthInBytes < 6) { - if (data.lengthInBytes != 0) { - logger.t("Ignoring short Accel payload: len=${data.lengthInBytes}"); - } - return const []; - } - - if (data.lengthInBytes > 6) { - logger.t( - "Accel payload has ${data.lengthInBytes} bytes, decoding first 6 bytes to match SDK behavior", - ); - } - - final int ts = receiveTs + samplePeriodMs; - final ByteData sample = ByteData.sublistView(data, 0, 6); - final Map accelData = _parseImuComp(sample); - - return [ - { - ...baseHeader, - "timestamp": ts, - "Accelerometer": accelData, - }, - ]; - } List> _parseAccelGyro({ required ByteData data,