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
1 change: 1 addition & 0 deletions lib/openstrap_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
134 changes: 134 additions & 0 deletions lib/src/polar_pmd.dart
Original file line number Diff line number Diff line change
@@ -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<int> polarPmdStartPpi() =>
const [kPolarPmdOpRequestMeasurementStart, kPolarPmdMeasTypePpi];
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

/// The bytes to write to the control point to stop the PPI stream.
List<int> 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<int> 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<PolarPpiSample>? parsePolarPmdPpiFrame(List<int> 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 = <PolarPpiSample>[];
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;
}
119 changes: 119 additions & 0 deletions test/polar_pmd_test.dart
Original file line number Diff line number Diff line change
@@ -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<int> ppiFrame(List<List<int>> 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);
});
});
}
Loading