Skip to content

Commit 5f42d7f

Browse files
authored
Merge pull request #342 from OpenStrap/feat/polar-pmd
polar pmd: pair + stream ppi from a verity sense or oh1
2 parents 28c702c + 6fad0c7 commit 5f42d7f

15 files changed

Lines changed: 697 additions & 36 deletions

lib/ble/adapters/_registry.dart

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ import 'signals.dart';
5353
const String kHeartRateServiceUuid = '0000180d-0000-1000-8000-00805f9b34fb';
5454
const String kHeartRateMeasurementUuid = '00002a37-0000-1000-8000-00805f9b34fb';
5555

56+
/// A Polar optical sensor's PMD (measurement data) service. Plain GATT — no
57+
/// encryption, no key exchange, no bonding requirement at this layer.
58+
const String kPolarPmdService = 'fb005c80-02e7-f387-1cad-8acd2d8df0c8';
59+
60+
/// Write-with-response, indicate. Every PMD command goes here; its indicate
61+
/// reply carries the command's outcome.
62+
const String kPolarPmdControlChar = 'fb005c81-02e7-f387-1cad-8acd2d8df0c8';
63+
64+
/// Notify. Every measurement stream this service can carry shares this one
65+
/// data characteristic — see `polar_pmd.dart` (adapter) for why only the PPI
66+
/// stream is decoded.
67+
const String kPolarPmdDataChar = 'fb005c82-02e7-f387-1cad-8acd2d8df0c8';
5668
/// A generic white-label smart ring's GATT service ("R11M"/"R10M", also sold
5769
/// as "TK5") — NOT the Colmi R11/R12, a different, unrelated product on a
5870
/// different protocol.
@@ -792,6 +804,29 @@ const BandEntry kBleHrs = BandEntry.notify(
792804
timeAnchor: TimeAnchor.arrival,
793805
);
794806

