From 199f245401553f9c362435051420c6dedc35e085 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:03:20 +0200 Subject: [PATCH 1/3] feat(ppg): decode firmware 2.3 samples while retaining legacy support --- CHANGELOG.md | 5 + example/pubspec.lock | 13 +- .../models/devices/open_earable_factory.dart | 5 +- lib/src/models/devices/open_earable_v2.dart | 2 +- .../v2_sensor_value_parser.dart | 113 +++++++++- pubspec.yaml | 6 +- test/compact_ppg_test.dart | 213 ++++++++++++++++++ 7 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 test/compact_ppg_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b33c9f..114dfd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding. +- Accept firmware 2.3.x in the OpenEarable V2 support range. + ## 2.4.1 * fixed microphone gain capability detection so it is only registered when the device exposes the required characteristic. diff --git a/example/pubspec.lock b/example/pubspec.lock index 4539e62..84a091c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -414,15 +414,16 @@ packages: path: ".." relative: true source: path - version: "2.4.1" + version: "2.5.0" open_earable_protocols: dependency: transitive description: - name: open_earable_protocols - sha256: c11cae4914827c1d7617d44647a5c49304f0c2a2c02c907c595682cfade6ddb2 - url: "https://pub.dev" - source: hosted - version: "0.0.2" + path: "generated/dart" + ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" + resolved-ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" + url: "https://github.com/OpenEarable/protocol.git" + source: git + version: "0.0.4" package_config: dependency: transitive description: diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index a1d82b0..832d505 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -91,7 +91,7 @@ class OpenEarableFactory extends WearableFactory { ); } else if (_v2Regex.hasMatch(firmwareVersion)) { (List, List) sensorInfo = - await _initSensors(device); + await _initSensors(device, firmwareVersion); final wearable = OpenEarableV2( name: device.name, disconnectNotifier: disconnectNotifier!, @@ -202,6 +202,7 @@ class OpenEarableFactory extends WearableFactory { Future<(List, List)> _initSensors( DiscoveredDevice device, + String firmwareVersion, ) async { List sensors = []; List sensorConfigurations = []; @@ -212,7 +213,7 @@ class OpenEarableFactory extends WearableFactory { bleManager: bleManager!, discoveredDevice: device, sensorSchemeParser: schemeParser, - sensorValueParser: V2SensorValueParser(), + sensorValueParser: V2SensorValueParser.forFirmware(firmwareVersion), ); List sensorSchemes = await schemeParser.readSensorSchemes(); diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index ad3a250..eaa0de5 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -44,7 +44,7 @@ const String _timeSyncRttCharacteristicUuid = "2e04cbf9-939d-4be5-823e-271838b75259"; final VersionConstraint _versionConstraint = - VersionConstraint.parse(">=2.1.0 <2.3.0"); + VersionConstraint.parse(">=2.1.0 <2.4.0"); // MARK: OpenEarableV2 diff --git a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart index 4a9b2d0..5f76f44 100644 --- a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart +++ b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart @@ -1,13 +1,32 @@ import 'dart:typed_data'; +import 'package:open_earable_protocols/open_earable_protocols.dart'; +import 'package:pub_semver/pub_semver.dart'; + import '../sensor_scheme_parser/sensor_scheme_reader.dart'; import 'sensor_value_parser.dart'; const int _boneAccelSensorId = 0x07; class V2SensorValueParser extends SensorValueParser { + /// Defaults to the legacy layout, which all SD/.oe files still use. + V2SensorValueParser({this.compactPpg = false}); + + /// Select once per BLE connection; never infer encoding from packet length. + factory V2SensorValueParser.forFirmware(String firmwareVersion) { + final version = Version.parse(firmwareVersion.trim()); + return V2SensorValueParser( + compactPpg: version.major == 2 && version.minor >= 3, + ); + } + + final bool compactPpg; + @override - List> parse(ByteData data, List sensorSchemes) { + List> parse( + ByteData data, + List sensorSchemes, + ) { int i = 0; // Header @@ -28,6 +47,10 @@ class V2SensorValueParser extends SensorValueParser { final baseTimestamp = _readUint64(data, i); i += 8; + if (compactPpg && sensorId == 4) { + return _parseCompactPpg(data, scheme, baseTimestamp); + } + // Precompute size of one component payload for efficiency. final compSizes = scheme.components.map((c) => c.type.size()).toList(); final payloadSizePerSample = compSizes.fold(0, (a, b) => a + b); @@ -36,11 +59,15 @@ class V2SensorValueParser extends SensorValueParser { const headerSize = 2; if (data.lengthInBytes - headerSize - payloadSizePerSample < 0) { - throw FormatException('Truncated frame: need at least ${timestampSize + offsetSize} bytes ' + throw FormatException( + 'Truncated frame: need at least ${timestampSize + offsetSize} bytes ' 'for first sample, have ${data.lengthInBytes - headerSize}.'); } - if ((data.lengthInBytes - headerSize - timestampSize) != payloadSizePerSample && - (data.lengthInBytes - headerSize - timestampSize - offsetSize) % payloadSizePerSample != 0) { + if ((data.lengthInBytes - headerSize - timestampSize) != + payloadSizePerSample && + (data.lengthInBytes - headerSize - timestampSize - offsetSize) % + payloadSizePerSample != + 0) { if (sensorId == _boneAccelSensorId) { final fixedBytes = Uint8List(data.lengthInBytes + 2); // Bulk-copy existing bytes @@ -55,18 +82,25 @@ class V2SensorValueParser extends SensorValueParser { data = fixedData; } - if ((data.lengthInBytes - headerSize - timestampSize) != payloadSizePerSample && - (data.lengthInBytes - headerSize - timestampSize - offsetSize) % payloadSizePerSample != 0) { - throw FormatException('Truncated frame: have ${data.lengthInBytes - headerSize} bytes, ' + if ((data.lengthInBytes - headerSize - timestampSize) != + payloadSizePerSample && + (data.lengthInBytes - headerSize - timestampSize - offsetSize) % + payloadSizePerSample != + 0) { + throw FormatException( + 'Truncated frame: have ${data.lengthInBytes - headerSize} bytes, ' 'which is not consistent with sample size $payloadSizePerSample, timestamp and offset sizes.'); } } int dataCount; - if (data.lengthInBytes - headerSize - timestampSize == payloadSizePerSample) { + if (data.lengthInBytes - headerSize - timestampSize == + payloadSizePerSample) { dataCount = 1; } else { - dataCount = (data.lengthInBytes - headerSize - timestampSize - offsetSize) ~/ payloadSizePerSample; + dataCount = + (data.lengthInBytes - headerSize - timestampSize - offsetSize) ~/ + payloadSizePerSample; } if (dataCount < 1) { @@ -93,6 +127,62 @@ class V2SensorValueParser extends SensorValueParser { return results; } + + List> _parseCompactPpg( + ByteData data, + SensorScheme scheme, + int timestamp, + ) { + final payloadSize = data.lengthInBytes - 10; + if (data.getUint8(1) != payloadSize || + (payloadSize != 10 && + (payloadSize < 22 || (payloadSize - 2) % 10 != 0))) { + throw const FormatException('Invalid compact PPG payload length'); + } + if (scheme.components.length != 4 || + scheme.components.any((c) => c.type != ParseType.uint32)) { + throw const FormatException( + 'Compact PPG requires four uint32 components', + ); + } + final count = payloadSize == 10 ? 1 : (payloadSize - 2) ~/ 10; + final period = count == 1 ? 0 : _getTimeDiff(data); + if (count > 1 && period == 0) { + throw const FormatException('Invalid compact PPG sample period'); + } + final expanded = ByteData(16); + final result = >[]; + for (var i = 0; i < count; i++) { + final sample = PpgCompactSample.fromBytes( + data.buffer.asUint8List(data.offsetInBytes + 10 + i * 10, 10), + ); + if (sample.bits_64_79 > 0x0fff) { + throw const FormatException('Nonzero compact PPG reserved bits'); + } + expanded.setUint32(0, sample.bits_0_31 & 0x7ffff, Endian.little); + expanded.setUint32( + 4, + (sample.bits_0_31 >>> 19) | ((sample.bits_32_63 & 0x3f) << 13), + Endian.little, + ); + expanded.setUint32(8, (sample.bits_32_63 >>> 6) & 0x7ffff, Endian.little); + expanded.setUint32( + 12, + (sample.bits_32_63 >>> 25) | (sample.bits_64_79 << 7), + Endian.little, + ); + result.add( + _parseSample( + data: expanded, + startIndex: 0, + scheme: scheme, + timestamp: timestamp + i * period, + compSizes: const [4, 4, 4, 4], + ).map, + ); + } + return result; + } } /// Helpers @@ -177,7 +267,10 @@ _ParsedSample _parseSample({ i += sz; // install group and component - out.putIfAbsent(comp.groupName, () => {'units': {}}); + out.putIfAbsent( + comp.groupName, + () => {'units': {}}, + ); (out[comp.groupName] as Map)[comp.componentName] = val; // units diff --git a/pubspec.yaml b/pubspec.yaml index 4773379..cc48cc6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,7 +39,11 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 - open_earable_protocols: ^0.0.2 + open_earable_protocols: + git: + url: https://github.com/OpenEarable/protocol.git + ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + path: generated/dart dev_dependencies: flutter_test: diff --git a/test/compact_ppg_test.dart b/test/compact_ppg_test.dart new file mode 100644 index 0000000..317a688 --- /dev/null +++ b/test/compact_ppg_test.dart @@ -0,0 +1,213 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:pub_semver/pub_semver.dart' as semver; +import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_scheme_reader.dart'; +import 'package:open_earable_flutter/src/utils/sensor_value_parser/v2_sensor_value_parser.dart'; + +const channels = ['Red', 'Infrared', 'Green', 'Ambient']; +const timestamp = 1700000000123456; + +class UnusedBle extends Fake implements BleGattManager {} + +final scheme = SensorScheme(4, 'PPG', 4, null) + ..components = [ + for (final name in channels) + Component(ParseType.uint32, 'PPG', name, 'raw'), + ]; + +ByteData packet( + List> samples, { + required bool compact, + int period = 1953, +}) { + final width = compact ? 10 : 16; + final data = + ByteData(10 + samples.length * width + (samples.length > 1 ? 2 : 0)); + data.setUint8(0, 4); + data.setUint8(1, data.lengthInBytes - 10); + data.setUint32(2, timestamp % 0x100000000, Endian.little); + data.setUint32(6, timestamp ~/ 0x100000000, Endian.little); + for (var n = 0; n < samples.length; n++) { + if (compact) { + // Independent reference packing, including on Dart web. + var bits = BigInt.zero; + for (var c = 0; c < 4; c++) { + bits |= BigInt.from(samples[n][c]) << (19 * c); + } + for (var b = 0; b < 10; b++) { + data.setUint8( + 10 + n * width + b, + ((bits >> (8 * b)) & BigInt.from(255)).toInt(), + ); + } + } else { + for (var c = 0; c < 4; c++) { + data.setUint32(10 + n * width + 4 * c, samples[n][c], Endian.little); + } + } + } + if (samples.length > 1) { + data.setUint16(data.lengthInBytes - 2, period, Endian.little); + } + return data; +} + +void expectSamples( + V2SensorValueParser parser, + ByteData bytes, + List> expected, +) { + final result = parser.parse(bytes, [scheme]); + expect(result, hasLength(expected.length)); + for (var n = 0; n < result.length; n++) { + expect(result[n]['sensorId'], 4); + expect(result[n]['timestamp'], timestamp + n * 1953); + for (var c = 0; c < 4; c++) { + expect(result[n]['PPG'][channels[c]], expected[n][c]); + } + } +} + +void main() { + test('device support range retains older firmware and accepts all 2.3.x', () { + final wearable = OpenEarableV2( + name: 'Test', + disconnectNotifier: WearableDisconnectNotifier(), + sensors: [], + sensorConfigurations: [], + bleManager: UnusedBle(), + discoveredDevice: DiscoveredDevice( + id: 'test', + name: 'Test', + manufacturerData: Uint8List(0), + rssi: -40, + serviceUuids: [], + ), + ); + for (final version in [ + '2.1.0', + '2.2.9', + '2.2.10', + '2.3.0-dev.1+gabc', + '2.3.0', + '2.3.99', + ]) { + expect( + wearable.supportedFirmwareRange.allows(semver.Version.parse(version)), + isTrue, + ); + } + expect( + wearable.supportedFirmwareRange.allows(semver.Version(2, 4, 0)), + isFalse, + ); + }); + + test( + 'selects compact BLE data by each device firmware, including prereleases', + () { + for (final version in ['2.1.0', '2.2.9', '2.2.10', '2.2.10-dev.3+gabc']) { + expect(V2SensorValueParser.forFirmware(version).compactPpg, isFalse); + } + for (final version in ['2.3.0', '2.3.0-dev.1+gabc', '2.3.99']) { + expect(V2SensorValueParser.forFirmware(version).compactPpg, isTrue); + } + }); + + test('retains every channel bit and timestamps in maximum-sized batches', () { + final random = Random(318); + for (var run = 0; run < 100; run++) { + final samples = List.generate( + 23, + (_) => List.generate(4, (_) => random.nextInt(1 << 19)), + ); + samples[0] = [0, 0x7ffff, 0x40000, 1]; + samples[1] = [0x7ffff, 0x7ffff, 0x7ffff, 0x7ffff]; + expectSamples( + V2SensorValueParser.forFirmware('2.3.0'), + packet(samples, compact: true), + samples, + ); + } + }); + + test('single sample fits a 20-byte notification and handles ByteData views', + () { + final samples = [ + [1, 2, 3, 4], + ]; + final bytes = packet(samples, compact: true); + expect(bytes.lengthInBytes, 20); + expect( + bytes.buffer.asUint8List().sublist(10), + [1, 0, 16, 0, 192, 0, 0, 8, 0, 0], + ); + final storage = Uint8List(30)..setRange(5, 25, bytes.buffer.asUint8List()); + expectSamples( + V2SensorValueParser.forFirmware('2.3.0'), + ByteData.sublistView(storage, 5, 25), + samples, + ); + }); + + test('mixed-version peers independently decode equal-length packets', () { + final oldSamples = List.generate(5, (i) => [i, i + 1, i + 2, i + 3]); + final newSamples = List.generate(8, (i) => [i, i + 1, i + 2, i + 3]); + final oldPacket = packet(oldSamples, compact: false); + final newPacket = packet(newSamples, compact: true); + expect(oldPacket.lengthInBytes, newPacket.lengthInBytes); + for (var i = 0; i < 3; i++) { + expectSamples( + V2SensorValueParser.forFirmware('2.2.9'), + oldPacket, + oldSamples, + ); + expectSamples( + V2SensorValueParser.forFirmware('2.3.0'), + newPacket, + newSamples, + ); + } + // File/SD decoding uses the legacy default even for recordings from 2.3.x. + expectSamples(V2SensorValueParser(), oldPacket, oldSamples); + }); + + test('rejects malformed compact packets without emitting partial results', + () { + final parser = V2SensorValueParser.forFirmware('2.3.0'); + final samples = [ + [1, 2, 3, 4], + [5, 6, 7, 8], + ]; + final badLength = packet(samples, compact: true)..setUint8(1, 10); + final badReserved = packet(samples, compact: true)..setUint8(29, 0x80); + final badPeriod = packet(samples, compact: true, period: 0); + for (final malformed in [badLength, badReserved, badPeriod]) { + expect(() => parser.parse(malformed, [scheme]), throwsFormatException); + } + final good = packet(samples, compact: true); + for (var length = 0; length < good.lengthInBytes; length++) { + expect( + () => parser.parse(ByteData.sublistView(good, 0, length), [scheme]), + throwsFormatException, + ); + } + }); + + test('2.3 parser leaves non-PPG sensor decoding unchanged', () { + final temperature = SensorScheme(6, 'Temperature', 1, null) + ..components = [Component(ParseType.float, 'Temperature', 'value', 'C')]; + final bytes = ByteData(14) + ..setUint8(0, 6) + ..setUint8(1, 4) + ..setUint32(2, 123, Endian.little) + ..setFloat32(10, 36.5, Endian.little); + expect( + V2SensorValueParser.forFirmware('2.3.0').parse(bytes, [temperature]), + V2SensorValueParser.forFirmware('2.2.9').parse(bytes, [temperature]), + ); + }); +} From bb202f8e14fdcb1a1cddc972aab044a27119c3bf Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:10:41 +0200 Subject: [PATCH 2/3] build: declare versioned protocol dependency with review overrides --- example/pubspec.lock | 2 +- example/pubspec.yaml | 8 ++++++++ pubspec.yaml | 14 +++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index 84a091c..72010f1 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -416,7 +416,7 @@ packages: source: path version: "2.5.0" open_earable_protocols: - dependency: transitive + dependency: "direct overridden" description: path: "generated/dart" ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e078869..04b84ea 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -62,6 +62,14 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 +# Remove after the protocol 0.0.4 package is published. +dependency_overrides: + open_earable_protocols: + git: + url: https://github.com/OpenEarable/protocol.git + ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + path: generated/dart + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/pubspec.yaml b/pubspec.yaml index cc48cc6..94639a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,11 +39,7 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 - open_earable_protocols: - git: - url: https://github.com/OpenEarable/protocol.git - ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 - path: generated/dart + open_earable_protocols: ^0.0.4 dev_dependencies: flutter_test: @@ -63,3 +59,11 @@ flutter: - assets/wearable_icons/polar/default/ - assets/wearable_icons/polar/watch/ - assets/wearable_icons/polar/strap_sensor/ + +# Remove after the protocol 0.0.4 package is published. +dependency_overrides: + open_earable_protocols: + git: + url: https://github.com/OpenEarable/protocol.git + ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + path: generated/dart From 358f790476fcd84b6d9026d7bf74ef95148de030 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:41:50 +0200 Subject: [PATCH 3/3] Restore compact IMU BLE samples to existing float values --- CHANGELOG.md | 2 + example/pubspec.lock | 4 +- example/pubspec.yaml | 2 +- .../v2_sensor_value_parser.dart | 62 ++++++- pubspec.yaml | 2 +- test/compact_imu_test.dart | 158 ++++++++++++++++++ 6 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 test/compact_imu_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 114dfd6..bb04eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- Decode compact firmware 2.3.x IMU samples back to the same float values and units; retain legacy firmware and SD formats. + - Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding. - Accept firmware 2.3.x in the OpenEarable V2 support range. diff --git a/example/pubspec.lock b/example/pubspec.lock index 72010f1..f13a885 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -419,8 +419,8 @@ packages: dependency: "direct overridden" description: path: "generated/dart" - ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" - resolved-ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" + ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 + resolved-ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 url: "https://github.com/OpenEarable/protocol.git" source: git version: "0.0.4" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 04b84ea..a52e69b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -67,7 +67,7 @@ dependency_overrides: open_earable_protocols: git: url: https://github.com/OpenEarable/protocol.git - ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 path: generated/dart # For information on the generic Dart part of this file, see the diff --git a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart index 5f76f44..1b5479d 100644 --- a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart +++ b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart @@ -10,17 +10,19 @@ const int _boneAccelSensorId = 0x07; class V2SensorValueParser extends SensorValueParser { /// Defaults to the legacy layout, which all SD/.oe files still use. - V2SensorValueParser({this.compactPpg = false}); + V2SensorValueParser({this.compactPpg = false, this.compactImu = false}); /// Select once per BLE connection; never infer encoding from packet length. factory V2SensorValueParser.forFirmware(String firmwareVersion) { final version = Version.parse(firmwareVersion.trim()); return V2SensorValueParser( compactPpg: version.major == 2 && version.minor >= 3, + compactImu: version.major == 2 && version.minor >= 3, ); } final bool compactPpg; + final bool compactImu; @override List> parse( @@ -50,6 +52,9 @@ class V2SensorValueParser extends SensorValueParser { if (compactPpg && sensorId == 4) { return _parseCompactPpg(data, scheme, baseTimestamp); } + if (compactImu && sensorId == 0) { + return _parseCompactImu(data, scheme, baseTimestamp); + } // Precompute size of one component payload for efficiency. final compSizes = scheme.components.map((c) => c.type.size()).toList(); @@ -128,6 +133,61 @@ class V2SensorValueParser extends SensorValueParser { return results; } + List> _parseCompactImu( + ByteData data, + SensorScheme scheme, + int timestamp, + ) { + final payloadSize = data.lengthInBytes - 10; + if (data.getUint8(1) != payloadSize || + (payloadSize != 24 && + (payloadSize < 50 || (payloadSize - 2) % 24 != 0))) { + throw const FormatException('Invalid compact IMU payload length'); + } + if (scheme.components.length != 9 || + scheme.components.any((c) => c.type != ParseType.float)) { + throw const FormatException('Compact IMU requires nine float components'); + } + final count = payloadSize == 24 ? 1 : (payloadSize - 2) ~/ 24; + final period = count == 1 ? 0 : _getTimeDiff(data); + if (count > 1 && period == 0) { + throw const FormatException('Invalid compact IMU sample period'); + } + final expanded = ByteData(36); + final result = >[]; + for (var i = 0; i < count; i++) { + final sample = ImuCompactSample.fromBytes( + data.buffer.asUint8List(data.offsetInBytes + 10 + i * 24, 24), + ); + final raw = [ + sample.accel_x, + sample.accel_y, + sample.accel_z, + sample.gyro_x, + sample.gyro_y, + sample.gyro_z, + ]; + for (var axis = 0; axis < 6; axis++) { + // Exact firmware float32 scales; round the product back to float32. + final scale = axis < 3 ? 0.0005985504249110818 : 0.06103515625; + expanded.setFloat32(axis * 4, raw[axis] * scale, Endian.little); + } + expanded.setFloat32(24, sample.mag_x, Endian.little); + expanded.setFloat32(28, sample.mag_y, Endian.little); + expanded.setFloat32(32, sample.mag_z, Endian.little); + result.add( + _parseSample( + data: expanded, + startIndex: 0, + scheme: scheme, + timestamp: timestamp + i * period, + compSizes: const [4, 4, 4, 4, 4, 4, 4, 4, 4], + ).map, + ); + } + return result; + } + List> _parseCompactPpg( ByteData data, SensorScheme scheme, diff --git a/pubspec.yaml b/pubspec.yaml index 94639a0..f241ced 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,5 +65,5 @@ dependency_overrides: open_earable_protocols: git: url: https://github.com/OpenEarable/protocol.git - ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 path: generated/dart diff --git a/test/compact_imu_test.dart b/test/compact_imu_test.dart new file mode 100644 index 0000000..ff788a8 --- /dev/null +++ b/test/compact_imu_test.dart @@ -0,0 +1,158 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_scheme_reader.dart'; +import 'package:open_earable_flutter/src/utils/sensor_value_parser/v2_sensor_value_parser.dart'; + +const groups = ['ACCELEROMETER', 'GYROSCOPE', 'MAGNETOMETER']; +const units = ['m/s^2', 'dps', 'uT']; +const axes = ['X', 'Y', 'Z']; +final scheme = SensorScheme(0, '9-Axis IMU', 9, null) + ..components = [ + for (var group = 0; group < 3; group++) + for (final axis in axes) + Component(ParseType.float, groups[group], axis, units[group]), + ]; +double f32(double value) => (ByteData(4)..setFloat32(0, value, Endian.little)) + .getFloat32(0, Endian.little); + +ByteData packet(List> samples, {required bool compact}) { + final width = compact ? 24 : 36; + final data = + ByteData(10 + samples.length * width + (samples.length > 1 ? 2 : 0)) + ..setUint8(0, 0) + ..setUint32(2, 123456, Endian.little); + data.setUint8(1, data.lengthInBytes - 10); + for (var i = 0; i < samples.length; i++) { + for (var axis = 0; axis < 6; axis++) { + if (compact) { + data.setInt16( + 10 + i * width + 2 * axis, + samples[i][axis], + Endian.little, + ); + } else { + // Independently reproduce the existing sensor's float32 calculation. + final scale = + axis < 3 ? f32(2.0 * f32(9.80665)) / 32768.0 : 2000.0 / 32768.0; + data.setFloat32( + 10 + i * width + 4 * axis, + samples[i][axis] * scale, + Endian.little, + ); + } + } + for (var axis = 0; axis < 3; axis++) { + data.setFloat32( + 10 + i * width + width - 12 + axis * 4, + [12.345, -67.89, -0.0][axis], + Endian.little, + ); + } + } + if (samples.length > 1) { + data.setUint16(data.lengthInBytes - 2, 10000, Endian.little); + } + return data; +} + +void main() { + final compact = V2SensorValueParser.forFirmware('2.3.0'); + final legacy = V2SensorValueParser.forFirmware('2.2.9'); + + test('firmware selects compact IMU per connection; files retain legacy', () { + for (final version in ['2.1.0', '2.2.9', '2.2.10', '2.2.10-dev.1']) { + expect(V2SensorValueParser.forFirmware(version).compactImu, isFalse); + } + for (final version in ['2.3.0', '2.3.0-dev.1+gabc', '2.3.99']) { + expect(V2SensorValueParser.forFirmware(version).compactImu, isTrue); + } + expect(V2SensorValueParser().compactImu, isFalse); + }); + + test('all int16 counts reconstruct the exact existing floats', () { + for (var raw = -32768; raw <= 32767; raw += 6) { + final samples = [ + for (var n = raw; n <= min(raw + 5, 32767); n++) List.filled(6, n), + ]; + expect( + compact.parse(packet(samples, compact: true), [scheme]), + legacy.parse(packet(samples, compact: false), [scheme]), + ); + } + }); + + test( + 'axis order, units, float types, signed limits, timestamps and views remain unchanged', + () { + final samples = + List.generate(6, (i) => [-32768 + i, 32767 - i, -i, i, 12345, -23456]); + final bytes = packet(samples, compact: true); + final storage = Uint8List(bytes.lengthInBytes + 7) + ..setRange(3, 3 + bytes.lengthInBytes, bytes.buffer.asUint8List()); + final result = compact.parse( + ByteData.sublistView(storage, 3, 3 + bytes.lengthInBytes), + [scheme], + ); + expect(result, legacy.parse(packet(samples, compact: false), [scheme])); + for (var i = 0; i < result.length; i++) { + expect(result[i]['timestamp'], 123456 + 10000 * i); + for (var group = 0; group < 3; group++) { + for (var axis = 0; axis < 3; axis++) { + expect(result[i][groups[group]][axes[axis]], isA()); + expect(scheme.components[group * 3 + axis].unitName, units[group]); + expect(scheme.components[group * 3 + axis].type, ParseType.float); + } + } + expect((result[i]['MAGNETOMETER']['Z'] as double).isNegative, isTrue); + } + expect( + V2SensorValueParser().parse(packet(samples, compact: false), [scheme]), + result, + ); + }); + + test( + 'nine-sample packets and single samples decode; mixed equal lengths do not select format', + () { + final samples = List.generate(9, (i) => List.filled(6, i)); + final bytes = packet(samples, compact: true); + expect(bytes.lengthInBytes, 228); + final parsed = compact.parse(bytes, [scheme]); + expect(parsed, hasLength(9)); + expect(parsed.last['timestamp'], 203456); + final oldBytes = packet(samples.sublist(0, 6), compact: false); + expect(oldBytes.lengthInBytes, bytes.lengthInBytes); + for (var i = 0; i < 3; i++) { + expect(legacy.parse(oldBytes, [scheme]), parsed.sublist(0, 6)); + expect(compact.parse(bytes, [scheme]), parsed); + } + final one = packet(samples.sublist(0, 1), compact: true); + expect(one.lengthInBytes, 34); + expect(compact.parse(one, [scheme]), parsed.sublist(0, 1)); + }); + + test('rejects truncated packets, bad lengths, zero periods and wrong schemes', + () { + final good = packet([List.filled(6, 0), List.filled(6, 1)], compact: true); + for (var length = 0; length < good.lengthInBytes; length++) { + expect( + () => compact.parse(ByteData.sublistView(good, 0, length), [scheme]), + throwsFormatException, + ); + } + final badLength = + ByteData.sublistView(Uint8List.fromList(good.buffer.asUint8List())) + ..setUint8(1, 1); + final badPeriod = + ByteData.sublistView(Uint8List.fromList(good.buffer.asUint8List())) + ..setUint16(good.lengthInBytes - 2, 0, Endian.little); + for (final bytes in [badLength, badPeriod]) { + expect(() => compact.parse(bytes, [scheme]), throwsFormatException); + } + final wrongScheme = SensorScheme(0, 'Wrong', 1, null) + ..components = [Component(ParseType.int16, 'Wrong', 'X', 'counts')]; + expect(() => compact.parse(good, [wrongScheme]), throwsFormatException); + }); +}