From 41f410b6a244f33ca4ef504b7fbddf1049814ce6 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Tue, 10 Nov 2026 19:51:39 -0700 Subject: [PATCH 1/4] fix(pairing): refuse accidental band pairing as a heart-rate sensor --- lib/ble/adapters/_registry.dart | 104 ++- lib/ble/band_identity.dart | 142 +++++ lib/ble/hrs_link.dart | 504 +++++++++------ lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 9 + lib/l10n/app_es.arb | 3 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_hi.arb | 3 +- lib/l10n/app_ru.arb | 10 +- lib/l10n/app_th.arb | 11 +- lib/l10n/app_zh.arb | 3 +- lib/ui2/pairing/device_picker.dart | 5 +- lib/ui2/pairing/pair_outcome_text.dart | 17 + lib/ui2/profile/pair_sensor.dart | 11 +- test/band_identity_test.dart | 847 +++++++++++++++++++++++++ test/device_picker_test.dart | 2 +- test/hrs_link_test.dart | 12 +- test/pair_sensor_test.dart | 1 + test/pairing_device_role_test.dart | 76 +++ test/sensor_cap_test.dart | 1 + 20 files changed, 1565 insertions(+), 202 deletions(-) create mode 100644 lib/ble/band_identity.dart create mode 100644 lib/ui2/pairing/pair_outcome_text.dart create mode 100644 test/band_identity_test.dart create mode 100644 test/pairing_device_role_test.dart diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index b7f7c3e29..a08979edf 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -310,6 +310,24 @@ class BandWireCommands { /// sessions only, a health measurement's own reading only. enum DeviceCategory { wearable, workoutSensor, healthMeasurement } +/// Whether a device can be the user's main device or only a workout add-on. +enum DeviceRole { band, sensor } + +/// What a device physically is, for the pairing lists' filter pills. Not +/// [DeviceCategory] (what the data is used for): a Colmi ring and an Oura ring +/// are both rings, a Polar armband and a chest strap are both workout sensors. +enum DeviceKind { + strap, + ring, + watch, + band, + chestStrap, + armband, + scale, + thermometer, + other, +} + /// One band the app can discover and connect to. /// /// The wire format itself stays in `protocol` ([BandProfile] = header length, @@ -421,7 +439,7 @@ class BandEntry { /// carried only a hint (and none of the entries' own services) is kept only /// when a [nameMatcher] claims it; a hint alone never confirms an entry, /// but this entry's name on an advertisement carrying its hint does, ahead - /// of another entry's service (`HrsLink.hintedNameMatch`). For + /// of another entry's service (`hintedNameMatch`). For /// a band whose advertisement carries a shared 16-bit UUID rather than the /// GATT service it is checked against after connect. final List scanHints; @@ -445,6 +463,24 @@ class BandEntry { /// What this device is for ([DeviceCategory]). A framed band is a wearable. final DeviceCategory category; + /// Whether the safeguard recognises this entry as a band (or a plain + /// sensor). Nullable only so a const constructor needs no default; every + /// registry entry sets it (pinned by pairing_device_role_test). + final DeviceRole? role; + + /// What the device physically is; drives the pairing lists' filter pills. + final DeviceKind kind; + + final bool _canBeMainDevice; + + /// Whether the first-run list (W2) may offer this entry as the user's main + /// device. Separate from [role], which only says "recognised as a band" for + /// the safeguard (DT10): only a framed WHOOP band has a supported + /// main-device path (DT12). Every other band is recognised but pairs as an + /// additional device under its own adapter, and a sensor never is one. + bool get canBeMainDevice => + role == DeviceRole.band && isFramed && _canBeMainDevice; + /// A framed WHOOP-family band: an envelope, a command characteristic, and a /// flash the offload engine trims. const BandEntry.framed({ @@ -463,7 +499,11 @@ class BandEntry { this.burstCountGateEnforced = false, this.logsConsoleOutput = false, this.nameMatcher, - }) : _requiredCharacteristics = requiredCharacteristics, + this.role, + this.kind = DeviceKind.other, + bool canBeMainDevice = true, + }) : _canBeMainDevice = canBeMainDevice, + _requiredCharacteristics = requiredCharacteristics, _commands = commands, scanByName = false, scanHints = const [], @@ -494,7 +534,11 @@ class BandEntry { this.scanHints = const [], this.scanCompanyIds = const [], this.scanServiceData = const [], - }) : _service = service, + this.role, + this.kind = DeviceKind.other, + bool canBeMainDevice = true, + }) : _canBeMainDevice = canBeMainDevice, + _service = service, _requiredCharacteristics = characteristics, gatt = null, wire = null, @@ -541,6 +585,8 @@ bool _nameContainsWhoop(String lowercaseName) => lowercaseName.contains('whoop') /// that says nothing about a band is not evidence that the band does nothing. const BandEntry kWhoopGen4 = BandEntry.framed( id: 'gen4', + role: DeviceRole.band, + kind: DeviceKind.strap, label: 'WHOOP 4', gatt: GattProfile.gen4, wire: BandProfile.gen4, @@ -572,6 +618,8 @@ const BandEntry kWhoopGen4 = BandEntry.framed( /// envelope differs, which is exactly what [BandProfile] models. const BandEntry kWhoopGen5 = BandEntry.framed( id: 'gen5', + role: DeviceRole.band, + kind: DeviceKind.strap, label: 'WHOOP 5', gatt: GattProfile.gen5, wire: BandProfile.gen5, @@ -612,6 +660,8 @@ const BandEntry kWhoopGen5 = BandEntry.framed( /// off, nothing derives from it. const BandEntry kBleHrs = BandEntry.notify( id: 'ble_hrs', + role: DeviceRole.sensor, + kind: DeviceKind.chestStrap, category: DeviceCategory.workoutSensor, label: 'Bluetooth heart rate sensor', service: kHeartRateServiceUuid, @@ -644,6 +694,8 @@ const BandEntry kBleHrs = BandEntry.notify( /// Pairing confirms the PMD characteristics and the PPI feature bit. const BandEntry kPolarPmd = BandEntry.notify( id: 'polar_pmd', + role: DeviceRole.sensor, + kind: DeviceKind.armband, category: DeviceCategory.workoutSensor, label: 'Polar sensor', service: kPolarPmdService, @@ -683,6 +735,8 @@ bool _looksLikePolarPpi(String lowercaseName) => /// proves nothing about decode correctness. const BandEntry kOura = BandEntry.notify( id: 'oura', + role: DeviceRole.band, + kind: DeviceKind.ring, category: DeviceCategory.wearable, label: 'Oura Ring', service: kOuraService, @@ -725,6 +779,9 @@ const BandEntry kOura = BandEntry.notify( /// session source either, flag or not (`kWorkoutArmedSensors`). const BandEntry kCoros = BandEntry.notify( id: 'coros', + role: DeviceRole.band, + kind: DeviceKind.watch, + canBeMainDevice: false, category: DeviceCategory.workoutSensor, label: 'Coros watch', service: kCorosService, @@ -770,6 +827,9 @@ bool _looksLikeCoros(String lowercaseName) => /// wearable's substrate, while its flag is on (`compute/inputs/canonical.dart`). const BandEntry kGarmin = BandEntry.notify( id: 'garmin', + role: DeviceRole.band, + kind: DeviceKind.watch, + canBeMainDevice: false, category: DeviceCategory.wearable, label: 'Garmin watch', service: kGarminService, @@ -815,6 +875,8 @@ const BandEntry kGarmin = BandEntry.notify( /// until a real ring's advertisement settles it. const BandEntry kUltrahuman = BandEntry.notify( id: 'ultrahuman', + role: DeviceRole.band, + kind: DeviceKind.ring, category: DeviceCategory.wearable, label: 'Ultrahuman Ring Air', service: kUltrahumanCommandService, @@ -854,6 +916,8 @@ bool _looksLikeUltrahuman(String lowercaseName) => /// `wearable_enabled:miband234` flag is on and it is the active wearable. const BandEntry kMiBand234 = BandEntry.notify( id: 'miband234', + role: DeviceRole.band, + kind: DeviceKind.band, category: DeviceCategory.wearable, label: 'Mi Band 2/3', service: kHuami234Service, @@ -883,6 +947,8 @@ bool _looksLikeMiBand23(String lowercaseName) => const { /// not a byte of this path has met hardware (ASSUMPTIONS R6). const BandEntry kPebble = BandEntry.notify( id: 'pebble', + role: DeviceRole.band, + kind: DeviceKind.watch, category: DeviceCategory.wearable, label: 'Pebble', service: kPebbleServiceUuid, @@ -943,6 +1009,8 @@ const String kColmiAdvertisedHint = '0000fee7-0000-1000-8000-00805f9b34fb'; /// found at all until a real ring's advertisement settles it. const BandEntry kColmi = BandEntry.notify( id: 'colmi', + role: DeviceRole.band, + kind: DeviceKind.ring, category: DeviceCategory.wearable, label: 'Colmi ring', service: kColmiService, @@ -960,6 +1028,8 @@ const BandEntry kColmi = BandEntry.notify( /// become attributed `body_temp` observations; nothing derives from them. const BandEntry kThermometer = BandEntry.notify( id: 'thermometer', + role: DeviceRole.sensor, + kind: DeviceKind.thermometer, category: DeviceCategory.healthMeasurement, label: 'Bluetooth thermometer', service: kHtpService, @@ -986,6 +1056,8 @@ bool _looksLikeThermometer(String lowercaseName) => /// stays a measurement only. const BandEntry kMiScaleComposition = BandEntry.notify( id: 'miscale_bc', + role: DeviceRole.sensor, + kind: DeviceKind.scale, category: DeviceCategory.healthMeasurement, label: 'Mi Body Composition Scale', service: kMiScaleBodyCompositionService, @@ -1008,6 +1080,8 @@ bool _looksLikeMiCompositionScale(String lowercaseName) => /// weight (`newestWeighing`). const BandEntry kMiScale2 = BandEntry.notify( id: 'miscale2', + role: DeviceRole.sensor, + kind: DeviceKind.scale, category: DeviceCategory.healthMeasurement, label: 'Mi Smart Scale 2', service: kMiScaleWeightService, @@ -1019,6 +1093,30 @@ const BandEntry kMiScale2 = BandEntry.notify( bool _looksLikeMiScale2(String lowercaseName) => lowercaseName == 'mi scale2'; +/// The entry whose [BandEntry.nameMatcher] claims [lowercaseName] while +/// [advertisedUuids] (canonical 128-bit lowercase) carries one of that same +/// entry's [BandEntry.scanHints], or null. Name and hint together name the +/// entry; either alone does not. +/// +/// [requireHint] false drops the hint condition: the plain "does this entry +/// claim this name" question, kept here so the name matcher is invoked from +/// ONE place (the heavy-calc baseline holds exactly this call). Lives with the +/// registry so identity code and the pairing link share it without importing +/// each other. +String? hintedNameMatch(List entries, + Iterable advertisedUuids, String lowercaseName, + {bool requireHint = true}) { + for (final e in entries) { + if ((e.nameMatcher?.call(lowercaseName) ?? false) && + (!requireHint || + e.scanHints + .any((h) => advertisedUuids.contains(h.toLowerCase())))) { + return e.id; + } + } + return null; +} + /// Every band this build can see. Order is match order during discovery. const List kBandRegistry = [ kWhoopGen4, diff --git a/lib/ble/band_identity.dart b/lib/ble/band_identity.dart new file mode 100644 index 000000000..e017762ad --- /dev/null +++ b/lib/ble/band_identity.dart @@ -0,0 +1,142 @@ +// Which main-device band (role == band) an advertisement or a GATT list belongs +// to, whatever scan found it, and what the pairing step reports. A WHOOP can +// advertise 0x180D under any name; filing it as a chest strap is the bug this +// guards against. Evidence order: a service beats a service-data UUID beats a +// name. A company id alone is no band evidence: it names the maker, not the +// product. +import 'adapters/_registry.dart'; + +/// [entry] is the band; [modelCertain] is false when only the NAME matched a +/// WHOOP (the name cannot tell gen4 from gen5), true when a service settled it. +typedef BandIdentity = ({BandEntry entry, bool modelCertain}); + +const String _sigBase = '-0000-1000-8000-00805f9b34fb'; + +/// A GATT UUID in canonical 128-bit lowercase form: '180D', '0000180D' and +/// '0000180d-0000-1000-8000-00805f9b34fb' all give the last one. +String normalizeGattUuid(String uuid) { + var u = uuid.trim().toLowerCase(); + if (u.startsWith('0x')) u = u.substring(2); + if (u.length == 4) return '0000$u$_sigBase'; + if (u.length == 8) return '$u$_sigBase'; + return u; +} + +Iterable get _bands => + kBandRegistry.where((e) => e.role == DeviceRole.band); + +/// Whether service [uuid] (any form) is [e]'s. A framed band is a family: +/// matched by its 32-bit service prefix. A notify-class band is matched by its +/// full UUID, because a shared prefix is a stranger (Nordic UART shares +/// Coros's 6e400001). +bool serviceIsEntry(BandEntry e, String uuid) { + final normalized = normalizeGattUuid(uuid); + return e.isFramed + ? normalized.startsWith(e.servicePrefix.toLowerCase()) + : normalized == normalizeGattUuid(e.service); +} + +BandEntry? _bandForService(Iterable uuids) { + for (final e in _bands) { + if (uuids.any((u) => serviceIsEntry(e, u))) return e; + } + return null; +} + +/// The band an advertisement belongs to, or null. A service match settles the +/// model; so does a service-data UUID an entry names as its own. A company id +/// does not count: Garmin's 0x0087 is on every Garmin product, so a Garmin +/// heart-rate strap would be refused as the watch. A name alone only proves +/// the family, so a framed band matched by name is +/// [BandIdentity.modelCertain] false. +BandIdentity? bandIdentityOf({ + required List advertisedServiceUuids, + required String? name, + List serviceDataUuids = const [], +}) { + final bySvc = _bandForService(advertisedServiceUuids); + if (bySvc != null) return (entry: bySvc, modelCertain: true); + final data = {for (final u in serviceDataUuids) normalizeGattUuid(u)}; + for (final e in _bands) { + if (e.scanServiceData.any((u) => data.contains(normalizeGattUuid(u)))) { + return (entry: e, modelCertain: true); + } + } + final lower = name?.toLowerCase() ?? ''; + if (lower.isEmpty) return null; + for (final e in _bands) { + if (hintedNameMatch([e], const [], lower, requireHint: false) != + null) { + return (entry: e, modelCertain: !e.isFramed); + } + } + return null; +} + +/// DEFINITIVE check after `discoverServices()`: the full GATT service list +/// against every role==band entry's service. +BandEntry? bandIdentityFromGatt(Iterable discoveredServiceUuids) => + _bandForService(discoveredServiceUuids); + +/// One discovered service and the characteristic UUIDs under it. +class DiscoveredService { + final String uuid; + final List characteristics; + const DiscoveredService(this.uuid, [this.characteristics = const []]); +} + +/// The user's explicit "yes, pair this band as a plain sensor": bound to ONE +/// physical device ([remoteId]) and ONE matched band entry ([bandEntryId]). +class PairConfirmation { + final String remoteId; + final String bandEntryId; + const PairConfirmation({required this.remoteId, required this.bandEntryId}); +} + +/// What the pairing operation asks its persistence callback to write: exactly +/// the arguments of `LocalDb.upsertDevice`. +typedef PairedDevice = ({ + String id, + String adapterId, + String remoteId, + String? label, + String? tier, +}); + +/// What `HrsLink.pairNotifySensor` reports: saved, failed (with a sentence), +/// or "this is a band" (nothing was saved). +class PairOutcome { + /// Non-null when the device turned out to be a band (nothing was saved). + final BandEntry? isBand; + + /// Non-null on a plain failure (nothing was saved). + final String? error; + + /// With [isBand]: false when only the device's name said which band it is + /// (a WHOOP name cannot tell 4 from 5); true when GATT showed it. + final bool modelCertain; + const PairOutcome._(this.isBand, this.error, {this.modelCertain = true}); + const PairOutcome.saved() : this._(null, null); + const PairOutcome.failed(String message) : this._(null, message); + const PairOutcome.band(BandEntry entry, {bool modelCertain = true}) + : this._(entry, null, modelCertain: modelCertain); + bool get saved => isBand == null && error == null; +} + +/// Everything [pairNotifyDevice] needs from the outside world once a device is +/// connected: GATT discovery, the Polar PMD feature read, the OS bond trigger +/// and persistence. One typed interface (production over BluetoothDevice and +/// LocalDb, a fake in tests) so the decision awaits plain methods. +abstract class PairingIo { + Future> discoverServices(); + + /// The PMD control characteristic's feature bytes, null when unreadable. + /// Only asked of a Polar PMD entry. + Future?> readPmdFeatures(); + + /// Writes the entry's bond-trigger characteristic; false when refused. Only + /// asked of an entry that names one ([BandEntry.bondTriggerCharacteristic]). + Future triggerBond(); + + Future persist(PairedDevice device); +} diff --git a/lib/ble/hrs_link.dart b/lib/ble/hrs_link.dart index b12174bed..dcb300e68 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -60,7 +60,7 @@ import 'package:openstrap_protocol/openstrap_protocol.dart' import '../compute/inputs/canonical.dart' show adoptReplacementWearable, pairedRowOf, wearableEnabled; import '../data/db.dart'; -import '../sync/paired_device.dart' show cleanDeviceLabel; +import '../sync/paired_device.dart' show cleanDeviceLabel, kBandSourceTier; import 'accessory_setup.dart'; import 'adapters/_registry.dart'; import 'adapters/adapter.dart'; @@ -68,6 +68,7 @@ import 'adapters/ble_hrs.dart'; import 'adapters/gatt_link.dart'; import '../sync/reset_gate.dart'; import 'adapters/host.dart' show BandHost, HrsReading; +import 'band_identity.dart'; import 'ble_state.dart' show BleBlocker, @@ -80,7 +81,7 @@ import 'colmi_link.dart' show ColmiLink; import 'session_link.dart' show SessionLink; import 'coros_link.dart' show CorosLink; import 'adapters/garmin.dart' show garminMlPair; -import 'garmin_link.dart' show GarminLink, garminMlCharsOf; +import 'garmin_link.dart' show GarminLink; import 'miband_link.dart' show MiBand234Link; import 'oura_link.dart' show OuraLink; import 'pebble_link.dart' show PebbleLink; @@ -107,6 +108,11 @@ typedef BandCandidate = ({ // scan (`scanFor`) always stamps its own id, so existing callers reading // `.label`/`.device`/`.rssi` are unaffected by this field's addition. String entryId, + // The role==band registry entry this advertisement ALSO belongs to (e.g. a + // WHOOP heard in a sensor scan because it advertises 0x180D), else null. + // Also set, to [entryId]'s own band with modelCertain false, when only the + // name matched a WHOOP: the name cannot tell WHOOP 4 from WHOOP 5. + BandIdentity? alsoBand, }); /// The live link to a paired heart-rate sensor. One instance; a second @@ -317,8 +323,8 @@ class HrsLink { Duration timeout = _scanWindow, }) { assert(entries.isNotEmpty, 'scanForAny needs at least one entry.'); - assert(entries.every((e) => !e.isFramed), - 'a framed band has no notify-class scan to join.'); + // Framed WHOOP entries may join: their band service is a plain scan + // filter, so a WHOOP advertising only that is still heard ("Add a device"). return withScanLock(() => _scanForEntries(entries, owner, onResults, timeout)); } @@ -345,65 +351,14 @@ class HrsLink { for (final e in entries) for (final h in e.scanHints) Guid(h), }..removeAll([for (final e in entries) Guid(e.service)]); - // Which entry a matched service belongs to, or NULL when this particular - // advertisement did not carry one of them. Services are each entry's own - // GATT identity, so a collision here would mean two registry rows sharing - // one service — a registry bug, not a runtime ambiguity to resolve softly. - // - // NULLABLE ON PURPOSE, and the whole point of the split with the caller - // below. `withServices` filters on the SCAN, not on the payload: an - // Android advertisement can match the filter and still arrive with an - // empty or truncated `serviceUuids` (the 31-byte advertising packet is - // full, and the rest is in a scan response that has not landed yet). A - // fallback returned from in here was indistinguishable from a real match, - // so the caller cached a guess and never looked again. - // [lowercaseName] is the belt-and-suspenders fallback: `BandEntry - // .nameMatcher` is the same per-entry escape hatch `transport.dart` uses - // for a framed band whose advertisement carries its name but not a - // matchable service UUID. It cannot rescue a device the OS-level - // `withServices` filter below already excluded from the scan entirely, - // which is why an entry that may not advertise its service at all sets - // `scanByName` and the filter is dropped. - // Split from the genuine service match on purpose: a name match is the - // same kind of guess the comment above already warns about, and caching - // it into `confirmed` would make it permanent the same way. Only - // [serviceMatchFor] is safe to lock in — a peripheral's GATT identity - // does not change mid-scan, but its advertised name matching a pattern - // is not proof of anything and gets re-tried every advertisement instead. - String? serviceMatchFor(List advertised) { - for (final g in advertised) { - for (final e in entries) { - if (g == Guid(e.service)) return e.id; - } - } - return null; - } - - // A company id or service-data UUID an entry names as its own - // ([BandEntry.scanCompanyIds], [BandEntry.scanServiceData]): an identity, - // as good as the service, so it confirms like one. - String? adMatchFor(AdvertisementData ad) { - for (final e in entries) { - if (e.scanCompanyIds.any(ad.manufacturerData.containsKey) || - e.scanServiceData.any((u) => ad.serviceData.containsKey(Guid(u)))) { - return e.id; - } - } - return null; - } - - String? nameMatchFor(String lowercaseName) { - for (final e in entries) { - if (e.nameMatcher?.call(lowercaseName) ?? false) return e.id; - } - return null; - } - // Remote ids whose entry is CONFIRMED by an advertisement that actually // carried the service. Everything else is shown under `entries.first` // until a real match lands — correct by construction for a single-entry // scan ([scanFor]), a placeholder that corrects itself for [scanForAny]. final confirmed = {}; + // Band evidence per remote id, kept for the whole scan: once an + // advertisement showed a band, a later bare 0x180D one does not erase it. + final bandSeen = {}; // Keyed by remote id: a scan re-reports the same peripheral several times // a second, and a list that grows a row per advertisement is not a picker. @@ -413,57 +368,25 @@ class HrsLink { for (final r in results) { final id = r.device.remoteId.str; final was = seen[id]; - // The ADVERTISED name first. `platformName` is the OS's cached name - // for a peripheral it has seen before, which can be stale or empty; - // the advertisement is what this sensor is saying now. Both go through - // `cleanDeviceLabel`, and null survives as null rather than becoming a - // placeholder. A name that drops out of a later advertisement keeps - // the one we already had — losing it mid-scan would make the row the - // user is reaching for change under their finger. - final label = cleanDeviceLabel(r.advertisementData.advName) ?? - cleanDeviceLabel(r.device.platformName) ?? - was?.label; - // Re-attempted on EVERY advertisement until one confirms, then fixed: - // a confirmed match cannot change (a peripheral does not swap GATT - // identity mid-scan) and re-reading it would only add work. - // A name an entry claims, on an advertisement carrying one of THAT - // entry's hints, outranks a plain service match: a Polar optical - // sensor advertises 0x180D, which is [kBleHrs]'s own service. Read - // off the sticky [label], so an advertisement whose name has not - // landed yet cannot flip the row back. - final svcMatch = hintedNameMatch(entries, - r.advertisementData.serviceUuids, label?.toLowerCase() ?? '') ?? - serviceMatchFor(r.advertisementData.serviceUuids) ?? - adMatchFor(r.advertisementData); - if (svcMatch != null) confirmed[id] = svcMatch; - final match = confirmed[id] ?? - svcMatch ?? - nameMatchFor((r.advertisementData.advName.isNotEmpty - ? r.advertisementData.advName - : r.device.platformName) - .toLowerCase()); - // Unfiltered scan: no OS filter vouched for this one, so a result - // with no match is some other peripheral, not a candidate. The same - // goes for one the filter let in on a shared hint alone - // ([BandEntry.scanHints]): every unrelated band carrying that UUID - // would otherwise show up as `entries.first`. - if (match == null && - (scanFilter.isEmpty || - r.advertisementData.serviceUuids.any(hints.contains))) { - continue; - } - final now = ( + // Everything per-advertisement lives in [candidateFromAd]. + final ad = r.advertisementData; + final now = candidateFromAd( + entries: entries, device: r.device, - label: label, + serviceUuids: ad.serviceUuids, + advName: ad.advName, + platformName: r.device.platformName, rssi: r.rssi, - entryId: match ?? entries.first.id, + manufacturerCompanyIds: ad.manufacturerData.keys.toList(), + serviceDataUuids: ad.serviceData.keys.toList(), + confirmed: confirmed, + bandSeen: bandSeen, + previousLabel: was?.label, + unfilteredScan: scanFilter.isEmpty, + hints: hints, ); - if (was == null || - was.rssi != now.rssi || - was.label != now.label || - was.entryId != now.entryId) { - changed = true; - } + if (now == null) continue; + if (candidateChanged(was, now)) changed = true; seen[id] = now; } if (changed) onResults(_ranked(seen)); @@ -496,6 +419,7 @@ class HrsLink { label: cleanDeviceLabel(d.platformName), rssi: null, entryId: e.id, + alsoBand: null, )); } } catch (err) { @@ -542,19 +466,127 @@ class HrsLink { onResults(_ranked(seen)); } - /// The entry whose [BandEntry.nameMatcher] claims [lowercaseName] while - /// [advertised] carries one of that same entry's [BandEntry.scanHints], or - /// null. Name and hint together name the entry; either alone does not. + /// Whether a re-heard [now] differs from the [was] the picker already has: + /// signal, label, entry, or the band evidence ([BandCandidate.alsoBand]). @visibleForTesting - static String? hintedNameMatch(List entries, - List advertised, String lowercaseName) { - for (final e in entries) { - if ((e.nameMatcher?.call(lowercaseName) ?? false) && - e.scanHints.any((h) => advertised.contains(Guid(h)))) { - return e.id; + static bool candidateChanged(BandCandidate? was, BandCandidate now) => + was == null || + was.rssi != now.rssi || + was.label != now.label || + was.entryId != now.entryId || + was.alsoBand != now.alsoBand; + + /// The per-advertisement step of the sensor scan: which of [entries] claims + /// this advertisement, and whether it ALSO belongs to a band + /// ([BandCandidate.alsoBand], from [bandIdentityOf]: a WHOOP advertises + /// 0x180D too). Null when no entry claims it. + /// + /// [confirmed] (remote id to entry id), [previousLabel], [unfilteredScan] + /// and [hints] carry the scan's per-peripheral state in and out; the + /// defaults describe a first, unfiltered sighting. + /// + /// A service (a framed band's by its prefix, as [serviceIsEntry]), company + /// id or service-data UUID that an entry names is its + /// identity and gets locked in [confirmed] (a peripheral does not swap GATT + /// identity mid-scan). A name match is only a guess and is + /// re-tried every advertisement. A name an entry claims on an advertisement + /// carrying one of THAT entry's hints outranks a plain service match: a + /// Polar optical sensor advertises 0x180D, which is [kBleHrs]'s own service. + /// + /// The label is the ADVERTISED name first, then [previousLabel] (a name that + /// drops out of a later advertisement keeps the row's label, so the row + /// under the user's finger does not change), then `platformName` (the OS's + /// cached one, possibly stale); names go through `cleanDeviceLabel`. + /// + /// Unfiltered scan, or one the filter let in on a shared hint alone + /// ([BandEntry.scanHints]): a result matching nothing is some other + /// peripheral, not a candidate. + @visibleForTesting + static BandCandidate? candidateFromAd({ + required List entries, + required BluetoothDevice device, + required List serviceUuids, + required String advName, + required String platformName, + required int rssi, + List manufacturerCompanyIds = const [], + List serviceDataUuids = const [], + Map? confirmed, + Map? bandSeen, + String? previousLabel, + bool unfilteredScan = true, + Set hints = const {}, + }) { + final key = device.remoteId.str; + final label = cleanDeviceLabel(advName) ?? + previousLabel ?? + cleanDeviceLabel(platformName); + // NULLABLE ON PURPOSE: `withServices` filters on the SCAN, not on the + // payload, so an advertisement can match the filter and still arrive with + // a truncated `serviceUuids`. A fallback returned from in here would be + // indistinguishable from a real match and the caller would cache a guess. + String? serviceMatch() { + for (final g in serviceUuids) { + for (final e in entries) { + if (serviceIsEntry(e, g.str128)) return e.id; + } } + return null; } - return null; + + String? adMatch() { + for (final e in entries) { + if (e.scanCompanyIds.any(manufacturerCompanyIds.contains) || + e.scanServiceData.any((u) => serviceDataUuids.contains(Guid(u)))) { + return e.id; + } + } + return null; + } + + final scanName = advName.isNotEmpty ? advName : platformName; + final adUuids = [for (final g in serviceUuids) g.str128]; + String? nameMatch() => hintedNameMatch( + entries, adUuids, scanName.toLowerCase(), + requireHint: false); + + final svcMatch = hintedNameMatch(entries, adUuids, + label?.toLowerCase() ?? '') ?? + serviceMatch() ?? + adMatch(); + if (svcMatch != null) confirmed?[key] = svcMatch; + final match = confirmed?[key] ?? svcMatch ?? nameMatch(); + final band = bandIdentityOf( + advertisedServiceUuids: adUuids, + name: scanName, + serviceDataUuids: [for (final g in serviceDataUuids) g.str128], + ); + // Recorded BEFORE the unmatched early return below: a band-only packet + // (no sensor match) must still leave its evidence for a later HR-only one. + // Evidence only accumulates: a later advertisement that shows a band + // replaces a weaker (name-only) guess, never a bare one erases it. + final earlier = bandSeen?[key]; + final best = band != null && (earlier == null || band.modelCertain) + ? band + : earlier ?? band; + if (best != null) bandSeen?[key] = best; + if (match == null && + (unfilteredScan || serviceUuids.any(hints.contains))) { + return null; + } + final entryId = match ?? entries.first.id; + return ( + device: device, + label: label, + rssi: rssi, + entryId: entryId, + // An entry that IS the band is not "also" a band, unless only the name + // matched: then the uncertain identity rides along, so nobody reads a + // WHOOP model off the name-matched entry. + alsoBand: best != null && (best.entry.id != entryId || !best.modelCertain) + ? best + : null, + ); } /// The OS-level service filter for a scan over [entries]: every entry's @@ -779,87 +811,42 @@ class HrsLink { return null; } - static Future pairNotifySensor( + /// Pairs [entry] on [device]; the decision itself is [pairNotifyDevice]. + /// [earlierBand] is what the scan already knew ([BandCandidate.alsoBand]) and + /// [confirmation] the user's explicit "pair it as a sensor anyway". + static Future pairNotifySensor( BandEntry entry, BluetoothDevice device, { String? label, String? tier, + BandIdentity? earlierBand, + PairConfirmation? confirmation, }) async { try { await device.connect(timeout: _connectTimeout); } catch (e) { debugPrint('[hrs] pair connect failed: $e'); - return 'That sensor did not answer. It may have gone back to sleep, or ' - 'it may already be connected to another phone or app.'; + return const PairOutcome.failed( + 'That sensor did not answer. It may have gone back to sleep, or it ' + 'may already be connected to another phone or app.'); } + final io = _DevicePairingIo(entry, device); try { - final services = await device.discoverServices(); - final link = GattBandLink( + return await pairNotifyDevice( entry: entry, - services: services, - onLog: (m) => debugPrint('[hrs] pair: $m'), - ); - final missing = - link.missingCharacteristics(entry.requiredCharacteristics); - if (missing.isNotEmpty) { - link.close(); - return 'That device answered, but it does not expose the ' - '${entry.label} data this needs ' - '(missing ${missing.map((u) => u.substring(0, 8)).join(", ")}). ' - 'Nothing was saved.'; - } - // A Garmin watch's data characteristic varies, so the generic list - // cannot name it; its own check stands in. - if (entry.id == kGarmin.id && - garminMlPair(garminMlCharsOf(services)) == null) { - link.close(); - return 'That device answered, but it does not expose the ' - '${entry.label} data this needs. Nothing was saved.'; - } - final refusal = identityRefusal( - entry, - services: [for (final s in services) s.uuid], - pmdFeatures: entry.id == kPolarPmd.id - ? await link.read(kPolarPmdControlChar) - : null, - ); - if (refusal != null) { - link.close(); - return refusal; - } - // Some notify-class bands (Pebble) gate everything past this point on - // OS-level bonding, triggered by a write here rather than by an - // app-layer key — see `BandEntry.bondTriggerCharacteristic`. - final bondChar = entry.bondTriggerCharacteristic; - if (bondChar != null && - !await link.write(bondChar, kPebblePairingTriggerValue)) { - link.close(); - return 'That device did not accept Bluetooth pairing. Nothing was ' - 'saved.'; - } - link.close(); - final deviceId = mintDeviceId(entry, device.remoteId.str); - await LocalDb.upsertDevice( - id: deviceId, - adapterId: entry.id, remoteId: device.remoteId.str, + io: io, + confirmation: confirmation, + earlierBand: earlierBand, label: label, - tier: deriveTier(tier, entry.id), + tier: tier, ); - // A replacement for the active wearable: syncs now land on this row, - // so derivation has to read it too. Best effort, the sensor is paired - // either way. - try { - await adoptReplacementWearable(deviceId); - } catch (e) { - debugPrint('[hrs] could not move the active wearable: $e'); - } - return null; } catch (e) { debugPrint('[hrs] pair setup failed: $e'); - return 'That sensor disconnected before it could be set up. Nothing was ' - 'saved.'; + return const PairOutcome.failed('That sensor disconnected before it ' + 'could be set up. Nothing was saved.'); } finally { + io.close(); // The pairing connection is not the session. `arm()` opens its own when // a workout starts, and holding this one would be a second GATT link // nobody asked for. @@ -1380,3 +1367,162 @@ class HrsLink { _host = null; } } + +/// [PairingIo] over a connected [BluetoothDevice] and [LocalDb]. Holds the GATT +/// link discovery builds so the PMD read and the bond write reuse it. +class _DevicePairingIo implements PairingIo { + _DevicePairingIo(this.entry, this.device); + final BandEntry entry; + final BluetoothDevice device; + GattBandLink? _link; + + @override + Future> discoverServices() async { + final services = await device.discoverServices(); + _link = GattBandLink( + entry: entry, + services: services, + onLog: (m) => debugPrint('[hrs] pair: $m'), + ); + return [ + for (final s in services) + DiscoveredService(s.uuid.str128, + [for (final c in s.characteristics) c.uuid.str128]), + ]; + } + + @override + Future?> readPmdFeatures() async => + _link?.read(kPolarPmdControlChar); + + // Some notify-class bands (Pebble) gate everything past this point on + // OS-level bonding, triggered by a write here rather than by an app-layer + // key — see `BandEntry.bondTriggerCharacteristic`. + @override + Future triggerBond() async => + await _link?.write(entry.bondTriggerCharacteristic!, + kPebblePairingTriggerValue) ?? + false; + + @override + Future persist(PairedDevice d) async { + await LocalDb.upsertDevice( + id: d.id, + adapterId: d.adapterId, + remoteId: d.remoteId, + label: d.label, + tier: d.tier, + ); + // A replacement for the active wearable: syncs now land on this row, so + // derivation has to read it too. Best effort, the sensor is paired either + // way. + try { + await adoptReplacementWearable(d.id); + } catch (e) { + debugPrint('[hrs] could not move the active wearable: $e'); + } + } + + void close() => _link?.close(); +} + +bool _hasCharacteristic(List services, String required) { + final want = required.substring(0, 8).toLowerCase(); + return services.any((s) => + s.characteristics.any((c) => normalizeGattUuid(c).startsWith(want))); +} + +/// The real pairing decision [HrsLink.pairNotifySensor] delegates to, after it +/// has connected: discovery, persistence and the two device writes/reads come +/// through [io] so no Bluetooth is needed. +/// +/// Order: discover (a throw gives a failed outcome), band cross-check +/// (it needs only the services, so a band missing a characteristic still +/// gets the band choice), required characteristics, Garmin multi-link check, +/// own-identity check (Coros, Polar PPI), bond trigger, THEN persist. Nothing is written or persisted on +/// any refusal. +/// +/// The band cross-check: the band is whatever GATT shows +/// ([bandIdentityFromGatt]), else [earlierBand] (advertisement, company id or +/// name evidence: standard-only discovery cannot erase it). It refuses with +/// [PairOutcome.band] unless it is [entry] itself or [confirmation] names this +/// [remoteId] AND that band entry. +Future pairNotifyDevice({ + required BandEntry entry, + required String remoteId, + required PairingIo io, + PairConfirmation? confirmation, + BandIdentity? earlierBand, + String? label, + String? tier, +}) async { + final List services; + try { + services = await io.discoverServices(); + } catch (_) { + return const PairOutcome.failed('That sensor disconnected before it ' + 'could be set up. Nothing was saved.'); + } + final gattBand = bandIdentityFromGatt([for (final s in services) s.uuid]); + final band = gattBand ?? earlierBand?.entry; + final confirmedBand = band != null && band.id != entry.id; + if (confirmedBand) { + final ok = confirmation != null && + confirmation.remoteId == remoteId && + confirmation.bandEntryId == band.id; + if (!ok) { + return PairOutcome.band(band, + modelCertain: gattBand != null || earlierBand?.modelCertain != false); + } + } + final missing = [ + for (final u in entry.requiredCharacteristics) + if (!_hasCharacteristic(services, u)) u, + ]; + if (missing.isNotEmpty) { + return PairOutcome.failed('That device answered, but it does not expose ' + 'the ${entry.label} data this needs ' + '(missing ${missing.map((u) => u.substring(0, 8)).join(", ")}). ' + 'Nothing was saved.'); + } + // A Garmin watch's data characteristic varies, so the generic list cannot + // name it; its own check stands in. + if (entry.id == kGarmin.id) { + final ml = [ + for (final s in services) + if (normalizeGattUuid(s.uuid) == normalizeGattUuid(kGarminService)) + for (final c in s.characteristics) normalizeGattUuid(c), + ]; + if (garminMlPair(ml) == null) { + return PairOutcome.failed('That device answered, but it does not expose ' + 'the ${entry.label} data this needs. Nothing was saved.'); + } + } + final refusal = HrsLink.identityRefusal( + entry, + services: [for (final s in services) Guid(s.uuid)], + pmdFeatures: entry.id == kPolarPmd.id ? await io.readPmdFeatures() : null, + ); + if (refusal != null) return PairOutcome.failed(refusal); + if (entry.bondTriggerCharacteristic != null && !await io.triggerBond()) { + return const PairOutcome.failed('That device did not accept Bluetooth ' + 'pairing. Nothing was saved.'); + } + try { + await io.persist(( + id: HrsLink.mintDeviceId(entry, remoteId), + adapterId: entry.id, + remoteId: remoteId, + label: label, + // A band the user kept as a plain sensor is still a wrist (or finger) + // optical source: deriveTier would rank it beat-to-beat off the + // heart-rate adapter's declared signals, above the band tier, on the + // R-peak rung and first for live heart rate. + tier: confirmedBand ? kBandSourceTier : HrsLink.deriveTier(tier, entry.id), + )); + } catch (_) { + return const PairOutcome.failed( + 'That sensor could not be saved. Nothing was saved.'); + } + return const PairOutcome.saved(); +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 32339bc6f..9423d7859 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3472,5 +3472,6 @@ "type": "int" } } - } + }, + "pairBandNotSensor": "Dieses Gerät ist ein {label}, also ein Band und kein einfacher Sensor. Es wurde nichts gespeichert." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0d39a254a..6d1467886 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16343,5 +16343,14 @@ "type": "int" } } + }, + "pairBandNotSensor": "That device is a {label}, a band, not a plain sensor. Nothing was saved.", + "@pairBandNotSensor": { + "description": "Shown when a device picked as a plain sensor turns out to be a band. Nothing was saved. {label} is the band’s name.", + "placeholders": { + "label": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0fb047874..09f22ebc6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2892,5 +2892,6 @@ } }, "reportContactLabel": "Contacto (opcional)", - "reportContactHint": "Correo o Discord, solo si quieres una respuesta" + "reportContactHint": "Correo o Discord, solo si quieres una respuesta", + "pairBandNotSensor": "Ese dispositivo es una pulsera {label}, no un sensor sencillo. No se guardó nada." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 41cab9ff7..872e19c8c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2863,5 +2863,6 @@ } }, "reportContactLabel": "Contact (facultatif)", - "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse" + "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse", + "pairBandNotSensor": "Cet appareil est un {label}, c’est-à-dire un bracelet, pas un simple capteur. Rien n’a été enregistré." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6159de0a0..83a3c0282 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2863,5 +2863,6 @@ } }, "reportContactLabel": "संपर्क (वैकल्पिक)", - "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं" + "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं", + "pairBandNotSensor": "यह डिवाइस {label} है, यानी एक बैंड, साधारण सेंसर नहीं। कुछ भी सहेजा नहीं गया।" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 84bc7cfc2..d7ac2e168 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7907,5 +7907,13 @@ }, "whoopFirmwareWarningTitle": "Пользователям WHOOP: не подключайтесь снова к приложению WHOOP", "whoopFirmwareWarningAction": "Если вы пользуетесь OpenStrap или другим приложением с открытым кодом либо планируете перейти на него позже, не подключайте браслет к официальному приложению WHOOP снова. Оно может автоматически установить обновления прошивки. Удалите или отключите приложение WHOOP, чтобы оно не обновляло ваш браслет.", - "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет." + "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет.", + "pairBandNotSensor": "Это устройство — {label}, то есть браслет, а не простой датчик. Ничего не сохранено.", + "@pairBandNotSensor": { + "placeholders": { + "label": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 6347b62ca..39b12dbcd 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16328,5 +16328,14 @@ }, "whoopFirmwareWarningTitle": "ผู้ใช้ WHOOP: อย่าเชื่อมต่อกับแอป WHOOP อีก", "whoopFirmwareWarningAction": "หากคุณใช้ OpenStrap หรือแอปโอเพนซอร์สอื่น หรือวางแผนจะเปลี่ยนไปใช้ในภายหลัง อย่าเชื่อมต่อสายรัดกับแอป WHOOP อย่างเป็นทางการอีก แอปอาจติดตั้งอัปเดตเฟิร์มแวร์โดยอัตโนมัติ ให้ถอนการติดตั้งหรือปิดใช้งานแอป WHOOP เพื่อไม่ให้แอปอัปเดตสายรัดของคุณ", - "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้" + "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้", + "pairBandNotSensor": "อุปกรณ์นั้นคือ {label} ซึ่งเป็นสายรัด ไม่ใช่เซ็นเซอร์ธรรมดา ไม่ได้บันทึกอะไรไว้", + "@pairBandNotSensor": { + "description": "Shown when a device picked as a plain sensor turns out to be a band. Nothing was saved. {label} is the band’s name.", + "placeholders": { + "label": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 472e0d493..615943a42 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2863,5 +2863,6 @@ } }, "reportContactLabel": "联系方式(可选)", - "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写" + "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写", + "pairBandNotSensor": "这台设备是 {label},是手环,不是普通传感器。没有保存任何内容。" } diff --git a/lib/ui2/pairing/device_picker.dart b/lib/ui2/pairing/device_picker.dart index 56458beb3..3a439eb34 100644 --- a/lib/ui2/pairing/device_picker.dart +++ b/lib/ui2/pairing/device_picker.dart @@ -42,6 +42,7 @@ import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart' show BleUnavailableException, bandStatusFor, classifyBleBlocker; import '../../ble/hrs_link.dart'; +import '../pairing/pair_outcome_text.dart'; import '../../ble/oura_link.dart' show pairOuraRingWithTypedKey; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; @@ -196,8 +197,8 @@ class _DevicePickerScreenState extends State { try { failure = sensor.pick != null ? await sensor.pick!(cand.device) - : await HrsLink.pairNotifySensor(sensor.entry, cand.device, - label: cand.label); + : pairOutcomeText(l, await HrsLink.pairNotifySensor(sensor.entry, cand.device, + label: cand.label, earlierBand: cand.alsoBand)); } catch (e) { failure = l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'; } diff --git a/lib/ui2/pairing/pair_outcome_text.dart b/lib/ui2/pairing/pair_outcome_text.dart new file mode 100644 index 000000000..0ff001504 --- /dev/null +++ b/lib/ui2/pairing/pair_outcome_text.dart @@ -0,0 +1,17 @@ +import '../../ble/band_identity.dart'; +import '../../l10n/app_localizations.dart'; +import '../../l10n/display_text.dart' show localizedText; + +/// The sentence for a failed or refused pairing, in the user's language; null +/// when the device was saved. [l] is null only where no localizations are +/// mounted (a bare test widget), which falls back to English. +String? pairOutcomeText(AppLocalizations? l, PairOutcome o) { + final b = o.isBand; + if (b != null) { + final label = o.modelCertain ? localizedText(l, b.label) : 'WHOOP'; + return l?.pairBandNotSensor(label) ?? + 'That device is a $label, a band, not a plain sensor. ' + 'Nothing was saved.'; + } + return o.error; +} diff --git a/lib/ui2/profile/pair_sensor.dart b/lib/ui2/profile/pair_sensor.dart index cfecbc300..09b437de2 100644 --- a/lib/ui2/profile/pair_sensor.dart +++ b/lib/ui2/profile/pair_sensor.dart @@ -59,7 +59,9 @@ import '../../ble/adapters/_registry.dart'; import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart' show BleUnavailableException, bandStatusFor, classifyBleBlocker; +import '../../ble/band_identity.dart' show BandIdentity; import '../../ble/hrs_link.dart'; +import '../pairing/pair_outcome_text.dart'; import '../../ble/ios_ble_restore.dart'; import '../../data/db.dart' show LocalDb; import '../../l10n/app_localizations.dart'; @@ -279,9 +281,9 @@ class _PairSensorScreenState extends State { } } - Future _pick(BandCandidate c) => _pickDevice(c.device, label: c.label); + Future _pick(BandCandidate c) => _pickDevice(c.device, label: c.label, earlierBand: c.alsoBand); - Future _pickDevice(BluetoothDevice device, {required String? label}) async { + Future _pickDevice(BluetoothDevice device, {required String? label, BandIdentity? earlierBand}) async { // THE exact cap check. This screen stays open after a pair and offers // "Pair another", and every route into it (Add sensor, onboarding, // re-pair) only checked on the way in. The minted id is the row only @@ -320,11 +322,12 @@ class _PairSensorScreenState extends State { ? await widget.onPickedWithKey!(device, typedKey) : widget.onPicked != null ? await widget.onPicked!(device) - : await HrsLink.pairNotifySensor( + : pairOutcomeText(l, await HrsLink.pairNotifySensor( widget.entry, device, label: label, - ); + earlierBand: earlierBand, + )); } catch (e) { failure = l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'; diff --git a/test/band_identity_test.dart b/test/band_identity_test.dart new file mode 100644 index 000000000..b9e7a43b9 --- /dev/null +++ b/test/band_identity_test.dart @@ -0,0 +1,847 @@ +// Band cross-check: a sensor-scan hit that is really a band must be recognised. +import 'dart:io'; + +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/band_identity.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_outcome_text.dart'; +import 'package:openstrap_edge/sync/paired_device.dart' show kBandSourceTier; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +BandEntry? _id(List svc, [String? name]) => + bandIdentityOf(advertisedServiceUuids: svc, name: name)?.entry; + +BandIdentity? _full(List svc, [String? name]) => + bandIdentityOf(advertisedServiceUuids: svc, name: name); + +void main() { + test('a refusal keeps a name-only WHOOP model unknown', () { + // WHY: detecting WHOOP by name must not label it as a confirmed WHOOP 4. + expect(pairOutcomeText(null, const PairOutcome.band(kWhoopGen4, modelCertain: false)), + 'That device is a WHOOP, a band, not a plain sensor. Nothing was saved.'); + expect(pairOutcomeText(null, const PairOutcome.band(kWhoopGen5)), + 'That device is a WHOOP 5, a band, not a plain sensor. Nothing was saved.'); + }); + + _gatt(); + _matching(); + _pairing(); + group('bandIdentityOf', () { + test('0x180D + WHOOP 4 service is gen4', () { + // WHY: the reported bug, a WHOOP 4 advertising 0x180D, filed as a strap. + expect(_id([kHeartRateServiceUuid, kWhoopGen4.service])?.id, 'gen4'); + }); + + test('0x180D + WHOOP 5 service is gen5', () { + // WHY: same bug on the WHOOP 5.0 / MG. + expect(_id([kHeartRateServiceUuid, kWhoopGen5.service])?.id, 'gen5'); + }); + + test('service match is case-insensitive and by 32-bit prefix', () { + // WHY: scan results carry upper/lower case and short forms; a framed band + // is identified by servicePrefix elsewhere in the registry. + expect(_id([kWhoopGen4.service.toUpperCase()])?.id, 'gen4'); + final sameFamily = '${kWhoopGen4.servicePrefix}-1111-2222-3333-444455556666'; + expect(_id([sameFamily])?.id, 'gen4'); + }); + + test('a service match settles the model', () { + // WHY: only a service tells gen4 from gen5; the UI may then name the model. + expect(_full([kHeartRateServiceUuid, kWhoopGen4.service])?.modelCertain, isTrue); + expect(_full([kHeartRateServiceUuid, kWhoopGen5.service])?.modelCertain, isTrue); + }); + + test("0x180D + \"Jordan's WHOOP\" is a WHOOP, model not certain", () { + // WHY: the owner names the band, so the name has no serial; it proves WHOOP, not which. + final r = _full([kHeartRateServiceUuid], "Jordan's WHOOP"); + expect(r, isNotNull); + expect(r!.modelCertain, isFalse); + expect({'gen4', 'gen5'}, contains(r.entry.id)); + }); + + test('name match is case-insensitive, anywhere in the name', () { + // WHY: users type any name; "whoop" can sit anywhere or in any case. + for (final n in ["jordan's whoop 4.0", 'WHOOP', 'My Whoop Band']) { + final r = _full([kHeartRateServiceUuid], n); + expect(r, isNotNull, reason: n); + expect(r!.modelCertain, isFalse, reason: n); + } + }); + + test('a serial-style name alone does not identify a band', () { + // WHY: "W 5AG0047521" (Discord) contains no "whoop"; do not guess from a serial shape. + expect(_id([kHeartRateServiceUuid], 'W 5AG0047521'), isNull); + }); + + test('only 0x180D and no name is not a band', () { + // WHY: a real chest strap must stay a sensor; never fabricate a band. + expect(_id([kHeartRateServiceUuid]), isNull); + expect(_id([kHeartRateServiceUuid], null), isNull); + expect(_id(const [], null), isNull); + }); + + test('only 0x180D with an ordinary strap name is not a band', () { + // WHY: names must be matched against band matchers only, not by guessing. + expect(_id([kHeartRateServiceUuid], 'Polar H10 12345678'), isNull); + expect(_id([kHeartRateServiceUuid], 'Polar H10 1234'), isNull); + }); + + test('Polar PMD service is a sensor, not a band', () { + // WHY: sensor-role entries never trigger the "this is a band" badge. + expect(_id([kPolarPmdService]), isNull); + expect(_id([kHeartRateServiceUuid, kPolarPmdService], 'Polar Sense AB12'), + isNull); + }); + + test('Coros service is the coros entry', () { + // WHY: Coros is a band; the check covers every band, not only WHOOP. + expect(_id([kCorosService])?.id, 'coros'); + }); + + test('a band service-data UUID alone identifies it (Garmin), name not needed', () { + // WHY: the name is user-set and may drop the brand; an entry's own + // service-data UUID is stronger. + final r = bandIdentityOf( + advertisedServiceUuids: [kHeartRateServiceUuid], + name: null, + serviceDataUuids: [kGarminServiceDataUuids.first]); + expect(r?.entry.id, 'garmin'); + expect(r?.modelCertain, isTrue); + }); + + test('service beats a misleading name: WHOOP 5 service, name "Polar H10"', () { + // WHY: the name is only a hint; a service match must win. + expect(_id([kWhoopGen5.service], 'Polar H10 1234')?.id, 'gen5'); + }); + + test('a band name alone is enough (Ultrahuman "UH_" name)', () { + // WHY: nameMatcher of ANY band entry counts, not just gen4's. + expect(_id(const [], 'UH_ABC123')?.id, 'ultrahuman'); + }); + }); + + group('HrsLink.candidateFromAd (sensor-scan per-advertisement seam)', () { + final sensors = [kBleHrs, kPolarPmd]; + BandCandidate? cand(List svc, String advName) => + HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:01'), + serviceUuids: [for (final s in svc) Guid(s)], + advName: advName, + platformName: '', + rssi: -55, + ); + + test('a WHOOP advertising 0x180D is a ble_hrs hit flagged alsoBand=gen4', () { + // WHY: the picker needs both facts: it matched the HR scan AND is a band. + final c = cand([kHeartRateServiceUuid, kWhoopGen4.service], 'WHOOP 4C01'); + expect(c, isNotNull); + expect(c!.entryId, 'ble_hrs'); + expect(c.alsoBand?.entry.id, 'gen4'); + expect(c.alsoBand?.modelCertain, isTrue); + expect(c.rssi, -55); + }); + + test('(CR r1) a Garmin company id on a 0x180D ad is a plain strap, not the watch', () async { + // WHY: 0x0087 names Garmin, not the watch. A Garmin heart-rate strap + // carrying it was flagged as the Garmin watch and refused at pairing. + final c = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:02'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: 'HRM-Pro:123456', + platformName: '', + rssi: -60, + manufacturerCompanyIds: [kGarminCompanyIds.first], + ); + expect(c?.entryId, 'ble_hrs'); + expect(c?.alsoBand, isNull); + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice( + entry: kBleHrs, remoteId: 'AA:BB:CC:DD:EE:02', io: io, + earlierBand: c?.alsoBand); + expect(out.saved, isTrue); + expect(io.writes, hasLength(1)); + // Control: the company id still finds a Garmin watch in a Garmin scan. + final watch = HrsLink.candidateFromAd( + entries: [kGarmin], + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:03'), + serviceUuids: const [], + advName: '', + platformName: '', + rssi: -60, + manufacturerCompanyIds: [kGarminCompanyIds.first], + ); + expect(watch?.entryId, kGarmin.id); + }); + + test('(CR r1) a framed band matched by its service prefix is a candidate', () { + // WHY: band identity matches a framed WHOOP by its 32-bit prefix; the + // entry match compared full UUIDs, so a WHOOP advertising a sibling + // UUID of its family was a band with no row in an unfiltered scan. + final sibling = '${kWhoopGen5.servicePrefix}-0000-1000-8000-00805f9b34fb'; + expect(sibling, isNot(kWhoopGen5.service)); + final c = HrsLink.candidateFromAd( + entries: kBandRegistry, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:04'), + serviceUuids: [Guid(sibling)], + advName: '', + platformName: '', + rssi: -60, + ); + expect(c?.entryId, 'gen5'); + }); + + test('(CR r1) a name-only WHOOP keeps its model uncertain', () { + // WHY: the name cannot tell WHOOP 4 from 5; the name-matched entry is + // gen4, and dropping the uncertain identity made it read as a WHOOP 4. + final c = HrsLink.candidateFromAd( + entries: kBandRegistry, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:05'), + serviceUuids: const [], + advName: 'WHOOP 4C0FFEE', + platformName: '', + rssi: -60, + ); + expect(c?.entryId, 'gen4'); + expect(c?.alsoBand?.entry.id, 'gen4'); + expect(c?.alsoBand?.modelCertain, isFalse); + // Control: a service settles the model, and the band is not "also". + final sure = HrsLink.candidateFromAd( + entries: kBandRegistry, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:06'), + serviceUuids: [Guid(kWhoopGen5.service)], + advName: 'WHOOP 4C0FFEE', + platformName: '', + rssi: -60, + ); + expect(sure?.entryId, 'gen5'); + expect(sure?.alsoBand, isNull); + }); + + test('(CR r1) a name that drops out keeps the row label over the OS cache', () { + // WHY: the OS-cached platform name can be stale; the row must not + // change under the user's finger. + final c = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:07'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: '', + platformName: 'Old Name', + rssi: -60, + previousLabel: 'Polar H10 1234', + ); + expect(c?.label, 'Polar H10 1234'); + // Control: with no earlier label the OS name is still used. + final first = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:08'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: '', + platformName: 'Old Name', + rssi: -60, + ); + expect(first?.label, 'Old Name'); + }); + + test('a chest strap has alsoBand null', () { + // WHY: no false "this is a band" badge on real sensors. + final c = cand([kHeartRateServiceUuid], 'Polar H10 12345678'); + expect(c, isNotNull); + expect(c!.entryId, 'ble_hrs'); + expect(c.alsoBand, isNull); + }); + + test('an advertisement no scanned entry claims yields no candidate', () { + // WHY: unrelated peripherals must not appear in the sensor list. + expect(cand(const [], 'Random Speaker'), isNull); + }); + + test('(S2) a service-data-only advertisement identifies a Mi scale and a Garmin', () { + // WHY: scanServiceData is registry Strings, the ad carries Guids; the + // two must be compared as Guids or service-data-only hits vanish. + final scale = HrsLink.candidateFromAd( + entries: [kMiScale2], + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:11'), + serviceUuids: const [], + advName: '', + platformName: '', + rssi: -70, + serviceDataUuids: [Guid(kMiScaleWeightService)], + ); + expect(scale?.entryId, kMiScale2.id); + final garmin = HrsLink.candidateFromAd( + entries: [kGarmin], + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:12'), + serviceUuids: const [], + advName: '', + platformName: '', + rssi: -70, + serviceDataUuids: [Guid(kGarminServiceDataUuids.first)], + ); + expect(garmin?.entryId, kGarmin.id); + }); + + test('(S1) band evidence from an earlier advertisement survives an HR-only one', () async { + // WHY: a WHOOP advertisement, then a bare 0x180D one for the same id. + // The candidate must still say "band", and pairing with standard-only + // discovery must refuse with zero writes (DT9). + final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:21'); + final seen = {}; + BandCandidate? ad(List svc, String name) => + HrsLink.candidateFromAd( + entries: sensors, + device: dev, + serviceUuids: [for (final s in svc) Guid(s)], + advName: name, + platformName: '', + rssi: -50, + bandSeen: seen, + ); + final first = ad([kHeartRateServiceUuid, kWhoopGen4.service], 'WHOOP 4C01'); + expect(first?.alsoBand?.entry.id, 'gen4'); + final second = ad([kHeartRateServiceUuid], ''); + expect(second?.alsoBand?.entry.id, 'gen4'); + // A different id must not inherit it. + final other = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:22'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: '', + platformName: '', + rssi: -50, + bandSeen: seen, + ); + expect(other?.alsoBand, isNull); + + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice( + entry: kBleHrs, + remoteId: dev.remoteId.str, + io: io, + earlierBand: second!.alsoBand, + ); + expect(out.isBand?.id, 'gen4'); + expect(io.writes, isEmpty); + }); + + test('(S1 r2) a band-only packet in an unfiltered scan still leaves evidence', () async { + // WHY: the unified picker scans unfiltered; a WHOOP-service-only packet + // matches no sensor entry and yields no row, but its evidence must stay + // for the later HR-only packet, or standard-only discovery saves it. + final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:24'); + final seen = {}; + BandCandidate? ad(List svc) => HrsLink.candidateFromAd( + entries: sensors, + device: dev, + serviceUuids: [for (final s in svc) Guid(s)], + advName: '', + platformName: '', + rssi: -50, + bandSeen: seen, + unfilteredScan: true, + ); + expect(ad([kWhoopGen5.service]), isNull); + final hrOnly = ad([kHeartRateServiceUuid]); + expect(hrOnly?.entryId, 'ble_hrs'); + expect(hrOnly?.alsoBand?.entry.id, 'gen5'); + + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice( + entry: kBleHrs, + remoteId: dev.remoteId.str, + io: io, + earlierBand: hrOnly!.alsoBand, + ); + expect(out.isBand?.id, 'gen5'); + expect(io.writes, isEmpty); + }); + + test('(S1) a change in band evidence alone counts as a result change', () { + // WHY: the picker is only told about changed rows; a row that gains the + // "band" badge with the same signal and label must be re-sent. + final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:23'); + final plain = (device: dev, label: 'X', rssi: -50, entryId: 'ble_hrs', alsoBand: null); + final banded = (device: dev, label: 'X', rssi: -50, entryId: 'ble_hrs', + alsoBand: (entry: kWhoopGen4, modelCertain: true)); + expect(HrsLink.candidateChanged(plain, banded), isTrue); + expect(HrsLink.candidateChanged(banded, banded), isFalse); + expect(HrsLink.candidateChanged(null, plain), isTrue); + }); + }); +} + +void _gatt() { + group('bandIdentityFromGatt (definitive, after discoverServices)', () { + test('WHOOP 4 / WHOOP 5 service in the full list gives that entry, no name needed', () { + // WHY: last line of defence before a band is saved as a sensor. + expect(bandIdentityFromGatt([kHeartRateServiceUuid, kWhoopGen4.service])?.id, 'gen4'); + expect(bandIdentityFromGatt(['0000180f-0000-1000-8000-00805f9b34fb', kWhoopGen5.service])?.id, 'gen5'); + }); + + test('only HR / battery / device-info services is null', () { + // WHY: a real strap must still pair as a sensor. + expect( + bandIdentityFromGatt([ + kHeartRateServiceUuid, + '0000180f-0000-1000-8000-00805f9b34fb', + '0000180a-0000-1000-8000-00805f9b34fb', + ]), + isNull); + }); + + test('a sensor-role service (Polar PMD) is null', () { + // WHY: only role==band entries count. + expect(bandIdentityFromGatt([kPolarPmdService]), isNull); + }); + }); + + group('PairOutcome (pairNotifySensor result)', () { + test('can carry "is a band" distinct from an error and from saved', () { + // WHY: the UI must show the slot comparison, not an error string. + final b = PairOutcome.band(kWhoopGen4); + expect(b.isBand?.id, 'gen4'); + expect(b.error, isNull); + expect(b.saved, isFalse); + final f = PairOutcome.failed('nope'); + expect(f.error, 'nope'); + expect(f.isBand, isNull); + expect(f.saved, isFalse); + expect(PairOutcome.saved().saved, isTrue); + }); + }); +} + +const _nus = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; // Nordic UART, NOT Coros +const _hr = '0000180d-0000-1000-8000-00805f9b34fb'; +const _bat = '0000180f-0000-1000-8000-00805f9b34fb'; +const _dis = '0000180a-0000-1000-8000-00805f9b34fb'; + +void _matching() { + group('matching rules (S5)', () { + test('normalizeGattUuid: case and 16/32/128-bit forms are one UUID', () { + // WHY: scans report any form; comparing strings would miss "180D" vs the full form. + const full = '0000180d-0000-1000-8000-00805f9b34fb'; + expect(normalizeGattUuid('180D'), full); + expect(normalizeGattUuid('0000180D'), full); + expect(normalizeGattUuid('0000180D-0000-1000-8000-00805F9B34FB'), full); + expect(normalizeGattUuid(full), full); + expect(normalizeGattUuid('FEE1'), '0000fee1-0000-1000-8000-00805f9b34fb'); + }); + + test('Mi Band FEE1 matches in 16-bit, upper and 128-bit forms (advert and GATT)', () { + // WHY: a notify-class entry is matched by the full UUID, whatever its spelling. + for (final u in ['FEE1', 'fee1', '0000FEE1-0000-1000-8000-00805F9B34FB', kHuami234Service]) { + expect(_id([u])?.id, 'miband234', reason: 'advert $u'); + expect(bandIdentityFromGatt([u])?.id, 'miband234', reason: 'gatt $u'); + } + }); + + test('framed entries match by 32-bit prefix at GATT discovery too', () { + // WHY: a WHOOP family member differs after the prefix; the registry matches framed bands by servicePrefix. + final fam = '${kWhoopGen5.servicePrefix}-1111-2222-3333-444455556666'.toUpperCase(); + expect(bandIdentityFromGatt([fam])?.id, 'gen5'); + }); + + test('notify-class entries match the FULL UUID only: a near miss is no band', () { + // WHY: a shared prefix must not claim a stranger (prefix matching for notify entries is the bug). + final nearCoros = '${kCorosService.substring(0, kCorosService.length - 1)}1'; + final nearGarmin = '${kGarminService.substring(0, kGarminService.length - 1)}1'; + for (final u in [nearCoros, nearGarmin]) { + expect(_id([u]), isNull, reason: 'advert $u'); + expect(bandIdentityFromGatt([u]), isNull, reason: 'gatt $u'); + } + // positive controls: the exact UUIDs do match + expect(bandIdentityFromGatt([kCorosService])?.id, 'coros'); + expect(bandIdentityFromGatt([kGarminService.toUpperCase()])?.id, 'garmin'); + }); + + test('Nordic UART alone is not a Coros (shares the 6e400001 prefix)', () { + // WHY: kCorosService ends ...77656c6f6f70 ("weloop"); NUS ends ...e50e24dcca9e. + expect(kCorosService.substring(0, 8), _nus.substring(0, 8), + reason: 'control: the prefixes really collide'); + expect(_id([_nus]), isNull); + expect(_id([_hr, _nus]), isNull); + expect(bandIdentityFromGatt([_nus]), isNull); + expect(bandIdentityFromGatt([_hr, _bat, _nus]), isNull); + }); + + test('a sensor service never hides a band service in the same list', () { + // WHY: HR + PMD + band service together is a band; the sensor match must not win. + for (final order in [ + [_hr, kPolarPmdService, kWhoopGen4.service], + [kWhoopGen4.service, kPolarPmdService, _hr], + ]) { + expect(_id(order)?.id, 'gen4'); + expect(bandIdentityFromGatt(order)?.id, 'gen4'); + } + expect(_id([_hr, kCorosService])?.id, 'coros'); + }); + + test('service evidence beats a conflicting name or service-data UUID', () { + // WHY: names are user-set and company ids shared; a service is the strongest evidence. + expect(_full([kCorosService], 'My WHOOP')?.entry.id, 'coros'); + expect(_full([kCorosService], 'My WHOOP')?.modelCertain, isTrue); + expect(_id([kWhoopGen4.service], 'UH_ABC123'), isNotNull); + expect(_id([kWhoopGen4.service], 'UH_ABC123')?.id, 'gen4'); + final r = bandIdentityOf( + advertisedServiceUuids: [kWhoopGen5.service], + name: 'Garmin Forerunner', + serviceDataUuids: [kGarminServiceDataUuids.first]); + expect(r?.entry.id, 'gen5'); + }); + }); +} + +DiscoveredService _svc(String uuid, [List chars = const []]) => + DiscoveredService(uuid, chars); + +/// A genuine heart-rate strap's GATT: HRS with its measurement, battery, DIS. +List _strap() => [ + _svc(_hr, [kHeartRateMeasurementUuid]), + _svc(_bat), + _svc(_dis), + ]; + +/// The pairing seam's fake: scripted discovery, recorded writes. +class _FakeIo implements PairingIo { + _FakeIo(this._discover, + {this.bondOk = true, this.persistError}); + final Future> Function() _discover; + final bool bondOk; + final Object? persistError; + final writes = []; + var bondCalls = 0; + var pmdReads = 0; + + @override + Future> discoverServices() => _discover(); + @override + Future?> readPmdFeatures() async { + pmdReads++; + return null; + } + + @override + Future triggerBond() async { + bondCalls++; + return bondOk; + } + + @override + Future persist(PairedDevice d) async { + if (persistError != null) throw persistError!; + writes.add(d); + } +} + +class _Pairing { + _FakeIo? io; + List get writes => io?.writes ?? const []; + Future run( + BandEntry entry, + Future> Function() discover, { + String remoteId = 'AA:BB:CC:DD:EE:01', + PairConfirmation? confirmation, + BandIdentity? earlierBand, + }) => + pairNotifyDevice( + entry: entry, + remoteId: remoteId, + io: io = _FakeIo(discover), + confirmation: confirmation, + earlierBand: earlierBand, + ); +} + +void _pairing() { + group('pairNotifyDevice (the real pairing decision; injected discovery + persistence)', () { + test('(a) a WHOOP 5 behind a 0x180D advert is a band outcome, zero writes', () async { + // WHY (S3): the reported bug. Advertisement said only 0x180D under an + // ordinary name; only discovery reveals the band. Nothing may be saved. + final p = _Pairing(); + final out = await p.run(kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service.toUpperCase())]); + expect(out.isBand?.id, 'gen5'); + expect(out.saved, isFalse); + expect(out.error, isNull); + expect(p.writes, isEmpty); + }); + + test('(a2) the same for a WHOOP 4', () async { + // WHY: both generations, not only the one the first test happened to use. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => [..._strap(), _svc(kWhoopGen4.service)]); + expect(out.isBand?.id, 'gen4'); + expect(p.writes, isEmpty); + }); + + test('(b) discovery throwing is an error outcome, zero writes', () async { + // WHY (S3): a failed discovery must never fall through to "save anyway". + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => throw StateError('gatt 133')); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(out.isBand, isNull); + expect(out.saved, isFalse); + expect(p.writes, isEmpty); + }); + + test('(c) a genuine strap (180D/180F/180A only) is saved exactly once', () async { + // WHY (S3): the safeguard must not block real sensors. Positive control for the zero-write tests. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), remoteId: 'AA:BB:CC:DD:EE:09'); + expect(out.saved, isTrue); + expect(p.writes, hasLength(1)); + final w = p.writes.single; + expect(w.adapterId, 'ble_hrs'); + expect(w.remoteId, 'AA:BB:CC:DD:EE:09'); + expect(w.id, HrsLink.mintDeviceId(kBleHrs, 'AA:BB:CC:DD:EE:09')); + expect(w.tier, 'beatToBeat', reason: 'HrsLink.deriveTier for a declared-rr entry'); + }); + + test('(S3) bond trigger and PMD read go through the interface only where they apply', () async { + // WHY: the interface methods are always present now; the decision to call + // them must stay with the entry (a plain strap writes nothing to the device). + final strap = _Pairing(); + await strap.run(kBleHrs, () async => _strap()); + expect(strap.io!.bondCalls, 0); + expect(strap.io!.pmdReads, 0); + List pebble() => [ + _svc(kPebbleServiceUuid, kPebble.requiredCharacteristics), + ]; + final bonded = _Pairing(); + final ok = await bonded.run(kPebble, () async => pebble()); + expect(ok.saved, isTrue); + expect(bonded.io!.bondCalls, 1); + final refused = _FakeIo(() async => pebble(), bondOk: false); + final out = await pairNotifyDevice( + entry: kPebble, remoteId: 'AA:BB:CC:DD:EE:31', io: refused); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(refused.writes, isEmpty); + }); + + test('(CR 4500472602) a band missing the measurement characteristic is still a band outcome', () async { + // WHY: the band cross-check needs only the discovered services. Run + // after the characteristic check, a WHOOP without the measurement + // characteristic got "does not expose the data" instead of the band + // choice this flow exists to offer. + final p = _Pairing(); + final out = await p.run( + kBleHrs, () async => [_svc(_hr), _svc(kWhoopGen5.service)]); + expect(out.isBand?.id, 'gen5'); + expect(out.error, isNull); + expect(p.writes, isEmpty); + }); + + test('(CR 4500539072) a band outcome says whether its model is certain', () async { + // WHY: the flow must not guess certainty from whether the entry changed. + // GATT proves the model; a name match alone does not. + final byName = (entry: kWhoopGen4, modelCertain: false); + final proven = await _Pairing().run(kBleHrs, + () async => [..._strap(), _svc(kWhoopGen4.service)], + earlierBand: byName); + expect(proven.isBand?.id, 'gen4'); + expect(proven.modelCertain, isTrue, reason: 'GATT showed the gen4 service'); + final nameOnly = await _Pairing().run(kBleHrs, () async => _strap(), + earlierBand: byName); + expect(nameOnly.isBand?.id, 'gen4'); + expect(nameOnly.modelCertain, isFalse, reason: 'only the name said WHOOP'); + }); + + test('a strap missing its measurement characteristic fails, zero writes', () async { + // WHY: a row pointing at a device that cannot answer is a sensor that appears paired and never beats. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => [_svc(_hr), _svc(_bat)]); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(out.saved, isFalse); + expect(p.writes, isEmpty); + }); + + test('a persistence failure is an error outcome, not a saved one', () async { + // WHY: never report success for a row that was not written. + final out = await pairNotifyDevice( + entry: kBleHrs, + remoteId: 'AA:BB:CC:DD:EE:01', + io: _FakeIo(() async => _strap(), persistError: StateError('disk full')), + ); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + }); + + test('Coros paired under kCoros, its own service discovered, saves', () async { + // WHY (S4): the band refusal must not fire for the entry being paired. + final p = _Pairing(); + final out = await p.run(kCoros, + () async => [_svc(kCorosService), _svc(_bat, [kBatteryLevelUuid])]); + expect(out.saved, isTrue); + expect(p.writes.single.adapterId, 'coros'); + }); + + test('Coros without its own service (Nordic UART only) is refused, zero writes', () async { + // WHY (S5): a Nordic UART peripheral is not a Coros; identity is the full vendor UUID. + final p = _Pairing(); + final out = await p.run(kCoros, () async => [_svc(_nus), _svc(_bat, [kBatteryLevelUuid])]); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(p.writes, isEmpty); + }); + + test('Garmin paired under kGarmin with its multi-link characteristics saves', () async { + // WHY (S4): garminMlPair needs a data characteristic 6A4E2810..2819; the + // Garmin service is a band service yet must not be refused as a band here. + final p = _Pairing(); + final out = await p.run(kGarmin, + () async => [_svc(kGarminService, [kGarminNotifyChar, kGarminWriteChar])]); + expect(out.saved, isTrue); + expect(p.writes.single.adapterId, 'garmin'); + }); + + test('Garmin service without a multi-link data characteristic fails, zero writes', () async { + // WHY: the Garmin shape check still stands behind the identity change. + final p = _Pairing(); + final out = await p.run(kGarmin, () async => [_svc(kGarminService, const [])]); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(p.writes, isEmpty); + }); + + test('a WHOOP paired as a sensor WITH a matching confirmation saves', () async { + // WHY (S4): option C, the user deliberately uses the WHOOP as a plain HR sensor. + final p = _Pairing(); + final out = await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:05', bandEntryId: 'gen5'), + ); + expect(out.saved, isTrue); + expect(p.writes, hasLength(1)); + expect(p.writes.single.adapterId, 'ble_hrs'); + }); + + test('(CR r2) a WHOOP kept as a plain sensor is stored at the band tier, not beat-to-beat', () async { + // WHY: deriveTier off ble_hrs's declared signals says beatToBeat, which + // would rank a wrist optical WHOOP above the band, fill the R-peak + // rung and win live heart rate. + final p = _Pairing(); + await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:05', bandEntryId: 'gen5'), + ); + expect(p.writes.single.tier, kBandSourceTier); + expect(kBandSourceTier, 'wristOptical'); + // Positive control: a real chest strap keeps its beat-to-beat tier. + final strap = _Pairing(); + await strap.run(kBleHrs, () async => _strap()); + expect(strap.writes.single.tier, 'beatToBeat'); + }); + + test('a confirmation for another device is still refused', () async { + // WHY (S4): the confirmation is bound to the device id; a stale one must not unlock a different strap. + final p = _Pairing(); + final out = await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:99', bandEntryId: 'gen5'), + ); + expect(out.isBand?.id, 'gen5'); + expect(p.writes, isEmpty); + }); + + test('a confirmation for another matched entry is still refused', () async { + // WHY (S4): confirmed "WHOOP 4" does not cover a device that turns out to be a WHOOP 5 (or a Coros). + final p = _Pairing(); + final out = await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:05', bandEntryId: 'gen4'), + ); + expect(out.isBand?.id, 'gen5'); + expect(p.writes, isEmpty); + }); + + test('(N1) earlier band evidence survives a standard-only GATT: refused, zero writes', () async { + // WHY (DT9): advert/company/name said "band"; discovery shows only + // 180D/180F/180A. Absence at GATT must not erase that evidence. + final earlier = { + 'service': (entry: kWhoopGen4, modelCertain: true), + 'company id': (entry: kGarmin, modelCertain: true), + 'name hint': (entry: kWhoopGen4, modelCertain: false), + }; + for (final e in earlier.entries) { + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), + remoteId: 'AA:BB:CC:DD:EE:07', earlierBand: e.value); + expect(out.isBand?.id, e.value.entry.id, reason: e.key); + expect(out.saved, isFalse, reason: e.key); + expect(p.writes, isEmpty, reason: e.key); + } + }); + + test('(N1) the same device with a matching confirmation is saved once', () async { + // WHY (DT9): positive control; the user's explicit yes unlocks exactly that evidence. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), + remoteId: 'AA:BB:CC:DD:EE:07', + earlierBand: (entry: kWhoopGen4, modelCertain: false), + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:07', bandEntryId: 'gen4')); + expect(out.saved, isTrue); + expect(p.writes, hasLength(1)); + }); + + test('(N1) a confirmation for another device or entry does not unlock earlier evidence', () async { + // WHY (DT9): the confirmation stays bound to device and entry. + for (final c in const [ + PairConfirmation(remoteId: 'AA:BB:CC:DD:EE:99', bandEntryId: 'gen4'), + PairConfirmation(remoteId: 'AA:BB:CC:DD:EE:07', bandEntryId: 'gen5'), + ]) { + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), + remoteId: 'AA:BB:CC:DD:EE:07', + earlierBand: (entry: kWhoopGen4, modelCertain: false), + confirmation: c); + expect(out.isBand?.id, 'gen4'); + expect(p.writes, isEmpty); + } + }); + + test('(N1) a GATT-found band beats an uncertain earlier name guess', () async { + // WHY (DT9): GATT is definitive; a name-only gen4 guess must not mislabel a WHOOP 5. + final p = _Pairing(); + final out = await p.run( + kBleHrs, () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:07', + earlierBand: (entry: kWhoopGen4, modelCertain: false)); + expect(out.isBand?.id, 'gen5'); + expect(p.writes, isEmpty); + }); + + test('HrsLink.pairNotifySensor delegates to pairNotifyDevice (backstop)', () { + // WHY: the seam is worthless if production does not call it. A source + // check, kept only as a backstop; positive control proves the matcher. + final src = File('lib/ble/hrs_link.dart').readAsStringSync() + .split('\n').where((l) => !l.trimLeft().startsWith('//')).join('\n'); + final start = src.indexOf('static Future pairNotifySensor('); + final start2 = src.indexOf('static Future pairNotifySensor('); + final at = start >= 0 ? start : start2; + expect(at, greaterThanOrEqualTo(0), reason: 'pairNotifySensor not found'); + final end = src.indexOf('static ', at + 10); + final body = src.substring(at, end); + expect(RegExp(r'pairNotifyDevice\s*\(').hasMatch(body), isTrue); + expect(RegExp(r'pairNotifyDevice\s*\(').hasMatch('x = pairNotifyDevice(a);'), isTrue, + reason: 'matcher control'); + }); + }); +} diff --git a/test/device_picker_test.dart b/test/device_picker_test.dart index a3a79e5bf..809769882 100644 --- a/test/device_picker_test.dart +++ b/test/device_picker_test.dart @@ -15,7 +15,7 @@ import 'package:openstrap_edge/ui2/ui2.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; BandCandidate _cand(String id, String entryId, {String? label, int rssi = -60}) => - (device: BluetoothDevice.fromId(id), label: label, rssi: rssi, entryId: entryId); + (device: BluetoothDevice.fromId(id), label: label, rssi: rssi, entryId: entryId, alsoBand: null); Future _pump(WidgetTester t, DevicePickerView view, {bool settle = true}) async { diff --git a/test/hrs_link_test.dart b/test/hrs_link_test.dart index 61a6bd8bf..58b8c84de 100644 --- a/test/hrs_link_test.dart +++ b/test/hrs_link_test.dart @@ -583,24 +583,24 @@ void main() { // A Polar advertises 0x180D and its name, never the PMD service. test('a Polar optical sensor on 0x180D is routed to PMD, not generic HR', () { - expect(HrsLink.hintedNameMatch(notify, [hr], 'polar sense 1a2b3c4d'), + expect(hintedNameMatch(notify, [hr.str128], 'polar sense 1a2b3c4d'), kPolarPmd.id); expect(HrsLink.scanServiceFilter([kPolarPmd]), contains(hr)); }); test('an H-series chest strap and an unnamed strap stay generic HR', () { - expect(HrsLink.hintedNameMatch(notify, [hr], 'polar h10 1a2b3c4d'), + expect(hintedNameMatch(notify, [hr.str128], 'polar h10 1a2b3c4d'), isNull); - expect(HrsLink.hintedNameMatch(notify, [hr], ''), isNull); + expect(hintedNameMatch(notify, [hr.str128], ''), isNull); // A Polar name without the hint is not enough on its own. - expect(HrsLink.hintedNameMatch(notify, const [], 'polar oh1 1a2b'), + expect(hintedNameMatch(notify, const [], 'polar oh1 1a2b'), isNull); }); test('a Coros is found by its name on the shared 0xFEE7 hint', () { expect( - HrsLink.hintedNameMatch( - notify, [Guid(kColmiAdvertisedHint)], 'coros pace 3 a1b2'), + hintedNameMatch( + notify, [Guid(kColmiAdvertisedHint).str128], 'coros pace 3 a1b2'), kCoros.id); }); }); diff --git a/test/pair_sensor_test.dart b/test/pair_sensor_test.dart index 0310dbc28..702144731 100644 --- a/test/pair_sensor_test.dart +++ b/test/pair_sensor_test.dart @@ -35,6 +35,7 @@ BandCandidate _cand(String id, {String? label, int? rssi = -60}) => ( label: label, rssi: rssi, entryId: 'ble_hrs', + alsoBand: null, ); Future _pump( diff --git a/test/pairing_device_role_test.dart b/test/pairing_device_role_test.dart new file mode 100644 index 000000000..a21e1bd3f --- /dev/null +++ b/test/pairing_device_role_test.dart @@ -0,0 +1,76 @@ +// Device role: which registry entries can be the user's main device. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; + +void main() { + test('every registry entry declares a role', () { + // WHY: an undeclared role would let a sensor or a band slip into the wrong list. + final missing = [ + for (final e in kBandRegistry) + if (e.role == null) e.id, + ]; + expect(missing, isEmpty, reason: 'entries without role: $missing'); + }); + + test('the sensor set is exactly ble_hrs, polar_pmd, thermometer, scales', () { + // WHY: workout add-ons and scales must never be offered as the main device. + final sensors = { + for (final e in kBandRegistry) + if (e.role == DeviceRole.sensor) e.id, + }; + expect(sensors, + {'ble_hrs', 'polar_pmd', 'thermometer', 'miscale_bc', 'miscale2'}); + }); + + test('every other entry is a band, including both WHOOPs', () { + // WHY: the WHOOP bug was a band filed as a sensor; bands must stay bands. + expect(kWhoopGen4.role, DeviceRole.band); + expect(kWhoopGen5.role, DeviceRole.band); + final bands = { + for (final e in kBandRegistry) + if (e.role == DeviceRole.band) e.id, + }; + expect(bands, { + 'gen4', 'gen5', 'oura', 'coros', 'ultrahuman', 'miband234', 'pebble', + 'colmi', 'garmin', + }); + }); + + test('recognised as a band is not the same as can be the main device (DT10)', () { + // WHY: Coros and Garmin must trip the "this is a band" safeguard, but have + // no supported main-device path, so the first-run list must not offer them. + for (final e in [kWhoopGen4, kWhoopGen5]) { + expect(e.role, DeviceRole.band, reason: e.id); + expect(e.canBeMainDevice, isTrue, reason: e.id); + } + for (final e in [kCoros, kGarmin]) { + expect(e.role, DeviceRole.band, reason: '${e.id} is recognised'); + expect(e.canBeMainDevice, isFalse, reason: '${e.id} is not on W2'); + } + }); + + test('a sensor can never be the main device (DT10)', () { + // WHY: workout add-ons and scales stay off the first-run list. + for (final e in kBandRegistry) { + if (e.role == DeviceRole.sensor) { + expect(e.canBeMainDevice, isFalse, reason: e.id); + } + } + expect(kBandRegistry.where((e) => e.canBeMainDevice).map((e) => e.id), + containsAll(['gen4', 'gen5']), + reason: 'positive control: the W2 list is not empty'); + }); + + test('only the framed WHOOP bands can be the main device (DT12)', () { + // WHY: the main-band path (PairingScreen, AppState.isPaired) runs framed + // WHOOP only. Oura, Colmi, Mi Band and the rest are recognised as bands + // but pair as additional devices under their own adapter. + expect({for (final e in kBandRegistry) if (e.canBeMainDevice) e.id}, + {'gen4', 'gen5'}); + for (final e in kBandRegistry) { + if (e.role == DeviceRole.band && !e.isFramed) { + expect(e.canBeMainDevice, isFalse, reason: e.id); + } + } + }); +} diff --git a/test/sensor_cap_test.dart b/test/sensor_cap_test.dart index 7bb6af557..07a07e2b9 100644 --- a/test/sensor_cap_test.dart +++ b/test/sensor_cap_test.dart @@ -144,6 +144,7 @@ void main() { label: 'New sensor', rssi: -50, entryId: entry.id, + alsoBand: null, ), ], ), From 011a76d87f7a1850aaba8591302d974dde4f5435 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Tue, 10 Nov 2026 20:44:24 -0700 Subject: [PATCH 2/4] fix(pairing): retain ranked scan evidence and save devices atomically --- lib/ble/adapters/_registry.dart | 12 ++-- lib/ble/band_identity.dart | 55 +++++++++++++++++ lib/ble/hrs_link.dart | 84 ++++++++++++++++---------- lib/data/db.dart | 10 +++- test/band_identity_test.dart | 96 +++++++++++++++++++++++++++++- test/paired_device_test.dart | 22 +++++++ test/pairing_device_role_test.dart | 10 +--- 7 files changed, 239 insertions(+), 50 deletions(-) diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index a08979edf..91e886c8a 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -464,9 +464,8 @@ class BandEntry { final DeviceCategory category; /// Whether the safeguard recognises this entry as a band (or a plain - /// sensor). Nullable only so a const constructor needs no default; every - /// registry entry sets it (pinned by pairing_device_role_test). - final DeviceRole? role; + /// sensor). Required so a new entry cannot silently bypass recognition. + final DeviceRole role; /// What the device physically is; drives the pairing lists' filter pills. final DeviceKind kind; @@ -499,7 +498,7 @@ class BandEntry { this.burstCountGateEnforced = false, this.logsConsoleOutput = false, this.nameMatcher, - this.role, + required this.role, this.kind = DeviceKind.other, bool canBeMainDevice = true, }) : _canBeMainDevice = canBeMainDevice, @@ -534,7 +533,7 @@ class BandEntry { this.scanHints = const [], this.scanCompanyIds = const [], this.scanServiceData = const [], - this.role, + required this.role, this.kind = DeviceKind.other, bool canBeMainDevice = true, }) : _canBeMainDevice = canBeMainDevice, @@ -661,7 +660,8 @@ const BandEntry kWhoopGen5 = BandEntry.framed( const BandEntry kBleHrs = BandEntry.notify( id: 'ble_hrs', role: DeviceRole.sensor, - kind: DeviceKind.chestStrap, + // HRS alone identifies no body position: watches and armbands use it too. + kind: DeviceKind.other, category: DeviceCategory.workoutSensor, label: 'Bluetooth heart rate sensor', service: kHeartRateServiceUuid, diff --git a/lib/ble/band_identity.dart b/lib/ble/band_identity.dart index e017762ad..cd8bc7e6d 100644 --- a/lib/ble/band_identity.dart +++ b/lib/ble/band_identity.dart @@ -73,6 +73,61 @@ BandIdentity? bandIdentityOf({ return null; } +// A scan owns this ledger; the last completed scan remains available while +// pairing awaits discovery/bonding. Model certainty does not rank evidence. +enum _BandEvidenceKind { cachedName, advertisedName, serviceData, service } +typedef _BandEvidence = ({BandIdentity identity, _BandEvidenceKind kind}); + +class BandEvidenceLedger { + final _seen = {}; + final _advertisedNames = {}; + + BandIdentity? observe(String remoteId, { + required List advertisedServiceUuids, + String? advertisedName, + String? cachedName, + List serviceDataUuids = const [], + }) { + final realName = advertisedName?.trim(); + if (realName != null && realName.isNotEmpty) { + _advertisedNames.add(remoteId); + if (_seen[remoteId]?.kind == _BandEvidenceKind.cachedName) { + _seen[remoteId] = null; + } + } + _BandEvidence? incoming; + final service = _bandForService(advertisedServiceUuids); + if (service != null) { + incoming = (identity: (entry: service, modelCertain: true), + kind: _BandEvidenceKind.service); + } else { + final data = bandIdentityOf(advertisedServiceUuids: const [], name: null, + serviceDataUuids: serviceDataUuids); + if (data != null) { + incoming = (identity: data, kind: _BandEvidenceKind.serviceData); + } else { + final name = realName != null && realName.isNotEmpty ? realName + : _advertisedNames.contains(remoteId) ? null : cachedName; + final identity = bandIdentityOf(advertisedServiceUuids: const [], name: name); + if (identity != null) { + incoming = (identity: identity, kind: realName != null && realName.isNotEmpty + ? _BandEvidenceKind.advertisedName : _BandEvidenceKind.cachedName); + } + } + } + final previous = _seen[remoteId]; + if (incoming != null && (previous == null || incoming.kind.index >= previous.kind.index)) { + _seen[remoteId] = incoming; + } else { + _seen.putIfAbsent(remoteId, () => null); + } + return _seen[remoteId]?.identity; + } + + BandIdentity? identityFor(String remoteId, {BandIdentity? fallback}) => + _seen.containsKey(remoteId) ? _seen[remoteId]?.identity : fallback; +} + /// DEFINITIVE check after `discoverServices()`: the full GATT service list /// against every role==band entry's service. BandEntry? bandIdentityFromGatt(Iterable discoveredServiceUuids) => diff --git a/lib/ble/hrs_link.dart b/lib/ble/hrs_link.dart index dcb300e68..ea753f4cf 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -118,6 +118,8 @@ typedef BandCandidate = ({ /// The live link to a paired heart-rate sensor. One instance; a second /// concurrent sensor is not a thing anyone asked for. class HrsLink { + static BandEvidenceLedger _scanBandEvidence = BandEvidenceLedger(); + HrsLink._(); static final HrsLink instance = HrsLink._(); @@ -358,7 +360,8 @@ class HrsLink { final confirmed = {}; // Band evidence per remote id, kept for the whole scan: once an // advertisement showed a band, a later bare 0x180D one does not erase it. - final bandSeen = {}; + final bandSeen = BandEvidenceLedger(); + _scanBandEvidence = bandSeen; // Keyed by remote id: a scan re-reports the same peripheral several times // a second, and a list that grows a row per advertisement is not a picker. @@ -412,15 +415,12 @@ class HrsLink { final id = d.remoteId.str; if (ours.contains(id)) continue; confirmed.putIfAbsent(id, () => e.id); - seen.putIfAbsent( - id, - () => ( - device: d, - label: cleanDeviceLabel(d.platformName), - rssi: null, - entryId: e.id, - alsoBand: null, - )); + final previous = seen[id]; + seen[id] = candidateFromSystemDevice( + entries: entries, device: d, queriedEntry: e, + platformName: d.platformName, bandSeen: bandSeen, + confirmed: confirmed, previous: previous, + ); } } catch (err) { debugPrint('[hrs] systemDevices(${e.id}) failed: $err'); @@ -501,6 +501,21 @@ class HrsLink { /// Unfiltered scan, or one the filter let in on a shared hint alone /// ([BandEntry.scanHints]): a result matching nothing is some other /// peripheral, not a candidate. + @visibleForTesting + static BandCandidate candidateFromSystemDevice({ + required List entries, + required BluetoothDevice device, + required BandEntry queriedEntry, + required String platformName, + required BandEvidenceLedger bandSeen, + Map? confirmed, + BandCandidate? previous, + }) => candidateFromAd(entries: entries, device: device, + serviceUuids: [Guid(queriedEntry.service)], advName: '', + platformName: platformName, rssi: previous?.rssi, + previousLabel: previous?.label, bandSeen: bandSeen, + confirmed: confirmed, unfilteredScan: false)!; + @visibleForTesting static BandCandidate? candidateFromAd({ required List entries, @@ -508,11 +523,11 @@ class HrsLink { required List serviceUuids, required String advName, required String platformName, - required int rssi, + required int? rssi, List manufacturerCompanyIds = const [], List serviceDataUuids = const [], Map? confirmed, - Map? bandSeen, + BandEvidenceLedger? bandSeen, String? previousLabel, bool unfilteredScan = true, Set hints = const {}, @@ -556,20 +571,14 @@ class HrsLink { adMatch(); if (svcMatch != null) confirmed?[key] = svcMatch; final match = confirmed?[key] ?? svcMatch ?? nameMatch(); - final band = bandIdentityOf( + // Keep cached OS names separate from real advertisements, and remember + // disproved guesses so the OS cache cannot resurrect them later. + final best = (bandSeen ?? BandEvidenceLedger()).observe(key, advertisedServiceUuids: adUuids, - name: scanName, + advertisedName: cleanDeviceLabel(advName), + cachedName: cleanDeviceLabel(platformName), serviceDataUuids: [for (final g in serviceDataUuids) g.str128], ); - // Recorded BEFORE the unmatched early return below: a band-only packet - // (no sensor match) must still leave its evidence for a later HR-only one. - // Evidence only accumulates: a later advertisement that shows a band - // replaces a weaker (name-only) guess, never a bare one erases it. - final earlier = bandSeen?[key]; - final best = band != null && (earlier == null || band.modelCertain) - ? band - : earlier ?? band; - if (best != null) bandSeen?[key] = best; if (match == null && (unfilteredScan || serviceUuids.any(hints.contains))) { return null; @@ -822,6 +831,7 @@ class HrsLink { BandIdentity? earlierBand, PairConfirmation? confirmation, }) async { + final scanEvidence = _scanBandEvidence; try { await device.connect(timeout: _connectTimeout); } catch (e) { @@ -838,6 +848,8 @@ class HrsLink { io: io, confirmation: confirmation, earlierBand: earlierBand, + latestBand: () => scanEvidence.identityFor(device.remoteId.str, + fallback: earlierBand), label: label, tier: tier, ); @@ -1453,6 +1465,7 @@ Future pairNotifyDevice({ required PairingIo io, PairConfirmation? confirmation, BandIdentity? earlierBand, + BandIdentity? Function()? latestBand, String? label, String? tier, }) async { @@ -1464,17 +1477,17 @@ Future pairNotifyDevice({ 'could be set up. Nothing was saved.'); } final gattBand = bandIdentityFromGatt([for (final s in services) s.uuid]); - final band = gattBand ?? earlierBand?.entry; - final confirmedBand = band != null && band.id != entry.id; - if (confirmedBand) { - final ok = confirmation != null && - confirmation.remoteId == remoteId && - confirmation.bandEntryId == band.id; - if (!ok) { - return PairOutcome.band(band, - modelCertain: gattBand != null || earlierBand?.modelCertain != false); - } + BandIdentity? currentEvidence() => latestBand != null ? latestBand() : earlierBand; + PairOutcome? bandRefusal() { + final evidence = currentEvidence(); + final band = gattBand ?? evidence?.entry; + if (band == null || band.id == entry.id) return null; + if (confirmation?.remoteId == remoteId && confirmation?.bandEntryId == band.id) return null; + return PairOutcome.band(band, + modelCertain: gattBand != null || evidence?.modelCertain != false); } + final initialRefusal = bandRefusal(); + if (initialRefusal != null) return initialRefusal; final missing = [ for (final u in entry.requiredCharacteristics) if (!_hasCharacteristic(services, u)) u, @@ -1508,6 +1521,11 @@ Future pairNotifyDevice({ return const PairOutcome.failed('That device did not accept Bluetooth ' 'pairing. Nothing was saved.'); } + // Pending platform lookups may finish even after scan shutdown times out. + final finalRefusal = bandRefusal(); + if (finalRefusal != null) return finalRefusal; + final band = gattBand ?? currentEvidence()?.entry; + final confirmedBand = band != null && band.id != entry.id; try { await io.persist(( id: HrsLink.mintDeviceId(entry, remoteId), diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..88b6e1e70 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -3322,7 +3322,15 @@ class LocalDb { bool clearAdapterId = false, DatabaseExecutor? executor, }) async { - final db = executor ?? await instance; + if (executor == null) { + final db = await instance; + // A failed UPDATE must not leave the initial INSERT behind. + await db.transaction((txn) => upsertDevice(id: id, adapterId: adapterId, + remoteId: remoteId, label: label, tier: tier, + clearAdapterId: clearAdapterId, executor: txn)); + return; + } + final db = executor; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; await db.insert('device', { 'id': id, diff --git a/test/band_identity_test.dart b/test/band_identity_test.dart index b9e7a43b9..f5f5d14fa 100644 --- a/test/band_identity_test.dart +++ b/test/band_identity_test.dart @@ -25,6 +25,7 @@ void main() { 'That device is a WHOOP 5, a band, not a plain sensor. Nothing was saved.'); }); + _reviewEvidence(); _gatt(); _matching(); _pairing(); @@ -289,7 +290,7 @@ void main() { // The candidate must still say "band", and pairing with standard-only // discovery must refuse with zero writes (DT9). final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:21'); - final seen = {}; + final seen = BandEvidenceLedger(); BandCandidate? ad(List svc, String name) => HrsLink.candidateFromAd( entries: sensors, @@ -332,7 +333,7 @@ void main() { // matches no sensor entry and yields no row, but its evidence must stay // for the later HR-only packet, or standard-only discovery saves it. final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:24'); - final seen = {}; + final seen = BandEvidenceLedger(); BandCandidate? ad(List svc) => HrsLink.candidateFromAd( entries: sensors, device: dev, @@ -508,9 +509,10 @@ List _strap() => [ /// The pairing seam's fake: scripted discovery, recorded writes. class _FakeIo implements PairingIo { _FakeIo(this._discover, - {this.bondOk = true, this.persistError}); + {this.bondOk = true, this.persistError, this.onBond}); final Future> Function() _discover; final bool bondOk; + final void Function()? onBond; final Object? persistError; final writes = []; var bondCalls = 0; @@ -527,6 +529,7 @@ class _FakeIo implements PairingIo { @override Future triggerBond() async { bondCalls++; + onBond?.call(); return bondOk; } @@ -845,3 +848,90 @@ void _pairing() { }); }); } + +void _reviewEvidence() { + group('review regressions: evidence provenance', () { + test('system-device cached WHOOP name refuses a standard-only sensor save', () async { + // WHY: iOS may return a connected WHOOP with only HRS discovery. + final ledger = BandEvidenceLedger(); + final device = BluetoothDevice.fromId('system-whoop'); + final candidate = HrsLink.candidateFromSystemDevice(entries: [kBleHrs], + device: device, queriedEntry: kBleHrs, platformName: 'WHOOP 5', + bandSeen: ledger); + expect(candidate.rssi, isNull); + expect(candidate.alsoBand?.entry.id, kWhoopGen4.id); + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice(entry: kBleHrs, remoteId: device.remoteId.str, + io: io, latestBand: () => ledger.identityFor(device.remoteId.str)); + expect(out.isBand, isNotNull); + expect(out.modelCertain, isFalse); + expect(io.writes, isEmpty); + }); + + test('evidence arriving during discovery refuses persistence', () async { + // WHY: the tapped row predates the final scan packet. + final ledger = BandEvidenceLedger(); + final io = _FakeIo(() async { + ledger.observe('late', advertisedServiceUuids: [kWhoopGen5.service]); + return _strap(); + }); + final out = await pairNotifyDevice(entry: kBleHrs, remoteId: 'late', io: io, + latestBand: () => ledger.identityFor('late')); + expect(out.isBand?.id, kWhoopGen5.id); + expect(io.writes, isEmpty); + }); + + test('evidence arriving during bonding is checked before saving', () async { + // WHY: shutdown can time out while a platform lookup is still pending. + final ledger = BandEvidenceLedger(); + const entry = BandEntry.notify(id: 'bonded_hr', label: 'Bonded sensor', + service: kHeartRateServiceUuid, characteristics: [kHeartRateMeasurementUuid], + timeAnchor: TimeAnchor.arrival, category: DeviceCategory.workoutSensor, + role: DeviceRole.sensor, bondTriggerCharacteristic: kHeartRateMeasurementUuid); + final io = _FakeIo(() async => _strap(), onBond: () { + ledger.observe('bond', advertisedServiceUuids: [kWhoopGen5.service]); + }); + final out = await pairNotifyDevice(entry: entry, remoteId: 'bond', io: io, + latestBand: () => ledger.identityFor('bond')); + expect(io.bondCalls, 1); + expect(out.isBand?.id, kWhoopGen5.id); + expect(io.writes, isEmpty); + }); + + test('an advertised strap name invalidates a cached WHOOP guess permanently', () async { + // WHY: empty later packets and OS lookups must not revive stale names. + final ledger = BandEvidenceLedger(); + final device = BluetoothDevice.fromId('renamed'); + final first = HrsLink.candidateFromSystemDevice(entries: [kBleHrs], device: device, + queriedEntry: kBleHrs, platformName: 'WHOOP', bandSeen: ledger); + expect(first.alsoBand, isNotNull); + final real = HrsLink.candidateFromAd(entries: [kBleHrs], device: device, + serviceUuids: [Guid(kHeartRateServiceUuid)], advName: 'Polar H10', + platformName: 'WHOOP', rssi: -50, bandSeen: ledger); + expect(real?.alsoBand, isNull); + final last = HrsLink.candidateFromSystemDevice(entries: [kBleHrs], device: device, + queriedEntry: kBleHrs, platformName: 'WHOOP', bandSeen: ledger, previous: real); + expect(last.alsoBand, isNull); + expect(last.label, 'Polar H10'); + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice(entry: kBleHrs, remoteId: device.remoteId.str, + io: io, earlierBand: first.alsoBand, + latestBand: () => ledger.identityFor(device.remoteId.str, fallback: first.alsoBand)); + expect(out.saved, isTrue); + expect(io.writes.single.remoteId, device.remoteId.str); + }); + + for (final serviceData in [false, true]) { + test('a model-certain name cannot replace stronger ${serviceData ? "data" : "service"} evidence', () { + // WHY: model certainty and evidence provenance are separate dimensions. + expect(_full([], 'COROS')?.entry.id, kCoros.id, + reason: 'positive control: the conflicting name is recognised'); + final ledger = BandEvidenceLedger(); + ledger.observe('band', advertisedServiceUuids: serviceData ? [] : [kGarmin.service], + serviceDataUuids: serviceData ? [kGarmin.scanServiceData.first] : []); + final identity = ledger.observe('band', advertisedServiceUuids: [], advertisedName: 'COROS'); + expect(identity?.entry.id, kGarmin.id); + }); + } + }); +} diff --git a/test/paired_device_test.dart b/test/paired_device_test.dart index 1af020a20..069fc254c 100644 --- a/test/paired_device_test.dart +++ b/test/paired_device_test.dart @@ -193,6 +193,28 @@ void main() { }, ); + test('a failed sensor upsert rolls back its initial insert', () async { + // WHY: pairing reports nothing saved if the second statement fails. + final db = await LocalDb.instance; + await db.execute( + 'CREATE TEMP TRIGGER refuse_sensor_update BEFORE UPDATE ON device ' + "WHEN NEW.id = 'ble_hrs:rollback' BEGIN " + "SELECT RAISE(ABORT, 'cannot update sensor'); END", + ); + addTearDown(() => db.execute('DROP TRIGGER IF EXISTS refuse_sensor_update')); + addTearDown(() => LocalDb.deleteDevice('ble_hrs:rollback')); + await expectLater(LocalDb.upsertDevice( + id: 'ble_hrs:rollback', adapterId: 'ble_hrs', remoteId: 'sensor-uuid', + label: 'Chest strap', tier: 'beatToBeat'), + throwsA(isA())); + expect(await LocalDb.deviceRow('ble_hrs:rollback'), isNull); + // Positive control: once the refusal is gone, the same row saves normally. + await db.execute('DROP TRIGGER refuse_sensor_update'); + await LocalDb.upsertDevice(id: 'ble_hrs:rollback', adapterId: 'ble_hrs', + remoteId: 'sensor-uuid', label: 'Chest strap', tier: 'beatToBeat'); + expect((await LocalDb.deviceRow('ble_hrs:rollback'))?['remote_id'], 'sensor-uuid'); + }); + test('a failed database update rolls back the initial insert', () async { final db = await LocalDb.instance; await db.execute( diff --git a/test/pairing_device_role_test.dart b/test/pairing_device_role_test.dart index a21e1bd3f..be0385496 100644 --- a/test/pairing_device_role_test.dart +++ b/test/pairing_device_role_test.dart @@ -3,13 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/adapters/_registry.dart'; void main() { - test('every registry entry declares a role', () { - // WHY: an undeclared role would let a sensor or a band slip into the wrong list. - final missing = [ - for (final e in kBandRegistry) - if (e.role == null) e.id, - ]; - expect(missing, isEmpty, reason: 'entries without role: $missing'); + test('standard heart-rate service does not imply a chest strap', () { + // WHY: the same service is advertised by watches and armbands. + expect(kBleHrs.kind, DeviceKind.other); }); test('the sensor set is exactly ble_hrs, polar_pmd, thermometer, scales', () { From 415ff7d0809bac2325bcdc39ea2cc6f60ad5e8fa Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Tue, 10 Nov 2026 20:56:28 -0700 Subject: [PATCH 3/4] fix(pairing): preserve the first iOS system-device adapter claim --- lib/ble/hrs_link.dart | 23 +++++++++++++++++------ test/band_identity_test.dart | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/lib/ble/hrs_link.dart b/lib/ble/hrs_link.dart index ea753f4cf..d1e34e2ed 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -414,7 +414,6 @@ class HrsLink { in await FlutterBluePlus.systemDevices([Guid(e.service)])) { final id = d.remoteId.str; if (ours.contains(id)) continue; - confirmed.putIfAbsent(id, () => e.id); final previous = seen[id]; seen[id] = candidateFromSystemDevice( entries: entries, device: d, queriedEntry: e, @@ -510,11 +509,23 @@ class HrsLink { required BandEvidenceLedger bandSeen, Map? confirmed, BandCandidate? previous, - }) => candidateFromAd(entries: entries, device: device, - serviceUuids: [Guid(queriedEntry.service)], advName: '', - platformName: platformName, rssi: previous?.rssi, - previousLabel: previous?.label, bandSeen: bandSeen, - confirmed: confirmed, unfilteredScan: false)!; + }) { + final id = device.remoteId.str; + // Query order deliberately prefers HRS to PMD for straps such as H10. + // Later queries contribute identity evidence, never replace that claim. + final entryId = confirmed?[id] ?? previous?.entryId ?? queriedEntry.id; + confirmed?.putIfAbsent(id, () => entryId); + final candidate = candidateFromAd(entries: entries, device: device, + serviceUuids: [Guid(queriedEntry.service)], advName: '', + platformName: platformName, rssi: previous?.rssi, + previousLabel: previous?.label, bandSeen: bandSeen, + unfilteredScan: false)!; + final band = bandSeen.identityFor(id); + return (device: device, label: candidate.label, rssi: candidate.rssi, + entryId: entryId, + alsoBand: band != null && (band.entry.id != entryId || !band.modelCertain) + ? band : null); + } @visibleForTesting static BandCandidate? candidateFromAd({ diff --git a/test/band_identity_test.dart b/test/band_identity_test.dart index f5f5d14fa..53ea64c85 100644 --- a/test/band_identity_test.dart +++ b/test/band_identity_test.dart @@ -851,6 +851,41 @@ void _pairing() { void _reviewEvidence() { group('review regressions: evidence provenance', () { + test('later PMD query retains HRS claim while later band query records evidence', () async { + // WHY: an H10 can answer both queries but cannot provide PMD PPI. + final ledger = BandEvidenceLedger(); + final confirmed = {}; + final device = BluetoothDevice.fromId('multi-query'); + final first = HrsLink.candidateFromSystemDevice(entries: [kBleHrs, kPolarPmd], + device: device, queriedEntry: kBleHrs, platformName: 'Polar H10', + bandSeen: ledger, confirmed: confirmed); + final second = HrsLink.candidateFromSystemDevice(entries: [kBleHrs, kPolarPmd], + device: device, queriedEntry: kPolarPmd, platformName: 'Polar H10', + bandSeen: ledger, confirmed: confirmed, previous: first); + expect(second.entryId, kBleHrs.id); + expect(confirmed[device.remoteId.str], kBleHrs.id); + final io = _FakeIo(() async => _strap()); + final outcome = await pairNotifyDevice(entry: kBandRegistry.firstWhere((e) => e.id == second.entryId), + remoteId: device.remoteId.str, io: io, + latestBand: () => ledger.identityFor(device.remoteId.str)); + expect(outcome.saved, isTrue); + expect(io.pmdReads, 0); + expect(io.writes.single.adapterId, kBleHrs.id); + + // Keeping the chosen adapter must not discard later safety evidence. + final third = HrsLink.candidateFromSystemDevice(entries: [kBleHrs, kWhoopGen5], + device: device, queriedEntry: kWhoopGen5, platformName: 'Polar H10', + bandSeen: ledger, confirmed: confirmed, previous: second); + expect(third.entryId, kBleHrs.id); + expect(third.alsoBand?.entry.id, kWhoopGen5.id); + final bandIo = _FakeIo(() async => _strap()); + final refusal = await pairNotifyDevice(entry: kBleHrs, + remoteId: device.remoteId.str, io: bandIo, + latestBand: () => ledger.identityFor(device.remoteId.str)); + expect(refusal.isBand?.id, kWhoopGen5.id); + expect(bandIo.writes, isEmpty); + }); + test('system-device cached WHOOP name refuses a standard-only sensor save', () async { // WHY: iOS may return a connected WHOOP with only HRS discovery. final ledger = BandEvidenceLedger(); From 4a09f55bef4226ff24072ebf22c1be6594ab7978 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 10:51:50 +0530 Subject: [PATCH 4/4] drop internal ticket tags from registry comments and pairing role tests --- lib/ble/adapters/_registry.dart | 6 +++--- test/pairing_device_role_test.dart | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index 91e886c8a..c67e7ecd5 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -472,10 +472,10 @@ class BandEntry { final bool _canBeMainDevice; - /// Whether the first-run list (W2) may offer this entry as the user's main + /// Whether the first-run list may offer this entry as the user's main /// device. Separate from [role], which only says "recognised as a band" for - /// the safeguard (DT10): only a framed WHOOP band has a supported - /// main-device path (DT12). Every other band is recognised but pairs as an + /// the safeguard: only a framed WHOOP band has a supported main-device + /// path. Every other band is recognised but pairs as an /// additional device under its own adapter, and a sensor never is one. bool get canBeMainDevice => role == DeviceRole.band && isFramed && _canBeMainDevice; diff --git a/test/pairing_device_role_test.dart b/test/pairing_device_role_test.dart index be0385496..97e6d4a6f 100644 --- a/test/pairing_device_role_test.dart +++ b/test/pairing_device_role_test.dart @@ -32,7 +32,7 @@ void main() { }); }); - test('recognised as a band is not the same as can be the main device (DT10)', () { + test('recognised as a band is not the same as can be the main device', () { // WHY: Coros and Garmin must trip the "this is a band" safeguard, but have // no supported main-device path, so the first-run list must not offer them. for (final e in [kWhoopGen4, kWhoopGen5]) { @@ -41,11 +41,11 @@ void main() { } for (final e in [kCoros, kGarmin]) { expect(e.role, DeviceRole.band, reason: '${e.id} is recognised'); - expect(e.canBeMainDevice, isFalse, reason: '${e.id} is not on W2'); + expect(e.canBeMainDevice, isFalse, reason: '${e.id} is not offered as a main device'); } }); - test('a sensor can never be the main device (DT10)', () { + test('a sensor can never be the main device', () { // WHY: workout add-ons and scales stay off the first-run list. for (final e in kBandRegistry) { if (e.role == DeviceRole.sensor) { @@ -54,10 +54,10 @@ void main() { } expect(kBandRegistry.where((e) => e.canBeMainDevice).map((e) => e.id), containsAll(['gen4', 'gen5']), - reason: 'positive control: the W2 list is not empty'); + reason: 'positive control: the main-device list is not empty'); }); - test('only the framed WHOOP bands can be the main device (DT12)', () { + test('only the framed WHOOP bands can be the main device', () { // WHY: the main-band path (PairingScreen, AppState.isPaired) runs framed // WHOOP only. Oura, Colmi, Mi Band and the rest are recognised as bands // but pair as additional devices under their own adapter.