807+
/// A Polar optical sensor (Verity Sense, OH1) speaking the PMD service, PPI
808+
/// stream only.
809+
///
810+
/// Plain unencrypted GATT — a control-point write plus a data notify, no
811+
/// bonding requirement at this layer, same shape as [kBleHrs] with one
812+
/// addition: streaming has to be switched on with a control-point write
813+
/// before the data characteristic says anything, and the adapter is what does
814+
/// that (see `polar_pmd.dart`).
815+
///
816+
/// EXPERIMENTAL and it stays that way: nobody on this project owns one, so not
817+
/// a byte of this path has met hardware (ASSUMPTIONS R6). It pairs, connects,
818+
/// and streams decoded beats; `kDerivableSources` stays empty until someone
819+
/// has actually held one.
820+
const BandEntry kPolarPmd = BandEntry.notify(
821+
id: 'polar_pmd',
822+
label: 'Polar sensor',
823+
service: kPolarPmdService,
824+
characteristics: <String>[kPolarPmdControlChar, kPolarPmdDataChar],
825+
// PPI carries beat-to-beat durations and no clock of its own. See
826+
// [TimeAnchor].
827+
timeAnchor: TimeAnchor.arrival,
828+
);
829+
795830
/// The Oura ring, a fetch-by-cursor band with a challenge-response handshake.
796831
///
797832
/// NOT framed, and the three fields a framed entry carries would each be wrong
@@ -1506,6 +1541,7 @@ const List<BandEntry> kBandRegistry = <BandEntry>[
15061541
kWhoopGen5,
15071542
kBleHrs,
15081543
kOura,
1544+
kPolarPmd,
15091545
kCoros,
15101546
kUltrahuman,
15111547
kWithingsSteelHr,
@@ -1594,6 +1630,10 @@ const Map<String, Map<InputSignal, Duration>> kAdapterSignals =
15941630
InputSignal.rrIntervals: Duration(seconds: 1),
15951631
},
15961632
'oura': <InputSignal, Duration>{},
1633+
'polar_pmd': {
1634+
InputSignal.hrSparse: Duration(seconds: 1),
1635+
InputSignal.rrIntervals: Duration(seconds: 1),
1636+
},
15971637
'ring11m': <InputSignal, Duration>{},
15981638
'coros': {
15991639
InputSignal.hrSparse: Duration(seconds: 1),

lib/ble/adapters/adapter.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,18 @@ class ReplayBandLink implements BandLink {
366366

367367
/// End every channel, which ends `run()`. Await the run subscription's
368368
/// `done` after this rather than guessing at a delay.
369+
///
370+
/// PLAIN AND UNCONDITIONAL, on purpose — a `hasListener`-gated version (skip
371+
/// the await, fire-and-forget `close()` on a channel with no listener yet)
372+
/// was tried here and reverted: it reproducibly hung `HrsLink.ingestForTest`
373+
/// teardown (`test/pair_sensor_test.dart`'s disarm case), bisected down to
374+
/// this exact conditional — not the round-draining wrapper some earlier
375+
/// version of this method also carried, which made no difference either
376+
/// way. A single-subscription `StreamController.close()` is normally safe to
377+
/// await even with no listener attached — it does not block waiting for one
378+
/// to appear — so there was no real bug this gate was fixing; whatever
379+
/// narrow race it was reasoning about did not hold up against the real
380+
/// fixture. Keep this plain.
369381
Future<void> close() async {
370382
for (final c in _channels.values) {
371383
await c.close();

lib/ble/adapters/polar_pmd.dart

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// A Polar optical sensor's PMD (measurement data) service, PPI stream only —
2+
// as a [BandAdapter].
3+
//
4+
// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one and
5+
// `flutter_blue_plus` has no simulator path, so everything below is verified
6+
// by the wire layout in `polar_pmd.dart` (protocol), the fixtures in
7+
// `test/adapters/polar_pmd_adapter_test.dart` and the compiler. It ships
8+
// EXPERIMENTAL (ASSUMPTIONS R6) until the owner has held one and cross-
9+
// confirms it.
10+
//
11+
// PPI ONLY, deliberately. The PMD service also carries PPG, ECG, accelerometer
12+
// and gyroscope streams — none of them are decoded here, and each would need
13+
// its own settings negotiation and its own undecoded-raw-archive format for a
14+
// stream nothing consumes today. PPI needs no settings block, streams online
15+
// with one control-point write, and is fully decoded per-sample, so there is
16+
// nothing to archive raw — same reasoning `ble_hrs` already gives for skipping
17+
// `raw`.
18+
19+
import 'dart:async';
20+
21+
import 'package:openstrap_protocol/openstrap_protocol.dart';
22+
23+
import '_registry.dart';
24+
import 'adapter.dart';
25+
import 'signals.dart';
26+
27+
/// The adapter. Const, and it holds no session state — everything a session
28+
/// needs lives inside [run].
29+
class PolarPmdAdapter extends BandAdapter {
30+
const PolarPmdAdapter();
31+
32+
@override
33+
BandEntry get entry => kPolarPmd;
34+
35+
/// HR + beat interval — functionally the same pair `ble_hrs` declares, from
36+
/// a different physical sensor. See that adapter's own doc on why a
37+
/// declared-but-absent signal is worse than a missing one; this is the
38+
/// honest statement of what a PPI stream physically carries.
39+
@override
40+
Map<InputSignal, Duration> get signals => const {
41+
InputSignal.hrSparse: Duration(seconds: 1),
42+
InputSignal.rrIntervals: Duration(seconds: 1),
43+
};
44+
45+
/// How long to wait for the START command's control-point reply before
46+
/// giving up on this session — short, because a sensor that never answers
47+
/// it will never stream either.
48+
static const Duration _startTimeout = Duration(seconds: 5);
49+
50+
@override
51+
Stream<BandEvent> run(BandLink link) async* {
52+
// The control-point reply this session is waiting for, resolved the
53+
// moment a START reply for the PPI type arrives. A misbehaving sensor
54+
// that never answers times out rather than hanging the session.
55+
final started = Completer<bool>();
56+
final controlSub = link.notify(kPolarPmdControlChar).listen((rec) {
57+
final r = parsePolarPmdControlResponse(rec.$2);
58+
if (r != null &&
59+
r.reqOpcode == kPolarPmdOpRequestMeasurementStart &&
60+
r.measType == kPolarPmdMeasTypePpi &&
61+
!started.isCompleted) {
62+
started.complete(r.ok);
63+
}
64+
});
65+
// SUBSCRIBED NOW, READ LATER. The sensor is free to start streaming the
66+
// moment the START write lands — before this session has even seen its
67+
// control-point ack — so the data characteristic has to be listened to
68+
// (and its notifications therefore buffered) from the same instant as
69+
// the control characteristic, not only once the ack arrives. A single-
70+
// subscription controller queues everything `.add`ed before `.stream`
71+
// gets its listener, which is what makes the two-step
72+
// subscribe-then-consume below lose nothing. Same shape `oura.dart`'s
73+
// `_Inbox` exists for; smaller because this stream needs no "next with
74+
// timeout", just a buffered pass-through.
75+
final dataEvents = StreamController<(int, List<int>)>();
76+
final dataSub = link.notify(kPolarPmdDataChar).listen(
77+
dataEvents.add,
78+
onDone: dataEvents.close,
79+
onError: dataEvents.addError,
80+
);
81+
try {
82+
if (!await link.write(kPolarPmdControlChar, polarPmdStartPpi())) {
83+
link.log('polar_pmd: START write refused; ending the session.');
84+
return;
85+
}
86+
final ok = await started.future
87+
.timeout(_startTimeout, onTimeout: () => false);
88+
if (!ok) {
89+
link.log('polar_pmd: PPI start was not confirmed; ending the '
90+
'session.');
91+
return;
92+
}
93+
await for (final (atSec, value) in dataEvents.stream) {
94+
final samples = parsePolarPmdPpiFrame(value);
95+
if (samples == null) continue;
96+
final neutrals = [
97+
for (final s in samples)
98+
// hr == 0 is the sensor's own "no valid beat this record" — a
99+
// refusal, not a low reading. Storing it would put a fabricated
100+
// zero into a heart-rate series, the same rule `ble_hrs` applies
101+
// to a strap reporting no skin contact.
102+
if (s.hr != 0)
103+
NeutralSample(
104+
anchor: TimeAnchor.arrival,
105+
tsEpoch: atSec,
106+
hr: s.hr,
107+
rrMs: [s.ppiMs],
108+
vendor: {
109+
'blocker': s.blocker,
110+
// Raw bits, under their own name — their real-world
111+
// polarity is not independently confirmed against
112+
// hardware, so nothing here gates on them (see
113+
// `PolarPpiSample.skinContactBits`'s own doc).
114+
'skin_contact': s.skinContactBits,
115+
'error_ms': s.errorEstimateMs,
116+
},
117+
),
118+
];
119+
if (neutrals.isNotEmpty) yield SampleBatch(neutrals);
120+
}
121+
} finally {
122+
// Best-effort: a link that has already dropped simply refuses this
123+
// write, which is fine — the sensor stops streaming on disconnect
124+
// regardless.
125+
unawaited(link.write(kPolarPmdControlChar, polarPmdStopPpi()));
126+
await controlSub.cancel();
127+
await dataSub.cancel();
128+
unawaited(dataEvents.close());
129+
}
130+
// No OffloadCheckpoint, ever. Online streaming only — nothing is stored
131+
// on the sensor for this stream, so there is nothing to tell it to forget.
132+
}
133+
}
134+
135+
/// The single instance. Const, so it costs nothing to reference.
136+
const PolarPmdAdapter kPolarPmdAdapter = PolarPmdAdapter();

lib/ble/hrs_link.dart

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import 'miband_link.dart' show MiBand234Link;
7676
import 'o2ring_link.dart' show O2RingLink;
7777
import 'oura_link.dart' show OuraLink;
7878
import 'pebble_link.dart' show PebbleLink;
79+
import 'polar_pmd_link.dart' show PolarPmdLink;
7980
import 'qhybrid_link.dart' show QHybridLink;
8081
import 'ring11m_link.dart' show Ring11mLink;
8182
import 'ringconn_link.dart' show RingConnLink;
@@ -757,12 +758,17 @@ class HrsLink {
757758
return;
758759
}
759760
// Before the row goes, not after: a live session would keep writing rows
760-
// under an id nothing can explain any more. GATED ON THE ROW BEING THE
761-
// ARMED HRS SENSOR, not called unconditionally — this fallback used to run
762-
// for ANY adapter without its own branch above, which would tear down a
763-
// live chest-strap session while forgetting an unrelated device (e.g. a
764-
// Lefun ring paired alongside one).
765-
if (row?['adapter_id'] == kBleHrsAdapter.id) {
761+
// under an id nothing can explain any more. Every live-session link
762+
// capable of writing under this id gets disarmed, not just this class's
763+
// own — a Polar row's live sensor is `PolarPmdLink`, not `instance`. GATED
764+
// ON THE ROW BEING THE ARMED HRS SENSOR for the `instance` branch, not
765+
// called unconditionally — this fallback used to run for ANY adapter
766+
// without its own branch above, which would tear down a live chest-strap
767+
// session while forgetting an unrelated device (e.g. a Lefun ring paired
768+
// alongside one).
769+
if (row?['adapter_id'] == kPolarPmd.id) {
770+
await PolarPmdLink.instance.disarm();
771+
} else if (row?['adapter_id'] == kBleHrsAdapter.id) {
766772
await instance.disarm();
767773
}
768774
await LocalDb.deleteDevice(id);

0 commit comments

Comments
 (0)