diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index c344d9d..ee87030 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'; export 'src/lefun.dart'; // Source 1 — record decoders. diff --git a/lib/src/polar_pmd.dart b/lib/src/polar_pmd.dart new file mode 100644 index 0000000..04591e5 --- /dev/null +++ b/lib/src/polar_pmd.dart @@ -0,0 +1,134 @@ +// 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. 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 + /// 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; 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 +/// 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] != 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..d7489e3 --- /dev/null +++ b/test/polar_pmd_test.dart @@ -0,0 +1,119 @@ +// 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 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([ + [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); + }); + }); +}