From 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 21:53:12 +0200 Subject: [PATCH 1/2] feat(ppg): define firmware-selected 19-bit BLE sample packing --- generated/c/CMakeLists.txt | 1 + generated/c/include/ppg_protocol.h | 65 ++++++++++++ generated/c/src/ppg_protocol.c | 99 +++++++++++++++++++ generated/dart/CHANGELOG.md | 4 + .../dart/lib/open_earable_protocols.dart | 1 + generated/dart/lib/src/ppg_protocol.dart | 86 ++++++++++++++++ generated/dart/pubspec.yaml | 2 +- schemas/CHANGELOG.md | 4 + schemas/ppg/README.md | 68 +++++++++++++ schemas/ppg/protocol.yml | 25 +++++ tests/test_ppg_codec.py | 45 +++++++++ 11 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 generated/c/include/ppg_protocol.h create mode 100644 generated/c/src/ppg_protocol.c create mode 100644 generated/dart/lib/src/ppg_protocol.dart create mode 100644 schemas/ppg/README.md create mode 100644 schemas/ppg/protocol.yml create mode 100644 tests/test_ppg_codec.py diff --git a/generated/c/CMakeLists.txt b/generated/c/CMakeLists.txt index ce7dc90..ccc129d 100644 --- a/generated/c/CMakeLists.txt +++ b/generated/c/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(open_earable_protocols STATIC "src/audio_response_protocol.c" "src/button_protocol.c" "src/led_protocol.c" + "src/ppg_protocol.c" "src/wireless_audio_configuration_protocol.c" ) add_library(OpenEarable::Protocols ALIAS open_earable_protocols) diff --git a/generated/c/include/ppg_protocol.h b/generated/c/include/ppg_protocol.h new file mode 100644 index 0000000..ab137f6 --- /dev/null +++ b/generated/c/include/ppg_protocol.h @@ -0,0 +1,65 @@ +// Generated from schemas/ppg/protocol.yml. Do not edit by hand. +#pragma once + +#include "protocol_runtime.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Unchanged SD/.oe sample and BLE sample before firmware 2.3.0. Channel order is red, + * infrared, green, ambient. + */ +typedef struct ppg_legacy_sample_t ppg_legacy_sample_t; +struct ppg_legacy_sample_t { + uint32_t red; + uint32_t infrared; + uint32_t green; + uint32_t ambient; +}; + + +/** + * BLE only from firmware 2.3.0. Concatenate red, infrared, green, ambient as four unsigned + * 19-bit values, least significant bit first. The high four bits of bits_64_79 are zero. + * See README for bit extraction and firmware selection. + */ +typedef struct ppg_compact_sample_t ppg_compact_sample_t; +struct ppg_compact_sample_t { + uint32_t bits_0_31; + uint32_t bits_32_63; + uint16_t bits_64_79; +}; + + +/** + * Encode a binary representation of this message. Unchanged SD/.oe sample and BLE sample + * before firmware 2.3.0. Channel order is red, infrared, green, ambient. + */ +protocol_status_t ppg_legacy_sample_encode(const ppg_legacy_sample_t *message, uint8_t *buffer, size_t buffer_size, size_t *bytes_written); +/** + * Decode a binary representation into this message. Unchanged SD/.oe sample and BLE sample + * before firmware 2.3.0. Channel order is red, infrared, green, ambient. + */ +protocol_status_t ppg_legacy_sample_decode(ppg_legacy_sample_t *message, const uint8_t *buffer, size_t buffer_size, size_t *bytes_read); + +/** + * Encode a binary representation of this message. BLE only from firmware 2.3.0. + * Concatenate red, infrared, green, ambient as four unsigned 19-bit values, least + * significant bit first. The high four bits of bits_64_79 are zero. See README for bit + * extraction and firmware selection. + */ +protocol_status_t ppg_compact_sample_encode(const ppg_compact_sample_t *message, uint8_t *buffer, size_t buffer_size, size_t *bytes_written); +/** + * Decode a binary representation into this message. BLE only from firmware 2.3.0. + * Concatenate red, infrared, green, ambient as four unsigned 19-bit values, least + * significant bit first. The high four bits of bits_64_79 are zero. See README for bit + * extraction and firmware selection. + */ +protocol_status_t ppg_compact_sample_decode(ppg_compact_sample_t *message, const uint8_t *buffer, size_t buffer_size, size_t *bytes_read); + +#ifdef __cplusplus +} +#endif + diff --git a/generated/c/src/ppg_protocol.c b/generated/c/src/ppg_protocol.c new file mode 100644 index 0000000..7428b2a --- /dev/null +++ b/generated/c/src/ppg_protocol.c @@ -0,0 +1,99 @@ +// Generated from schemas/ppg/protocol.yml. Do not edit by hand. +#include "ppg_protocol.h" + +static protocol_status_t ppg_legacy_sample_write(protocol_writer_t *writer, const ppg_legacy_sample_t *message) { + protocol_status_t status; + status = protocol_write_uint32(writer, message->red); + if (status != PROTOCOL_OK) return status; + status = protocol_write_uint32(writer, message->infrared); + if (status != PROTOCOL_OK) return status; + status = protocol_write_uint32(writer, message->green); + if (status != PROTOCOL_OK) return status; + status = protocol_write_uint32(writer, message->ambient); + if (status != PROTOCOL_OK) return status; + return PROTOCOL_OK; +} + +static protocol_status_t ppg_legacy_sample_read(protocol_reader_t *reader, ppg_legacy_sample_t *message) { + protocol_status_t status; + status = protocol_read_uint32(reader, &message->red); + if (status != PROTOCOL_OK) return status; + status = protocol_read_uint32(reader, &message->infrared); + if (status != PROTOCOL_OK) return status; + status = protocol_read_uint32(reader, &message->green); + if (status != PROTOCOL_OK) return status; + status = protocol_read_uint32(reader, &message->ambient); + if (status != PROTOCOL_OK) return status; + return PROTOCOL_OK; +} + +protocol_status_t ppg_legacy_sample_encode(const ppg_legacy_sample_t *message, uint8_t *buffer, size_t buffer_size, size_t *bytes_written) { + protocol_writer_t writer = { buffer, buffer_size, 0 }; + protocol_status_t status = ppg_legacy_sample_write(&writer, message); + if (status != PROTOCOL_OK) { + return status; + } + if (bytes_written != NULL) { + *bytes_written = writer.offset; + } + return PROTOCOL_OK; +} + +protocol_status_t ppg_legacy_sample_decode(ppg_legacy_sample_t *message, const uint8_t *buffer, size_t buffer_size, size_t *bytes_read) { + protocol_reader_t reader = { buffer, buffer_size, 0 }; + protocol_status_t status = ppg_legacy_sample_read(&reader, message); + if (status != PROTOCOL_OK) { + return status; + } + if (bytes_read != NULL) { + *bytes_read = reader.offset; + } + return PROTOCOL_OK; +} + +static protocol_status_t ppg_compact_sample_write(protocol_writer_t *writer, const ppg_compact_sample_t *message) { + protocol_status_t status; + status = protocol_write_uint32(writer, message->bits_0_31); + if (status != PROTOCOL_OK) return status; + status = protocol_write_uint32(writer, message->bits_32_63); + if (status != PROTOCOL_OK) return status; + status = protocol_write_uint16(writer, message->bits_64_79); + if (status != PROTOCOL_OK) return status; + return PROTOCOL_OK; +} + +static protocol_status_t ppg_compact_sample_read(protocol_reader_t *reader, ppg_compact_sample_t *message) { + protocol_status_t status; + status = protocol_read_uint32(reader, &message->bits_0_31); + if (status != PROTOCOL_OK) return status; + status = protocol_read_uint32(reader, &message->bits_32_63); + if (status != PROTOCOL_OK) return status; + status = protocol_read_uint16(reader, &message->bits_64_79); + if (status != PROTOCOL_OK) return status; + return PROTOCOL_OK; +} + +protocol_status_t ppg_compact_sample_encode(const ppg_compact_sample_t *message, uint8_t *buffer, size_t buffer_size, size_t *bytes_written) { + protocol_writer_t writer = { buffer, buffer_size, 0 }; + protocol_status_t status = ppg_compact_sample_write(&writer, message); + if (status != PROTOCOL_OK) { + return status; + } + if (bytes_written != NULL) { + *bytes_written = writer.offset; + } + return PROTOCOL_OK; +} + +protocol_status_t ppg_compact_sample_decode(ppg_compact_sample_t *message, const uint8_t *buffer, size_t buffer_size, size_t *bytes_read) { + protocol_reader_t reader = { buffer, buffer_size, 0 }; + protocol_status_t status = ppg_compact_sample_read(&reader, message); + if (status != PROTOCOL_OK) { + return status; + } + if (bytes_read != NULL) { + *bytes_read = reader.offset; + } + return PROTOCOL_OK; +} + diff --git a/generated/dart/CHANGELOG.md b/generated/dart/CHANGELOG.md index c811969..08db9f2 100644 --- a/generated/dart/CHANGELOG.md +++ b/generated/dart/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.0.4 + +- Define lossless 10-byte BLE PPG samples for firmware 2.3.x alongside the unchanged 16-byte legacy/SD layout. + ## 0.0.3 * added wireless-audio-configuration protocol diff --git a/generated/dart/lib/open_earable_protocols.dart b/generated/dart/lib/open_earable_protocols.dart index c279de4..58b1fee 100644 --- a/generated/dart/lib/open_earable_protocols.dart +++ b/generated/dart/lib/open_earable_protocols.dart @@ -11,4 +11,5 @@ export 'src/audio_configuration_protocol.dart'; export 'src/audio_response_protocol.dart'; export 'src/button_protocol.dart'; export 'src/led_protocol.dart'; +export 'src/ppg_protocol.dart'; export 'src/wireless_audio_configuration_protocol.dart'; diff --git a/generated/dart/lib/src/ppg_protocol.dart b/generated/dart/lib/src/ppg_protocol.dart new file mode 100644 index 0000000..05058e3 --- /dev/null +++ b/generated/dart/lib/src/ppg_protocol.dart @@ -0,0 +1,86 @@ +// Generated from schemas/ppg/protocol.yml. Do not edit by hand. +import 'dart:typed_data'; +import 'protocol_runtime.dart'; + +/// Unchanged SD/.oe sample and BLE sample before firmware 2.3.0. Channel order is red, +/// infrared, green, ambient. +class PpgLegacySample { + /// Creates a PpgLegacySample value. + PpgLegacySample({required this.red, required this.infrared, required this.green, required this.ambient}); + + final int red; + final int infrared; + final int green; + final int ambient; + + /// Decodes a complete PpgLegacySample value from [bytes]. + factory PpgLegacySample.fromBytes(Uint8List bytes) { + final reader = ProtocolReader(bytes); + final value = PpgLegacySample._read(reader); + reader.finish(); + return value; + } + + static PpgLegacySample _read(ProtocolReader reader) { + final red = reader.uint32(); + final infrared = reader.uint32(); + final green = reader.uint32(); + final ambient = reader.uint32(); + return PpgLegacySample(red: red, infrared: infrared, green: green, ambient: ambient); + } + + /// Encodes this value to the protocol binary representation. + Uint8List toBytes() { + final writer = ProtocolWriter(); + _write(writer); + return writer.takeBytes(); + } + + void _write(ProtocolWriter writer) { + writer.uint32(red); + writer.uint32(infrared); + writer.uint32(green); + writer.uint32(ambient); + } +} + +/// BLE only from firmware 2.3.0. Concatenate red, infrared, green, ambient as four unsigned +/// 19-bit values, least significant bit first. The high four bits of bits_64_79 are zero. +/// See README for bit extraction and firmware selection. +class PpgCompactSample { + /// Creates a PpgCompactSample value. + PpgCompactSample({required this.bits_0_31, required this.bits_32_63, required this.bits_64_79}); + + final int bits_0_31; + final int bits_32_63; + final int bits_64_79; + + /// Decodes a complete PpgCompactSample value from [bytes]. + factory PpgCompactSample.fromBytes(Uint8List bytes) { + final reader = ProtocolReader(bytes); + final value = PpgCompactSample._read(reader); + reader.finish(); + return value; + } + + static PpgCompactSample _read(ProtocolReader reader) { + final bits_0_31 = reader.uint32(); + final bits_32_63 = reader.uint32(); + final bits_64_79 = reader.uint16(); + return PpgCompactSample(bits_0_31: bits_0_31, bits_32_63: bits_32_63, bits_64_79: bits_64_79); + } + + /// Encodes this value to the protocol binary representation. + Uint8List toBytes() { + final writer = ProtocolWriter(); + _write(writer); + return writer.takeBytes(); + } + + void _write(ProtocolWriter writer) { + writer.uint32(bits_0_31); + writer.uint32(bits_32_63); + writer.uint16(bits_64_79); + } +} + diff --git a/generated/dart/pubspec.yaml b/generated/dart/pubspec.yaml index 0b1f6cd..6b3efb0 100644 --- a/generated/dart/pubspec.yaml +++ b/generated/dart/pubspec.yaml @@ -1,6 +1,6 @@ name: open_earable_protocols description: Generated binary protocol bindings for OpenEarable devices. -version: 0.0.3 +version: 0.0.4 repository: https://github.com/OpenEarable/protocol issue_tracker: https://github.com/OpenEarable/protocol/issues topics: diff --git a/schemas/CHANGELOG.md b/schemas/CHANGELOG.md index 374d012..dc62fd1 100644 --- a/schemas/CHANGELOG.md +++ b/schemas/CHANGELOG.md @@ -1,3 +1,7 @@ +## Compact PPG + +- Define firmware-selected BLE PPG packing; retain sensor ID, envelope, timestamps, and SD samples. + ## [Unreleased] * created wireless-audio-configuration protocol for device-owned ACL and diff --git a/schemas/ppg/README.md b/schemas/ppg/README.md new file mode 100644 index 0000000..de8a9a9 --- /dev/null +++ b/schemas/ppg/README.md @@ -0,0 +1,68 @@ +# PPG sensor data + +The sensor stream uses service `34c2e3bb-34aa-11eb-adc1-0242ac120002` +and notify characteristic `34c2e3bc-34aa-11eb-adc1-0242ac120002`. +PPG keeps sensor ID **4** and its advertised four `uint32` logical components. + +## Select the BLE format by firmware version + +Read each device's firmware version before subscribing. Firmware **2.1.x and +2.2.x**, including 2.2.9 and 2.2.10, sends 16-byte samples. Firmware **2.3.x** +sends 10-byte samples. Development versions such as `2.3.0-dev.1+gabc` use the +2.3 format too. Keep this selection per connection, including mixed-version +stereo pairs, and refresh it after reconnect/FOTA. Do not infer the format from +packet length: some lengths are valid in both encodings. + +There is no new packet discriminator, sensor ID, or characteristic. This is a +minor-version wire-format change; older clients need the new decoder to stream +PPG from 2.3 firmware. Updated clients continue supporting older firmware. + +## Packet envelope (unchanged) + +| Offset | Field | +|---|---| +| 0 | Sensor ID, `04` | +| 1 | Payload byte length, excluding the ten-byte header | +| 2–9 | First sample's uint64 timestamp, microseconds, little-endian | +| 10… | One or more samples | +| Last two bytes, only with multiple samples | uint16 sample period in microseconds, little-endian | + +A single sample has no period suffix. Multiple samples share the first +timestamp and one period: timestamp(i) = first + i × period. The firmware's +existing bounded timestamp batching policy is unchanged. + +## Sample encodings + +The legacy sample is four little-endian uint32 values in **red, infrared, +green, ambient** order (16 bytes). It remains the format in **all SD/.oe files, +even when recorded by firmware 2.3.x**. File readers must not select compact +encoding based on the recording firmware version. + +For BLE on 2.3.x, pack each of those values into 19 bits in the same order: + +| Bits in the ten-byte little-endian sample | Value | +|---|---| +| 0–18 | Red | +| 19–37 | Infrared | +| 38–56 | Green | +| 57–75 | Ambient | +| 76–79 | Reserved, zero | + +All sensor bits are retained; no rescaling, quantization, or channel removal +occurs. Values outside 0…524287 and nonzero reserved bits are invalid. + +The schema represents the packed sample as two uint32 words `a`, `b` and a +uint16 word `c`, avoiding 64-bit bitwise arithmetic on Dart web. Decode with: + +``` +red = a & 0x7ffff +infrared = (a >> 19) | ((b & 0x3f) << 13) +green = (b >> 6) & 0x7ffff +ambient = (b >> 25) | (c << 7) +``` + +For all four channels at full scale, the encoded sample is +`ff ff ff ff ff ff ff ff ff 0f`. Zero channels encode as ten zero bytes. +Per-sample payload shrinks by 37.5%. A 244-byte notification holds 23 compact +samples (242 bytes including header and period), versus 14 legacy samples +(236 bytes). This is packet capacity, not a guarantee of achieved BLE rate. diff --git a/schemas/ppg/protocol.yml b/schemas/ppg/protocol.yml new file mode 100644 index 0000000..fc51f8d --- /dev/null +++ b/schemas/ppg/protocol.yml @@ -0,0 +1,25 @@ +protocol: ppg +version: 1 +description: Lossless PPG samples in the BLE sensor stream. Firmware 2.3.x packs four 19-bit channels into ten bytes; earlier firmware and all SD files use four uint32 values. + +messages: + legacy_sample: + description: Unchanged SD/.oe sample and BLE sample before firmware 2.3.0. Channel order is red, infrared, green, ambient. + fields: + - name: red + type: uint32 + - name: infrared + type: uint32 + - name: green + type: uint32 + - name: ambient + type: uint32 + compact_sample: + description: BLE only from firmware 2.3.0. Concatenate red, infrared, green, ambient as four unsigned 19-bit values, least significant bit first. The high four bits of bits_64_79 are zero. See README for bit extraction and firmware selection. + fields: + - name: bits_0_31 + type: uint32 + - name: bits_32_63 + type: uint32 + - name: bits_64_79 + type: uint16 diff --git a/tests/test_ppg_codec.py b/tests/test_ppg_codec.py new file mode 100644 index 0000000..84ff7d2 --- /dev/null +++ b/tests/test_ppg_codec.py @@ -0,0 +1,45 @@ +"""Cross-check generated PPG C bindings against fixed wire bytes.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class PpgCodecTest(unittest.TestCase): + def test_c_wire_layout(self): + cc = shutil.which('cc') + if cc is None: + self.skipTest('C compiler unavailable') + source = r''' +#include +#include +#include "ppg_protocol.h" +int main(void) { + const uint8_t full[10] = {255,255,255,255,255,255,255,255,255,15}; + uint8_t out[16]; size_t size = 0; + ppg_compact_sample_t packed = {0xffffffff, 0xffffffff, 0x0fff}; + assert(ppg_compact_sample_encode(&packed, out, sizeof(out), &size) == PROTOCOL_OK); + assert(size == 10 && memcmp(full, out, 10) == 0); + ppg_compact_sample_t decoded = {0}; + assert(ppg_compact_sample_decode(&decoded, full, 10, &size) == PROTOCOL_OK); + assert(size == 10 && decoded.bits_0_31 == packed.bits_0_31); + assert(decoded.bits_32_63 == packed.bits_32_63 && decoded.bits_64_79 == 0xfff); + for (size_t n = 0; n < 10; ++n) + assert(ppg_compact_sample_decode(&decoded, full, n, &size) != PROTOCOL_OK); + ppg_legacy_sample_t legacy = {1, 2, 3, 4}; + const uint8_t old[16] = {1,0,0,0,2,0,0,0,3,0,0,0,4,0,0,0}; + assert(ppg_legacy_sample_encode(&legacy, out, sizeof(out), &size) == PROTOCOL_OK); + assert(size == 16 && memcmp(old, out, 16) == 0); + return 0; +} +''' + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path/'test.c').write_text(source) + subprocess.run([cc, '-std=c99', '-Wall', '-Wextra', '-Werror', + '-Igenerated/c/include', str(path/'test.c'), + 'generated/c/src/ppg_protocol.c', + 'generated/c/src/protocol_runtime.c', + '-o', str(path/'test')], check=True) + subprocess.run([str(path/'test')], check=True) From bf61cbe72922045ff850dbdbabfaece8c7a38177 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:35:31 +0200 Subject: [PATCH 2/2] feat(imu): define lossless compact BLE motion sample layout --- generated/c/CMakeLists.txt | 1 + generated/c/include/imu_protocol.h | 44 +++++++++++ generated/c/src/imu_protocol.c | 73 +++++++++++++++++++ generated/dart/CHANGELOG.md | 1 + .../dart/lib/open_earable_protocols.dart | 1 + generated/dart/lib/src/imu_protocol.dart | 61 ++++++++++++++++ schemas/imu/README.md | 38 ++++++++++ schemas/imu/protocol.yml | 26 +++++++ tests/test_imu_codec.py | 43 +++++++++++ 9 files changed, 288 insertions(+) create mode 100644 generated/c/include/imu_protocol.h create mode 100644 generated/c/src/imu_protocol.c create mode 100644 generated/dart/lib/src/imu_protocol.dart create mode 100644 schemas/imu/README.md create mode 100644 schemas/imu/protocol.yml create mode 100644 tests/test_imu_codec.py diff --git a/generated/c/CMakeLists.txt b/generated/c/CMakeLists.txt index ccc129d..d0241c3 100644 --- a/generated/c/CMakeLists.txt +++ b/generated/c/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(open_earable_protocols STATIC "src/audio_configuration_protocol.c" "src/audio_response_protocol.c" "src/button_protocol.c" + "src/imu_protocol.c" "src/led_protocol.c" "src/ppg_protocol.c" "src/wireless_audio_configuration_protocol.c" diff --git a/generated/c/include/imu_protocol.h b/generated/c/include/imu_protocol.h new file mode 100644 index 0000000..7dbe739 --- /dev/null +++ b/generated/c/include/imu_protocol.h @@ -0,0 +1,44 @@ +// Generated from schemas/imu/protocol.yml. Do not edit by hand. +#pragma once + +#include "protocol_runtime.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * BLE only from firmware 2.3.0; six signed raw motion readings followed by three unchanged + * compensated magnetometer floats. See README for exact scales and firmware selection. + */ +typedef struct imu_compact_sample_t imu_compact_sample_t; +struct imu_compact_sample_t { + int16_t accel_x; + int16_t accel_y; + int16_t accel_z; + int16_t gyro_x; + int16_t gyro_y; + int16_t gyro_z; + float mag_x; + float mag_y; + float mag_z; +}; + + +/** + * Encode a binary representation of this message. BLE only from firmware 2.3.0; six signed + * raw motion readings followed by three unchanged compensated magnetometer floats. See + * README for exact scales and firmware selection. + */ +protocol_status_t imu_compact_sample_encode(const imu_compact_sample_t *message, uint8_t *buffer, size_t buffer_size, size_t *bytes_written); +/** + * Decode a binary representation into this message. BLE only from firmware 2.3.0; six + * signed raw motion readings followed by three unchanged compensated magnetometer floats. + * See README for exact scales and firmware selection. + */ +protocol_status_t imu_compact_sample_decode(imu_compact_sample_t *message, const uint8_t *buffer, size_t buffer_size, size_t *bytes_read); + +#ifdef __cplusplus +} +#endif + diff --git a/generated/c/src/imu_protocol.c b/generated/c/src/imu_protocol.c new file mode 100644 index 0000000..0d830af --- /dev/null +++ b/generated/c/src/imu_protocol.c @@ -0,0 +1,73 @@ +// Generated from schemas/imu/protocol.yml. Do not edit by hand. +#include "imu_protocol.h" + +static protocol_status_t imu_compact_sample_write(protocol_writer_t *writer, const imu_compact_sample_t *message) { + protocol_status_t status; + status = protocol_write_int16(writer, message->accel_x); + if (status != PROTOCOL_OK) return status; + status = protocol_write_int16(writer, message->accel_y); + if (status != PROTOCOL_OK) return status; + status = protocol_write_int16(writer, message->accel_z); + if (status != PROTOCOL_OK) return status; + status = protocol_write_int16(writer, message->gyro_x); + if (status != PROTOCOL_OK) return status; + status = protocol_write_int16(writer, message->gyro_y); + if (status != PROTOCOL_OK) return status; + status = protocol_write_int16(writer, message->gyro_z); + if (status != PROTOCOL_OK) return status; + status = protocol_write_float(writer, message->mag_x); + if (status != PROTOCOL_OK) return status; + status = protocol_write_float(writer, message->mag_y); + if (status != PROTOCOL_OK) return status; + status = protocol_write_float(writer, message->mag_z); + if (status != PROTOCOL_OK) return status; + return PROTOCOL_OK; +} + +static protocol_status_t imu_compact_sample_read(protocol_reader_t *reader, imu_compact_sample_t *message) { + protocol_status_t status; + status = protocol_read_int16(reader, &message->accel_x); + if (status != PROTOCOL_OK) return status; + status = protocol_read_int16(reader, &message->accel_y); + if (status != PROTOCOL_OK) return status; + status = protocol_read_int16(reader, &message->accel_z); + if (status != PROTOCOL_OK) return status; + status = protocol_read_int16(reader, &message->gyro_x); + if (status != PROTOCOL_OK) return status; + status = protocol_read_int16(reader, &message->gyro_y); + if (status != PROTOCOL_OK) return status; + status = protocol_read_int16(reader, &message->gyro_z); + if (status != PROTOCOL_OK) return status; + status = protocol_read_float(reader, &message->mag_x); + if (status != PROTOCOL_OK) return status; + status = protocol_read_float(reader, &message->mag_y); + if (status != PROTOCOL_OK) return status; + status = protocol_read_float(reader, &message->mag_z); + if (status != PROTOCOL_OK) return status; + return PROTOCOL_OK; +} + +protocol_status_t imu_compact_sample_encode(const imu_compact_sample_t *message, uint8_t *buffer, size_t buffer_size, size_t *bytes_written) { + protocol_writer_t writer = { buffer, buffer_size, 0 }; + protocol_status_t status = imu_compact_sample_write(&writer, message); + if (status != PROTOCOL_OK) { + return status; + } + if (bytes_written != NULL) { + *bytes_written = writer.offset; + } + return PROTOCOL_OK; +} + +protocol_status_t imu_compact_sample_decode(imu_compact_sample_t *message, const uint8_t *buffer, size_t buffer_size, size_t *bytes_read) { + protocol_reader_t reader = { buffer, buffer_size, 0 }; + protocol_status_t status = imu_compact_sample_read(&reader, message); + if (status != PROTOCOL_OK) { + return status; + } + if (bytes_read != NULL) { + *bytes_read = reader.offset; + } + return PROTOCOL_OK; +} + diff --git a/generated/dart/CHANGELOG.md b/generated/dart/CHANGELOG.md index 08db9f2..581706e 100644 --- a/generated/dart/CHANGELOG.md +++ b/generated/dart/CHANGELOG.md @@ -1,5 +1,6 @@ ## 0.0.4 +- Define 24-byte BLE IMU samples for firmware 2.3.x; retain float values/units and the 36-byte SD layout. - Define lossless 10-byte BLE PPG samples for firmware 2.3.x alongside the unchanged 16-byte legacy/SD layout. ## 0.0.3 diff --git a/generated/dart/lib/open_earable_protocols.dart b/generated/dart/lib/open_earable_protocols.dart index 58b1fee..6b8f93e 100644 --- a/generated/dart/lib/open_earable_protocols.dart +++ b/generated/dart/lib/open_earable_protocols.dart @@ -10,6 +10,7 @@ export 'src/protocol_runtime.dart' export 'src/audio_configuration_protocol.dart'; export 'src/audio_response_protocol.dart'; export 'src/button_protocol.dart'; +export 'src/imu_protocol.dart'; export 'src/led_protocol.dart'; export 'src/ppg_protocol.dart'; export 'src/wireless_audio_configuration_protocol.dart'; diff --git a/generated/dart/lib/src/imu_protocol.dart b/generated/dart/lib/src/imu_protocol.dart new file mode 100644 index 0000000..c3e0287 --- /dev/null +++ b/generated/dart/lib/src/imu_protocol.dart @@ -0,0 +1,61 @@ +// Generated from schemas/imu/protocol.yml. Do not edit by hand. +import 'dart:typed_data'; +import 'protocol_runtime.dart'; + +/// BLE only from firmware 2.3.0; six signed raw motion readings followed by three unchanged +/// compensated magnetometer floats. See README for exact scales and firmware selection. +class ImuCompactSample { + /// Creates a ImuCompactSample value. + ImuCompactSample({required this.accel_x, required this.accel_y, required this.accel_z, required this.gyro_x, required this.gyro_y, required this.gyro_z, required this.mag_x, required this.mag_y, required this.mag_z}); + + final int accel_x; + final int accel_y; + final int accel_z; + final int gyro_x; + final int gyro_y; + final int gyro_z; + final double mag_x; + final double mag_y; + final double mag_z; + + /// Decodes a complete ImuCompactSample value from [bytes]. + factory ImuCompactSample.fromBytes(Uint8List bytes) { + final reader = ProtocolReader(bytes); + final value = ImuCompactSample._read(reader); + reader.finish(); + return value; + } + + static ImuCompactSample _read(ProtocolReader reader) { + final accel_x = reader.int16(); + final accel_y = reader.int16(); + final accel_z = reader.int16(); + final gyro_x = reader.int16(); + final gyro_y = reader.int16(); + final gyro_z = reader.int16(); + final mag_x = reader.float32(); + final mag_y = reader.float32(); + final mag_z = reader.float32(); + return ImuCompactSample(accel_x: accel_x, accel_y: accel_y, accel_z: accel_z, gyro_x: gyro_x, gyro_y: gyro_y, gyro_z: gyro_z, mag_x: mag_x, mag_y: mag_y, mag_z: mag_z); + } + + /// Encodes this value to the protocol binary representation. + Uint8List toBytes() { + final writer = ProtocolWriter(); + _write(writer); + return writer.takeBytes(); + } + + void _write(ProtocolWriter writer) { + writer.int16(accel_x); + writer.int16(accel_y); + writer.int16(accel_z); + writer.int16(gyro_x); + writer.int16(gyro_y); + writer.int16(gyro_z); + writer.float32(mag_x); + writer.float32(mag_y); + writer.float32(mag_z); + } +} + diff --git a/schemas/imu/README.md b/schemas/imu/README.md new file mode 100644 index 0000000..cfdbf50 --- /dev/null +++ b/schemas/imu/README.md @@ -0,0 +1,38 @@ +# IMU sensor data + +Keep sensor ID **0**, the existing sensor-stream UUIDs, and the existing packet +header/timestamps. Read each device's firmware version before subscribing: +firmware 2.1.x/2.2.x sends nine little-endian float32 values (36 bytes/sample); +firmware 2.3.x, including prereleases, sends the 24-byte sample below. Select +per connection and refresh after reconnect/FOTA; do not infer from packet size. + +| Byte offsets | Wire value | +|---|---| +| 0–5 | Accelerometer X/Y/Z, three little-endian signed int16 counts | +| 6–11 | Gyroscope X/Y/Z, three little-endian signed int16 counts | +| 12–23 | Magnetometer X/Y/Z, three unchanged little-endian float32 values in µT | + +Restore the six motion values to float32 before applying the existing parsing +scheme or exposing values to applications: + +- Acceleration in m/s²: `float32(raw * 0.0005985504249110818)`. + This is exactly the firmware's float32 `(2.0f * 9.80665f) / 32768.0f` scale. +- Angular velocity in degrees/s: `float32(raw * 0.06103515625)`. + This is exactly `2000.0f / 32768.0f`. + +These scales are part of the 2.3.x wire contract, matching the existing ±2 g +and ±2000 degrees/s sensor configuration. Recover counts by rounding the +existing float divided by its scale; reject values outside int16 or values +that do not reconstruct the original float32 exactly. Do not round, rescale, +or remove magnetometer values: they already include factory compensation. + +The advertised parsing scheme, library values/types/units and phone CSVs stay +unchanged. **All SD/.oe IMU samples remain nine float32 values (36 bytes)**, +including recordings from 2.3.x. File readers must use the legacy layout. + +The packet envelope is unchanged: sensor ID (uint8), payload byte length +(uint8), first timestamp (uint64 microseconds), samples, then a uint16 period +in microseconds for multi-sample packets only. All integers are little-endian. +A 244-byte notification holds nine compact samples (228 bytes), versus six +legacy samples (228 bytes). Sample payload shrinks by one third; actual BLE +throughput still depends on radio scheduling and batching. diff --git a/schemas/imu/protocol.yml b/schemas/imu/protocol.yml new file mode 100644 index 0000000..8105953 --- /dev/null +++ b/schemas/imu/protocol.yml @@ -0,0 +1,26 @@ +protocol: imu +version: 1 +description: BLE IMU samples for firmware 2.3.x. Logical values and all SD records remain nine float32 values. + +messages: + compact_sample: + description: BLE only from firmware 2.3.0; six signed raw motion readings followed by three unchanged compensated magnetometer floats. See README for exact scales and firmware selection. + fields: + - name: accel_x + type: int16 + - name: accel_y + type: int16 + - name: accel_z + type: int16 + - name: gyro_x + type: int16 + - name: gyro_y + type: int16 + - name: gyro_z + type: int16 + - name: mag_x + type: float + - name: mag_y + type: float + - name: mag_z + type: float diff --git a/tests/test_imu_codec.py b/tests/test_imu_codec.py new file mode 100644 index 0000000..5c8208e --- /dev/null +++ b/tests/test_imu_codec.py @@ -0,0 +1,43 @@ +"""Verify signed IMU fields and unchanged float32 magnetometer bytes.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class ImuCodecTest(unittest.TestCase): + def test_c_wire_layout(self): + cc = shutil.which('cc') + if cc is None: + self.skipTest('C compiler unavailable') + source = r''' +#include +#include +#include "imu_protocol.h" +int main(void) { + const uint8_t expected[24] = { + 0,128,255,255,0,0,1,0,0,64,255,127, + 0,0,160,63,0,0,32,192,0,0,0,128}; + imu_compact_sample_t value = {-32768,-1,0,1,16384,32767,1.25f,-2.5f,-0.0f}; + uint8_t out[24]; size_t size; + assert(imu_compact_sample_encode(&value, out, sizeof(out), &size) == PROTOCOL_OK); + assert(size == 24 && memcmp(out, expected, sizeof(out)) == 0); + imu_compact_sample_t decoded; + assert(imu_compact_sample_decode(&decoded, out, sizeof(out), &size) == PROTOCOL_OK); + assert(decoded.accel_x == -32768 && decoded.accel_y == -1 && decoded.accel_z == 0); + assert(decoded.gyro_x == 1 && decoded.gyro_y == 16384 && decoded.gyro_z == 32767); + assert(memcmp(&decoded.mag_x, &value.mag_x, 3 * sizeof(float)) == 0); + for (size_t n = 0; n < 24; ++n) + assert(imu_compact_sample_decode(&decoded, out, n, &size) != PROTOCOL_OK); +} +''' + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / 'test.c').write_text(source) + subprocess.run([cc, '-std=c99', '-Wall', '-Wextra', '-Werror', + '-Igenerated/c/include', str(path / 'test.c'), + 'generated/c/src/imu_protocol.c', + 'generated/c/src/protocol_runtime.c', + '-o', str(path / 'test')], check=True) + subprocess.run([str(path / 'test')], check=True)