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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 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.

## 2.4.1

* fixed microphone gain capability detection so it is only registered when the device exposes the required characteristic.
Expand Down
17 changes: 9 additions & 8 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
dependency: "direct overridden"
description:
path: "generated/dart"
ref: bf61cbe72922045ff850dbdbabfaece8c7a38177
resolved-ref: bf61cbe72922045ff850dbdbabfaece8c7a38177
url: "https://github.com/OpenEarable/protocol.git"
source: git
version: "0.0.4"
package_config:
dependency: transitive
description:
Expand Down
8 changes: 8 additions & 0 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: bf61cbe72922045ff850dbdbabfaece8c7a38177
path: generated/dart

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

Expand Down
5 changes: 3 additions & 2 deletions lib/src/models/devices/open_earable_factory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class OpenEarableFactory extends WearableFactory {
);
} else if (_v2Regex.hasMatch(firmwareVersion)) {
(List<Sensor>, List<SensorConfiguration>) sensorInfo =
await _initSensors(device);
await _initSensors(device, firmwareVersion);
final wearable = OpenEarableV2(
name: device.name,
disconnectNotifier: disconnectNotifier!,
Expand Down Expand Up @@ -202,6 +202,7 @@ class OpenEarableFactory extends WearableFactory {

Future<(List<Sensor>, List<SensorConfiguration>)> _initSensors(
DiscoveredDevice device,
String firmwareVersion,
) async {
List<Sensor> sensors = [];
List<SensorConfiguration> sensorConfigurations = [];
Expand All @@ -212,7 +213,7 @@ class OpenEarableFactory extends WearableFactory {
bleManager: bleManager!,
discoveredDevice: device,
sensorSchemeParser: schemeParser,
sensorValueParser: V2SensorValueParser(),
sensorValueParser: V2SensorValueParser.forFirmware(firmwareVersion),
);

List<SensorScheme> sensorSchemes = await schemeParser.readSensorSchemes();
Expand Down
2 changes: 1 addition & 1 deletion lib/src/models/devices/open_earable_v2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
173 changes: 163 additions & 10 deletions lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
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, 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(ByteData data, List<SensorScheme> sensorSchemes) {
List<Map<String, dynamic>> parse(
ByteData data,
List<SensorScheme> sensorSchemes,
) {
int i = 0;

// Header
Expand All @@ -28,6 +49,13 @@ class V2SensorValueParser extends SensorValueParser {
final baseTimestamp = _readUint64(data, i);
i += 8;

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();
final payloadSizePerSample = compSizes.fold<int>(0, (a, b) => a + b);
Expand All @@ -36,11 +64,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
Expand All @@ -55,18 +87,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) {
Expand All @@ -93,6 +132,117 @@ 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,
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 = <Map<String, dynamic>>[];
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
Expand Down Expand Up @@ -177,7 +327,10 @@ _ParsedSample _parseSample({
i += sz;

// install group and component
out.putIfAbsent(comp.groupName, () => <String, dynamic>{'units': <String, String>{}});
out.putIfAbsent(
comp.groupName,
() => <String, dynamic>{'units': <String, String>{}},
);
(out[comp.groupName] as Map<String, dynamic>)[comp.componentName] = val;

// units
Expand Down
10 changes: 9 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ dependencies:
bloc: ^9.1.0
meta: ^1.16.0
pub_semver: ^2.2.0
open_earable_protocols: ^0.0.2
open_earable_protocols: ^0.0.4

dev_dependencies:
flutter_test:
Expand All @@ -59,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: bf61cbe72922045ff850dbdbabfaece8c7a38177
path: generated/dart
Loading
Loading