diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 642bc34..09201ae 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -60,6 +60,32 @@ C17F5157291EBD9900555EB5 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F513F291EB27D00555EB5 /* TimeInterval.swift */; }; C19C9F4E29C91C4C00A6D3D0 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19C9F4D29C91C4C00A6D3D0 /* LocalizedString.swift */; }; C1E71720292D84FE00DA646F /* G7ProgressBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E7171F292D84FE00DA646F /* G7ProgressBarState.swift */; }; + FADE00000000000000000310 /* G7SensorKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C17F50C9291EAC3800555EB5 /* G7SensorKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FADE00000000000000000311 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADE00000000000000000301 /* LoopKit.framework */; }; + FADE00000000000000000312 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F6C2E6A803800471149 /* Localizable.xcstrings */; }; + FADE00000000000000000320 /* G7CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E5291EAC6500555EB5 /* G7CGMManager.swift */; }; + FADE00000000000000000321 /* SensorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5146291EB57700555EB5 /* SensorMessage.swift */; }; + FADE00000000000000000322 /* G7BackfillMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50DF291EAC6500555EB5 /* G7BackfillMessage.swift */; }; + FADE00000000000000000323 /* G7Opcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E3291EAC6500555EB5 /* G7Opcode.swift */; }; + FADE00000000000000000324 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F513F291EB27D00555EB5 /* TimeInterval.swift */; }; + FADE00000000000000000325 /* G7CGMManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E6291EAC6500555EB5 /* G7CGMManagerState.swift */; }; + FADE00000000000000000326 /* CBPeripheral.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5144291EB45900555EB5 /* CBPeripheral.swift */; }; + FADE00000000000000000327 /* ExtendedVersionMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */; }; + FADE00000000000000000328 /* GlucoseLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5139291EB0D900555EB5 /* GlucoseLimits.swift */; }; + FADE00000000000000000329 /* AuthChallengeRxMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5142291EB36700555EB5 /* AuthChallengeRxMessage.swift */; }; + FADE0000000000000000032A /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A08291EC22F006BE8D0 /* OSLog.swift */; }; + FADE0000000000000000032B /* AlgorithmState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5134291EB06A00555EB5 /* AlgorithmState.swift */; }; + FADE0000000000000000032C /* G7BluetoothManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E7291EAC6500555EB5 /* G7BluetoothManager.swift */; }; + FADE0000000000000000032D /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5137291EB0A800555EB5 /* Data.swift */; }; + FADE0000000000000000032E /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F514A291EB6F000555EB5 /* HKUnit.swift */; }; + FADE0000000000000000032F /* G7LastReading.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E2291EAC6500555EB5 /* G7LastReading.swift */; }; + FADE00000000000000000330 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F513B291EB13D00555EB5 /* Locked.swift */; }; + FADE00000000000000000331 /* BluetoothServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F513D291EB1A500555EB5 /* BluetoothServices.swift */; }; + FADE00000000000000000332 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5148291EB6B600555EB5 /* LocalizedString.swift */; }; + FADE00000000000000000333 /* AlgorithmError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F514C291EB79E00555EB5 /* AlgorithmError.swift */; }; + FADE00000000000000000334 /* G7Sensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E1291EAC6500555EB5 /* G7Sensor.swift */; }; + FADE00000000000000000335 /* G7GlucoseMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E8291EAC6500555EB5 /* G7GlucoseMessage.swift */; }; + FADE00000000000000000336 /* G7PeripheralManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E4291EAC6500555EB5 /* G7PeripheralManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -160,6 +186,8 @@ C17F5158291EBE7500555EB5 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C19C9F4D29C91C4C00A6D3D0 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; C1E7171F292D84FE00DA646F /* G7ProgressBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7ProgressBarState.swift; sourceTree = ""; }; + FADE00000000000000000301 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FADE00000000000000000302 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -196,6 +224,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FADE00000000000000000303 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FADE00000000000000000311 /* LoopKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -219,6 +255,7 @@ C17F50CE291EAC3800555EB5 /* G7SensorKitTests.xctest */, C17F50F8291EAC9100555EB5 /* G7SensorKitUI.framework */, C17F511D291EACCD00555EB5 /* G7SensorPlugin.loopplugin */, + FADE00000000000000000302 /* G7SensorKit.framework */, ); name = Products; sourceTree = ""; @@ -305,6 +342,7 @@ isa = PBXGroup; children = ( 3B0FD2A42D803BF000E5E921 /* LoopKitUI.framework */, + FADE00000000000000000301 /* LoopKit.framework */, ); name = Frameworks; sourceTree = ""; @@ -379,6 +417,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FADE00000000000000000304 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + FADE00000000000000000310 /* G7SensorKit.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -458,6 +504,24 @@ productReference = C17F511D291EACCD00555EB5 /* G7SensorPlugin.loopplugin */; productType = "com.apple.product-type.framework"; }; + FADE00000000000000000305 /* G7SensorKit-watchOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = FADE00000000000000000306 /* Build configuration list for PBXNativeTarget "G7SensorKit-watchOS" */; + buildPhases = ( + FADE00000000000000000304 /* Headers */, + FADE00000000000000000307 /* Sources */, + FADE00000000000000000303 /* Frameworks */, + FADE00000000000000000308 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "G7SensorKit-watchOS"; + productName = G7SensorKit; + productReference = FADE00000000000000000302 /* G7SensorKit.framework */; + productType = "com.apple.product-type.framework"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -526,6 +590,7 @@ C17F50CD291EAC3800555EB5 /* G7SensorKitTests */, C17F50F7291EAC9100555EB5 /* G7SensorKitUI */, C17F511C291EACCD00555EB5 /* G7SensorPlugin */, + FADE00000000000000000305 /* G7SensorKit-watchOS */, ); }; /* End PBXProject section */ @@ -562,6 +627,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FADE00000000000000000308 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FADE00000000000000000312 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -633,6 +706,36 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FADE00000000000000000307 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FADE00000000000000000320 /* G7CGMManager.swift in Sources */, + FADE00000000000000000321 /* SensorMessage.swift in Sources */, + FADE00000000000000000322 /* G7BackfillMessage.swift in Sources */, + FADE00000000000000000323 /* G7Opcode.swift in Sources */, + FADE00000000000000000324 /* TimeInterval.swift in Sources */, + FADE00000000000000000325 /* G7CGMManagerState.swift in Sources */, + FADE00000000000000000326 /* CBPeripheral.swift in Sources */, + FADE00000000000000000327 /* ExtendedVersionMessage.swift in Sources */, + FADE00000000000000000328 /* GlucoseLimits.swift in Sources */, + FADE00000000000000000329 /* AuthChallengeRxMessage.swift in Sources */, + FADE0000000000000000032A /* OSLog.swift in Sources */, + FADE0000000000000000032B /* AlgorithmState.swift in Sources */, + FADE0000000000000000032C /* G7BluetoothManager.swift in Sources */, + FADE0000000000000000032D /* Data.swift in Sources */, + FADE0000000000000000032E /* HKUnit.swift in Sources */, + FADE0000000000000000032F /* G7LastReading.swift in Sources */, + FADE00000000000000000330 /* Locked.swift in Sources */, + FADE00000000000000000331 /* BluetoothServices.swift in Sources */, + FADE00000000000000000332 /* LocalizedString.swift in Sources */, + FADE00000000000000000333 /* AlgorithmError.swift in Sources */, + FADE00000000000000000334 /* G7Sensor.swift in Sources */, + FADE00000000000000000335 /* G7GlucoseMessage.swift in Sources */, + FADE00000000000000000336 /* G7PeripheralManager.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -815,7 +918,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.loopkit.G7SensorKit; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = auto; + SDKROOT = iphoneos; /* PODLOAN watch-from-stock: was `auto` — a specializable candidate hijacks the watch extension's implicit G7SensorKit.framework dependency from G7SensorKit-watchOS; effective iOS SDK unchanged (SUPPORTED_PLATFORMS pins iphoneos) */ SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -851,7 +954,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.loopkit.G7SensorKit; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = auto; + SDKROOT = iphoneos; /* PODLOAN watch-from-stock: was `auto` — a specializable candidate hijacks the watch extension's implicit G7SensorKit.framework dependency from G7SensorKit-watchOS; effective iOS SDK unchanged (SUPPORTED_PLATFORMS pins iphoneos) */ SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1045,6 +1148,70 @@ }; name = Release; }; + FADE00000000000000000309 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.loopkit.G7SensorKit; + PRODUCT_NAME = G7SensorKit; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.6; + }; + name = Debug; + }; + FADE0000000000000000030A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.loopkit.G7SensorKit; + PRODUCT_NAME = G7SensorKit; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.6; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1093,6 +1260,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FADE00000000000000000306 /* Build configuration list for PBXNativeTarget "G7SensorKit-watchOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FADE00000000000000000309 /* Debug */, + FADE0000000000000000030A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = C17F50BD291EAC3800555EB5 /* Project object */; diff --git a/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit-watchOS.xcscheme b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit-watchOS.xcscheme new file mode 100644 index 0000000..abe0ecf --- /dev/null +++ b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit-watchOS.xcscheme @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 3edb0e2..412b785 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -7,6 +7,60 @@ // import CoreBluetooth + +/// FORK ADDITION (Sport Mode #101): public sink for the radio census, since the BLE types +/// themselves are module-internal. The watch app assigns it; nil (default) = os_log only. +/// +/// #101 phase 2 additionally publishes two lock-protected timestamps so the app can gate +/// pod radio work on live G7 acquisition state (2026-08-10 23:31:48: the pod scan fired +/// 100ms before the D2W ride appeared and the G7 connect never completed — the app needs +/// to SEE an in-flight connect / fresh ride activity, not infer it from the clock): +/// - `connectPendingSince`: a `centralManager.connect` we issued that has neither +/// didConnect nor didFailToConnect'd yet. The fragile establishment phase. +/// - `lastRideSignalAt`: most recent acquisition signal of any kind (connection event, +/// sensor advertisement, connect issued/landed). "Fresh signal" means a ride is in +/// progress or imminent; silence means the radio is ours to use. +public enum G7RadioCensus { + public static var sink: ((String) -> Void)? + + /// Every sensor NAME this radio sees, as a signal rather than as prose. + /// + /// The census already logged these names, but only inside sentences — recovering "which + /// sensors are actually in range" meant parsing log strings. The host needs it as data to + /// break the stranded-identity trap: when the persisted sensor is gone and a replacement is + /// advertising beside it, nothing in the manager can notice, because it is busy failing + /// authentication against a corpse and therefore never learns the new sensor's ID. + /// (Ported from the pure/SportMode line, 2026-08-21.) + public static var sensorSighted: ((String) -> Void)? + + private static let stateLock = NSLock() + private static var _connectPendingSince: Date? + private static var _lastRideSignalAt: Date? + + public static var connectPendingSince: Date? { + stateLock.lock(); defer { stateLock.unlock() } + return _connectPendingSince + } + public static var lastRideSignalAt: Date? { + stateLock.lock(); defer { stateLock.unlock() } + return _lastRideSignalAt + } + + static func noteConnectPending() { + stateLock.lock(); defer { stateLock.unlock() } + if _connectPendingSince == nil { _connectPendingSince = Date() } + _lastRideSignalAt = Date() + } + static func noteConnectResolved() { + stateLock.lock(); defer { stateLock.unlock() } + _connectPendingSince = nil + _lastRideSignalAt = Date() + } + static func noteRideSignal() { + stateLock.lock(); defer { stateLock.unlock() } + _lastRideSignalAt = Date() + } +} import Foundation import os.log @@ -105,6 +159,45 @@ class G7BluetoothManager: NSObject { /// Isolated to `managerQueue` private var managedPeripherals: [UUID:G7PeripheralManager] = [:] + // SETTLED 2026-08-25 — no longer lab toggles. All three acquisition doorways proven over + // weeks of field use (ride + events + scan together are the piggyback mechanism); the + // UserDefaults reads are gone deliberately, so a stale `false` from an old experiment can + // never silently disable a doorway. The accessors stay so the call sites read as gates. + static var labRideEnabled: Bool { true } + static var labEventsEnabled: Bool { true } + static var labScanEnabled: Bool { true } + + // SCAN WATCHDOG (H14 probe + remedy, 2026-08-20). The night of 08-19 the known-sensor branch sat in + // a bare pending connect for 37 minutes while the sensor advertised on grid (Mac observer). Whatever + // the root cause (H14: a scan session dead at the bluetoothd level while isScanning reads true), a + // full recycle of the acquisition is correct under every theory. 320 s = one full sensor window plus + // jitter: a whole window with acquisition armed and NOTHING delivered is deafness, not bad luck. + private var scanWatchdog: DispatchSourceTimer? + private var lastDeliveryAt: Date? + + private func armScanWatchdog() { + scanWatchdog?.cancel() + if lastDeliveryAt == nil { lastDeliveryAt = Date() } // baseline, so the first check is not "∞" + let t = DispatchSource.makeTimerSource(queue: managerQueue) + t.schedule(deadline: .now() + 320, repeating: 320) + t.setEventHandler { [weak self] in self?.scanWatchdogFired() } + t.resume() + scanWatchdog = t + } + + private func scanWatchdogFired() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + guard activePeripheral?.state != .connected else { return } + let age = lastDeliveryAt.map { Int(-$0.timeIntervalSinceNow) } + guard (age ?? Int.max) > 315 else { return } + Self.census("scan-watchdog: NOTHING delivered in \(age.map(String.init) ?? "∞")s with acquisition armed — recycling scan + connect (H14 probe)") + if let p = activePeripheral, p.state == .connecting { + centralManager.cancelPeripheralConnection(p) + } + managerQueue_stopScanning() + managerQueue_scanForPeripheral() + } + var activePeripheralIdentifier: UUID? { get { return lockedPeripheralIdentifier.value @@ -124,6 +217,21 @@ class G7BluetoothManager: NSObject { private let managerQueue = DispatchQueue(label: "com.loudnate.CGMBLEKit.bluetoothManagerQueue", qos: .unspecified) + /// FORK ADDITION (Sport Mode #101, 2026-08-10): radio-census sink. os_log lines from this + /// layer never reach the on-watch mirrored log, which is what the field analysis reads — + /// the 2026-08-10 acquisition investigation had to infer the mechanism because discovery, + /// connection events, and connect verdicts were all invisible. Same pattern as OmnipodKit's + /// `podLoanLogSink`; the watch wires it, the phone leaves it nil (os_log only). + /// + /// Acquisition has THREE triggers, and the census must name which one fired: + /// (a) retrieveConnectedPeripherals at scan start — riding a link D2W already holds + /// (b) connectionEventDidOccur — the OS reporting D2W (or anyone) connecting to a sensor + /// (c) advertisement scan — the only path that needs active scanning + private static func census(_ line: String) { G7RadioCensus.sink?(line) } + /// didDiscover fires many times per transmit window; log each peripheral at most + /// once per 30 s. managerQueue-confined. + private var lastDiscoveryLog: [UUID: Date] = [:] + override init() { super.init() @@ -136,7 +244,12 @@ class G7BluetoothManager: NSObject { /// restoration option, which raises an exception outside an app with the /// bluetooth-central background mode. func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { +#if os(iOS) return CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"]) +#else + // watchOS has no CoreBluetooth state restoration; the watch host owns reconnect policy. + return CBCentralManager(delegate: self, queue: queue, options: nil) +#endif } // MARK: - Actions @@ -187,6 +300,13 @@ class G7BluetoothManager: NSObject { func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) { managerQueue.async { + // Trigger (b): the OS saw a connection event on a sensor-service peripheral — + // in practice, D2W connecting for its reading. Log ALWAYS (even when we have an + // active peripheral and ignore it): the census needs D2W's rhythm either way. + if event == .peerConnected { G7RadioCensus.noteRideSignal() } + self.lastDeliveryAt = Date() + Self.census("connection-event \(event.rawValue == 1 ? "CONNECT" : "disconnect") \(peripheral.name ?? "unnamed") — \(self.activePeripheralIdentifier == nil ? "handling (trigger b)" : "ignored, have active")") + if let name = peripheral.name { G7RadioCensus.sensorSighted?(name) } if self.activePeripheralIdentifier == nil { self.log.default("Discovered peripheral from connectionEventDidOccur %{public}@", peripheral.identifier.uuidString) self.handleDiscoveredPeripheral(peripheral) @@ -206,34 +326,56 @@ class G7BluetoothManager: NSObject { return } - if let peripheralID = activePeripheralIdentifier, let peripheral = centralManager.retrievePeripherals(withIdentifiers: [peripheralID]).first { + if Self.labRideEnabled, let peripheralID = activePeripheralIdentifier, let peripheral = centralManager.retrievePeripherals(withIdentifiers: [peripheralID]).first { log.default("Retrieved peripheral %{public}@", peripheral.identifier.uuidString) + Self.census("scan-start: retrieved KNOWN peripheral \(peripheral.name ?? "unnamed") state=\(peripheral.state.rawValue)") handleDiscoveredPeripheral(peripheral) } else { - for peripheral in centralManager.retrieveConnectedPeripherals(withServices: [ + let systemConnected = centralManager.retrieveConnectedPeripherals(withServices: [ SensorServiceUUID.advertisement.cbUUID, SensorServiceUUID.cgmService.cbUUID - ]) { + ]) + // Trigger (a): the literal piggyback. Empty means D2W held no sensor link at this + // exact moment — its connections last ~10-20 s per 5-min window, so this is a + // timing lottery and the census must show every draw. + Self.census("scan-start: system-connected list = [\(systemConnected.map { $0.name ?? "unnamed" }.joined(separator: ","))] (\(systemConnected.count))") + for peripheral in systemConnected { log.default("Found system-connected peripheral: %{public}@", peripheral.identifier.uuidString) handleDiscoveredPeripheral(peripheral) } } - if activePeripheral == nil { + // THE 20-40 MINUTE OUTAGE FIX (2026-08-20). This used to be `activePeripheral == nil`, so the + // known-sensor branch above — which retrieves the peripheral and issues a bare connect() — + // left NO scan armed. A pending connect depends on bluetoothd's own duty-cycled background + // scan, which against a 1-4 s advertising burst per 300 s window is a lottery: measured + // 2026-08-19/20, seven consecutive windows missed, 20-40 min outages ending only when the + // sensor escalated to ~60 s distress cadence (also measured — the "exact 300 s grid" is the + // COLLECTED regime only). An armed scan catches the FIRST burst instead. Crude proved the same + // lesson ("scan is the primitive"). didConnect stops the scan via readied → + // managerQueue_stopScanning, and handleDiscoveredPeripheral's #101 guard makes a discovery + // during a pending connect a no-op, so this cannot churn. + if activePeripheral?.state != .connected { log.default("Scanning for peripherals and listening for connection events") - centralManager.registerForConnectionEvents(options: [CBConnectionEventMatchingOption.serviceUUIDs: [ - SensorServiceUUID.advertisement.cbUUID, - SensorServiceUUID.cgmService.cbUUID - ]]) + if Self.labEventsEnabled { + centralManager.registerForConnectionEvents(options: [CBConnectionEventMatchingOption.serviceUUIDs: [ + SensorServiceUUID.advertisement.cbUUID, + SensorServiceUUID.cgmService.cbUUID + ]]) + } - centralManager.scanForPeripherals(withServices: [ - SensorServiceUUID.advertisement.cbUUID - ], - options: nil - ) + if Self.labScanEnabled { + centralManager.scanForPeripherals(withServices: [ + SensorServiceUUID.advertisement.cbUUID + ], + options: nil + ) + } + Self.census("scan STARTED (lab: a=\(Self.labRideEnabled) b=\(Self.labEventsEnabled) c=\(Self.labScanEnabled)) — trigger c \(Self.labScanEnabled ? "armed" : "DISABLED BY LAB"), events \(Self.labEventsEnabled ? "armed" : "DISABLED BY LAB")") delegate?.bluetoothManagerScanningStatusDidChange(self) } + armScanWatchdog() } /** @@ -244,10 +386,25 @@ class G7BluetoothManager: NSObject { The sleep gives the transmitter time to shut down, but keeps the app running. */ + /// #101 churn fix: single-flight. Every didFail/didDisconnect used to schedule its own + /// 2s-delayed rescan; a failed ride produced a burst of them and each rescan re-fired + /// connection events that produced more failures (2026-08-10 23:31:52-59, ~10 scan + /// restarts/second). N failures now schedule exactly one rescan. + private let scanRestartPending = NSLock() + private var _scanRestartPending = false + fileprivate func scanAfterDelay() { + scanRestartPending.lock() + let alreadyPending = _scanRestartPending + _scanRestartPending = true + scanRestartPending.unlock() + guard !alreadyPending else { return } + DispatchQueue.global(qos: .utility).async { Thread.sleep(forTimeInterval: 2) - + self.scanRestartPending.lock() + self._scanRestartPending = false + self.scanRestartPending.unlock() self.scanForPeripheral() } } @@ -277,6 +434,16 @@ class G7BluetoothManager: NSObject { private func handleDiscoveredPeripheral(_ peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) + // #101 churn fix (2026-08-10 23:31:52-59): during a failed ride, every scan restart + // re-registers connection events and the OS re-fires CONNECT for the already-linked + // D2W peripheral — each firing landed here and issued ANOTHER connect() while the + // first was still pending, minting a fresh G7PeripheralManager per event (~10/s). + // A pending connect is already doing everything a duplicate would; skip it. + if peripheral.state == .connecting, managedPeripherals[peripheral.identifier] != nil { + G7RadioCensus.noteRideSignal() + return + } + if let delegate = delegate { switch delegate.bluetoothManager(self, shouldConnectPeripheral: peripheral) { case .makeActive: @@ -293,10 +460,12 @@ class G7BluetoothManager: NSObject { activePeripheralManager?.delegate = self } self.managedPeripherals[peripheral.identifier] = activePeripheralManager + G7RadioCensus.noteConnectPending() self.centralManager.connect(peripheral) case .connect: log.default("Connecting to peripheral: %{public}@", peripheral.identifier.uuidString) + G7RadioCensus.noteConnectPending() self.centralManager.connect(peripheral) let peripheralManager = G7PeripheralManager( peripheral: peripheral, @@ -341,6 +510,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { } } +#if os(iOS) // watchOS has no CoreBluetooth state restoration (willRestoreState / restored-state keys are iOS-only) func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { dispatchPrecondition(condition: .onQueue(managerQueue)) @@ -351,12 +521,24 @@ extension G7BluetoothManager: CBCentralManagerDelegate { } } } +#endif func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { dispatchPrecondition(condition: .onQueue(managerQueue)) log.default("%{public}@: %{public}@, data = %{public}@", #function, peripheral, String(describing: advertisementData)) + // Trigger (c): an advertisement reached our scan. Rate-limited per peripheral; the + // interesting signal is PRESENCE vs ABSENCE per window — a held-pod-link arm with no + // didDiscover lines while D2W reads fine is scan starvation, observed directly. + G7RadioCensus.noteRideSignal() + if lastDiscoveryLog[peripheral.identifier].map({ Date().timeIntervalSince($0) > 30 }) ?? true { + lastDiscoveryLog[peripheral.identifier] = Date() + lastDeliveryAt = Date() + Self.census("ad DISCOVERED (trigger c) \(peripheral.name ?? "unnamed") rssi \(RSSI)") + if let name = peripheral.name { G7RadioCensus.sensorSighted?(name) } + } + managerQueue.async { self.handleDiscoveredPeripheral(peripheral) } @@ -364,6 +546,9 @@ extension G7BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) + G7RadioCensus.noteConnectResolved() + lastDeliveryAt = Date() + Self.census("didConnect \(peripheral.name ?? "unnamed")") log.default("%{public}@: %{public}@", #function, peripheral) @@ -380,6 +565,13 @@ extension G7BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { dispatchPrecondition(condition: .onQueue(managerQueue)) + // #101: the failing half of the churn cycle was invisible — the census had + // didConnect but neither terminal callback, so a ride that died looked identical + // to one that never started. + G7RadioCensus.noteConnectResolved() + // [domain#code] alongside Apple's prose (from the pure line, where a grep for Code=11 + // returned zero while 34 connection-limit failures sat in the log as text only). + Self.census("didDisconnect \(peripheral.name ?? "unnamed")\(error.map { " [\(($0 as NSError).domain)#\(($0 as NSError).code)] \($0.localizedDescription)" } ?? "")") log.default("%{public}@: %{public}@", #function, peripheral) // Ignore errors indicating the peripheral disconnected remotely, as that's expected behavior if let error = error as NSError?, CBError(_nsError: error).code != .peripheralDisconnected { @@ -408,6 +600,8 @@ extension G7BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { dispatchPrecondition(condition: .onQueue(managerQueue)) + G7RadioCensus.noteConnectResolved() + Self.census("didFailToConnect \(peripheral.name ?? "unnamed")\(error.map { " [\(($0 as NSError).domain)#\(($0 as NSError).code)] \($0.localizedDescription)" } ?? "")") log.error("%{public}@: %{public}@", #function, String(describing: error)) if let error = error, let peripheralManager = activePeripheralManager { diff --git a/G7SensorKit/G7CGMManager/G7PeripheralManager.swift b/G7SensorKit/G7CGMManager/G7PeripheralManager.swift index cffae13..70b6fbd 100644 --- a/G7SensorKit/G7CGMManager/G7PeripheralManager.swift +++ b/G7SensorKit/G7CGMManager/G7PeripheralManager.swift @@ -180,11 +180,17 @@ extension G7PeripheralManager { for (serviceUUID, characteristicUUIDs) in configuration.notifyingCharacteristics { guard let service = peripheral.services?.itemWithUUID(serviceUUID) else { + // FORK (#101): dump what discovery ACTUALLY returned before dying. An empty or + // partial inventory here on a link that D2W was using is the signature of the + // shared link dropping mid-discovery; a full-but-different inventory would mean + // wrong GATT. The bare error could not tell those apart (field 2026-08-08..10). + G7RadioCensus.sink?("unknownCharacteristic: service \(serviceUUID.uuidString.prefix(8)) MISSING on \(peripheral.name ?? "unnamed") — discovered: \(Self.gattInventory(peripheral))") throw PeripheralManagerError.unknownCharacteristic } for characteristicUUID in characteristicUUIDs { guard let characteristic = service.characteristics?.itemWithUUID(characteristicUUID) else { + G7RadioCensus.sink?("unknownCharacteristic: char \(characteristicUUID.uuidString.prefix(8)) MISSING in service \(serviceUUID.uuidString.prefix(8)) on \(peripheral.name ?? "unnamed") — discovered: \(Self.gattInventory(peripheral))") throw PeripheralManagerError.unknownCharacteristic } @@ -196,6 +202,17 @@ extension G7PeripheralManager { } } } + + /// FORK (#101): one-line GATT inventory — "svc(8chars):[char8,char8] | svc:[...]". + /// nil services = discovery never completed at all. + static func gattInventory(_ peripheral: CBPeripheral) -> String { + guard let services = peripheral.services else { return "NO SERVICES (discovery incomplete)" } + if services.isEmpty { return "0 services" } + return services.map { svc in + let chars = svc.characteristics?.map { String($0.uuid.uuidString.prefix(8)) }.joined(separator: ",") ?? "none-discovered" + return "\(svc.uuid.uuidString.prefix(8)):[\(chars)]" + }.joined(separator: " | ") + } } extension CBManagerState { diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index 70578c7..bf11fb2 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -72,7 +72,11 @@ public final class G7Sensor: G7BluetoothManagerDelegate { // MARK: - Passive observation state, confined to `bluetoothManager.managerQueue` /// The initial activation date of the sensor - var activationDate: Date? + // watch-from-stock: `public` (was internal) — the transport-injection seam. An external + // transport driving the G7SensorDelegate callbacks (in place of this class's passive + // CoreBluetooth listener) must set the activation date before delivering readings, exactly + // as handleGlucoseMessage does below; G7CGMManager reads it to timestamp samples. + public var activationDate: Date? /// The initial activation date of the sensor var needsVersionInfo: Bool = false