|
| 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(); |
0 commit comments