From 72f3855b1b13b4bd72f65c70f8ecc64d02f95e75 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 21 Sep 2026 23:57:16 -0500 Subject: [PATCH 1/2] Make manual G7 pairing robust when other sensors are nearby A 4-digit pairing code does not identify a sensor over the air, so with several G7s in range pairing must try each until one matches the code. Two problems made the manual path fail where a scan (which knows the serial and filters to one sensor) succeeded: - On a code mismatch against the last discovered sensor, the run gave up immediately, even with the scan clock still running and the user's own sensor about to advertise. A mismatch in manual entry only means wrong sensor, not wrong code, so keep scanning until the deadline. A scan still stops on a mismatch, since there it is the one intended sensor. - Candidates were ordered only by slot class and discovery order, so a distant neighbour discovered first was tried before the sensor in the user's hand. Order by signal strength within a class as well: the sensor being paired is nearest, so it goes first. RSSI is plumbed from the discovery callback through to the planner. Adds a pairing-screen note that pairing may check several nearby sensors in turn, so a multi-sensor scan does not look stuck. --- .../G7CGMManager/G7BluetoothManager.swift | 8 +- G7SensorKit/G7CGMManager/G7Sensor.swift | 2 +- G7SensorKit/Pairing/G7PairingPlanner.swift | 136 ++++++++++++------ G7SensorKit/Pairing/G7PairingService.swift | 43 ++++-- G7SensorKitTests/G7PairingPlannerTests.swift | 71 +++++++++ .../ViewModels/G7PairingViewModel.swift | 2 +- 6 files changed, 202 insertions(+), 60 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 35760d5..f418280 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -51,7 +51,7 @@ protocol G7BluetoothManagerDelegate: AnyObject { - returns: PeripheralConnectionCommand indicating what should be done with this peripheral */ - func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any]) -> PeripheralConnectionCommand + func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) -> PeripheralConnectionCommand /** Asks the delegate whether peripherals restored by CoreBluetooth's state @@ -432,11 +432,11 @@ class G7BluetoothManager: NSObject { return G7PeripheralManager(peripheral: peripheral, configuration: .dexcomG7, centralManager: centralManager) } - private func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any] = [:]) { + private func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any] = [:], rssi: NSNumber = 127) { dispatchPrecondition(condition: .onQueue(managerQueue)) if let delegate = delegate { - switch delegate.bluetoothManager(self, shouldConnectPeripheral: peripheral, advertisementData: advertisementData) { + switch delegate.bluetoothManager(self, shouldConnectPeripheral: peripheral, advertisementData: advertisementData, rssi: rssi) { case .makeActive: log.default("Making peripheral active: %{public}@", peripheral.identifier.uuidString) @@ -524,7 +524,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { log.default("%{public}@: %{public}@, data = %{public}@", #function, peripheral, String(describing: advertisementData)) managerQueue.async { - self.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData) + self.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: RSSI) } } diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index 1a8bb53..170d5a9 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -687,7 +687,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } - func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any]) -> PeripheralConnectionCommand { + func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) -> PeripheralConnectionCommand { let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name diff --git a/G7SensorKit/Pairing/G7PairingPlanner.swift b/G7SensorKit/Pairing/G7PairingPlanner.swift index 82b2e2e..0963e63 100644 --- a/G7SensorKit/Pairing/G7PairingPlanner.swift +++ b/G7SensorKit/Pairing/G7PairingPlanner.swift @@ -5,7 +5,7 @@ // Copyright © 2026 LoopKit Authors. All rights reserved. // // Derived from DexKit by Erik Tolboom (https://github.com/nightscout/DexKit): -// candidate planning follows its G7PairingPlanner. +// candidate planning follows its G7PairingRunner. // import Foundation @@ -17,7 +17,9 @@ import Foundation /// sensors in range. The order matters: a sensor whose display slot is held /// by another phone will reject us, and four rejections in a row make a /// sensor stop accepting connections for a while. So unheld sensors go -/// first, a sensor that rejects us is dropped rather than retried, and +/// first, and within a class the strongest signal goes first — the sensor +/// being paired is in the user's hand, so it is almost always the nearest +/// one. A sensor that rejects us is dropped rather than retried, and /// ordinary failures (a dropped link, a timeout) get a bounded number of /// retries before moving on. /// @@ -25,10 +27,18 @@ import Foundation /// in isolation. struct G7PairingPlanner { + /// RSSI stand-in for an advertisement whose signal strength is unknown + /// (CoreBluetooth reports 127 when it cannot be read). Sorts weakest, so + /// candidates with a real reading are preferred, and equal-signal ties — + /// including every candidate when no signal is known — fall back to the + /// order already established. + static let unknownRSSI = Int.min + struct Candidate: Equatable { let id: UUID let name: String var isPhoneSlotHeld: Bool + var rssi: Int } enum Action: Equatable { @@ -36,6 +46,9 @@ struct G7PairingPlanner { case retryCurrent /// Move on to the next candidate. case advanceToNext + /// Every sensor discovered so far has been tried, but the scan is + /// still open: keep looking for the one the code belongs to. + case keepScanning /// Nothing left to try. case giveUp(reason: String) } @@ -43,6 +56,14 @@ struct G7PairingPlanner { /// Ordinary failures tolerated per candidate before moving on. static let attemptsPerCandidate = 3 + /// When the discovered candidates are exhausted, whether to keep scanning + /// for more rather than giving up. Manual code entry cannot tell the + /// intended sensor from a neighbour, so a sensor that does not match the + /// code is only a wrong guess, not a wrong code — the real one may not + /// have advertised yet. A scan (serial known) has already filtered to the + /// one sensor, so a mismatch there is a wrong code and stops. + let keepScanningWhenExhausted: Bool + private(set) var candidates: [Candidate] = [] private(set) var currentIndex = 0 private var attemptsOnCurrent = 0 @@ -50,6 +71,10 @@ struct G7PairingPlanner { /// Why candidates were dropped, for the failure message if nothing works. private(set) var abandonmentReasons: [String] = [] + init(keepScanningWhenExhausted: Bool = false) { + self.keepScanningWhenExhausted = keepScanningWhenExhausted + } + var currentCandidate: Candidate? { currentIndex < candidates.count ? candidates[currentIndex] : nil } @@ -59,57 +84,90 @@ struct G7PairingPlanner { attemptsOnCurrent + 1 } + /// The message to show if the scan is stopped with nothing paired. + var exhaustionReason: String { + if candidates.isEmpty { + return LocalizedString( + "No sensor was found. Make sure the sensor is inserted and within range.", + comment: "Pairing failure reason when no G7 sensor was discovered" + ) + } + if !abandonmentReasons.isEmpty { + return abandonmentReasons.joined(separator: "\n") + } + return LocalizedString( + "Could not pair with any sensor in range.", + comment: "Pairing failure reason when every discovered G7 sensor failed" + ) + } + /// Adds a newly discovered sensor. Returns false if it was already known. /// - /// New candidates go behind everything already tried, and behind - /// untried candidates of a better class: an unheld newcomer is queued - /// ahead of untried held candidates, since those are likely to reject us. + /// New candidates are ordered behind anything already tried, then by + /// class (unheld before held, since held ones are likely to reject us) + /// and by signal strength within a class. @discardableResult - mutating func addCandidate(id: UUID, name: String, isPhoneSlotHeld: Bool) -> Bool { + mutating func addCandidate(id: UUID, name: String, isPhoneSlotHeld: Bool, rssi: Int = unknownRSSI) -> Bool { guard !candidates.contains(where: { $0.id == id }) else { return false } - let candidate = Candidate(id: id, name: name, isPhoneSlotHeld: isPhoneSlotHeld) - - // Never reorder anything at or before the current index: the current - // candidate may be mid-handshake. - let untried = candidates.indices.filter { $0 > currentIndex } - if !isPhoneSlotHeld, let firstHeld = untried.first(where: { candidates[$0].isPhoneSlotHeld }) { - candidates.insert(candidate, at: firstHeld) - } else { - candidates.append(candidate) - } + candidates.append(Candidate(id: id, name: name, isPhoneSlotHeld: isPhoneSlotHeld, rssi: rssi)) + sortUntriedTail() return true } /// Records a fresh advertisement from a known candidate. A held slot /// frees up after ~15 minutes of silence, so a candidate deferred earlier - /// can become preferable. Returns whether anything changed. + /// can become preferable; a new signal reading can reorder it too. + /// Returns whether anything changed. A `nil` slot state or an + /// `unknownRSSI` reading leaves that stored value alone. @discardableResult - mutating func updateSlot(id: UUID, isPhoneSlotHeld: Bool) -> Bool { - guard let index = candidates.firstIndex(where: { $0.id == id }), - candidates[index].isPhoneSlotHeld != isPhoneSlotHeld - else { + mutating func updateSlot(id: UUID, isPhoneSlotHeld: Bool?, rssi: Int = unknownRSSI) -> Bool { + guard let index = candidates.firstIndex(where: { $0.id == id }) else { return false } - candidates[index].isPhoneSlotHeld = isPhoneSlotHeld + var changed = false + if let isPhoneSlotHeld = isPhoneSlotHeld, candidates[index].isPhoneSlotHeld != isPhoneSlotHeld { + candidates[index].isPhoneSlotHeld = isPhoneSlotHeld + changed = true + } + if rssi != G7PairingPlanner.unknownRSSI, candidates[index].rssi != rssi { + candidates[index].rssi = rssi + changed = true + } + guard changed else { + return false + } + sortUntriedTail() + return true + } - // Re-sort only the untried tail, preserving discovery order within - // each class. + /// Orders the untried tail: unheld before held, then strongest signal, + /// then the order already established (a stable sort, so equal readings — + /// and unknown ones — keep their place). Never touches the current + /// candidate or anything before it: the current one may be mid-handshake. + private mutating func sortUntriedTail() { let tailStart = currentIndex + 1 guard tailStart < candidates.count else { - return true + return } - let tail = candidates[tailStart...] - candidates.replaceSubrange(tailStart..., with: tail.filter { !$0.isPhoneSlotHeld } + tail.filter { $0.isPhoneSlotHeld }) - return true + let ordered = candidates[tailStart...].enumerated().sorted { lhs, rhs in + if lhs.element.isPhoneSlotHeld != rhs.element.isPhoneSlotHeld { + return !lhs.element.isPhoneSlotHeld + } + if lhs.element.rssi != rhs.element.rssi { + return lhs.element.rssi > rhs.element.rssi + } + return lhs.offset < rhs.offset + }.map(\.element) + candidates.replaceSubrange(tailStart..., with: ordered) } /// An ordinary failure on the current candidate: retry it, or move on if /// it has used up its attempts. mutating func recordFailure() -> Action { guard currentCandidate != nil else { - return giveUp() + return exhausted() } attemptsOnCurrent += 1 if attemptsOnCurrent < G7PairingPlanner.attemptsPerCandidate { @@ -122,7 +180,7 @@ struct G7PairingPlanner { /// does not belong to this code): drop it without retrying. mutating func abandonCurrentCandidate(reason: String) -> Action { guard let candidate = currentCandidate else { - return giveUp() + return exhausted() } abandonmentReasons.append("\(candidate.name): \(reason)") return advance() @@ -131,22 +189,10 @@ struct G7PairingPlanner { private mutating func advance() -> Action { currentIndex += 1 attemptsOnCurrent = 0 - return currentCandidate != nil ? .advanceToNext : giveUp() + return currentCandidate != nil ? .advanceToNext : exhausted() } - private func giveUp() -> Action { - if candidates.isEmpty { - return .giveUp(reason: LocalizedString( - "No sensor was found. Make sure the sensor is inserted and within range.", - comment: "Pairing failure reason when no G7 sensor was discovered" - )) - } - if !abandonmentReasons.isEmpty { - return .giveUp(reason: abandonmentReasons.joined(separator: "\n")) - } - return .giveUp(reason: LocalizedString( - "Could not pair with any sensor in range.", - comment: "Pairing failure reason when every discovered G7 sensor failed" - )) + private func exhausted() -> Action { + keepScanningWhenExhausted ? .keepScanning : .giveUp(reason: exhaustionReason) } } diff --git a/G7SensorKit/Pairing/G7PairingService.swift b/G7SensorKit/Pairing/G7PairingService.swift index 86353e4..fa69b91 100644 --- a/G7SensorKit/Pairing/G7PairingService.swift +++ b/G7SensorKit/Pairing/G7PairingService.swift @@ -205,6 +205,12 @@ public final class G7PairingService { self.pairingCode = code expectedSerial = serial excludedPeripheralIdentifier = excluded + // A scan knows the serial and has filtered to the one sensor, so a + // mismatch there is a wrong code and stops. Manual entry cannot tell + // the intended sensor from a neighbour, so keep looking until the + // scan deadline instead of failing on the first sensor that does not + // match the code. + planner = G7PairingPlanner(keepScanningWhenExhausted: serial == nil) #if targetEnvironment(simulator) startSimulatedRun() @@ -230,13 +236,20 @@ public final class G7PairingService { manager.scanForPeripheral() let watchdog = DispatchWorkItem { [weak self] in - guard let self = self, case .scanning(let candidates) = self.state, candidates.isEmpty else { + guard let self = self, self.isRunActive, !self.authenticationInFlight else { return } - self.fail(LocalizedString( - "No sensor was found in 20 minutes. Make sure the sensor is inserted and within range, and that no other phone or app is using it.", - comment: "Pairing failure reason when the scan for a G7 sensor times out" - )) + // With nothing ever found, or every sensor tried and none the + // code's, the deadline is where the run stops. A candidate still + // mid-handshake at the deadline is left to its own timeout. + if self.planner.candidates.isEmpty { + self.fail(LocalizedString( + "No sensor was found in 20 minutes. Make sure the sensor is inserted and within range, and that no other phone or app is using it.", + comment: "Pairing failure reason when the scan for a G7 sensor times out" + )) + } else { + self.fail(self.planner.exhaustionReason) + } } scanWatchdog = watchdog DispatchQueue.main.asyncAfter(deadline: .now() + G7PairingService.scanTimeout, execute: watchdog) @@ -469,6 +482,15 @@ public final class G7PairingService { armCandidateWatchdog() } + case .keepScanning: + // Manual entry: the sensors seen so far are not the code's, but the + // intended one may not have advertised yet. Keep looking until the + // scan watchdog's deadline rather than failing now. + setState(.scanning(candidates: planner.candidates.map(\.name))) + report("None of the sensors seen so far match the code; still scanning") + bluetoothManager?.disconnectAll() + bluetoothManager?.scanForPeripheral() + case .giveUp(let reason): fail(reason) } @@ -482,7 +504,7 @@ public final class G7PairingService { extension G7PairingService: G7BluetoothManagerDelegate { - func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any]) -> PeripheralConnectionCommand { + func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) -> PeripheralConnectionCommand { // A finished run must never connect again: the sensor it just paired // belongs to the session manager now. guard !state.isFinished, @@ -501,19 +523,22 @@ extension G7PairingService: G7BluetoothManagerDelegate { return .ignore } + // CoreBluetooth reports 127 when it cannot read the signal; only a + // real (negative dBm) reading orders candidates by proximity. + let signal = rssi.intValue < 0 ? rssi.intValue : G7PairingPlanner.unknownRSSI let id = peripheral.identifier onMain { [weak self] in guard let self = self, self.isRunActive else { return } let isHeld = advertisement.isSlotHeld(for: displayType) ?? false - if self.planner.addCandidate(id: id, name: advertisement.name, isPhoneSlotHeld: isHeld) { + if self.planner.addCandidate(id: id, name: advertisement.name, isPhoneSlotHeld: isHeld, rssi: signal) { self.report(isHeld ? "Found \(advertisement.name); another phone connected recently, so trying others first" : "Found \(advertisement.name)") if case .scanning = self.state { self.setState(.scanning(candidates: self.planner.candidates.map(\.name))) } - } else if let isHeld = advertisement.isSlotHeld(for: displayType), self.planner.updateSlot(id: id, isPhoneSlotHeld: isHeld) { - self.report("\(advertisement.name) slot is now \(isHeld ? "held" : "free")") + } else if self.planner.updateSlot(id: id, isPhoneSlotHeld: advertisement.isSlotHeld(for: displayType), rssi: signal) { + self.report("\(advertisement.name) advertisement updated") } self.armCandidateWatchdog() } diff --git a/G7SensorKitTests/G7PairingPlannerTests.swift b/G7SensorKitTests/G7PairingPlannerTests.swift index 3ef3da3..e634e7b 100644 --- a/G7SensorKitTests/G7PairingPlannerTests.swift +++ b/G7SensorKitTests/G7PairingPlannerTests.swift @@ -122,4 +122,75 @@ class G7PairingPlannerTests: XCTestCase { planner.updateSlot(id: a, isPhoneSlotHeld: true) XCTAssertEqual(planner.currentCandidate?.id, a, "a candidate mid-handshake must stay put") } + + // MARK: - Signal strength ordering + + /// The sensor being paired is in the user's hand, so it is almost always + /// the strongest signal: try the nearest untried sensor first. + func testStrongerSignalGoesFirstWithinClass() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "current", isPhoneSlotHeld: false, rssi: -50) + planner.addCandidate(id: b, name: "weak", isPhoneSlotHeld: false, rssi: -85) + planner.addCandidate(id: c, name: "strong", isPhoneSlotHeld: false, rssi: -42) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "strong", "weak"]) + } + + /// Signal strength orders within a class but never ahead of it: a held + /// sensor is likely to reject us however strong it is. + func testSignalDoesNotOverrideHeldFreeOrder() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "current", isPhoneSlotHeld: false, rssi: -50) + planner.addCandidate(id: b, name: "held-strong", isPhoneSlotHeld: true, rssi: -30) + planner.addCandidate(id: c, name: "free-weak", isPhoneSlotHeld: false, rssi: -88) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "free-weak", "held-strong"]) + } + + /// With no signal reading, ordering is unchanged from discovery order. + func testUnknownSignalKeepsDiscoveryOrder() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "current", isPhoneSlotHeld: false) + planner.addCandidate(id: b, name: "first", isPhoneSlotHeld: false) + planner.addCandidate(id: c, name: "second", isPhoneSlotHeld: false) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "first", "second"]) + } + + /// A fresh advertisement can carry a new signal reading that reorders an + /// untried candidate; an unchanged reading reports no change. + func testUpdateSlotAppliesNewSignal() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "current", isPhoneSlotHeld: false, rssi: -50) + planner.addCandidate(id: b, name: "b", isPhoneSlotHeld: false, rssi: -80) + planner.addCandidate(id: c, name: "c", isPhoneSlotHeld: false, rssi: -70) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "c", "b"]) + + XCTAssertTrue(planner.updateSlot(id: b, isPhoneSlotHeld: nil, rssi: -30)) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "b", "c"]) + + XCTAssertFalse(planner.updateSlot(id: b, isPhoneSlotHeld: nil, rssi: -30), "no change reports no change") + } + + // MARK: - Keep scanning after exhaustion (manual entry) + + /// Manual entry cannot tell the intended sensor from a neighbour, so a + /// sensor that does not match the code is a wrong guess, not a wrong code: + /// keep looking, and let a later discovery become the next to try. + func testManualModeKeepsScanningAfterExhaustion() { + var planner = G7PairingPlanner(keepScanningWhenExhausted: true) + planner.addCandidate(id: a, name: "neighbour", isPhoneSlotHeld: false, rssi: -60) + XCTAssertEqual(planner.abandonCurrentCandidate(reason: "wrong sensor"), .keepScanning) + XCTAssertNil(planner.currentCandidate) + + XCTAssertTrue(planner.addCandidate(id: b, name: "real", isPhoneSlotHeld: false, rssi: -45)) + XCTAssertEqual(planner.currentCandidate?.name, "real", "a sensor found later still gets tried") + } + + /// A scan has filtered to the one sensor by serial, so a mismatch there is + /// a wrong code and stops. + func testScanModeGivesUpAfterExhaustion() { + var planner = G7PairingPlanner(keepScanningWhenExhausted: false) + planner.addCandidate(id: a, name: "only", isPhoneSlotHeld: false) + guard case .giveUp = planner.abandonCurrentCandidate(reason: "wrong code") else { + return XCTFail("a scan should give up on a mismatch") + } + } } diff --git a/G7SensorKitUI/ViewModels/G7PairingViewModel.swift b/G7SensorKitUI/ViewModels/G7PairingViewModel.swift index 5cf6a18..6a1c980 100644 --- a/G7SensorKitUI/ViewModels/G7PairingViewModel.swift +++ b/G7SensorKitUI/ViewModels/G7PairingViewModel.swift @@ -126,7 +126,7 @@ final class G7PairingViewModel: ObservableObject { ) case .scanning(let candidates): return String( - format: LocalizedString("Found %@", comment: "Pairing detail listing discovered sensors (1: comma-separated names)"), + format: LocalizedString("Found %@. If other Dexcom sensors are nearby, pairing checks each in turn until it finds the one your code belongs to, which can take a few minutes.", comment: "Pairing detail listing discovered sensors (1: comma-separated names)"), candidates.joined(separator: ", ") ) case .authenticating(let candidate, let attempt): From a8465d790fa92bbd443f739d923b15da637d80e0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 22 Sep 2026 00:11:23 -0500 Subject: [PATCH 2/2] Reword RSSI rationale: sensor is on the body, not in hand Comment-only. The intended sensor is usually nearest because the phone is held up to the freshly inserted sensor during pairing, not because it is in the user's hand. --- G7SensorKit/Pairing/G7PairingPlanner.swift | 11 ++++++----- G7SensorKitTests/G7PairingPlannerTests.swift | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/G7SensorKit/Pairing/G7PairingPlanner.swift b/G7SensorKit/Pairing/G7PairingPlanner.swift index 0963e63..a8e76aa 100644 --- a/G7SensorKit/Pairing/G7PairingPlanner.swift +++ b/G7SensorKit/Pairing/G7PairingPlanner.swift @@ -17,11 +17,12 @@ import Foundation /// sensors in range. The order matters: a sensor whose display slot is held /// by another phone will reject us, and four rejections in a row make a /// sensor stop accepting connections for a while. So unheld sensors go -/// first, and within a class the strongest signal goes first — the sensor -/// being paired is in the user's hand, so it is almost always the nearest -/// one. A sensor that rejects us is dropped rather than retried, and -/// ordinary failures (a dropped link, a timeout) get a bounded number of -/// retries before moving on. +/// first, and within a class the strongest signal goes first — pairing +/// happens with the phone held up to the freshly inserted sensor, so the +/// intended one is usually (not always) the nearest and loudest. A sensor +/// that rejects us is dropped rather than retried, and ordinary failures +/// (a dropped link, a timeout) get a bounded number of retries before +/// moving on. /// /// Pure bookkeeping with no Bluetooth of its own, so the policy is testable /// in isolation. diff --git a/G7SensorKitTests/G7PairingPlannerTests.swift b/G7SensorKitTests/G7PairingPlannerTests.swift index e634e7b..eae5d2c 100644 --- a/G7SensorKitTests/G7PairingPlannerTests.swift +++ b/G7SensorKitTests/G7PairingPlannerTests.swift @@ -125,8 +125,9 @@ class G7PairingPlannerTests: XCTestCase { // MARK: - Signal strength ordering - /// The sensor being paired is in the user's hand, so it is almost always - /// the strongest signal: try the nearest untried sensor first. + /// Pairing happens with the phone up to the freshly inserted sensor, so + /// the intended one is usually the strongest signal: try the nearest + /// untried sensor first. func testStrongerSignalGoesFirstWithinClass() { var planner = G7PairingPlanner() planner.addCandidate(id: a, name: "current", isPhoneSlotHeld: false, rssi: -50)