Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions G7SensorKit/G7CGMManager/G7BluetoothManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion G7SensorKit/G7CGMManager/G7Sensor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
141 changes: 94 additions & 47 deletions G7SensorKit/Pairing/G7PairingPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,39 +17,65 @@ 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
/// 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.
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 {
/// Try the current candidate again.
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)
}

/// 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

/// 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
}
Expand All @@ -59,57 +85,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 {
Expand All @@ -122,7 +181,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()
Expand All @@ -131,22 +190,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)
}
}
43 changes: 34 additions & 9 deletions G7SensorKit/Pairing/G7PairingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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,
Expand All @@ -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()
}
Expand Down
Loading