Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
4 changes: 2 additions & 2 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, dynamic>> parse(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -128,6 +133,61 @@ class V2SensorValueParser extends SensorValueParser {
return results;
}

List<Map<String, dynamic>> _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 = <Map<String, dynamic>>[];
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<Map<String, dynamic>> _parseCompactPpg(
ByteData data,
SensorScheme scheme,
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,5 @@ dependency_overrides:
open_earable_protocols:
git:
url: https://github.com/OpenEarable/protocol.git
ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7
ref: bf61cbe72922045ff850dbdbabfaece8c7a38177
path: generated/dart
158 changes: 158 additions & 0 deletions test/compact_imu_test.dart
Original file line number Diff line number Diff line change
@@ -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<List<int>> 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<double>());
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);
});
}
Loading