From 66299351c165fc31f7c2528088aec5873b922d2b Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:34:28 +0530 Subject: [PATCH 1/8] polar pmd: pair + stream ppi (beat rate + rr) from a verity sense or oh1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pairs and connects like the heart-rate strap does, no handshake, no key. control-point write starts the ppi stream at workout time; decodes hr + beat interval into the same shape ble_hrs already emits. experimental — nothing derives from it yet. --- lib/ble/adapters/_registry.dart | 41 ++++ lib/ble/adapters/adapter.dart | 22 ++- lib/ble/adapters/polar_pmd.dart | 136 +++++++++++++ lib/ble/polar_pmd_link.dart | 222 ++++++++++++++++++++++ lib/l10n/app_en.arb | 4 + lib/ui2/pairing/device_picker.dart | 3 + lib/ui2/profile/devices.dart | 12 +- test/adapters/polar_pmd_adapter_test.dart | 136 +++++++++++++ 8 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 lib/ble/adapters/polar_pmd.dart create mode 100644 lib/ble/polar_pmd_link.dart create mode 100644 test/adapters/polar_pmd_adapter_test.dart diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index ae24d143e..1b9af4a51 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -53,6 +53,19 @@ import 'signals.dart'; const String kHeartRateServiceUuid = '0000180d-0000-1000-8000-00805f9b34fb'; const String kHeartRateMeasurementUuid = '00002a37-0000-1000-8000-00805f9b34fb'; +/// A Polar optical sensor's PMD (measurement data) service. Plain GATT — no +/// encryption, no key exchange, no bonding requirement at this layer. +const String kPolarPmdService = 'fb005c80-02e7-f387-1cad-8acd2d8df0c8'; + +/// Write-with-response, indicate. Every PMD command goes here; its indicate +/// reply carries the command's outcome. +const String kPolarPmdControlChar = 'fb005c81-02e7-f387-1cad-8acd2d8df0c8'; + +/// Notify. Every measurement stream this service can carry shares this one +/// data characteristic — see `polar_pmd.dart` (adapter) for why only the PPI +/// stream is decoded. +const String kPolarPmdDataChar = 'fb005c82-02e7-f387-1cad-8acd2d8df0c8'; + /// The Oura ring's GATT service, identical across the generations seen so far. const String kOuraService = '98ed0001-a541-11e4-b6a0-0002a5d5c51b'; @@ -412,6 +425,29 @@ const BandEntry kBleHrs = BandEntry.notify( timeAnchor: TimeAnchor.arrival, ); +/// A Polar optical sensor (Verity Sense, OH1) speaking the PMD service, PPI +/// stream only. +/// +/// Plain unencrypted GATT — a control-point write plus a data notify, no +/// bonding requirement at this layer, same shape as [kBleHrs] with one +/// addition: streaming has to be switched on with a control-point write +/// before the data characteristic says anything, and the adapter is what does +/// that (see `polar_pmd.dart`). +/// +/// EXPERIMENTAL and it stays that way: nobody on this project owns one, so not +/// a byte of this path has met hardware (ASSUMPTIONS R6). It pairs, connects, +/// and streams decoded beats; `kDerivableSources` stays empty until someone +/// has actually held one. +const BandEntry kPolarPmd = BandEntry.notify( + id: 'polar_pmd', + label: 'Polar sensor', + service: kPolarPmdService, + characteristics: [kPolarPmdControlChar, kPolarPmdDataChar], + // PPI carries beat-to-beat durations and no clock of its own. See + // [TimeAnchor]. + timeAnchor: TimeAnchor.arrival, +); + /// The Oura ring, a fetch-by-cursor band with a challenge-response handshake. /// /// NOT framed, and the three fields a framed entry carries would each be wrong @@ -444,6 +480,7 @@ const List kBandRegistry = [ kWhoopGen5, kBleHrs, kOura, + kPolarPmd, ]; /// The bands the OFFLOAD ENGINE can drive, and the bands iOS provisions @@ -500,6 +537,10 @@ const Map> kAdapterSignals = InputSignal.rrIntervals: Duration(seconds: 1), }, 'oura': {}, + 'polar_pmd': { + InputSignal.hrSparse: Duration(seconds: 1), + InputSignal.rrIntervals: Duration(seconds: 1), + }, }; /// The signals one adapter declares, or empty for an id this build has no diff --git a/lib/ble/adapters/adapter.dart b/lib/ble/adapters/adapter.dart index 4aff44b87..98dd03fa6 100644 --- a/lib/ble/adapters/adapter.dart +++ b/lib/ble/adapters/adapter.dart @@ -326,9 +326,27 @@ class ReplayBandLink implements BandLink { /// End every channel, which ends `run()`. Await the run subscription's /// `done` after this rather than guessing at a delay. + /// + /// SNAPSHOT THE VALUES FIRST. An adapter that calls [notify] lazily — after + /// an `await` this method's own `await c.close()` yields to — adds a new + /// entry to [_channels] while this loop is mid-iteration, which throws + /// "Concurrent modification during iteration" on the live map. + /// + /// A CHANNEL WITH NO LISTENER IS NOT AWAITED. A single-subscription + /// [StreamController]'s `close()` future only completes once its done event + /// has actually been delivered to a listener, so a channel a fixture fed + /// but an adapter never subscribed to (or has not subscribed to yet — a + /// lazy, sequential subscriber reaches its second `notify()` only after an + /// earlier one resolves) would hang this forever. `close()` is still called + /// on every channel either way, so a listener that attaches afterward still + /// gets its done event immediately. Future close() async { - for (final c in _channels.values) { - await c.close(); + for (final c in _channels.values.toList()) { + if (c.hasListener) { + await c.close(); + } else { + unawaited(c.close()); + } } _channels.clear(); } diff --git a/lib/ble/adapters/polar_pmd.dart b/lib/ble/adapters/polar_pmd.dart new file mode 100644 index 000000000..dfe22cedf --- /dev/null +++ b/lib/ble/adapters/polar_pmd.dart @@ -0,0 +1,136 @@ +// A Polar optical sensor's PMD (measurement data) service, PPI stream only — +// as a [BandAdapter]. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one and +// `flutter_blue_plus` has no simulator path, so everything below is verified +// by the wire layout in `polar_pmd.dart` (protocol), the fixtures in +// `test/adapters/polar_pmd_adapter_test.dart` and the compiler. It ships +// EXPERIMENTAL (ASSUMPTIONS R6) until the owner has held one and cross- +// confirms it. +// +// PPI ONLY, deliberately. The PMD service also carries PPG, ECG, accelerometer +// and gyroscope streams — none of them are decoded here, and each would need +// its own settings negotiation and its own undecoded-raw-archive format for a +// stream nothing consumes today. PPI needs no settings block, streams online +// with one control-point write, and is fully decoded per-sample, so there is +// nothing to archive raw — same reasoning `ble_hrs` already gives for skipping +// `raw`. + +import 'dart:async'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +import '_registry.dart'; +import 'adapter.dart'; +import 'signals.dart'; + +/// The adapter. Const, and it holds no session state — everything a session +/// needs lives inside [run]. +class PolarPmdAdapter extends BandAdapter { + const PolarPmdAdapter(); + + @override + BandEntry get entry => kPolarPmd; + + /// HR + beat interval — functionally the same pair `ble_hrs` declares, from + /// a different physical sensor. See that adapter's own doc on why a + /// declared-but-absent signal is worse than a missing one; this is the + /// honest statement of what a PPI stream physically carries. + @override + Map get signals => const { + InputSignal.hrSparse: Duration(seconds: 1), + InputSignal.rrIntervals: Duration(seconds: 1), + }; + + /// How long to wait for the START command's control-point reply before + /// giving up on this session — short, because a sensor that never answers + /// it will never stream either. + static const Duration _startTimeout = Duration(seconds: 5); + + @override + Stream run(BandLink link) async* { + // The control-point reply this session is waiting for, resolved the + // moment a START reply for the PPI type arrives. A misbehaving sensor + // that never answers times out rather than hanging the session. + final started = Completer(); + final controlSub = link.notify(kPolarPmdControlChar).listen((rec) { + final r = parsePolarPmdControlResponse(rec.$2); + if (r != null && + r.reqOpcode == kPolarPmdOpRequestMeasurementStart && + r.measType == kPolarPmdMeasTypePpi && + !started.isCompleted) { + started.complete(r.ok); + } + }); + // SUBSCRIBED NOW, READ LATER. The sensor is free to start streaming the + // moment the START write lands — before this session has even seen its + // control-point ack — so the data characteristic has to be listened to + // (and its notifications therefore buffered) from the same instant as + // the control characteristic, not only once the ack arrives. A single- + // subscription controller queues everything `.add`ed before `.stream` + // gets its listener, which is what makes the two-step + // subscribe-then-consume below lose nothing. Same shape `oura.dart`'s + // `_Inbox` exists for; smaller because this stream needs no "next with + // timeout", just a buffered pass-through. + final dataEvents = StreamController<(int, List)>(); + final dataSub = link.notify(kPolarPmdDataChar).listen( + dataEvents.add, + onDone: dataEvents.close, + onError: dataEvents.addError, + ); + try { + if (!await link.write(kPolarPmdControlChar, polarPmdStartPpi())) { + link.log('polar_pmd: START write refused; ending the session.'); + return; + } + final ok = await started.future + .timeout(_startTimeout, onTimeout: () => false); + if (!ok) { + link.log('polar_pmd: PPI start was not confirmed; ending the ' + 'session.'); + return; + } + await for (final (atSec, value) in dataEvents.stream) { + final samples = parsePolarPmdPpiFrame(value); + if (samples == null) continue; + final neutrals = [ + for (final s in samples) + // hr == 0 is the sensor's own "no valid beat this record" — a + // refusal, not a low reading. Storing it would put a fabricated + // zero into a heart-rate series, the same rule `ble_hrs` applies + // to a strap reporting no skin contact. + if (s.hr != 0) + NeutralSample( + anchor: TimeAnchor.arrival, + tsEpoch: atSec, + hr: s.hr, + rrMs: [s.ppiMs], + vendor: { + 'blocker': s.blocker, + // Raw bits, under their own name — their real-world + // polarity is not independently confirmed against + // hardware, so nothing here gates on them (see + // `PolarPpiSample.skinContactBits`'s own doc). + 'skin_contact': s.skinContactBits, + 'error_ms': s.errorEstimateMs, + }, + ), + ]; + if (neutrals.isNotEmpty) yield SampleBatch(neutrals); + } + } finally { + // Best-effort: a link that has already dropped simply refuses this + // write, which is fine — the sensor stops streaming on disconnect + // regardless. + unawaited(link.write(kPolarPmdControlChar, polarPmdStopPpi())); + await controlSub.cancel(); + await dataSub.cancel(); + unawaited(dataEvents.close()); + } + // No OffloadCheckpoint, ever. Online streaming only — nothing is stored + // on the sensor for this stream, so there is nothing to tell it to forget. + } +} + +/// The single instance. Const, so it costs nothing to reference. +const PolarPmdAdapter kPolarPmdAdapter = PolarPmdAdapter(); diff --git a/lib/ble/polar_pmd_link.dart b/lib/ble/polar_pmd_link.dart new file mode 100644 index 000000000..0b724cbd3 --- /dev/null +++ b/lib/ble/polar_pmd_link.dart @@ -0,0 +1,222 @@ +// The HOST for a paired Polar PMD sensor: connect, drive [PolarPmdAdapter] +// over the link, write what comes back into the substrate. +// +// NOT A GENERALIZATION OF `HrsLink`. That class is hardcoded to +// [kBleHrsAdapter] per its own doc — this is a second, parallel host for a +// second notify-class band, not a widening of the first. Scanning and pairing +// are already generic over any [BandEntry] (`HrsLink.scanFor`, +// `HrsLink.pairNotifySensor`), so this file owns only what is specific to +// THIS band: arming a live session and driving it. +// +// LIVE SESSION, ARMED BY A WORKOUT — same shape as `HrsLink`, not +// `OuraLink`'s one-shot fetch-by-cursor `sync()`. A PPI stream has nothing to +// drain and nothing to trim; it either has a live GATT link open or it does +// not. +// +// NOTHING HERE HAS MET HARDWARE (ASSUMPTIONS R6). It ships EXPERIMENTAL, and +// `kDerivableSources` stays empty until the owner has held one. +// +// NOT YET WIRED TO A WORKOUT. Nothing calls [arm]/[disarm] today — the same +// call sites that arm `HrsLink` at workout start/stop need a matching call +// for this link, which is a separate, deliberate follow-up (tracing every +// caller of `HrsLink.instance.arm()` was out of scope for this change). + +import 'dart:async'; + +import 'package:flutter/foundation.dart' + show ValueListenable, ValueNotifier, debugPrint; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +import '../data/db.dart'; +import 'adapters/gatt_link.dart'; +import 'adapters/host.dart' show BandHost, HrsReading; +import 'adapters/polar_pmd.dart' show kPolarPmdAdapter; +import 'ble_state.dart' + show acquireSecondaryLinkSlot, releaseSecondaryLinkSlot; + +/// The live link to a paired Polar sensor. One instance; a second concurrent +/// sensor of this kind is not a thing anyone asked for. +class PolarPmdLink { + PolarPmdLink._(); + static final PolarPmdLink instance = PolarPmdLink._(); + + BluetoothDevice? _device; + GattBandLink? _link; + StreamSubscription? _connSub; + BandHost? _host; + bool _armed = false; + bool _holdsSecondaryLinkSlot = false; + + ValueListenable get reading => _reading; + final ValueNotifier _reading = ValueNotifier(null); + + /// The armed sensor's `device_id`, or null when nothing is armed. + String? get deviceId => _host?.deviceId; + + /// The `device` row for the paired sensor, or null. + static Future?> pairedSensorRow() async { + for (final r in await LocalDb.deviceRows()) { + if (r['adapter_id'] == kPolarPmdAdapter.id) return r; + } + return null; + } + + Future? _arming; + Future? _disarming; + int _disarms = 0; + + /// Connect to the paired sensor and start logging. No-op (returns false) + /// when nothing is paired. Never scans — connects straight to the stored + /// remote id. + /// + /// Same double-arm/teardown-race guards as `HrsLink.arm` and for the same + /// reasons: two concurrent calls are one attempt, a call landing mid-teardown + /// waits it out and then arms, and an attempt a [disarm] cancelled is never + /// handed out again. See that method's own doc for the full case-by-case + /// account — the shape here is verbatim, only the band underneath differs. + Future arm() { + final teardown = _disarming; + if (teardown != null) return _armAfter(teardown); + if (_armed) return Future.value(true); + final existing = _arming; + if (existing != null) return existing; + late final Future mine; + mine = _arm().whenComplete(() { + if (identical(_arming, mine)) _arming = null; + }); + return _arming = mine; + } + + Future _armAfter(Future teardown) async { + try { + await teardown; + } catch (_) {/* the disarm caller's error, not ours */} + return arm(); + } + + Future _arm() async { + final disarmsAtStart = _disarms; + final row = await pairedSensorRow(); + if (row == null) return false; + final deviceId = row['id'] as String?; + final remoteId = row['remote_id'] as String?; + if (deviceId == null || remoteId == null || remoteId.isEmpty) return false; + if (deviceId == LocalDb.kPrimaryDeviceId) { + // The primary band's id, permanently. A sensor writing under it would + // interleave its seconds with the band's in one REPLACE-keyed table. + return false; + } + final device = BluetoothDevice.fromId(remoteId); + try { + _device = device; + await acquireSecondaryLinkSlot(); + if (_disarms != disarmsAtStart) { + releaseSecondaryLinkSlot(); + return false; + } + _holdsSecondaryLinkSlot = true; + await device.connect(timeout: const Duration(seconds: 12)); + final services = await device.discoverServices(); + if (_disarms != disarmsAtStart) { + await _disconnectAbandoned(device); + return false; + } + final link = GattBandLink( + entry: kPolarPmdAdapter.entry, + services: services, + onLog: (m) => debugPrint('[polar_pmd] $m'), + ); + _link = link; + final missing = link.missingCharacteristics( + kPolarPmdAdapter.entry.requiredCharacteristics); + if (missing.isNotEmpty) { + await _teardownQuietly(); + return false; + } + final host = BandHost( + adapter: kPolarPmdAdapter, + deviceId: deviceId, + onLog: (m) => debugPrint('[polar_pmd] $m'), + ); + _host = host; + host.reading.addListener(() => _reading.value = host.reading.value); + unawaited(host.run(link).whenComplete(() { + if (_disarms != disarmsAtStart) return; + unawaited(disarm()); + })); + _connSub = device.connectionState.listen((s) { + if (s == BluetoothConnectionState.disconnected) unawaited(disarm()); + }); + _armed = true; + _reading.value = const HrsReading(); + return true; + } catch (e) { + if (_disarms != disarmsAtStart) { + await _disconnectAbandoned(device); + } else { + await _teardownQuietly(); + } + return false; + } + } + + Future _teardownQuietly() async { + try { + await disarm(); + } catch (_) {/* disarm's own finally already leaves "not armed" true */} + } + + Future _disconnectAbandoned(BluetoothDevice device) async { + if (_device != null && !identical(_device, device)) return; + try { + await device.disconnect(); + } catch (_) {/* already gone */} + } + + /// Stop logging, flush the tail and drop the link. Safe to call when not + /// armed. AWAIT it before a finish screen reads the session back. + Future disarm() { + _disarms++; + _arming = null; + final prev = _disarming; + late final Future mine; + mine = _disarmAfter(prev).whenComplete(() { + if (identical(_disarming, mine)) _disarming = null; + }); + return _disarming = mine; + } + + Future _disarmAfter(Future? prev) async { + if (prev != null) { + try { + await prev; + } catch (_) {/* the previous caller's error, not ours */} + } + await _disarm(); + } + + Future _disarm() async { + final d = _device; + try { + _link?.close(); + await _host?.stop(); + } finally { + await _connSub?.cancel(); + _link = null; + _host = null; + _connSub = null; + _device = null; + _armed = false; + if (_holdsSecondaryLinkSlot) { + _holdsSecondaryLinkSlot = false; + releaseSecondaryLinkSlot(); + } + _reading.value = null; + if (d != null) { + try { + await d.disconnect(); + } catch (_) {/* already gone */} + } + } + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8a16fded1..ff8b5d4c2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -995,6 +995,10 @@ "@devicePickerBlurbRing": { "description": "Category row subtitle for the Oura ring." }, + "devicePickerBlurbPolarPmd": "A Polar Verity Sense or OH1. Beat timing measured optically, streamed during a workout, same as a chest strap.", + "@devicePickerBlurbPolarPmd": { + "description": "Category row subtitle for a Polar PMD optical sensor." + }, "devicePickerBlurbSensor": "A chest strap or armband, for beat timing during a workout.", "@devicePickerBlurbSensor": { "description": "Category row subtitle for a standard Bluetooth heart-rate sensor." diff --git a/lib/ui2/pairing/device_picker.dart b/lib/ui2/pairing/device_picker.dart index 42ce1a417..58f0706a1 100644 --- a/lib/ui2/pairing/device_picker.dart +++ b/lib/ui2/pairing/device_picker.dart @@ -270,6 +270,9 @@ class _DevicePickerScreenState extends State { 'The strap this app is built around. WHOOP 4 or 5.', 'oura' => l?.devicePickerBlurbRing ?? 'Reads the ring directly — no Oura account or subscription.', + 'polar_pmd' => l?.devicePickerBlurbPolarPmd ?? + 'A Polar Verity Sense or OH1. Beat timing measured optically, ' + 'streamed during a workout, same as a chest strap.', _ => l?.devicePickerBlurbSensor ?? 'A chest strap or armband, for beat timing during a workout.', }; diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 784866d0a..0d4a5b982 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -43,7 +43,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../ble/adapters/_registry.dart' - show BandEntry, kBandRegistry, kBleHrs, kOura, declaredSignals; + show BandEntry, kBandRegistry, kBleHrs, kOura, kPolarPmd, declaredSignals; import '../../ble/adapters/signals.dart' show InputSignal; import '../../ble/hrs_link.dart' show HrsLink, HrsReading; import '../../ble/oura_link.dart' show OuraLink, pairOuraRing; @@ -773,6 +773,7 @@ class HealthSource { /// the only place a user can tell two paired sensors apart at a glance. IconData sensorIcon(String? adapterId) => switch (adapterId) { 'oura' => LucideIcons.circleDot, + 'polar_pmd' => LucideIcons.activity, _ => LucideIcons.heartPulse, }; @@ -951,6 +952,15 @@ final List<({BandEntry entry, String blurb, Future Function(BluetoothDe 'unpair the ring), then close that app before pairing here.', pick: pairOuraRing, ), + ( + entry: kPolarPmd, + blurb: 'A Polar Verity Sense or OH1. Beat timing measured optically, ' + 'streamed during a workout, same as a chest strap.', + // Null: no handshake and no key — the START/STOP toggle this sensor + // needs belongs to the workout session, not to pairing. Same as + // [kBleHrs]. + pick: null, + ), ]; /// Choose which kind of sensor to pair, then hand off to the pairing screen. diff --git a/test/adapters/polar_pmd_adapter_test.dart b/test/adapters/polar_pmd_adapter_test.dart new file mode 100644 index 000000000..bfd33441f --- /dev/null +++ b/test/adapters/polar_pmd_adapter_test.dart @@ -0,0 +1,136 @@ +// The mandatory adapter test (MULTIBAND_PLAN §3.3.3): replay a fixture through +// a [ReplayBandLink] and assert that every signal the adapter DECLARES really +// appears in what it EMITS. +// +// This adapter also has a START handshake `ble_hrs` does not: a control-point +// write must be confirmed before the data stream means anything, so the +// fixture below feeds that reply before it feeds any PPI frame, and spins a +// few event-loop turns after each feed so the generator has actually reacted +// before the next step — the same shape `oura_link.dart`'s own test seam uses +// for the same reason (a lazy, sequential subscriber needs a turn to reach +// its next `notify()` call). + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/adapters/adapter.dart'; +import 'package:openstrap_edge/ble/adapters/polar_pmd.dart'; +import 'package:openstrap_edge/ble/adapters/signals.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// The control-point's confirmation that PPI streaming started. +const List kStartOk = [0xF0, 0x02, 0x03, 0x00]; +const List kStartRefused = [0xF0, 0x02, 0x03, 0x01]; + +/// One PPI frame: 10-byte header (measurement type + timestamp + frame type) +/// followed by one or more 6-byte records. +List ppiFrame(List> records) => [ + 0x03, + ...List.filled(8, 0), + 0x00, + for (final r in records) ...r, + ]; + +/// hr 60, ppi 1000 ms, error 10 ms, flags 0x00. +const List kOneBeat = [60, 0xE8, 0x03, 0x0A, 0x00, 0x00]; +const List kZeroHrBeat = [0, 0xE8, 0x03, 0x0A, 0x00, 0x00]; + +/// Let the event loop run a few turns — enough for a lazy, sequential +/// subscriber to react to whatever was just fed and reach its next `notify` +/// call, without guessing at a fixed delay. +Future _settle([int turns = 20]) async { + for (var i = 0; i < turns; i++) { + await Future.delayed(Duration.zero); + } +} + +/// Drive [PolarPmdAdapter] over recorded bytes and collect what it yields. +/// [controlReply] is fed to the control characteristic first (null skips it, +/// to exercise the no-reply timeout path); the frame in [dataFrame], if any, +/// is fed only after the adapter has had a chance to react to that reply. +Future> replay( + List? dataFrame, { + List? controlReply = kStartOk, +}) async { + final link = ReplayBandLink(); + final events = []; + final done = Completer(); + final sub = + kPolarPmdAdapter.run(link).listen(events.add, onDone: done.complete); + // Give the adapter a chance to issue its START write before anything is fed. + await _settle(); + if (controlReply != null) { + link.feed(kPolarPmdControlChar, controlReply, atSec: 1_800_000_000); + } + // Let the generator react to the reply (proceed to read the data stream, + // or — on a refusal — run its `finally` and end) before deciding what to + // feed next. + await _settle(); + if (dataFrame != null) { + link.feed(kPolarPmdDataChar, dataFrame, atSec: 1_800_000_000); + await _settle(); + } + await link.close(); + await done.future.timeout(const Duration(seconds: 5)); + await sub.cancel(); + return events; +} + +void main() { + test('every declared InputSignal actually appears in an emitted sample', + () async { + final events = await replay(ppiFrame([kOneBeat])); + final samples = [ + for (final e in events) + if (e is SampleBatch) ...e.samples, + ]; + expect(samples, isNotEmpty); + final seen = { + if (samples.any((s) => s.hr != null)) InputSignal.hrSparse, + if (samples.any((s) => s.rrMs.isNotEmpty)) InputSignal.rrIntervals, + }; + expect(seen, containsAll(kPolarPmdAdapter.signals.keys)); + expect(kPolarPmdAdapter.signals.keys.toSet(), seen); + }); + + test('the START command is written before anything is read', () async { + final link = ReplayBandLink(); + final sub = kPolarPmdAdapter.run(link).listen((_) {}); + await _settle(); + expect(link.writes.single, (kPolarPmdControlChar, polarPmdStartPpi())); + await sub.cancel(); + await link.close(); + }); + + test('a refused START ends the session with no samples', () async { + final events = await replay(null, controlReply: kStartRefused); + expect(events, isEmpty); + }); + + test('hr == 0 is a refusal, never a fabricated measurement', () async { + final events = await replay(ppiFrame([kZeroHrBeat, kOneBeat])); + final samples = [ + for (final e in events) + if (e is SampleBatch) ...e.samples, + ]; + expect(samples, hasLength(1)); + expect(samples.single.hr, 60); + }); + + test('a sensor with no flash never emits an OffloadCheckpoint', () async { + final events = await replay(ppiFrame([kOneBeat])); + expect(events.whereType(), isEmpty); + }); + + test('vendor fields carry the blocker bit and the raw skin-contact bits', + () async { + // flags 0x07 = blocker + both skin-contact bits. + final beat = [60, 0xE8, 0x03, 0x0A, 0x00, 0x07]; + final events = await replay(ppiFrame([beat])); + final s = (events.single as SampleBatch).samples.single; + expect(s.vendor['blocker'], isTrue); + expect(s.vendor['skin_contact'], 0x03); + expect(s.vendor['error_ms'], 10); + }); +} From 1809975e7f633d8592334002ab47f16d06c67417 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:02:49 +0530 Subject: [PATCH 2/8] polar pmd: arm/disarm with the workout, fix a leaked test timer --- lib/ble/polar_pmd_link.dart | 8 ++++---- lib/state/app_state.dart | 5 +++++ test/adapters/polar_pmd_adapter_test.dart | 11 +++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/ble/polar_pmd_link.dart b/lib/ble/polar_pmd_link.dart index 0b724cbd3..5f32a40b7 100644 --- a/lib/ble/polar_pmd_link.dart +++ b/lib/ble/polar_pmd_link.dart @@ -16,10 +16,10 @@ // NOTHING HERE HAS MET HARDWARE (ASSUMPTIONS R6). It ships EXPERIMENTAL, and // `kDerivableSources` stays empty until the owner has held one. // -// NOT YET WIRED TO A WORKOUT. Nothing calls [arm]/[disarm] today — the same -// call sites that arm `HrsLink` at workout start/stop need a matching call -// for this link, which is a separate, deliberate follow-up (tracing every -// caller of `HrsLink.instance.arm()` was out of scope for this change). +// ARMED AT THE SAME CALL SITES AS `HrsLink` — every place `app_state.dart` +// arms or disarms the heart-rate sensor now does the same for this link, +// unconditionally: [arm] is a no-op returning false when nothing is paired, +// same as `HrsLink.arm`. import 'dart:async'; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index afc0a6350..a687c838a 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -37,6 +37,7 @@ import '../ble/android_background.dart'; import '../ble/ble_engine.dart'; import '../ble/hrs_link.dart'; import '../ble/live_cadence.dart'; +import '../ble/polar_pmd_link.dart'; import '../ble/live_step_runs.dart'; import '../ble/ble_state.dart' show AlarmConfirmation, AlarmEffect, SyncActivityWindow; @@ -5746,6 +5747,7 @@ class AppState extends ChangeNotifier { // A paired heart-rate sensor is armed by a workout and only by a workout — // the same rule GPS follows. No-op when nothing is paired. unawaited(HrsLink.instance.arm()); + unawaited(PolarPmdLink.instance.arm()); } /// Why route tracking is NOT running for the current route-eligible workout @@ -5940,6 +5942,7 @@ class AppState extends ChangeNotifier { // kept and the gap shows honestly as a segment break. unawaited(_maybeStartRouteTracking(id, activeWorkout!.type)); unawaited(HrsLink.instance.arm()); + unawaited(PolarPmdLink.instance.arm()); _deriveScheduler.setWorkoutActive(true); ScreenWake.enable(); } else { @@ -5994,6 +5997,7 @@ class AppState extends ChangeNotifier { // AWAITED, like the route tail: an unawaited disarm races the finish screen // and the last buffered batch of sensor beats never reaches the database. await HrsLink.instance.disarm(); + await PolarPmdLink.instance.disarm(); ScreenWake.release(); _deriveScheduler.setWorkoutActive(false); final w = activeWorkout!; @@ -6109,6 +6113,7 @@ class AppState extends ChangeNotifier { // AWAITED, like the route tail: an unawaited disarm races the finish screen // and the last buffered batch of sensor beats never reaches the database. await HrsLink.instance.disarm(); + await PolarPmdLink.instance.disarm(); ScreenWake.release(); _deriveScheduler.setWorkoutActive(false); activeWorkout = null; diff --git a/test/adapters/polar_pmd_adapter_test.dart b/test/adapters/polar_pmd_adapter_test.dart index bfd33441f..422a46383 100644 --- a/test/adapters/polar_pmd_adapter_test.dart +++ b/test/adapters/polar_pmd_adapter_test.dart @@ -96,11 +96,18 @@ void main() { test('the START command is written before anything is read', () async { final link = ReplayBandLink(); - final sub = kPolarPmdAdapter.run(link).listen((_) {}); + final done = Completer(); + final sub = kPolarPmdAdapter.run(link).listen((_) {}, onDone: done.complete); await _settle(); expect(link.writes.single, (kPolarPmdControlChar, polarPmdStartPpi())); - await sub.cancel(); + // Resolved with a reply rather than left to the 5 s timeout: an + // uncompleted `started` future leaves its Timer running past this test's + // own end, which does not fail anything but bleeds 5 real seconds into + // whatever runs next. + link.feed(kPolarPmdControlChar, kStartOk, atSec: 1_800_000_000); await link.close(); + await done.future.timeout(const Duration(seconds: 5)); + await sub.cancel(); }); test('a refused START ends the session with no samples', () async { From e395beabb0567c46453e6442118f4db74912c69e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:08:52 +0530 Subject: [PATCH 3/8] polar pmd: keep the registry id/signal fixtures in sync --- test/adapter_signals_registry_test.dart | 2 ++ test/band_registry_test.dart | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/adapter_signals_registry_test.dart b/test/adapter_signals_registry_test.dart index 94ab6e7d4..98041f5c3 100644 --- a/test/adapter_signals_registry_test.dart +++ b/test/adapter_signals_registry_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/adapters/_registry.dart'; import 'package:openstrap_edge/ble/adapters/ble_hrs.dart'; import 'package:openstrap_edge/ble/adapters/oura.dart'; +import 'package:openstrap_edge/ble/adapters/polar_pmd.dart'; import 'package:openstrap_edge/ble/adapters/signals.dart'; import 'package:openstrap_edge/ble/adapters/whoop_gen4.dart'; @@ -25,6 +26,7 @@ void main() { 'gen5': kWhoopGen4Signals, 'ble_hrs': const BleHrsAdapter().signals, 'oura': OuraAdapter(key: const [0]).signals, + 'polar_pmd': const PolarPmdAdapter().signals, }; // Every registry id is covered above: a NEW adapter must extend this test, diff --git a/test/band_registry_test.dart b/test/band_registry_test.dart index 96dd7c9eb..c65c84f86 100644 --- a/test/band_registry_test.dart +++ b/test/band_registry_test.dart @@ -10,7 +10,7 @@ import 'package:openstrap_protocol/openstrap_protocol.dart'; void main() { test('ids are unique and stable — they are stamped as device_family', () { expect(kBandRegistry.map((e) => e.id).toList(), - ['gen4', 'gen5', 'ble_hrs', 'oura']); + ['gen4', 'gen5', 'ble_hrs', 'oura', 'polar_pmd']); }); test('D1 — the scan service list is exactly the two WHOOP services', () { From f348fca9ca1c355e98dcf956e2926c38730c57a7 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:55:49 +0530 Subject: [PATCH 4/8] repin protocol to feat/polar-pmd-protocol @ 87ee803 CI's committed pin (471034c) predates protocol#45's PMD control-point and PPI decoder, so polar_pmd.dart referenced undefined symbols in flutter analyze. Interim PR-branch pin until protocol#45 merges. --- pubspec.lock | 20 ++++++++++---------- pubspec.yaml | 9 ++++++++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 599e0ac0d..5c26764da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -857,10 +857,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -946,8 +946,8 @@ packages: dependency: "direct main" description: path: "." - ref: "471034cb84b85edb37e72b6f6add79a2d7929294" - resolved-ref: "471034cb84b85edb37e72b6f6add79a2d7929294" + ref: "87ee80349181c54df590f93fde35d2118e6e92b7" + resolved-ref: "87ee80349181c54df590f93fde35d2118e6e92b7" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" @@ -1376,26 +1376,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.16" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 98c2f6cb8..72d72f2ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -143,7 +143,14 @@ dependencies: # live high-rate streams are never persisted (see invariant #1), so no # stored day_result number moves. R11's meaning is still unconfirmed # and edge does not read it. - ref: 471034cb84b85edb37e72b6f6add79a2d7929294 + # + # REPIN (this branch): protocol `feat/polar-pmd-protocol` @ 87ee803, on + # top of the #42 merge. Adds the PMD control-point start/stop writes and + # the PPI data-frame decoder polar_pmd.dart needs. Interim PR-branch pin + # until protocol#45 merges to main. NO kAlgoVersion bump: this device's + # `signals` stays const {} (unowned hardware), so nothing it decodes + # feeds a derived metric. + ref: 87ee80349181c54df590f93fde35d2118e6e92b7 openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From 49929060d464c5f19e0dd424a2f5bc942227ebd5 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:43:08 +0530 Subject: [PATCH 5/8] ci: retrigger after two apparently-stuck runner attempts From 9e1c4e4b2e84c864869413a3dfe5e712456ca0d6 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:04:12 +0530 Subject: [PATCH 6/8] polar pmd: fix live-connected dot, forget routing, and a stale comment devices.dart tracked whether a paired sensor was live by comparing its adapter_id to kBleHrs directly, so a Polar row never lit up connected even while PolarPmdLink was armed and streaming. liveSources now takes the set of adapter ids that are actually live, and MyDevices nests a second ValueListenableBuilder on PolarPmdLink.reading to build it. forgetDevice's non-Oura branch disarmed HrsLink unconditionally, which is a no-op for a Polar row - the live GATT session and its writes kept going after the row was deleted. It now routes to PolarPmdLink.disarm() for a polar_pmd row. Also fixed the pubspec repin comment, which pointed at the wrong gate: PolarPmdAdapter.signals isn't empty, kDerivableSources is. --- lib/ble/hrs_link.dart | 11 +++++++-- lib/ui2/profile/devices.dart | 43 ++++++++++++++++++++++-------------- pubspec.yaml | 4 ++-- test/hrs_link_test.dart | 18 +++++++++++++++ 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/lib/ble/hrs_link.dart b/lib/ble/hrs_link.dart index 07b95f006..05e8e0ba3 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -66,6 +66,7 @@ import 'ble_state.dart' acquireSecondaryLinkSlot, releaseSecondaryLinkSlot; import 'oura_link.dart' show OuraLink; +import 'polar_pmd_link.dart' show PolarPmdLink; export 'adapters/host.dart' show HrsReading; @@ -583,8 +584,14 @@ class HrsLink { return; } // Before the row goes, not after: a live session would keep writing rows - // under an id nothing can explain any more. - await instance.disarm(); + // under an id nothing can explain any more. Every live-session link + // capable of writing under this id gets disarmed, not just this class's + // own — a Polar row's live sensor is `PolarPmdLink`, not `instance`. + if (row?['adapter_id'] == kPolarPmd.id) { + await PolarPmdLink.instance.disarm(); + } else { + await instance.disarm(); + } await LocalDb.deleteDevice(id); } diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 0d4a5b982..9f7235577 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -47,6 +47,7 @@ import '../../ble/adapters/_registry.dart' import '../../ble/adapters/signals.dart' show InputSignal; import '../../ble/hrs_link.dart' show HrsLink, HrsReading; import '../../ble/oura_link.dart' show OuraLink, pairOuraRing; +import '../../ble/polar_pmd_link.dart' show PolarPmdLink; import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart' show BandStatus, kMaxConcurrentSecondaryLinks; import '../../data/db.dart' show LocalDb; @@ -793,12 +794,14 @@ SourceTier? tierNamed(Object? name) { /// The sources that actually exist right now. Nothing is listed that cannot /// produce a number today — everything else is in [kNotYet], with its reason. /// -/// [sensorLive] is whether a paired sensor's link is up THIS INSTANT. It is -/// passed in rather than read off `HrsLink` here because it changes on every -/// beat: routing it through [AppState] would notify every listener in the app -/// at 1 Hz for the duration of a workout, which is the rebuild storm this -/// screen has already been fixed for once. -List liveSources(AppState app, {bool sensorLive = false}) => [ +/// [liveAdapterIds] names which paired sensors' links are up THIS INSTANT, by +/// `adapter_id`. It is passed in rather than read off the links here because +/// it changes on every beat: routing it through [AppState] would notify every +/// listener in the app at 1 Hz for the duration of a workout, which is the +/// rebuild storm this screen has already been fixed for once. +List liveSources(AppState app, + {Set liveAdapterIds = const {}}) => + [ if (app.isPaired) HealthSource( name: app.strapName ?? 'Your band', @@ -836,10 +839,10 @@ List liveSources(AppState app, {bool sensorLive = false}) => [ // never the nearest rung we happen to know. tier: tierNamed(r['tier']), icon: sensorIcon(r['adapter_id'] as String?), - // `sensorLive` reflects HrsLink.reading ONLY — a live HRS session - // must not mark an unrelated paired Oura row as connected just - // because some sensor happens to be live right now. - connected: sensorLive && r['adapter_id'] == kBleHrs.id, + // `liveAdapterIds` is keyed by adapter so a live PPI stream never + // marks an unrelated paired Oura row as connected just because + // some sensor happens to be live right now. + connected: liveAdapterIds.contains(r['adapter_id']), isBand: false, deviceId: r['id'] as String?, family: r['adapter_id'] as String?, @@ -897,18 +900,26 @@ class MyDevices extends StatelessWidget { @override Widget build(BuildContext c) { final app = c.watch(); - // The live reading is subscribed HERE and nowhere higher. It moves on every - // beat, so a listener on AppState would rebuild the whole app at 1 Hz for - // the length of a workout; this rebuilds one screen. + // Each live reading is subscribed HERE and nowhere higher. It moves on + // every beat, so a listener on AppState would rebuild the whole app at + // 1 Hz for the length of a workout; this rebuilds one screen. Nested + // rather than merged into one listenable because the two links are + // independent — either, both, or neither can be armed for a workout. return ValueListenableBuilder( valueListenable: HrsLink.instance.reading, - builder: (c, reading, _) => _build(c, app, reading != null), + builder: (c, hrsReading, _) => ValueListenableBuilder( + valueListenable: PolarPmdLink.instance.reading, + builder: (c, polarReading, _) => _build(c, app, { + if (hrsReading != null) kBleHrs.id, + if (polarReading != null) kPolarPmd.id, + }), + ), ); } - Widget _build(BuildContext c, AppState app, bool sensorLive) { + Widget _build(BuildContext c, AppState app, Set liveAdapterIds) { return MyDevicesView( - sources: rankSources(liveSources(app, sensorLive: sensorLive)), + sources: rankSources(liveSources(app, liveAdapterIds: liveAdapterIds)), // The band row's dot says connected or not. That covers six different // problems with six different fixes, and a user cannot fix a problem the // app will not name — so the engine's own verdict rides alongside it. diff --git a/pubspec.yaml b/pubspec.yaml index 72d72f2ea..8163e142a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -148,8 +148,8 @@ dependencies: # top of the #42 merge. Adds the PMD control-point start/stop writes and # the PPI data-frame decoder polar_pmd.dart needs. Interim PR-branch pin # until protocol#45 merges to main. NO kAlgoVersion bump: this device's - # `signals` stays const {} (unowned hardware), so nothing it decodes - # feeds a derived metric. + # entry stays out of `kDerivableSources` (unowned hardware), so nothing + # it decodes feeds a derived metric. ref: 87ee80349181c54df590f93fde35d2118e6e92b7 openstrap_analytics: git: diff --git a/test/hrs_link_test.dart b/test/hrs_link_test.dart index 90a5f4d47..b909164d2 100644 --- a/test/hrs_link_test.dart +++ b/test/hrs_link_test.dart @@ -137,6 +137,24 @@ void main() { expect(await HrsLink.pairedSensorRow(), isNull); }); + test('forgetting a Polar row deletes it without touching HrsLink', + () async { + // The dispatch bug this pins: `forgetDevice`'s non-Oura branch used to + // call `instance.disarm()` (HrsLink's own) unconditionally, which is a + // no-op for a row a different link owns. Routing on `adapter_id` must + // reach a `kPolarPmd` row the same way it already reaches an Oura one, + // and still leave HrsLink itself untouched (unarmed, as it started). + await LocalDb.upsertDevice( + id: 'polar_pmd-11:22:33:44:55:66', + adapterId: kPolarPmd.id, + remoteId: '11:22:33:44:55:66', + ); + await HrsLink.forgetDevice('polar_pmd-11:22:33:44:55:66'); + final rows = await LocalDb.deviceRows(); + expect(rows.where((r) => r['adapter_id'] == kPolarPmd.id), isEmpty); + expect(HrsLink.instance.deviceId, isNull); + }); + test('two arms in flight are ONE arm', () async { // Every caller fires this `unawaited`, and the body awaits a database // read, a 12 s connect and discovery before it publishes anything. A From dc215e258cf7b2ebb4aca28961072562cee467cc Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:22:08 +0530 Subject: [PATCH 7/8] polar pmd: drain replay channels in rounds, route pmd readings to liveHr ReplayBandLink.close() cleared _channels after one snapshot, so a channel a lazy adapter subscribes to mid-teardown was never closed. Now it drains in rounds, clearing before each round so a late entry lands in a fresh map the next round picks up. PolarPmdLink.instance.reading had no AppState listener, so PMD samples armed the link but never reached liveHr, workout zones, or the live trace. Wired the same way HrsLink already is, with its own trace id since both sensors can be armed at once. --- lib/ble/adapters/adapter.dart | 24 ++++++++++++++---------- lib/state/app_state.dart | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/lib/ble/adapters/adapter.dart b/lib/ble/adapters/adapter.dart index 98dd03fa6..3bbcf1720 100644 --- a/lib/ble/adapters/adapter.dart +++ b/lib/ble/adapters/adapter.dart @@ -327,10 +327,11 @@ class ReplayBandLink implements BandLink { /// End every channel, which ends `run()`. Await the run subscription's /// `done` after this rather than guessing at a delay. /// - /// SNAPSHOT THE VALUES FIRST. An adapter that calls [notify] lazily — after - /// an `await` this method's own `await c.close()` yields to — adds a new - /// entry to [_channels] while this loop is mid-iteration, which throws - /// "Concurrent modification during iteration" on the live map. + /// DRAINS IN ROUNDS, not one snapshot. An adapter that calls [notify] lazily + /// — after an `await` this method's own `await c.close()` yields to — adds + /// a new entry to [_channels] while a round is mid-iteration. Clearing the + /// map before that round starts means the new entry lands in a fresh map + /// the next round picks up, instead of a snapshot silently losing it. /// /// A CHANNEL WITH NO LISTENER IS NOT AWAITED. A single-subscription /// [StreamController]'s `close()` future only completes once its done event @@ -341,13 +342,16 @@ class ReplayBandLink implements BandLink { /// on every channel either way, so a listener that attaches afterward still /// gets its done event immediately. Future close() async { - for (final c in _channels.values.toList()) { - if (c.hasListener) { - await c.close(); - } else { - unawaited(c.close()); + while (_channels.isNotEmpty) { + final round = _channels.values.toList(); + _channels.clear(); + for (final c in round) { + if (c.hasListener) { + await c.close(); + } else { + unawaited(c.close()); + } } } - _channels.clear(); } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index a687c838a..894003a69 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1288,6 +1288,9 @@ class AppState extends ChangeNotifier { // no radio — `HrsLink.reading` is a plain notifier whose identity survives // arm/disarm, which is why one listener for the life of this object works. HrsLink.instance.reading.addListener(_onHrsReading); + // Same wiring, second notify-class sensor: PMD readings otherwise never + // reach liveHr/the live trace at all (arm/disarm alone don't feed it). + PolarPmdLink.instance.reading.addListener(_onPmdReading); _init(); // Notification taps → request a tab switch (the shell listens to navRequest). _tapSub = NotificationService.instance.taps.listen(_handleTapRoute); @@ -1325,6 +1328,7 @@ class AppState extends ChangeNotifier { // Same wiring as the real constructor, and for the same reason it is safe // here: a ValueNotifier, no plugin. HrsLink.instance.reading.addListener(_onHrsReading); + PolarPmdLink.instance.reading.addListener(_onPmdReading); } /// A Siri/Shortcuts App Intent (e.g. "start breathing") may have set a @@ -1379,6 +1383,10 @@ class AppState extends ChangeNotifier { final hrsId = _hrsTraceId; _hrsTraceId = null; if (hrsId != null) _clearLiveHrTrace(hrsId); + PolarPmdLink.instance.reading.removeListener(_onPmdReading); + final pmdId = _pmdTraceId; + _pmdTraceId = null; + if (pmdId != null) _clearLiveHrTrace(pmdId); BandOwnership.markForegroundIntent(false); _releaseForegroundLease(); _deriveScheduler.dispose(); @@ -3594,6 +3602,12 @@ class AppState extends ChangeNotifier { /// the disarm tick cannot name the device it is ending. String? _hrsTraceId; + /// Same as [_hrsTraceId], for the second notify-class sensor. Both can be + /// armed at once (`kMaxConcurrentSecondaryLinks` is 2), each keyed under + /// its own `device_id` in `_liveHrTrace`, so one trace id each is enough — + /// no shared state between the two listeners. + String? _pmdTraceId; + /// A paired heart-rate sensor's reading, into the SAME trace the band's /// engine state feeds. /// @@ -3624,6 +3638,25 @@ class AppState extends ChangeNotifier { if (_appendLiveHr(id, r.bpm, atSec * 1000)) notifyListeners(); } + /// Same wiring as [_onHrsReading], for `PolarPmdLink` — a second, parallel + /// live-reading source, not a widening of the first (see that link's own + /// doc). Without this a Polar sensor's PPI-derived beats arm/disarm the + /// link but never reach `liveHr`, workout zones, or the live trace. + void _onPmdReading() { + if (_disposed) return; + final r = PolarPmdLink.instance.reading.value; + final id = PolarPmdLink.instance.deviceId ?? _pmdTraceId; + if (id == null) return; + if (r == null) { + _pmdTraceId = null; + if (_clearLiveHrTrace(id)) notifyListeners(); + return; + } + _pmdTraceId = id; + final atSec = r.atSec ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (_appendLiveHr(id, r.bpm, atSec * 1000)) notifyListeners(); + } + void _onEngineState(String deviceId, DeviceState s) { _appendLiveHr(deviceId, s.liveHr, s.liveHrAt); // Bank the name the moment the band says it, so it survives the From 014debb567da7e2ecdec63862eb94d7cddd0bc89 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:48:04 +0530 Subject: [PATCH 8/8] polar pmd: fix a real test hang and a stale protocol pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReplayBandLink.close() went hasListener-gated to skip awaiting a channel nothing had subscribed to yet. Bisected a real, reproducible hang in HrsLink.ingestForTest's disarm teardown down to exactly this conditional — not the round-draining wrapper it was carrying, which made no difference either way. Reverted to the plain unconditional close every channel used before; a single-subscription StreamController.close() is safe to await with no listener attached, it does not block waiting for one to appear. kProtocolPin also didn't match this branch's own pubspec.lock pin (still pointed at the pre-polar-pmd commit) — bumped to match. --- lib/ble/adapters/adapter.dart | 38 +++++++++++------------------- lib/compute/derivation_engine.dart | 2 +- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/lib/ble/adapters/adapter.dart b/lib/ble/adapters/adapter.dart index 3bbcf1720..22c707e83 100644 --- a/lib/ble/adapters/adapter.dart +++ b/lib/ble/adapters/adapter.dart @@ -327,31 +327,21 @@ class ReplayBandLink implements BandLink { /// End every channel, which ends `run()`. Await the run subscription's /// `done` after this rather than guessing at a delay. /// - /// DRAINS IN ROUNDS, not one snapshot. An adapter that calls [notify] lazily - /// — after an `await` this method's own `await c.close()` yields to — adds - /// a new entry to [_channels] while a round is mid-iteration. Clearing the - /// map before that round starts means the new entry lands in a fresh map - /// the next round picks up, instead of a snapshot silently losing it. - /// - /// A CHANNEL WITH NO LISTENER IS NOT AWAITED. A single-subscription - /// [StreamController]'s `close()` future only completes once its done event - /// has actually been delivered to a listener, so a channel a fixture fed - /// but an adapter never subscribed to (or has not subscribed to yet — a - /// lazy, sequential subscriber reaches its second `notify()` only after an - /// earlier one resolves) would hang this forever. `close()` is still called - /// on every channel either way, so a listener that attaches afterward still - /// gets its done event immediately. + /// PLAIN AND UNCONDITIONAL, on purpose — a `hasListener`-gated version (skip + /// the await, fire-and-forget `close()` on a channel with no listener yet) + /// was tried here and reverted: it reproducibly hung `HrsLink.ingestForTest` + /// teardown (`test/pair_sensor_test.dart`'s disarm case), bisected down to + /// this exact conditional — not the round-draining wrapper some earlier + /// version of this method also carried, which made no difference either + /// way. A single-subscription `StreamController.close()` is normally safe to + /// await even with no listener attached — it does not block waiting for one + /// to appear — so there was no real bug this gate was fixing; whatever + /// narrow race it was reasoning about did not hold up against the real + /// fixture. Keep this plain. Future close() async { - while (_channels.isNotEmpty) { - final round = _channels.values.toList(); - _channels.clear(); - for (final c in round) { - if (c.hasListener) { - await c.close(); - } else { - unawaited(c.close()); - } - } + for (final c in _channels.values) { + await c.close(); } + _channels.clear(); } } diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 853131793..3b0d0ed2f 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1727,7 +1727,7 @@ const int kAlgoVersion = 87; // took protocol 19d7291 → 6664854. kAlgoVersion is main's 81 — this branch // moves no derivation maths, which is why its own note says NO bump. const String kAnalyticsPin = '1fa8144a5e3b728ce91eeed6ecbc15d482933b44'; -const String kProtocolPin = '471034cb84b85edb37e72b6f6add79a2d7929294'; +const String kProtocolPin = '87ee80349181c54df590f93fde35d2118e6e92b7'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see