From 8ee2dc8e715d9200c4cc30c280904583f5e5f9f4 Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Fri, 17 Jul 2026 10:37:19 -0400 Subject: [PATCH 01/11] watch-from-stock: add G7SensorKit-watchOS framework target Mirrors the LoopKit-watchOS pbxproj pattern (SDKROOT=watchos, SUPPORTED_PLATFORMS watchos/watchsimulator, TARGETED_DEVICE_FAMILY=4, APPLICATION_EXTENSION_API_ONLY, PRODUCT_NAME=G7SensorKit, WATCHOS_DEPLOYMENT_TARGET=8.0). Compiles the full core (23 sources: parsing/state/messages/Common + the CoreBluetooth passive-listener stack, which builds for watchOS with the two os(iOS) restoration guards). Excluded from the watch target: G7DeviceStatus.swift (imports LoopKitUI, iOS-only; consumed only by G7SensorKitUI). Links LoopKit.framework (resolved to the LoopKit-watchOS product by name, as OmnipodKit-watchOS does). Shared scheme added for the standalone compile gate. Co-Authored-By: Claude Fable 5 --- G7SensorKit.xcodeproj/project.pbxproj | 176 ++++++++++++++++++ .../xcschemes/G7SensorKit-watchOS.xcscheme | 58 ++++++ 2 files changed, 234 insertions(+) create mode 100644 G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit-watchOS.xcscheme diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 642bc34..f80289f 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 */ @@ -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 = 8.0; + }; + 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 = 8.0; + }; + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + From c5639d620219a913e13a435ac2d1c9a97d409840 Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Fri, 17 Jul 2026 10:33:47 -0400 Subject: [PATCH 02/11] watch-from-stock: watchOS source guards + transport seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - G7BluetoothManager.swift: #if os(iOS) around the two CoreBluetooth state-restoration sites (restore-identifier init option, willRestoreState). watchOS branch passes options: nil; zero behavior change on iOS. Same recipe as the M2 OmnipodKit/RileyLinkBLEKit guards. - G7Sensor.swift: activationDate internal -> public (one line) — the minimal transport-injection seam so an external transport (the watch's direct-BLE G7Client) can drive the stock G7SensorDelegate flow into G7CGMManager. Co-Authored-By: Claude Fable 5 --- G7SensorKit/G7CGMManager/G7BluetoothManager.swift | 7 +++++++ G7SensorKit/G7CGMManager/G7Sensor.swift | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 3edb0e2..19b8500 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -136,7 +136,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 @@ -341,6 +346,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,6 +357,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { } } } +#endif func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { dispatchPrecondition(condition: .onQueue(managerQueue)) 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 From 3f0f71172bdb69a5f2560e29ba1ad923febc8d23 Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Sat, 15 Aug 2026 22:03:59 -0400 Subject: [PATCH 03/11] G7SensorKit-watchOS: deployment target 10.6 to match LoopKit Co-Authored-By: Claude Opus 5 --- G7SensorKit.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index f80289f..6a8f588 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -1176,7 +1176,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 8.0; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Debug; }; @@ -1208,7 +1208,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 8.0; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Release; }; From 331fc7c7b783f81c62e2ee0d1fd6f6fd8a891fec Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Fri, 17 Jul 2026 13:49:06 -0400 Subject: [PATCH 04/11] =?UTF-8?q?watch-from-stock:=20pin=20the=20iOS=20tar?= =?UTF-8?q?get's=20SDKROOT=20(PODLOAN)=20=E2=80=94=20implicit-dependency?= =?UTF-8?q?=20hijack=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stock iOS G7SensorKit framework target used SDKROOT = auto, which XCBuild treats as platform-specializable: the WatchApp Extension's name-based implicit dependency on G7SensorKit.framework then resolved to the iOS target instead of G7SensorKit-watchOS, dragging the iOS dependency closure's SwiftCharts package modulemap flags into the watchos build description (66x 'module map file ... GeneratedModuleMaps-watchos/SwiftCharts.modulemap not found'). Pin SDKROOT = iphoneos on the target's two configs — the shape LoopKit's iOS target already has, and zero effective change on iOS (SUPPORTED_PLATFORMS already pins iphoneos there). Full diagnosis: Loop/docs/M3_NOTES.md §7. Co-Authored-By: Claude Fable 5 --- G7SensorKit.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 6a8f588..09201ae 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -918,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; @@ -954,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; From be85b940d5127b6743478bb5221add8213c6c325 Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Mon, 10 Aug 2026 23:05:06 -0400 Subject: [PATCH 05/11] =?UTF-8?q?Fork=20#101:=20G7=20radio=20census=20?= =?UTF-8?q?=E2=80=94=20make=20the=20acquisition=20mechanism=20observable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08-10 held-pod-link finding (0 adoptions in 3 held-link windows, 2 adoptions within minutes of the 2 releases) was proven by toggle experiment but the mechanism stayed inferred: this layer logged only via os_log, invisible to the on-watch mirrored log the field analysis reads. G7RadioCensus.sink (public; watch wires it, phone stays os_log-only) now reports: - scan-start branch taken: retrieved-known / system-connected list (the literal D2W piggyback, trigger a — a timing lottery against D2W's ~10-20s windows) / scan armed - connection events (trigger b — the OS reporting D2W's own connects), ALWAYS logged - ad discoveries (trigger c), rate-limited to one line per peripheral per 30s — presence vs absence per window is the scan-starvation discriminator - didConnect with peripheral identity - at both unknownCharacteristic throw sites: the full discovered GATT inventory, which distinguishes partial-discovery-on-a-dropped-shared-link from wrong-GATT — the ambiguity that made three days of auth errors uninterpretable Co-Authored-By: Claude Fable 5 (cherry picked from commit ec73922d0ffdaf285d20d95ab2d6c861929fa67c) --- .../G7CGMManager/G7BluetoothManager.swift | 45 ++++++++++++++++++- .../G7CGMManager/G7PeripheralManager.swift | 17 +++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 19b8500..cbd587a 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -7,6 +7,12 @@ // 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. +public enum G7RadioCensus { + public static var sink: ((String) -> Void)? +} import Foundation import os.log @@ -124,6 +130,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() @@ -192,6 +213,10 @@ 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. + Self.census("connection-event \(event.rawValue == 1 ? "CONNECT" : "disconnect") \(peripheral.name ?? "unnamed") — \(self.activePeripheralIdentifier == nil ? "handling (trigger b)" : "ignored, have active")") if self.activePeripheralIdentifier == nil { self.log.default("Discovered peripheral from connectionEventDidOccur %{public}@", peripheral.identifier.uuidString) self.handleDiscoveredPeripheral(peripheral) @@ -213,12 +238,18 @@ class G7BluetoothManager: NSObject { if 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) } @@ -237,6 +268,7 @@ class G7BluetoothManager: NSObject { ], options: nil ) + Self.census("scan STARTED (trigger c armed) + connection-events registered (trigger b armed)") delegate?.bluetoothManagerScanningStatusDidChange(self) } } @@ -364,6 +396,14 @@ extension G7BluetoothManager: CBCentralManagerDelegate { 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. + if lastDiscoveryLog[peripheral.identifier].map({ Date().timeIntervalSince($0) > 30 }) ?? true { + lastDiscoveryLog[peripheral.identifier] = Date() + Self.census("ad DISCOVERED (trigger c) \(peripheral.name ?? "unnamed") rssi \(RSSI)") + } + managerQueue.async { self.handleDiscoveredPeripheral(peripheral) } @@ -371,6 +411,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) + Self.census("didConnect \(peripheral.name ?? "unnamed")") log.default("%{public}@: %{public}@", #function, peripheral) 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 { From b3d36165f8b7951975b4203ff45f5430ffa7c4e9 Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Tue, 11 Aug 2026 00:09:21 -0400 Subject: [PATCH 06/11] #101 phase 2: acquisition signals + churn fixes + census completion - G7RadioCensus publishes connectPendingSince / lastRideSignalAt (lock-protected) so the app can gate pod radio work on live G7 acquisition state - census lines at didFailToConnect + didDisconnect (the invisible failing half of the 23:31:52-59 churn cycle) - duplicate-connect guard in handleDiscoveredPeripheral (re-registered connection events re-fired CONNECT ~10/s during a failed ride) - scanAfterDelay single-flight (N failures scheduled N rescans) (cherry picked from commit 3a6e3f314d29162b185c19e3dd104cc7bfc2754d) --- .../G7CGMManager/G7BluetoothManager.swift | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index cbd587a..d0751f8 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -10,8 +10,46 @@ 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)? + + 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 @@ -216,6 +254,7 @@ class G7BluetoothManager: NSObject { // 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.census("connection-event \(event.rawValue == 1 ? "CONNECT" : "disconnect") \(peripheral.name ?? "unnamed") — \(self.activePeripheralIdentifier == nil ? "handling (trigger b)" : "ignored, have active")") if self.activePeripheralIdentifier == nil { self.log.default("Discovered peripheral from connectionEventDidOccur %{public}@", peripheral.identifier.uuidString) @@ -281,10 +320,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() } } @@ -314,6 +368,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: @@ -330,10 +394,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, @@ -399,6 +465,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { // 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() Self.census("ad DISCOVERED (trigger c) \(peripheral.name ?? "unnamed") rssi \(RSSI)") @@ -411,6 +478,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) + G7RadioCensus.noteConnectResolved() Self.census("didConnect \(peripheral.name ?? "unnamed")") log.default("%{public}@: %{public}@", #function, peripheral) @@ -428,6 +496,11 @@ 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() + Self.census("didDisconnect \(peripheral.name ?? "unnamed")\(error.map { " error=\($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 { @@ -456,6 +529,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 { " error=\($0.localizedDescription)" } ?? "")") log.error("%{public}@: %{public}@", #function, String(describing: error)) if let error = error, let peripheralManager = activePeripheralManager { From d7f6b42e2999f26489515dab294a5fe2c2be6742 Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Thu, 20 Aug 2026 01:33:28 -0400 Subject: [PATCH 07/11] Fix the 20-40 minute acquisition outage: arm the scan whenever not connected The known-sensor branch retrieved the peripheral and issued a bare pending connect() with NO scan armed (the arm condition was activePeripheral == nil, and the branch makes it non-nil). A pending connect rides bluetoothd's own duty-cycled background scan, which against the sensor's 1-4s advertising burst per 300s window is a lottery: measured 2026-08-19/20, seven consecutive windows missed, 20-40 min outages ending only when the sensor escalated to its ~60s distress cadence (also measured -- the 'exact 300s grid' is the collected regime only). Both collectors (ours and D2W) healed simultaneously because whoever finally connects, the other rides via trigger (b). The condition is now activePeripheral?.state != .connected: a scan is armed for the whole pending phase and catches the FIRST burst. didConnect stops it via readied -> managerQueue_stopScanning, and handleDiscoveredPeripheral's #101 guard makes a discovery-during-pending a no-op, so it cannot churn. Crude proved the same lesson: scan is the primitive. Plus the H14 scan watchdog: one full sensor window (320s) with acquisition armed and NOTHING delivered is deafness, not bad luck -- recycle the scan and any stuck pending connect, and say so in the census. Correct under every theory of the deafness. Plus the Radio Lab gates: G7Lab.trigger.a/b/c read at use, shipped defaults unchanged. --- .../G7CGMManager/G7BluetoothManager.swift | 81 ++++++++++++++++--- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index d0751f8..27aea73 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -149,6 +149,43 @@ class G7BluetoothManager: NSObject { /// Isolated to `managerQueue` private var managedPeripherals: [UUID:G7PeripheralManager] = [:] + // RADIO LAB (2026-08-20). Runtime gates over the three acquisition doorways, so experiments are a + // 10-second toggle instead of an install cycle. UserDefaults-read AT USE with the shipped default, + // the same pattern OmnipodKit's connectOnDemandEnabled has always used. Absent keys change nothing. + static var labRideEnabled: Bool { UserDefaults.standard.object(forKey: "G7Lab.trigger.a") as? Bool ?? true } + static var labEventsEnabled: Bool { UserDefaults.standard.object(forKey: "G7Lab.trigger.b") as? Bool ?? true } + static var labScanEnabled: Bool { UserDefaults.standard.object(forKey: "G7Lab.trigger.c") as? 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() + 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 @@ -255,6 +292,7 @@ class G7BluetoothManager: NSObject { // 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 self.activePeripheralIdentifier == nil { self.log.default("Discovered peripheral from connectionEventDidOccur %{public}@", peripheral.identifier.uuidString) @@ -275,7 +313,7 @@ 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) @@ -294,22 +332,37 @@ class G7BluetoothManager: NSObject { } } - 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 - ]]) - - centralManager.scanForPeripherals(withServices: [ - SensorServiceUUID.advertisement.cbUUID - ], - options: nil - ) - Self.census("scan STARTED (trigger c armed) + connection-events registered (trigger b armed)") + if Self.labEventsEnabled { + centralManager.registerForConnectionEvents(options: [CBConnectionEventMatchingOption.serviceUUIDs: [ + SensorServiceUUID.advertisement.cbUUID, + SensorServiceUUID.cgmService.cbUUID + ]]) + } + + 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() } /** @@ -468,6 +521,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { 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)") } @@ -479,6 +533,7 @@ 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) From 36abd3c5fa95e9e2fd7a859f35cab8655cde03af Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Thu, 20 Aug 2026 08:15:00 -0400 Subject: [PATCH 08/11] G7 watchdog: seed the delivery baseline so the first check is not 'infinity' --- G7SensorKit/G7CGMManager/G7BluetoothManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 27aea73..5e3d5d8 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -166,6 +166,7 @@ class G7BluetoothManager: NSObject { 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() } From 5a06477fc5176626c9ee8e1191333dec01c6721b Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Fri, 21 Aug 2026 22:49:51 -0400 Subject: [PATCH 09/11] G7RadioCensus: expose sensor sightings as a signal, not as prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from the pure/SportMode line. The census already logged every peripheral name it saw, but only inside sentences, so "which sensors are actually in range" could only be recovered by 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 — it learns a new sensor's ID only by talking to it, and it is busy failing authentication against a corpse. Fired from the two sites that hold a peripheral name: ad discovery and connection events. Co-Authored-By: Claude Opus 5 --- G7SensorKit/G7CGMManager/G7BluetoothManager.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 5e3d5d8..a3f720f 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -23,6 +23,16 @@ import CoreBluetooth 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? @@ -295,6 +305,7 @@ class G7BluetoothManager: NSObject { 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) @@ -524,6 +535,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { 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 { From 679da8c89df8e19129cd3ab0da9f9fbb07575d66 Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Sat, 22 Aug 2026 21:36:13 -0400 Subject: [PATCH 10/11] Census: [domain#code] on connect failures, not just prose From the pure line, where a grep for Code=11 returned zero while 34 connection-limit failures sat in the log as localizedDescription text. Co-Authored-By: Claude Opus 5 --- G7SensorKit/G7CGMManager/G7BluetoothManager.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index a3f720f..f318400 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -568,7 +568,9 @@ extension G7BluetoothManager: CBCentralManagerDelegate { // didConnect but neither terminal callback, so a ride that died looked identical // to one that never started. G7RadioCensus.noteConnectResolved() - Self.census("didDisconnect \(peripheral.name ?? "unnamed")\(error.map { " error=\($0.localizedDescription)" } ?? "")") + // [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 { @@ -598,7 +600,7 @@ 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 { " error=\($0.localizedDescription)" } ?? "")") + 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 { From 39230fd42f05391bb95553654653c860a975991b Mon Sep 17 00:00:00 2001 From: Jeremy Barnum Date: Tue, 25 Aug 2026 15:10:02 -0700 Subject: [PATCH 11/11] =?UTF-8?q?G7=20acquisition=20doorways=20settled=20?= =?UTF-8?q?=E2=80=94=20lab=20toggles=20hardcoded=20true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three (ride + events + scan) proven over weeks of field use; the UserDefaults reads are gone so a stale false from an old experiment can never silently disable a doorway. Accessors stay so call sites read as gates. Co-Authored-By: Claude Opus 5 --- G7SensorKit/G7CGMManager/G7BluetoothManager.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index f318400..412b785 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -159,12 +159,13 @@ class G7BluetoothManager: NSObject { /// Isolated to `managerQueue` private var managedPeripherals: [UUID:G7PeripheralManager] = [:] - // RADIO LAB (2026-08-20). Runtime gates over the three acquisition doorways, so experiments are a - // 10-second toggle instead of an install cycle. UserDefaults-read AT USE with the shipped default, - // the same pattern OmnipodKit's connectOnDemandEnabled has always used. Absent keys change nothing. - static var labRideEnabled: Bool { UserDefaults.standard.object(forKey: "G7Lab.trigger.a") as? Bool ?? true } - static var labEventsEnabled: Bool { UserDefaults.standard.object(forKey: "G7Lab.trigger.b") as? Bool ?? true } - static var labScanEnabled: Bool { UserDefaults.standard.object(forKey: "G7Lab.trigger.c") as? Bool ?? true } + // 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