From 358f790476fcd84b6d9026d7bf74ef95148de030 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:41:50 +0200 Subject: [PATCH] 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); + }); +}