From 87ee80349181c54df590f93fde35d2118e6e92b7 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:23:01 +0530 Subject: [PATCH 1/3] polar pmd: decode the ppi stream (control-point + data frame) --- lib/openstrap_protocol.dart | 1 + lib/src/polar_pmd.dart | 131 ++++++++++++++++++++++++++++++++++++ test/polar_pmd_test.dart | 110 ++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 lib/src/polar_pmd.dart create mode 100644 test/polar_pmd_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 10abe3c..f99ac42 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -18,6 +18,7 @@ export 'src/band.dart' show DeviceType, GattProfile, BandProfile; // sharing one barrel must not share a bare verb. export 'src/oura.dart'; export 'src/hrs.dart'; +export 'src/polar_pmd.dart'; // Source 1 — record decoders. export 'src/records.dart' diff --git a/lib/src/polar_pmd.dart b/lib/src/polar_pmd.dart new file mode 100644 index 0000000..142b2bb --- /dev/null +++ b/lib/src/polar_pmd.dart @@ -0,0 +1,131 @@ +// Polar's PMD (measurement data) service, PPI stream only — plain functions, +// no crypto, no key exchange. Any Polar optical sensor that exposes this GATT +// service (armband, ring, chest strap). +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one and +// `flutter_blue_plus` has no simulator path, so this is verified by the wire +// layout and by the compiler. +// +// PPI ONLY. The service also carries ECG, PPG, accelerometer and gyroscope +// streams under the same control point and data characteristic; none of them +// are decoded here; there is no decoder to run over their bytes. PPI needs no +// settings negotiation and is never compressed, which is what makes it the one +// stream worth decoding without a settings-block parser or a per-type +// reassembler. + +/// Control-point request opcodes (first byte of a control-point write). +const int kPolarPmdOpGetMeasurementSettings = 0x01; +const int kPolarPmdOpRequestMeasurementStart = 0x02; +const int kPolarPmdOpStopMeasurement = 0x03; + +/// Measurement-type byte. Low 6 bits of a data frame's first byte carry the +/// same value. +const int kPolarPmdMeasTypePpi = 0x03; + +/// First byte of every control-point INDICATE reply. +const int kPolarPmdControlPointResponseCode = 0xF0; + +/// The bytes to write to the control point to start online PPI streaming. +/// `(recording << 7) | measType` with `recording` clear (online streaming, not +/// on-sensor recording) and no setting blocks — PPI has none to negotiate. +List polarPmdStartPpi() => + const [kPolarPmdOpRequestMeasurementStart, kPolarPmdMeasTypePpi]; + +/// The bytes to write to the control point to stop the PPI stream. +List polarPmdStopPpi() => + const [kPolarPmdOpStopMeasurement, kPolarPmdMeasTypePpi]; + +/// One control-point indicate reply: `[0xF0, reqOpcode, measType, status, …]`. +class PolarPmdControlResponse { + final int reqOpcode; + final int measType; + + /// 0 is success. Anything else is a refusal — this file names no other + /// codes because nothing downstream branches on which one it was. + final int status; + + const PolarPmdControlResponse({ + required this.reqOpcode, + required this.measType, + required this.status, + }); + + bool get ok => status == 0; +} + +/// Parse one control-point notification. Null when it is too short or does +/// not carry the `0xF0` response marker — a malformed reply is dropped, never +/// read as a success. +PolarPmdControlResponse? parsePolarPmdControlResponse(List value) { + if (value.length < 4) return null; + if (value[0] != kPolarPmdControlPointResponseCode) return null; + return PolarPmdControlResponse( + reqOpcode: value[1], + measType: value[2], + status: value[3], + ); +} + +/// One Pulse-to-Pulse Interval record. +class PolarPpiSample { + /// Beats per minute, or 0 when the sensor found no valid beat this record — + /// a refusal, never a measurement (see [parsePolarPmdPpiFrame]'s caller). + final int hr; + + /// The beat-to-beat interval, in milliseconds. + final int ppiMs; + + /// The sensor's own error estimate for [ppiMs], in milliseconds. + final int errorEstimateMs; + + /// Flags byte, bit 0: this reading should be dropped from an HRV + /// computation (a "blocker" sample). + final bool blocker; + + /// Flags byte, bits 1-2, verbatim. These are documented as carrying + /// skin-contact information, but which value means contact and which means + /// none is NOT independently confirmed against hardware — captured under + /// its own name rather than gated on. + final int skinContactBits; + + const PolarPpiSample({ + required this.hr, + required this.ppiMs, + required this.errorEstimateMs, + required this.blocker, + required this.skinContactBits, + }); +} + +/// Parse one PMD data-characteristic notification as a PPI frame. +/// +/// Layout: byte 0 measurement type (low 6 bits); bytes 1-8 a u64 LE PMD +/// timestamp (unused here — PPI carries no clock this decoder needs, see +/// [PolarPpiSample]'s field list); byte 9 frame type (bit 7 = compressed); +/// bytes 10+ one or more fixed 6-byte PPI records: +/// `[hr][ppiMs u16 LE][errorEstimateMs u16 LE][flags]`. +/// +/// Returns null when the frame is too short, is not measurement type PPI, is +/// flagged compressed (PPI is never compressed — a compressed bit here means +/// this is not the shape this decoder expects), or its body is not a whole +/// number of 6-byte records. A malformed frame is dropped, never patched up +/// into a plausible-looking beat. +List? parsePolarPmdPpiFrame(List value) { + if (value.length < 10) return null; + if ((value[0] & 0x3F) != kPolarPmdMeasTypePpi) return null; + if ((value[9] & 0x80) != 0) return null; + final bodyLen = value.length - 10; + if (bodyLen == 0 || bodyLen % 6 != 0) return null; + final out = []; + for (var i = 10; i + 6 <= value.length; i += 6) { + final flags = value[i + 5]; + out.add(PolarPpiSample( + hr: value[i], + ppiMs: value[i + 1] | (value[i + 2] << 8), + errorEstimateMs: value[i + 3] | (value[i + 4] << 8), + blocker: (flags & 0x01) != 0, + skinContactBits: (flags >> 1) & 0x03, + )); + } + return out; +} diff --git a/test/polar_pmd_test.dart b/test/polar_pmd_test.dart new file mode 100644 index 0000000..119d4b5 --- /dev/null +++ b/test/polar_pmd_test.dart @@ -0,0 +1,110 @@ +// The Polar PMD control-point and PPI decoders. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a Polar sensor, +// so these fixtures are built from the PMD wire layout, not captured off a +// device. They pin the decode; they do not prove any real sensor behaves +// this way. + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +void main() { + test('start/stop PPI commands', () { + expect(polarPmdStartPpi(), [0x02, 0x03]); + expect(polarPmdStopPpi(), [0x03, 0x03]); + }); + + group('control-point response', () { + test('a success reply parses', () { + final r = parsePolarPmdControlResponse([0xF0, 0x02, 0x03, 0x00])!; + expect(r.reqOpcode, 0x02); + expect(r.measType, 0x03); + expect(r.ok, isTrue); + }); + + test('a non-zero status is a refusal, not success', () { + final r = parsePolarPmdControlResponse([0xF0, 0x02, 0x03, 0x01])!; + expect(r.ok, isFalse); + }); + + test('missing the 0xF0 marker is not a control-point reply', () { + expect(parsePolarPmdControlResponse([0x01, 0x02, 0x03, 0x00]), isNull); + }); + + test('truncated replies are dropped', () { + expect(parsePolarPmdControlResponse([0xF0, 0x02]), isNull); + }); + }); + + group('PPI frames', () { + List ppiFrame(List> records) => [ + 0x03, // measurement type, low 6 bits + ...List.filled(8, 0), // timestamp, unused by this decoder + 0x00, // frame type: not compressed + for (final r in records) ...r, + ]; + + test('one record decodes', () { + final samples = parsePolarPmdPpiFrame(ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, 0x00], // hr 60, ppi 1000ms, err 10ms + ]))!; + expect(samples, hasLength(1)); + expect(samples.single.hr, 60); + expect(samples.single.ppiMs, 1000); + expect(samples.single.errorEstimateMs, 10); + expect(samples.single.blocker, isFalse); + expect(samples.single.skinContactBits, 0); + }); + + test('several records in one notification all decode, in order', () { + final samples = parsePolarPmdPpiFrame(ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, 0x00], + [61, 0xF0, 0x03, 0x0A, 0x00, 0x00], + ]))!; + expect(samples.map((s) => s.hr), [60, 61]); + }); + + test('the blocker bit and the skin-contact bits are read from flags', () { + // flags 0x07 = blocker (bit0) + both skin-contact bits (bit1, bit2). + final s = parsePolarPmdPpiFrame( + ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, 0x07], + ]))! + .single; + expect(s.blocker, isTrue); + expect(s.skinContactBits, 0x03); + }); + + test('a non-PPI measurement type is not this decoder\'s frame', () { + final frame = ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, 0x00], + ]); + frame[0] = 0x01; // PPG + expect(parsePolarPmdPpiFrame(frame), isNull); + }); + + test('a compressed frame is refused — PPI is never compressed', () { + final frame = ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, 0x00], + ]); + frame[9] = 0x80; + expect(parsePolarPmdPpiFrame(frame), isNull); + }); + + test('a body that is not a whole number of 6-byte records is refused', + () { + final frame = ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, 0x00], + ])..add(0x00); // one trailing byte + expect(parsePolarPmdPpiFrame(frame), isNull); + }); + + test('too short to hold a header is refused', () { + expect(parsePolarPmdPpiFrame([0x03, 0, 0, 0, 0, 0, 0, 0, 0]), isNull); + }); + + test('an empty body is refused', () { + expect(parsePolarPmdPpiFrame(ppiFrame(const [])), isNull); + }); + }); +} From 425a6969f13b7448879ddb5b58d9c99c633e4706 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:44:37 +0530 Subject: [PATCH 2/3] polar pmd: hedge the blocker bit's confidence to match skin-contact bits same never-tested flags byte, same lack of hardware confirmation. --- lib/src/polar_pmd.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/src/polar_pmd.dart b/lib/src/polar_pmd.dart index 142b2bb..550e49d 100644 --- a/lib/src/polar_pmd.dart +++ b/lib/src/polar_pmd.dart @@ -78,8 +78,11 @@ class PolarPpiSample { /// The sensor's own error estimate for [ppiMs], in milliseconds. final int errorEstimateMs; - /// Flags byte, bit 0: this reading should be dropped from an HRV - /// computation (a "blocker" sample). + /// Flags byte, bit 0. Documented as marking a reading that should be + /// dropped from an HRV computation (a "blocker" sample), but — like + /// [skinContactBits] below — this comes from the same never-tested flags + /// byte, so which bit is blocker is NOT independently confirmed against + /// hardware. final bool blocker; /// Flags byte, bits 1-2, verbatim. These are documented as carrying From 75d1221667eb53205787ef88c648291600446ffc Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:08:10 +0530 Subject: [PATCH 3/3] polar pmd: PPI frame type must be exactly 0, not just uncompressed PPI defines only frame type 0; any other value (compressed or not) is not a shape this decoder should accept. --- lib/src/polar_pmd.dart | 14 +++++++------- test/polar_pmd_test.dart | 9 +++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/src/polar_pmd.dart b/lib/src/polar_pmd.dart index 550e49d..04591e5 100644 --- a/lib/src/polar_pmd.dart +++ b/lib/src/polar_pmd.dart @@ -104,19 +104,19 @@ class PolarPpiSample { /// /// Layout: byte 0 measurement type (low 6 bits); bytes 1-8 a u64 LE PMD /// timestamp (unused here — PPI carries no clock this decoder needs, see -/// [PolarPpiSample]'s field list); byte 9 frame type (bit 7 = compressed); -/// bytes 10+ one or more fixed 6-byte PPI records: +/// [PolarPpiSample]'s field list); byte 9 frame type; bytes 10+ one or more +/// fixed 6-byte PPI records: /// `[hr][ppiMs u16 LE][errorEstimateMs u16 LE][flags]`. /// /// Returns null when the frame is too short, is not measurement type PPI, is -/// flagged compressed (PPI is never compressed — a compressed bit here means -/// this is not the shape this decoder expects), or its body is not a whole -/// number of 6-byte records. A malformed frame is dropped, never patched up -/// into a plausible-looking beat. +/// not frame type 0 (PPI defines only frame type 0 — any other value, +/// compressed included, is not the shape this decoder expects), or its body +/// is not a whole number of 6-byte records. A malformed frame is dropped, +/// never patched up into a plausible-looking beat. List? parsePolarPmdPpiFrame(List value) { if (value.length < 10) return null; if ((value[0] & 0x3F) != kPolarPmdMeasTypePpi) return null; - if ((value[9] & 0x80) != 0) return null; + if (value[9] != 0) return null; final bodyLen = value.length - 10; if (bodyLen == 0 || bodyLen % 6 != 0) return null; final out = []; diff --git a/test/polar_pmd_test.dart b/test/polar_pmd_test.dart index 119d4b5..d7489e3 100644 --- a/test/polar_pmd_test.dart +++ b/test/polar_pmd_test.dart @@ -91,6 +91,15 @@ void main() { expect(parsePolarPmdPpiFrame(frame), isNull); }); + test('a non-zero frame type is refused — PPI defines only frame type 0', + () { + final frame = ppiFrame([ + [60, 0xE8, 0x03, 0x0A, 0x00, 0x00], + ]); + frame[9] = 0x01; + expect(parsePolarPmdPpiFrame(frame), isNull); + }); + test('a body that is not a whole number of 6-byte records is refused', () { final frame = ppiFrame([