diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index 0db1c4b29..bfc7ae552 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -53,6 +53,18 @@ 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'; /// A generic white-label smart ring's GATT service ("R11M"/"R10M", also sold /// as "TK5") — NOT the Colmi R11/R12, a different, unrelated product on a /// different protocol. @@ -792,6 +804,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 @@ -1506,6 +1541,7 @@ const List kBandRegistry = [ kWhoopGen5, kBleHrs, kOura, + kPolarPmd, kCoros, kUltrahuman, kWithingsSteelHr, @@ -1594,6 +1630,10 @@ const Map> kAdapterSignals = InputSignal.rrIntervals: Duration(seconds: 1), }, 'oura': {}, + 'polar_pmd': { + InputSignal.hrSparse: Duration(seconds: 1), + InputSignal.rrIntervals: Duration(seconds: 1), + }, 'ring11m': {}, 'coros': { InputSignal.hrSparse: Duration(seconds: 1), diff --git a/lib/ble/adapters/adapter.dart b/lib/ble/adapters/adapter.dart index 30c29796b..ff57d6003 100644 --- a/lib/ble/adapters/adapter.dart +++ b/lib/ble/adapters/adapter.dart @@ -366,6 +366,18 @@ class ReplayBandLink implements BandLink { /// End every channel, which ends `run()`. Await the run subscription's /// `done` after this rather than guessing at a delay. + /// + /// 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 { for (final c in _channels.values) { await c.close(); 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/hrs_link.dart b/lib/ble/hrs_link.dart index fdee74857..4d4f7836f 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -76,6 +76,7 @@ import 'miband_link.dart' show MiBand234Link; import 'o2ring_link.dart' show O2RingLink; import 'oura_link.dart' show OuraLink; import 'pebble_link.dart' show PebbleLink; +import 'polar_pmd_link.dart' show PolarPmdLink; import 'qhybrid_link.dart' show QHybridLink; import 'ring11m_link.dart' show Ring11mLink; import 'ringconn_link.dart' show RingConnLink; @@ -757,12 +758,17 @@ class HrsLink { return; } // Before the row goes, not after: a live session would keep writing rows - // under an id nothing can explain any more. GATED ON THE ROW BEING THE - // ARMED HRS SENSOR, not called unconditionally — this fallback used to run - // for ANY adapter without its own branch above, which would tear down a - // live chest-strap session while forgetting an unrelated device (e.g. a - // Lefun ring paired alongside one). - if (row?['adapter_id'] == kBleHrsAdapter.id) { + // 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`. GATED + // ON THE ROW BEING THE ARMED HRS SENSOR for the `instance` branch, not + // called unconditionally — this fallback used to run for ANY adapter + // without its own branch above, which would tear down a live chest-strap + // session while forgetting an unrelated device (e.g. a Lefun ring paired + // alongside one). + if (row?['adapter_id'] == kPolarPmd.id) { + await PolarPmdLink.instance.disarm(); + } else if (row?['adapter_id'] == kBleHrsAdapter.id) { await instance.disarm(); } await LocalDb.deleteDevice(id); diff --git a/lib/ble/polar_pmd_link.dart b/lib/ble/polar_pmd_link.dart new file mode 100644 index 000000000..5f32a40b7 --- /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. +// +// 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'; + +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/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 687f09342..90183fbab 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1782,21 +1782,23 @@ const int kAlgoVersion = 87; // branch's own pin) IS an ancestor of `fe1464d` — the wearfit protocol // commit is already folded in, nothing is lost by moving to the tip. const String kAnalyticsPin = '1fa8144a5e3b728ce91eeed6ecbc15d482933b44'; -// REPIN (this branch): protocol dafit/moyoung head @ 06cb5f2 — the DaFit/ -// MOYOUNG-V2 frame envelope this branch's adapter banks raw. NO kAlgoVersion -// bump: dafit carries no derivable signal. -// -// MERGE (main → this branch): protocol's own origin/main has since merged -// dafit/moyoung along with #47 (zetime), #48 (ringconn) and #50 -// (wearfit) — 06cb5f2 is verified an ancestor of the tip below (`git -// merge-base --is-ancestor 06cb5f2… fe1464d…`), so moving the pin there -// loses nothing from this branch's own repin. -// REPIN (this branch, superseded by the merge): the ring11m adapter's own -// protocol needs `feat/ring11m-protocol` (c6cc5ef), but protocol's own -// `origin/main` tip below is THAT SAME PR's merge commit — verified -// (`git merge-base --is-ancestor c6cc5ef… fe1464d…`) — so main's pin already +// REPIN (this branch, superseded by the merge): polar pmd's own protocol +// needs `feat/polar-pmd-protocol` (87ee803), but protocol's own `origin/main` +// tip below is THAT SAME PR's merge commit — verified +// (`git merge-base --is-ancestor 87ee803… fe1464d…`) — so main's pin already // carries this branch's wire format; nothing is lost by taking it as-is. -// NO kAlgoVersion bump either way: ring11m's adapter declares no signal. +// NO kAlgoVersion bump either way: polar pmd's adapter declares no signal. +// +// REPIN (main): protocol dafit/moyoung head @ 06cb5f2 — the DaFit/MOYOUNG-V2 +// frame envelope. NO kAlgoVersion bump: dafit carries no derivable signal. +// Protocol's own origin/main has since merged dafit/moyoung along with #47 +// (zetime), #48 (ringconn) and #50 (wearfit) — 06cb5f2 is verified an +// ancestor of the tip below. +// REPIN (main, superseded further): the ring11m adapter's own protocol needs +// `feat/ring11m-protocol` (c6cc5ef), but protocol's own `origin/main` tip +// below is THAT SAME PR's merge commit — verified — so main's pin already +// carries that wire format too. NO kAlgoVersion bump: ring11m declares no +// signal either. const String kProtocolPin = 'fe1464db98b84ac4d3ce6175d54ada11356d6c62'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d518edd2b..6aed45b73 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1035,6 +1035,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." + }, "devicePickerBlurbO2Ring": "Reads its battery, model and serial. No reading from the ring itself is decoded yet.", "@devicePickerBlurbO2Ring": { "description": "Category row subtitle for the Wellue O2Ring." diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index afc0a6350..894003a69 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; @@ -1287,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); @@ -1324,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 @@ -1378,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(); @@ -3593,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. /// @@ -3623,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 @@ -5746,6 +5780,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 +5975,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 +6030,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 +6146,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/lib/ui2/pairing/device_picker.dart b/lib/ui2/pairing/device_picker.dart index f4672218c..6b2035213 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.', 'coros' => l?.devicePickerBlurbCoros ?? 'A sports watch. Reads battery and live heart rate — recorded ' 'activities stay on the watch.', diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 2a3a31f85..c9d838df0 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -62,6 +62,7 @@ import '../../ble/adapters/_registry.dart' kOura, kO2Ring, kPebble, + kPolarPmd, kRing11m, kRingConn, kCasio, @@ -91,6 +92,7 @@ import '../../ble/makibeshr3_link.dart' show MakibesHr3Link; import '../../ble/miband_link.dart' show MiBand234Link, pairMiBand234; import '../../ble/oura_link.dart' show OuraLink, pairOuraRing; import '../../ble/pebble_link.dart' show PebbleLink; +import '../../ble/polar_pmd_link.dart' show PolarPmdLink; import '../../ble/ring11m_link.dart' show Ring11mLink; import '../../ble/smaq2oss_link.dart' show Smaq2ossLink; import '../../ble/o2ring_link.dart' show O2RingLink, pairO2Ring; @@ -833,6 +835,7 @@ class HealthSource { IconData sensorIcon(String? adapterId) => switch (adapterId) { 'oura' || 'o2ring' || 'lefun' || 'colmi' || 'ringconn' || 'ring11m' => LucideIcons.circleDot, + 'polar_pmd' => LucideIcons.activity, 'coros' => LucideIcons.timer, 'ultrahuman' => LucideIcons.circle, 'dafit' || @@ -875,12 +878,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', @@ -918,10 +923,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?, @@ -979,18 +984,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. @@ -1034,6 +1047,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, + ), ( entry: kRing11m, blurb: 'An unbranded smart ring sold under many storefront names ' diff --git a/pubspec.yaml b/pubspec.yaml index 19974359a..fd3ee920b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -141,6 +141,18 @@ dependencies: # stored day_result number moves. R11's meaning is still unconfirmed # and edge does not read it. # + # 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. NO kAlgoVersion bump: + # this device's entry stays out of `kDerivableSources` (unowned + # hardware), so nothing it decodes feeds a derived metric. + # + # MERGE (main → this branch): main's own repin below (protocol tip + # fe1464d) already contains 87ee803 as an ancestor (verified via + # `git merge-base --is-ancestor 87ee803… fe1464d…` on the protocol + # repo), so the ref moves to that tip; nothing from this branch's own + # repin is lost. + # # REPIN (this branch): protocol `feat/garmin-gfdi-protocol` @ 96d47d7, # on top of the #42 merge above. Adds `garmin.dart`: COBS + Multi-Link # framing, the GFDI frame/CRC16, the device-information parser, and a diff --git a/test/adapter_signals_registry_test.dart b/test/adapter_signals_registry_test.dart index be2bd1e25..94d8f3b5f 100644 --- a/test/adapter_signals_registry_test.dart +++ b/test/adapter_signals_registry_test.dart @@ -29,6 +29,7 @@ import 'package:openstrap_edge/ble/adapters/o2ring.dart'; import 'package:openstrap_edge/ble/adapters/oura.dart'; import 'package:openstrap_edge/ble/adapters/pebble.dart'; import 'package:openstrap_edge/ble/adapters/pinetime.dart'; +import 'package:openstrap_edge/ble/adapters/polar_pmd.dart'; import 'package:openstrap_edge/ble/adapters/qhybrid.dart'; import 'package:openstrap_edge/ble/adapters/ring11m.dart'; import 'package:openstrap_edge/ble/adapters/ringconn.dart'; @@ -52,6 +53,7 @@ void main() { 'gen5': kWhoopGen4Signals, 'ble_hrs': const BleHrsAdapter().signals, 'oura': OuraAdapter(key: const [0]).signals, + 'polar_pmd': const PolarPmdAdapter().signals, 'ring11m': const Ring11mAdapter().signals, 'coros': const CorosAdapter().signals, 'garmin': const GarminAdapter().signals, diff --git a/test/adapters/polar_pmd_adapter_test.dart b/test/adapters/polar_pmd_adapter_test.dart new file mode 100644 index 000000000..422a46383 --- /dev/null +++ b/test/adapters/polar_pmd_adapter_test.dart @@ -0,0 +1,143 @@ +// 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 done = Completer(); + final sub = kPolarPmdAdapter.run(link).listen((_) {}, onDone: done.complete); + await _settle(); + expect(link.writes.single, (kPolarPmdControlChar, polarPmdStartPpi())); + // 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 { + 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); + }); +} diff --git a/test/band_registry_test.dart b/test/band_registry_test.dart index 9c37ca2cc..9331f5722 100644 --- a/test/band_registry_test.dart +++ b/test/band_registry_test.dart @@ -16,6 +16,7 @@ void main() { 'gen5', 'ble_hrs', 'oura', + 'polar_pmd', 'coros', 'ultrahuman', 'withings_steel_hr', diff --git a/test/hrs_link_test.dart b/test/hrs_link_test.dart index 87dbdb515..252656301 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