diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index b7f7c3e29..c67e7ecd5 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,23 @@ 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). 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; + + final bool _canBeMainDevice; + + /// 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: 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; + /// A framed WHOOP-family band: an envelope, a command characteristic, and a /// flash the offload engine trims. const BandEntry.framed({ @@ -463,7 +498,11 @@ class BandEntry { this.burstCountGateEnforced = false, this.logsConsoleOutput = false, this.nameMatcher, - }) : _requiredCharacteristics = requiredCharacteristics, + required this.role, + this.kind = DeviceKind.other, + bool canBeMainDevice = true, + }) : _canBeMainDevice = canBeMainDevice, + _requiredCharacteristics = requiredCharacteristics, _commands = commands, scanByName = false, scanHints = const [], @@ -494,7 +533,11 @@ class BandEntry { this.scanHints = const [], this.scanCompanyIds = const [], this.scanServiceData = const [], - }) : _service = service, + required this.role, + this.kind = DeviceKind.other, + bool canBeMainDevice = true, + }) : _canBeMainDevice = canBeMainDevice, + _service = service, _requiredCharacteristics = characteristics, gatt = null, wire = null, @@ -541,6 +584,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 +617,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 +659,9 @@ const BandEntry kWhoopGen5 = BandEntry.framed( /// off, nothing derives from it. const BandEntry kBleHrs = BandEntry.notify( id: 'ble_hrs', + role: DeviceRole.sensor, + // 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, @@ -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..cd8bc7e6d --- /dev/null +++ b/lib/ble/band_identity.dart @@ -0,0 +1,197 @@ +// 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; +} + +// 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) => + _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..d1e34e2ed 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,11 +108,18 @@ 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 /// concurrent sensor is not a thing anyone asked for. class HrsLink { + static BandEvidenceLedger _scanBandEvidence = BandEvidenceLedger(); + HrsLink._(); static final HrsLink instance = HrsLink._(); @@ -317,8 +325,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 +353,15 @@ 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 = 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. @@ -413,57 +371,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)); @@ -488,15 +414,12 @@ class HrsLink { in await FlutterBluePlus.systemDevices([Guid(e.service)])) { 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, - )); + 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'); @@ -542,19 +465,148 @@ 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 candidateFromSystemDevice({ + required List entries, + required BluetoothDevice device, + required BandEntry queriedEntry, + required String platformName, + required BandEvidenceLedger bandSeen, + Map? confirmed, + BandCandidate? previous, + }) { + 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({ + 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, + BandEvidenceLedger? 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(); + // 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, + advertisedName: cleanDeviceLabel(advName), + cachedName: cleanDeviceLabel(platformName), + serviceDataUuids: [for (final g in serviceDataUuids) g.str128], + ); + 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 +831,45 @@ 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 { + final scanEvidence = _scanBandEvidence; 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, + latestBand: () => scanEvidence.identityFor(device.remoteId.str, + fallback: 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 +1390,168 @@ 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, + BandIdentity? Function()? latestBand, + 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]); + 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, + ]; + 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.'); + } + // 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), + 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/data/db.dart b/lib/data/db.dart index f10d64fd2..69c2eca0e 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -3332,7 +3332,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/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ec11109a7..233bed5aa 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3475,6 +3475,7 @@ } } }, + "pairBandNotSensor": "Dieses Gerät ist ein {label}, also ein Band und kein einfacher Sensor. Es wurde nichts gespeichert.", "welcomeCoachKeyReentry": "Dein API-Schlüssel für den Coach bleibt im sicheren Speicher und wurde nicht gesichert. Gib ihn in den KI-Einstellungen erneut ein, um den Coach auf diesem Telefon zu nutzen.", "welcomeSettingsSkipped": "{n, plural, =1{1 Einstellung in der Sicherung war nicht lesbar und wurde übersprungen} other{{n} Einstellungen in der Sicherung waren nicht lesbar und wurden übersprungen}}", "@welcomeSettingsSkipped": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e01bfdaae..ec5231ef9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16352,6 +16352,15 @@ } } }, + "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" + } + } + }, "welcomeCoachKeyReentry": "Your coach API key stays in secure storage and was not backed up. Enter it again in AI settings to use the coach on this phone.", "welcomeSettingsSkipped": "{n, plural, =1{1 setting in the backup could not be read and was skipped} other{{n} settings in the backup could not be read and were skipped}}", "@welcomeSettingsSkipped": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 09007a1fa..a236842ec 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2921,6 +2921,7 @@ "strengthSaveFailed": "No se pudieron guardar los cambios", "reportContactLabel": "Contacto (opcional)", "reportContactHint": "Correo o Discord, solo si quieres una respuesta", + "pairBandNotSensor": "Ese dispositivo es una pulsera {label}, no un sensor sencillo. No se guardó nada.", "alarmNextLabel": "SIGUIENTE", "alarmScheduleGroup": "Horario semanal", "alarmWakeTimeRowTitle": "Hora de despertar", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3d074c7d8..2875f5709 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2892,6 +2892,7 @@ "strengthSaveFailed": "Échec de l'enregistrement des modifications", "reportContactLabel": "Contact (facultatif)", "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é.", "whoopFirmwareWarningTitle": "Utilisateurs WHOOP : ne vous reconnectez pas à l’application WHOOP", "whoopFirmwareWarningAction": "Si vous utilisez OpenStrap ou une autre application open source, ou si vous comptez y passer plus tard, ne reconnectez pas votre bracelet à l’application WHOOP officielle. Elle peut installer des mises à jour du firmware automatiquement. Désinstallez ou désactivez l’application WHOOP pour l’empêcher de mettre à jour votre bracelet.", "whoopFirmwareWarningDetails": "Des signalements concernant les firmwares 50.43.1.0 et 50.43.2.0 des WHOOP 5.0 / MG décrivent un accès aux données bloqué. Le Bluetooth peut encore se connecter. OpenStrap ne peut pas émettre le jeton d’abonnement signé par WHOOP nécessaire pour déverrouiller les bracelets concernés. Nous travaillons sur une solution, mais aucun correctif n’est encore disponible." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a1c26f43d..b9fea6734 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2892,6 +2892,7 @@ "strengthSaveFailed": "परिवर्तन सहेजने में विफल", "reportContactLabel": "संपर्क (वैकल्पिक)", "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं", + "pairBandNotSensor": "यह डिवाइस {label} है, यानी एक बैंड, साधारण सेंसर नहीं। कुछ भी सहेजा नहीं गया।", "whoopFirmwareWarningTitle": "WHOOP उपयोगकर्ता: WHOOP ऐप से दोबारा कनेक्ट न करें", "whoopFirmwareWarningAction": "अगर आप OpenStrap या कोई दूसरा ओपन-सोर्स ऐप इस्तेमाल करते हैं, या बाद में किसी पर जाने की सोच रहे हैं, तो अपना बैंड आधिकारिक WHOOP ऐप से दोबारा कनेक्ट न करें। वह फ़र्मवेयर अपडेट अपने-आप इंस्टॉल कर सकता है। WHOOP ऐप को अनइंस्टॉल या अक्षम कर दें, ताकि वह आपके बैंड को अपडेट न कर सके।", "whoopFirmwareWarningDetails": "WHOOP 5.0 / MG के फ़र्मवेयर 50.43.1.0 और 50.43.2.0 के बारे में रिपोर्टें बताती हैं कि डेटा तक पहुँच रोक दी जाती है। ब्लूटूथ फिर भी कनेक्ट हो सकता है। प्रभावित बैंड को अनलॉक करने के लिए WHOOP द्वारा साइन किया गया मेंबरशिप टोकन चाहिए, जिसे OpenStrap जारी नहीं कर सकता। हम समाधान पर काम कर रहे हैं, लेकिन अभी कोई उपाय उपलब्ध नहीं है।" diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ee1ca5bfe..f25901a16 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7935,5 +7935,13 @@ "strengthSaveFailed": "Не удалось сохранить изменения", "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 c271ba8b8..19e7265dd 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16356,5 +16356,14 @@ "strengthSaveFailed": "ไม่สามารถบันทึกการเปลี่ยนแปลงได้", "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 a1c1769d2..111dc74e2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2892,6 +2892,7 @@ "strengthSaveFailed": "保存更改失败", "reportContactLabel": "联系方式(可选)", "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写", + "pairBandNotSensor": "这台设备是 {label},是手环,不是普通传感器。没有保存任何内容。", "whoopFirmwareWarningTitle": "WHOOP 用户:请勿重新连接 WHOOP 应用", "whoopFirmwareWarningAction": "如果你在使用 OpenStrap 或其他开源应用,或打算以后改用,请不要再把手环连接到 WHOOP 官方应用。它可能会自动安装固件更新。请卸载或停用 WHOOP 应用,以免它更新你的手环。", "whoopFirmwareWarningDetails": "有报告称,WHOOP 5.0 / MG 固件 50.43.1.0 和 50.43.2.0 会阻止访问数据。蓝牙可能仍能连接。解锁受影响的手环需要由 WHOOP 签名的会员令牌,OpenStrap 无法签发。我们正在寻找解决办法,但目前还没有可用的修复。" 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..53ea64c85 --- /dev/null +++ b/test/band_identity_test.dart @@ -0,0 +1,972 @@ +// 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.'); + }); + + _reviewEvidence(); + _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 = BandEvidenceLedger(); + 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 = BandEvidenceLedger(); + 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, this.onBond}); + final Future> Function() _discover; + final bool bondOk; + final void Function()? onBond; + 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++; + onBond?.call(); + 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'); + }); + }); +} + +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(); + 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/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/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 new file mode 100644 index 000000000..97e6d4a6f --- /dev/null +++ b/test/pairing_device_role_test.dart @@ -0,0 +1,72 @@ +// 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('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', () { + // 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', () { + // 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 offered as a main device'); + } + }); + + 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) { + expect(e.canBeMainDevice, isFalse, reason: e.id); + } + } + expect(kBandRegistry.where((e) => e.canBeMainDevice).map((e) => e.id), + containsAll(['gen4', 'gen5']), + reason: 'positive control: the main-device list is not empty'); + }); + + 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. + 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, ), ], ),