diff --git a/Common/Locked.swift b/Common/Locked.swift index fd6e35b..cb05498 100644 --- a/Common/Locked.swift +++ b/Common/Locked.swift @@ -9,31 +9,40 @@ import os.lock internal class Locked { - private var lock = os_unfair_lock() + // A heap pointer rather than a stored struct: `&lock` on a property holds + // an inout access for as long as the thread blocks in os_unfair_lock_lock, + // and a second thread reaching `&lock` then trips Swift's exclusivity + // check ("Simultaneous accesses"). Apple documents the same pitfall. + private let lock: os_unfair_lock_t private var _value: T init(_ value: T) { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + lock = .allocate(capacity: 1) + lock.initialize(to: os_unfair_lock()) _value = value } + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + } + var value: T { get { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } return _value } set { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } _value = newValue } } func mutate(_ changes: (_ value: inout T) -> Void) -> T { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } changes(&_value) return _value } diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index ef97315..c0cad76 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -7,15 +7,48 @@ objects = { /* Begin PBXBuildFile section */ + 0BE316E9E980C04C39842D94 /* G7SensorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A301AE5854C83E5BD9C6539 /* G7SensorModel.swift */; }; + 0C557E84497DE72C92503098 /* G7AlertsFromLoopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A62D050A4DCCED5F4A51F9 /* G7AlertsFromLoopView.swift */; }; + 0F2F5FD6CB203E12707A95F0 /* G7SensorPackageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AE2890ABEAE0FB78B276F1 /* G7SensorPackageTests.swift */; }; + 149BC17BD2172421ABE67CD5 /* TransmitterVersionMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5018527436D26E887380354 /* TransmitterVersionMessage.swift */; }; + 14BC59D876A3473596F29B02 /* G7SensorRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9BEFF95BF6608CE8B1A7B2 /* G7SensorRecord.swift */; }; + 1689B0403302877CE35C8BF3 /* TransmitterVersionMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9491D07D366D9B6E1486F1F6 /* TransmitterVersionMessageTests.swift */; }; + 23C825825ED9241FDEE8B666 /* G7BigUInt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B28BDB6307B48D8B1DA03E3 /* G7BigUInt.swift */; }; + 276A1D9AF75BA8A64DA82CC0 /* G7SensorModel+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DE9588AA9650F31A029AC0 /* G7SensorModel+Image.swift */; }; + 2A6FED527A83B3BFB84624E3 /* G7PairingSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846E1ADD7624F7057E63E28C /* G7PairingSuccessView.swift */; }; + 2C8F7D1C21CDF25E11497035 /* G7PairingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C17FD748A65DD33BE4B62E /* G7PairingService.swift */; }; + 2ED1C8E18DD717A8FB98CD3C /* G7AES.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C50AD3678079467EE110CD1 /* G7AES.swift */; }; + 3105BF303A53E64B9C14C4DE /* G7LifecycleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7244B8E7EFF1898AF50B85EB /* G7LifecycleBar.swift */; }; + 310BA65F04D790184F86A2AF /* G7PreviousSensorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A3F076AE2157B22DE43B91A /* G7PreviousSensorView.swift */; }; 3B0FD2A52D803BF100E5E921 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B0FD2A42D803BF000E5E921 /* LoopKitUI.framework */; }; + 3C0E7E072DEBAD9E27C4E3E8 /* G7LifecycleAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BAFBA741A5D33EDA31E9122 /* G7LifecycleAlert.swift */; }; + 3EDC19C8067352F7FBC21C26 /* G7DisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB0ED7F4C52EF2B7DCF8DFFB /* G7DisplayType.swift */; }; + 49563257D2DEE29AABBFEC7D /* G7P256.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081992BE86DC6126B0151B4F /* G7P256.swift */; }; + 576A7AA8FC9457FE0F540A86 /* G7Authenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899E19256E3224A54FA35AD8 /* G7Authenticator.swift */; }; + 58C2B6119E4710D970C3AB5D /* G7PairingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D3DBE7E0F724F99BFB7BC0 /* G7PairingViewModel.swift */; }; + 64F873CAFE778918BD2F749A /* G7EnterCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854B2FDB6A6325716E4FD46D /* G7EnterCodeView.swift */; }; + 69AE26EEA16A596AA47DF83A /* G7DexcomAppWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ED0ADBD1E5197198A0B215 /* G7DexcomAppWarningView.swift */; }; + 70DD3285A1241531F66439E3 /* G7DexcomCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31D1424E75ED6303B966EF89 /* G7DexcomCredentials.swift */; }; + 72A6F9D68F93AE44814C3C3D /* G7StartupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090FCA4FBE539EA494492C16 /* G7StartupView.swift */; }; + 74F6EF71F734A96B0DB47F52 /* G7PeripheralManager+Handshake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6180916611D0990A46355025 /* G7PeripheralManager+Handshake.swift */; }; + 77A93A0B05D5ADEDF217770B /* G7PairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7CEC5E1884364C2F6DCCF4 /* G7PairingView.swift */; }; + 79901713849AC6091E281008 /* G7ApplySensorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AAC94118FFDBD4B3850EE26 /* G7ApplySensorView.swift */; }; + 86DE2F2A3F8F5B73AFED5E75 /* G7CalibrationFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D820703F56DC84E12221E63A /* G7CalibrationFlowView.swift */; }; + 8BB9F821830FA00C1939FFE3 /* G7JPAKE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E50F6710E2494A0E14A6D8 /* G7JPAKE.swift */; }; + 8C80F065A36E33C97219C570 /* G7P256Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64134474341A32F8DFDA266F /* G7P256Tests.swift */; }; + 97F1FB097E42B4F376FA206F /* G7PairingPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F6EDAE3E7F2B5FF3E87922 /* G7PairingPlannerTests.swift */; }; + A3C2FD34AC1FB93C9DE1A066 /* G7BigUIntTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8990966223D8FEFF85D5D535 /* G7BigUIntTests.swift */; }; + A49D784AE6D465FF59F20CA7 /* G7PackageScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56525F710EBD017C3949A51 /* G7PackageScannerView.swift */; }; + AEF5658ACA06903991663159 /* G7LifecycleAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 724358361262BEA3A8F375AA /* G7LifecycleAlertTests.swift */; }; + B139368E33ED6ED8694408FC /* G7SessionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAA6FF14E0E9C16A25FD3B6 /* G7SessionMode.swift */; }; B60BB2E42BC649DA00D2BB39 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60BB2E32BC649DA00D2BB39 /* Bundle.swift */; }; B66D1F6D2E6A803800471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F6C2E6A803800471149 /* Localizable.xcstrings */; }; B66D1F6F2E6A803800471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F6E2E6A803800471149 /* Localizable.xcstrings */; }; + C068D4AE8B0154298062E34C /* G7DexcomApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8667803A1805893E8701B7 /* G7DexcomApp.swift */; }; C107607F2F059130008B2B39 /* ExtendedVersionMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */; }; C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */; }; C109F14A291ECCE2008EA5B6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C109F149291ECCE2008EA5B6 /* Assets.xcassets */; }; C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */; }; - C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */; }; C1409A07291EC21C006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5126291EAF2F00555EB5 /* OSLog.swift */; }; C1409A09291EC22F006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A08291EC22F006BE8D0 /* OSLog.swift */; }; C1409A0B291EC258006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A0A291EC258006BE8D0 /* OSLog.swift */; }; @@ -38,7 +71,6 @@ C17F50F2291EAC6500555EB5 /* G7GlucoseMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F50E8291EAC6500555EB5 /* G7GlucoseMessage.swift */; }; C17F50FB291EAC9100555EB5 /* G7SensorKitUI.h in Headers */ = {isa = PBXBuildFile; fileRef = C17F50FA291EAC9100555EB5 /* G7SensorKitUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; C17F5106291EAC9D00555EB5 /* G7SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5100291EAC9D00555EB5 /* G7SettingsViewModel.swift */; }; - C17F5107291EAC9D00555EB5 /* G7StartupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5101291EAC9D00555EB5 /* G7StartupView.swift */; }; C17F5108291EAC9D00555EB5 /* G7SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5102291EAC9D00555EB5 /* G7SettingsView.swift */; }; C17F5109291EAC9D00555EB5 /* G7CGMManager+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5103291EAC9D00555EB5 /* G7CGMManager+UI.swift */; }; C17F510A291EAC9D00555EB5 /* G7UICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5104291EAC9D00555EB5 /* G7UICoordinator.swift */; }; @@ -59,7 +91,21 @@ C17F5156291EBD8600555EB5 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5155291EBD8600555EB5 /* Image.swift */; }; C17F5157291EBD9900555EB5 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F513F291EB27D00555EB5 /* TimeInterval.swift */; }; C19C9F4E29C91C4C00A6D3D0 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19C9F4D29C91C4C00A6D3D0 /* LocalizedString.swift */; }; + C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */; }; C1E71720292D84FE00DA646F /* G7ProgressBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E7171F292D84FE00DA646F /* G7ProgressBarState.swift */; }; + CF8E76B21D92A5ED046EBA26 /* G7Advertisement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C635B2D3F90E42F5907A39 /* G7Advertisement.swift */; }; + D2F6C2D8999636C3D4F31DB1 /* G7AdvertisementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742542EDF25C573BA2E8B09A /* G7AdvertisementTests.swift */; }; + D3CEBAE892F7B88160295F5F /* G7CalibrationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA8EE3244D1FE42461E792D /* G7CalibrationRecord.swift */; }; + D3DA1DF230DBADA4CB37F401 /* G7SessionModeMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588CB2A449236A6A47F4579B /* G7SessionModeMigrationTests.swift */; }; + DDCA164A5054CBE3AF748726 /* G7PairingPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7028829033065F083529D6A8 /* G7PairingPlanner.swift */; }; + E18DCCF3754672CBF129B139 /* G7PCert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F8C967DDB24EB158A1A45 /* G7PCert.swift */; }; + E654B56A0D3A74C9199EE1B7 /* G7SensorPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADB2921131FCF7BE4BAAA78 /* G7SensorPackage.swift */; }; + EA756ED51BB567AB19020B47 /* G7CalibrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DDCCED9AACFF7A7EF879459 /* G7CalibrationTests.swift */; }; + EE80FBB62E9FF65D5946BBD7 /* G7ChallengeSigner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A771C4DCFFA924E278E352 /* G7ChallengeSigner.swift */; }; + EF41639FF51B727324A04A49 /* G7NotificationPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BAC6D6BA20FCF904FA1465 /* G7NotificationPermissionsView.swift */; }; + F5F8174E48028D9B111B8C59 /* G7JPAKETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888359A74F9F636FBDCA8BB /* G7JPAKETests.swift */; }; + F998BB980898C160A2440473 /* G7AuthCryptoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7A4E8FF66BE03B02B2E01 /* G7AuthCryptoTests.swift */; }; + FD0DF29C00B05DECD53A4E2C /* G7CalibrationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF238DACCD930310363D3EB5 /* G7CalibrationMessage.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -109,15 +155,52 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 081992BE86DC6126B0151B4F /* G7P256.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7P256.swift; sourceTree = ""; }; + 090FCA4FBE539EA494492C16 /* G7StartupView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7StartupView.swift; sourceTree = ""; }; + 0BAFBA741A5D33EDA31E9122 /* G7LifecycleAlert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7LifecycleAlert.swift; sourceTree = ""; }; + 11ED0ADBD1E5197198A0B215 /* G7DexcomAppWarningView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7DexcomAppWarningView.swift; sourceTree = ""; }; + 14C635B2D3F90E42F5907A39 /* G7Advertisement.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7Advertisement.swift; sourceTree = ""; }; + 1AAC94118FFDBD4B3850EE26 /* G7ApplySensorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7ApplySensorView.swift; sourceTree = ""; }; + 21F6EDAE3E7F2B5FF3E87922 /* G7PairingPlannerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingPlannerTests.swift; sourceTree = ""; }; + 2A301AE5854C83E5BD9C6539 /* G7SensorModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorModel.swift; sourceTree = ""; }; + 31D1424E75ED6303B966EF89 /* G7DexcomCredentials.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7DexcomCredentials.swift; sourceTree = ""; }; + 324F8C967DDB24EB158A1A45 /* G7PCert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PCert.swift; sourceTree = ""; }; 3B0FD2A42D803BF000E5E921 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 43AE2890ABEAE0FB78B276F1 /* G7SensorPackageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorPackageTests.swift; sourceTree = ""; }; + 4A3F076AE2157B22DE43B91A /* G7PreviousSensorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PreviousSensorView.swift; sourceTree = ""; }; + 4CAA6FF14E0E9C16A25FD3B6 /* G7SessionMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SessionMode.swift; sourceTree = ""; }; + 588CB2A449236A6A47F4579B /* G7SessionModeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SessionModeMigrationTests.swift; sourceTree = ""; }; + 6180916611D0990A46355025 /* G7PeripheralManager+Handshake.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G7PeripheralManager+Handshake.swift"; sourceTree = ""; }; + 63E50F6710E2494A0E14A6D8 /* G7JPAKE.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7JPAKE.swift; sourceTree = ""; }; + 64134474341A32F8DFDA266F /* G7P256Tests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7P256Tests.swift; sourceTree = ""; }; + 6888359A74F9F636FBDCA8BB /* G7JPAKETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7JPAKETests.swift; sourceTree = ""; }; + 6C50AD3678079467EE110CD1 /* G7AES.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7AES.swift; sourceTree = ""; }; + 7028829033065F083529D6A8 /* G7PairingPlanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingPlanner.swift; sourceTree = ""; }; + 724358361262BEA3A8F375AA /* G7LifecycleAlertTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7LifecycleAlertTests.swift; sourceTree = ""; }; + 7244B8E7EFF1898AF50B85EB /* G7LifecycleBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7LifecycleBar.swift; sourceTree = ""; }; + 72D3DBE7E0F724F99BFB7BC0 /* G7PairingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingViewModel.swift; sourceTree = ""; }; + 742542EDF25C573BA2E8B09A /* G7AdvertisementTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7AdvertisementTests.swift; sourceTree = ""; }; + 7B28BDB6307B48D8B1DA03E3 /* G7BigUInt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7BigUInt.swift; sourceTree = ""; }; + 846E1ADD7624F7057E63E28C /* G7PairingSuccessView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingSuccessView.swift; sourceTree = ""; }; + 854B2FDB6A6325716E4FD46D /* G7EnterCodeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7EnterCodeView.swift; sourceTree = ""; }; + 8990966223D8FEFF85D5D535 /* G7BigUIntTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7BigUIntTests.swift; sourceTree = ""; }; + 899E19256E3224A54FA35AD8 /* G7Authenticator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7Authenticator.swift; sourceTree = ""; }; + 8BA8EE3244D1FE42461E792D /* G7CalibrationRecord.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7CalibrationRecord.swift; sourceTree = ""; }; + 8DDCCED9AACFF7A7EF879459 /* G7CalibrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7CalibrationTests.swift; sourceTree = ""; }; + 9491D07D366D9B6E1486F1F6 /* TransmitterVersionMessageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransmitterVersionMessageTests.swift; sourceTree = ""; }; + 94A7A4E8FF66BE03B02B2E01 /* G7AuthCryptoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7AuthCryptoTests.swift; sourceTree = ""; }; + 9F9BEFF95BF6608CE8B1A7B2 /* G7SensorRecord.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorRecord.swift; sourceTree = ""; }; + A56525F710EBD017C3949A51 /* G7PackageScannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PackageScannerView.swift; sourceTree = ""; }; + B1A771C4DCFFA924E278E352 /* G7ChallengeSigner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7ChallengeSigner.swift; sourceTree = ""; }; + B1DE9588AA9650F31A029AC0 /* G7SensorModel+Image.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G7SensorModel+Image.swift"; sourceTree = ""; }; B60BB2E32BC649DA00D2BB39 /* Bundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Bundle.swift; path = G7SensorKitUI/Extensions/Bundle.swift; sourceTree = SOURCE_ROOT; }; B66D1F6C2E6A803800471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B66D1F6E2E6A803800471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + BF7CEC5E1884364C2F6DCCF4 /* G7PairingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingView.swift; sourceTree = ""; }; C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessage.swift; sourceTree = ""; }; C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessageTests.swift; sourceTree = ""; }; C109F149291ECCE2008EA5B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7GlucoseMessageTests.swift; sourceTree = ""; }; - C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7CGMManagerTests.swift; sourceTree = ""; }; C1409A08291EC22F006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C1409A0A291EC258006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C17F50C6291EAC3800555EB5 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -137,7 +220,6 @@ C17F50F8291EAC9100555EB5 /* G7SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G7SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C17F50FA291EAC9100555EB5 /* G7SensorKitUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = G7SensorKitUI.h; sourceTree = ""; }; C17F5100291EAC9D00555EB5 /* G7SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7SettingsViewModel.swift; sourceTree = ""; }; - C17F5101291EAC9D00555EB5 /* G7StartupView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7StartupView.swift; sourceTree = ""; }; C17F5102291EAC9D00555EB5 /* G7SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7SettingsView.swift; sourceTree = ""; }; C17F5103291EAC9D00555EB5 /* G7CGMManager+UI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "G7CGMManager+UI.swift"; sourceTree = ""; }; C17F5104291EAC9D00555EB5 /* G7UICoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7UICoordinator.swift; sourceTree = ""; }; @@ -159,7 +241,17 @@ C17F5155291EBD8600555EB5 /* Image.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; 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 = ""; }; + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7CGMManagerTests.swift; sourceTree = ""; }; C1E7171F292D84FE00DA646F /* G7ProgressBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7ProgressBarState.swift; sourceTree = ""; }; + D820703F56DC84E12221E63A /* G7CalibrationFlowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7CalibrationFlowView.swift; sourceTree = ""; }; + DF238DACCD930310363D3EB5 /* G7CalibrationMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7CalibrationMessage.swift; sourceTree = ""; }; + E4C17FD748A65DD33BE4B62E /* G7PairingService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PairingService.swift; sourceTree = ""; }; + EADB2921131FCF7BE4BAAA78 /* G7SensorPackage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SensorPackage.swift; sourceTree = ""; }; + ED8667803A1805893E8701B7 /* G7DexcomApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7DexcomApp.swift; sourceTree = ""; }; + F2A62D050A4DCCED5F4A51F9 /* G7AlertsFromLoopView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7AlertsFromLoopView.swift; sourceTree = ""; }; + F3BAC6D6BA20FCF904FA1465 /* G7NotificationPermissionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7NotificationPermissionsView.swift; sourceTree = ""; }; + F5018527436D26E887380354 /* TransmitterVersionMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransmitterVersionMessage.swift; sourceTree = ""; }; + FB0ED7F4C52EF2B7DCF8DFFB /* G7DisplayType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7DisplayType.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -199,6 +291,47 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 916AA24FF5580EF7EF04D7B5 /* Crypto */ = { + isa = PBXGroup; + children = ( + 7B28BDB6307B48D8B1DA03E3 /* G7BigUInt.swift */, + 081992BE86DC6126B0151B4F /* G7P256.swift */, + 6C50AD3678079467EE110CD1 /* G7AES.swift */, + B1A771C4DCFFA924E278E352 /* G7ChallengeSigner.swift */, + 31D1424E75ED6303B966EF89 /* G7DexcomCredentials.swift */, + 63E50F6710E2494A0E14A6D8 /* G7JPAKE.swift */, + 324F8C967DDB24EB158A1A45 /* G7PCert.swift */, + ); + name = Crypto; + path = Crypto; + sourceTree = ""; + }; + 99878E3FDAD9DC23C298BD37 /* Calibration */ = { + isa = PBXGroup; + children = ( + D820703F56DC84E12221E63A /* G7CalibrationFlowView.swift */, + ); + name = Calibration; + path = Calibration; + sourceTree = ""; + }; + B85965DB1CB0331072FFC730 /* Onboarding */ = { + isa = PBXGroup; + children = ( + 11ED0ADBD1E5197198A0B215 /* G7DexcomAppWarningView.swift */, + 854B2FDB6A6325716E4FD46D /* G7EnterCodeView.swift */, + A56525F710EBD017C3949A51 /* G7PackageScannerView.swift */, + 846E1ADD7624F7057E63E28C /* G7PairingSuccessView.swift */, + BF7CEC5E1884364C2F6DCCF4 /* G7PairingView.swift */, + 090FCA4FBE539EA494492C16 /* G7StartupView.swift */, + 1AAC94118FFDBD4B3850EE26 /* G7ApplySensorView.swift */, + F2A62D050A4DCCED5F4A51F9 /* G7AlertsFromLoopView.swift */, + F3BAC6D6BA20FCF904FA1465 /* G7NotificationPermissionsView.swift */, + ); + name = Onboarding; + path = Onboarding; + sourceTree = ""; + }; C17F50BC291EAC3800555EB5 = { isa = PBXGroup; children = ( @@ -236,6 +369,8 @@ C17F5139291EB0D900555EB5 /* GlucoseLimits.swift */, C17F5141291EB34800555EB5 /* Messages */, C1409A08291EC22F006BE8D0 /* OSLog.swift */, + 916AA24FF5580EF7EF04D7B5 /* Crypto */, + EB2C6BB0C231173F9D111A4B /* Pairing */, ); path = G7SensorKit; sourceTree = ""; @@ -247,6 +382,17 @@ C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */, C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */, C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */, + 8990966223D8FEFF85D5D535 /* G7BigUIntTests.swift */, + 64134474341A32F8DFDA266F /* G7P256Tests.swift */, + 94A7A4E8FF66BE03B02B2E01 /* G7AuthCryptoTests.swift */, + 6888359A74F9F636FBDCA8BB /* G7JPAKETests.swift */, + 588CB2A449236A6A47F4579B /* G7SessionModeMigrationTests.swift */, + 742542EDF25C573BA2E8B09A /* G7AdvertisementTests.swift */, + 21F6EDAE3E7F2B5FF3E87922 /* G7PairingPlannerTests.swift */, + 43AE2890ABEAE0FB78B276F1 /* G7SensorPackageTests.swift */, + 724358361262BEA3A8F375AA /* G7LifecycleAlertTests.swift */, + 9491D07D366D9B6E1486F1F6 /* TransmitterVersionMessageTests.swift */, + 8DDCCED9AACFF7A7EF879459 /* G7CalibrationTests.swift */, ); path = G7SensorKitTests; sourceTree = ""; @@ -262,6 +408,13 @@ C17F50E2291EAC6500555EB5 /* G7LastReading.swift */, C17F50E4291EAC6500555EB5 /* G7PeripheralManager.swift */, C17F50E1291EAC6500555EB5 /* G7Sensor.swift */, + 899E19256E3224A54FA35AD8 /* G7Authenticator.swift */, + 6180916611D0990A46355025 /* G7PeripheralManager+Handshake.swift */, + 4CAA6FF14E0E9C16A25FD3B6 /* G7SessionMode.swift */, + 0BAFBA741A5D33EDA31E9122 /* G7LifecycleAlert.swift */, + 2A301AE5854C83E5BD9C6539 /* G7SensorModel.swift */, + 9F9BEFF95BF6608CE8B1A7B2 /* G7SensorRecord.swift */, + 8BA8EE3244D1FE42461E792D /* G7CalibrationRecord.swift */, ); path = G7CGMManager; sourceTree = ""; @@ -277,6 +430,8 @@ C17F50FA291EAC9100555EB5 /* G7SensorKitUI.h */, C17F5126291EAF2F00555EB5 /* OSLog.swift */, C19C9F4D29C91C4C00A6D3D0 /* LocalizedString.swift */, + ED8667803A1805893E8701B7 /* G7DexcomApp.swift */, + E54DEC336F7D9CB4EC087ADF /* ViewModels */, ); path = G7SensorKitUI; sourceTree = ""; @@ -328,6 +483,9 @@ C17F5146291EB57700555EB5 /* SensorMessage.swift */, C17F50E8291EAC6500555EB5 /* G7GlucoseMessage.swift */, C17F5142291EB36700555EB5 /* AuthChallengeRxMessage.swift */, + F5018527436D26E887380354 /* TransmitterVersionMessage.swift */, + DF238DACCD930310363D3EB5 /* G7CalibrationMessage.swift */, + FB0ED7F4C52EF2B7DCF8DFFB /* G7DisplayType.swift */, ); path = Messages; sourceTree = ""; @@ -337,6 +495,7 @@ children = ( B60BB2E32BC649DA00D2BB39 /* Bundle.swift */, C17F5155291EBD8600555EB5 /* Image.swift */, + B1DE9588AA9650F31A029AC0 /* G7SensorModel+Image.swift */, ); path = Extensions; sourceTree = ""; @@ -346,12 +505,36 @@ children = ( C17F5102291EAC9D00555EB5 /* G7SettingsView.swift */, C17F5100291EAC9D00555EB5 /* G7SettingsViewModel.swift */, - C17F5101291EAC9D00555EB5 /* G7StartupView.swift */, C1E7171F292D84FE00DA646F /* G7ProgressBarState.swift */, + B85965DB1CB0331072FFC730 /* Onboarding */, + 7244B8E7EFF1898AF50B85EB /* G7LifecycleBar.swift */, + 4A3F076AE2157B22DE43B91A /* G7PreviousSensorView.swift */, + 99878E3FDAD9DC23C298BD37 /* Calibration */, ); path = Views; sourceTree = ""; }; + E54DEC336F7D9CB4EC087ADF /* ViewModels */ = { + isa = PBXGroup; + children = ( + 72D3DBE7E0F724F99BFB7BC0 /* G7PairingViewModel.swift */, + ); + name = ViewModels; + path = ViewModels; + sourceTree = ""; + }; + EB2C6BB0C231173F9D111A4B /* Pairing */ = { + isa = PBXGroup; + children = ( + 14C635B2D3F90E42F5907A39 /* G7Advertisement.swift */, + 7028829033065F083529D6A8 /* G7PairingPlanner.swift */, + EADB2921131FCF7BE4BAAA78 /* G7SensorPackage.swift */, + E4C17FD748A65DD33BE4B62E /* G7PairingService.swift */, + ); + name = Pairing; + path = Pairing; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -591,6 +774,27 @@ C17F50EB291EAC6500555EB5 /* G7Sensor.swift in Sources */, C17F50F2291EAC6500555EB5 /* G7GlucoseMessage.swift in Sources */, C17F50EE291EAC6500555EB5 /* G7PeripheralManager.swift in Sources */, + 23C825825ED9241FDEE8B666 /* G7BigUInt.swift in Sources */, + 49563257D2DEE29AABBFEC7D /* G7P256.swift in Sources */, + 2ED1C8E18DD717A8FB98CD3C /* G7AES.swift in Sources */, + EE80FBB62E9FF65D5946BBD7 /* G7ChallengeSigner.swift in Sources */, + 70DD3285A1241531F66439E3 /* G7DexcomCredentials.swift in Sources */, + 8BB9F821830FA00C1939FFE3 /* G7JPAKE.swift in Sources */, + E18DCCF3754672CBF129B139 /* G7PCert.swift in Sources */, + 576A7AA8FC9457FE0F540A86 /* G7Authenticator.swift in Sources */, + 74F6EF71F734A96B0DB47F52 /* G7PeripheralManager+Handshake.swift in Sources */, + B139368E33ED6ED8694408FC /* G7SessionMode.swift in Sources */, + CF8E76B21D92A5ED046EBA26 /* G7Advertisement.swift in Sources */, + DDCA164A5054CBE3AF748726 /* G7PairingPlanner.swift in Sources */, + E654B56A0D3A74C9199EE1B7 /* G7SensorPackage.swift in Sources */, + 2C8F7D1C21CDF25E11497035 /* G7PairingService.swift in Sources */, + 3C0E7E072DEBAD9E27C4E3E8 /* G7LifecycleAlert.swift in Sources */, + 0BE316E9E980C04C39842D94 /* G7SensorModel.swift in Sources */, + 149BC17BD2172421ABE67CD5 /* TransmitterVersionMessage.swift in Sources */, + 14BC59D876A3473596F29B02 /* G7SensorRecord.swift in Sources */, + D3CEBAE892F7B88160295F5F /* G7CalibrationRecord.swift in Sources */, + FD0DF29C00B05DECD53A4E2C /* G7CalibrationMessage.swift in Sources */, + 3EDC19C8067352F7FBC21C26 /* G7DisplayType.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -602,6 +806,17 @@ C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */, C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */, C17F50D4291EAC3800555EB5 /* G7SensorKitTests.swift in Sources */, + A3C2FD34AC1FB93C9DE1A066 /* G7BigUIntTests.swift in Sources */, + 8C80F065A36E33C97219C570 /* G7P256Tests.swift in Sources */, + F998BB980898C160A2440473 /* G7AuthCryptoTests.swift in Sources */, + F5F8174E48028D9B111B8C59 /* G7JPAKETests.swift in Sources */, + D3DA1DF230DBADA4CB37F401 /* G7SessionModeMigrationTests.swift in Sources */, + D2F6C2D8999636C3D4F31DB1 /* G7AdvertisementTests.swift in Sources */, + 97F1FB097E42B4F376FA206F /* G7PairingPlannerTests.swift in Sources */, + 0F2F5FD6CB203E12707A95F0 /* G7SensorPackageTests.swift in Sources */, + AEF5658ACA06903991663159 /* G7LifecycleAlertTests.swift in Sources */, + 1689B0403302877CE35C8BF3 /* TransmitterVersionMessageTests.swift in Sources */, + EA756ED51BB567AB19020B47 /* G7CalibrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -617,9 +832,23 @@ C1409A07291EC21C006BE8D0 /* OSLog.swift in Sources */, C17F510A291EAC9D00555EB5 /* G7UICoordinator.swift in Sources */, C17F5109291EAC9D00555EB5 /* G7CGMManager+UI.swift in Sources */, - C17F5107291EAC9D00555EB5 /* G7StartupView.swift in Sources */, C1E71720292D84FE00DA646F /* G7ProgressBarState.swift in Sources */, C17F5106291EAC9D00555EB5 /* G7SettingsViewModel.swift in Sources */, + C068D4AE8B0154298062E34C /* G7DexcomApp.swift in Sources */, + 58C2B6119E4710D970C3AB5D /* G7PairingViewModel.swift in Sources */, + 69AE26EEA16A596AA47DF83A /* G7DexcomAppWarningView.swift in Sources */, + 64F873CAFE778918BD2F749A /* G7EnterCodeView.swift in Sources */, + A49D784AE6D465FF59F20CA7 /* G7PackageScannerView.swift in Sources */, + 2A6FED527A83B3BFB84624E3 /* G7PairingSuccessView.swift in Sources */, + 77A93A0B05D5ADEDF217770B /* G7PairingView.swift in Sources */, + 72A6F9D68F93AE44814C3C3D /* G7StartupView.swift in Sources */, + 79901713849AC6091E281008 /* G7ApplySensorView.swift in Sources */, + 276A1D9AF75BA8A64DA82CC0 /* G7SensorModel+Image.swift in Sources */, + 3105BF303A53E64B9C14C4DE /* G7LifecycleBar.swift in Sources */, + 310BA65F04D790184F86A2AF /* G7PreviousSensorView.swift in Sources */, + 0C557E84497DE72C92503098 /* G7AlertsFromLoopView.swift in Sources */, + EF41639FF51B727324A04A49 /* G7NotificationPermissionsView.swift in Sources */, + 86DE2F2A3F8F5B73AFED5E75 /* G7CalibrationFlowView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/G7SensorKit/AlgorithmState.swift b/G7SensorKit/AlgorithmState.swift index 1bce93f..6607abe 100644 --- a/G7SensorKit/AlgorithmState.swift +++ b/G7SensorKit/AlgorithmState.swift @@ -37,6 +37,10 @@ public enum AlgorithmState: RawRepresentable { case sensorFailedDueToRestart = 22 case expired = 24 case sensorFailed = 25 + // Reported by newer firmware (first seen on Stelo). + case transmitterFailed = 27 + case sivFailed = 28 + case sessionFailedOutOfRange = 29 case sessionEnded = 26 } @@ -67,7 +71,7 @@ public enum AlgorithmState: RawRepresentable { } switch state { - case .sensorFailed, .sensorFailedDuetoCountsAberration, .sensorFailedDuetoResidualAberration, .sessionFailedDueToTransmitterError, .sessionFailedDueToUnrecoverableError, .sensorFailedDueToProgressiveSensorDecline, .sensorFailedDueToHighCountsAberration, .sensorFailedDueToLowCountsAberration, .sensorFailedDueToRestart: + case .sensorFailed, .sensorFailedDuetoCountsAberration, .sensorFailedDuetoResidualAberration, .sessionFailedDueToTransmitterError, .sessionFailedDueToUnrecoverableError, .sensorFailedDueToProgressiveSensorDecline, .sensorFailedDueToHighCountsAberration, .sensorFailedDueToLowCountsAberration, .sensorFailedDueToRestart, .transmitterFailed, .sivFailed, .sessionFailedOutOfRange: return true default: return false diff --git a/G7SensorKit/BluetoothServices.swift b/G7SensorKit/BluetoothServices.swift index cb17342..a472209 100644 --- a/G7SensorKit/BluetoothServices.swift +++ b/G7SensorKit/BluetoothServices.swift @@ -35,6 +35,12 @@ enum CGMServiceCharacteristicUUID: String, CBUUIDRawValue { // Read/Write/Notify case backfill = "F8083536-849E-531C-C594-30F1F86A4EA5" + + /// Write/Notify. Carries the bulk payloads of the direct pairing + /// handshake (J-PAKE round certificates, X.509 certificates, the key + /// challenge signature), streamed in 20-byte chunks while the + /// authentication characteristic carries the framing. + case certificate = "F8083538-849E-531C-C594-30F1F86A4EA5" } @@ -54,6 +60,7 @@ extension G7PeripheralManager.Configuration { CGMServiceCharacteristicUUID.authentication.cbUUID, CGMServiceCharacteristicUUID.control.cbUUID, CGMServiceCharacteristicUUID.backfill.cbUUID, + CGMServiceCharacteristicUUID.certificate.cbUUID, ] ], notifyingCharacteristics: [:], diff --git a/G7SensorKit/Crypto/G7AES.swift b/G7SensorKit/Crypto/G7AES.swift new file mode 100644 index 0000000..af6f786 --- /dev/null +++ b/G7SensorKit/Crypto/G7AES.swift @@ -0,0 +1,58 @@ +// +// G7AES.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CommonCrypto +import Foundation + +enum G7AESError: Error { + case invalidKeyLength(Int) + case invalidInputLength(Int) + case cryptoFailure(CCCryptorStatus) +} + +enum G7AES { + + /// Dexcom's challenge transform: the 8-byte challenge is repeated to fill + /// one AES block, encrypted under the session key in ECB mode, and the + /// first 8 bytes of the result are the answer. Both sides compute it, and + /// a mismatch means the two ends do not share a key. + static func encryptChallenge(_ challenge: Data, key: Data) throws -> Data { + guard key.count == kCCKeySizeAES128 else { + throw G7AESError.invalidKeyLength(key.count) + } + guard challenge.count == 8 else { + throw G7AESError.invalidInputLength(challenge.count) + } + + let block = challenge + challenge + let outputCount = block.count + var output = Data(count: outputCount) + var bytesWritten = 0 + + let status = output.withUnsafeMutableBytes { outputBuffer in + block.withUnsafeBytes { inputBuffer in + key.withUnsafeBytes { keyBuffer in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionECBMode), + keyBuffer.baseAddress, key.count, + nil, + inputBuffer.baseAddress, block.count, + outputBuffer.baseAddress, outputCount, + &bytesWritten + ) + } + } + } + + guard status == kCCSuccess else { + throw G7AESError.cryptoFailure(status) + } + return output.prefix(8) + } +} diff --git a/G7SensorKit/Crypto/G7BigUInt.swift b/G7SensorKit/Crypto/G7BigUInt.swift new file mode 100644 index 0000000..fb7e728 --- /dev/null +++ b/G7SensorKit/Crypto/G7BigUInt.swift @@ -0,0 +1,328 @@ +// +// G7BigUInt.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Minimal arbitrary-precision unsigned integer, sized for the 256- and +/// 512-bit values P-256 curve arithmetic needs. +/// +/// Little-endian 32-bit limbs: `limbs[0]` is least significant, and the +/// representation is always normalized, so zero is the empty array and +/// `limbs.last` is never 0. +/// +/// This exists so the G7 pairing handshake can do elliptic-curve arithmetic +/// without a third-party big-integer package. CryptoKit exposes no primitive +/// point addition or arbitrary-scalar multiplication, and a Loop plugin that +/// links SwiftPM products needs the host app to embed them or it fails to +/// load at runtime. +struct G7BigUInt: Equatable, Comparable, CustomStringConvertible { + + private(set) var limbs: [UInt32] + + static let zero = G7BigUInt() + static let one = G7BigUInt(1) + + // MARK: - Creation + + init() { + limbs = [] + } + + init(_ value: UInt32) { + limbs = value == 0 ? [] : [value] + } + + /// Takes limbs least-significant first and normalizes them. + init(limbs: [UInt32]) { + var limbs = limbs + while limbs.last == 0 { + limbs.removeLast() + } + self.limbs = limbs + } + + /// Interprets `data` as a big-endian unsigned integer. + init(bigEndianBytes data: Data) { + var limbs = [UInt32]() + limbs.reserveCapacity((data.count + 3) / 4) + var accumulator: UInt32 = 0 + var shift: UInt32 = 0 + for byte in data.reversed() { + accumulator |= UInt32(byte) << shift + shift += 8 + if shift == 32 { + limbs.append(accumulator) + accumulator = 0 + shift = 0 + } + } + if shift > 0 { + limbs.append(accumulator) + } + self.init(limbs: limbs) + } + + /// Big-endian bytes, left-zero-padded to `length`. Truncates from the + /// most significant end if the value does not fit, which never happens + /// for values already reduced modulo a 256-bit modulus. + func bigEndianBytes(paddedTo length: Int) -> Data { + var bytes = [UInt8]() + bytes.reserveCapacity(limbs.count * 4) + for limb in limbs { + bytes.append(UInt8(truncatingIfNeeded: limb)) + bytes.append(UInt8(truncatingIfNeeded: limb >> 8)) + bytes.append(UInt8(truncatingIfNeeded: limb >> 16)) + bytes.append(UInt8(truncatingIfNeeded: limb >> 24)) + } + while bytes.last == 0 { + bytes.removeLast() + } + bytes.reverse() + + if bytes.count >= length { + return Data(bytes.suffix(length)) + } + return Data(repeating: 0, count: length - bytes.count) + Data(bytes) + } + + // MARK: - Inspection + + var isZero: Bool { + limbs.isEmpty + } + + var isEven: Bool { + limbs.first.map { $0 & 1 == 0 } ?? true + } + + /// Position of the most significant set bit, plus one. Zero for zero. + var bitWidth: Int { + guard let top = limbs.last else { + return 0 + } + return limbs.count * 32 - Int(top.leadingZeroBitCount) + } + + func bit(at index: Int) -> Bool { + let limbIndex = index / 32 + guard limbIndex < limbs.count else { + return false + } + return limbs[limbIndex] >> UInt32(index % 32) & 1 == 1 + } + + var description: String { + isZero ? "0x0" : "0x" + bigEndianBytes(paddedTo: (bitWidth + 7) / 8).hexadecimalString + } + + // MARK: - Comparison + + static func < (lhs: G7BigUInt, rhs: G7BigUInt) -> Bool { + if lhs.limbs.count != rhs.limbs.count { + return lhs.limbs.count < rhs.limbs.count + } + for index in stride(from: lhs.limbs.count - 1, through: 0, by: -1) where lhs.limbs[index] != rhs.limbs[index] { + return lhs.limbs[index] < rhs.limbs[index] + } + return false + } + + // MARK: - Arithmetic + + static func + (lhs: G7BigUInt, rhs: G7BigUInt) -> G7BigUInt { + var result = [UInt32]() + result.reserveCapacity(max(lhs.limbs.count, rhs.limbs.count) + 1) + var carry: UInt64 = 0 + for index in 0 ..< max(lhs.limbs.count, rhs.limbs.count) { + let sum = UInt64(index < lhs.limbs.count ? lhs.limbs[index] : 0) + + UInt64(index < rhs.limbs.count ? rhs.limbs[index] : 0) + + carry + result.append(UInt32(truncatingIfNeeded: sum)) + carry = sum >> 32 + } + if carry > 0 { + result.append(UInt32(carry)) + } + return G7BigUInt(limbs: result) + } + + /// Truncating subtraction. `lhs` must be at least `rhs`; the callers here + /// all compare first, and a borrow out of the top limb would silently + /// produce a wrapped value. + static func - (lhs: G7BigUInt, rhs: G7BigUInt) -> G7BigUInt { + precondition(lhs >= rhs, "G7BigUInt subtraction would underflow") + var result = [UInt32]() + result.reserveCapacity(lhs.limbs.count) + var borrow: Int64 = 0 + for index in 0 ..< lhs.limbs.count { + let difference = Int64(lhs.limbs[index]) + - Int64(index < rhs.limbs.count ? rhs.limbs[index] : 0) + - borrow + if difference < 0 { + result.append(UInt32(truncatingIfNeeded: difference + 0x1_0000_0000)) + borrow = 1 + } else { + result.append(UInt32(difference)) + borrow = 0 + } + } + return G7BigUInt(limbs: result) + } + + static func * (lhs: G7BigUInt, rhs: G7BigUInt) -> G7BigUInt { + guard !lhs.isZero, !rhs.isZero else { + return .zero + } + var result = [UInt32](repeating: 0, count: lhs.limbs.count + rhs.limbs.count) + for i in 0 ..< lhs.limbs.count { + var carry: UInt64 = 0 + let a = UInt64(lhs.limbs[i]) + for j in 0 ..< rhs.limbs.count { + let product = a * UInt64(rhs.limbs[j]) + UInt64(result[i + j]) + carry + result[i + j] = UInt32(truncatingIfNeeded: product) + carry = product >> 32 + } + var index = i + rhs.limbs.count + while carry > 0 { + let sum = UInt64(result[index]) + carry + result[index] = UInt32(truncatingIfNeeded: sum) + carry = sum >> 32 + index += 1 + } + } + return G7BigUInt(limbs: result) + } + + static func << (lhs: G7BigUInt, shift: Int) -> G7BigUInt { + guard !lhs.isZero, shift > 0 else { + return lhs + } + let limbShift = shift / 32 + let bitShift = UInt32(shift % 32) + var result = [UInt32](repeating: 0, count: limbShift) + var carry: UInt32 = 0 + for limb in lhs.limbs { + result.append(bitShift == 0 ? limb : (limb << bitShift) | carry) + carry = bitShift == 0 ? 0 : limb >> (32 - bitShift) + } + if carry > 0 { + result.append(carry) + } + return G7BigUInt(limbs: result) + } + + static func >> (lhs: G7BigUInt, shift: Int) -> G7BigUInt { + guard !lhs.isZero, shift > 0 else { + return lhs + } + let limbShift = shift / 32 + guard limbShift < lhs.limbs.count else { + return .zero + } + let bitShift = UInt32(shift % 32) + var result = Array(lhs.limbs[limbShift...]) + if bitShift > 0 { + for index in 0 ..< result.count { + let high = index + 1 < result.count ? result[index + 1] << (32 - bitShift) : 0 + result[index] = (result[index] >> bitShift) | high + } + } + return G7BigUInt(limbs: result) + } + + // MARK: - Division + + /// Binary long division. Chosen over Knuth D for auditability: the values + /// here are at most 512 bits and every hot-path reduction goes through + /// `G7P256`'s Solinas reduction instead. + func quotientAndRemainder(dividingBy divisor: G7BigUInt) -> (quotient: G7BigUInt, remainder: G7BigUInt) { + precondition(!divisor.isZero, "G7BigUInt division by zero") + if self < divisor { + return (.zero, self) + } + + var quotient = [UInt32](repeating: 0, count: limbs.count) + var remainder = G7BigUInt.zero + for index in stride(from: bitWidth - 1, through: 0, by: -1) { + remainder = remainder << 1 + if bit(at: index) { + remainder = remainder + .one + } + if remainder >= divisor { + remainder = remainder - divisor + quotient[index / 32] |= 1 << UInt32(index % 32) + } + } + return (G7BigUInt(limbs: quotient), remainder) + } + + func modulo(_ modulus: G7BigUInt) -> G7BigUInt { + if self < modulus { + return self + } + return quotientAndRemainder(dividingBy: modulus).remainder + } + + // MARK: - Modular arithmetic + + /// Both operands must already be reduced modulo `modulus`. + static func addMod(_ lhs: G7BigUInt, _ rhs: G7BigUInt, _ modulus: G7BigUInt) -> G7BigUInt { + let sum = lhs + rhs + return sum >= modulus ? sum - modulus : sum + } + + /// Both operands must already be reduced modulo `modulus`. + static func subMod(_ lhs: G7BigUInt, _ rhs: G7BigUInt, _ modulus: G7BigUInt) -> G7BigUInt { + lhs >= rhs ? lhs - rhs : modulus - (rhs - lhs) + } + + static func mulMod(_ lhs: G7BigUInt, _ rhs: G7BigUInt, _ modulus: G7BigUInt) -> G7BigUInt { + (lhs * rhs).modulo(modulus) + } + + /// Modular inverse by the binary extended Euclidean algorithm, which + /// requires an odd modulus. Both P-256 moduli (the field prime and the + /// group order) are odd primes. Returns nil when no inverse exists. + func inverse(modulo modulus: G7BigUInt) -> G7BigUInt? { + precondition(!modulus.isEven, "G7BigUInt modular inverse requires an odd modulus") + var u = modulo(modulus) + guard !u.isZero else { + return nil + } + var v = modulus + var x1 = G7BigUInt.one + var x2 = G7BigUInt.zero + + // Halving x under an odd modulus: if x is odd, adding the modulus + // makes it even without changing the residue. + func halve(_ x: G7BigUInt) -> G7BigUInt { + x.isEven ? (x >> 1) : ((x + modulus) >> 1) + } + + while u != .one, v != .one { + while u.isEven { + u = u >> 1 + x1 = halve(x1) + } + while v.isEven { + v = v >> 1 + x2 = halve(x2) + } + if u.isZero || v.isZero { + return nil + } + if u >= v { + u = u - v + x1 = G7BigUInt.subMod(x1, x2, modulus) + } else { + v = v - u + x2 = G7BigUInt.subMod(x2, x1, modulus) + } + } + return (u == .one ? x1 : x2).modulo(modulus) + } +} diff --git a/G7SensorKit/Crypto/G7ChallengeSigner.swift b/G7SensorKit/Crypto/G7ChallengeSigner.swift new file mode 100644 index 0000000..d2d677a --- /dev/null +++ b/G7SensorKit/Crypto/G7ChallengeSigner.swift @@ -0,0 +1,44 @@ +// +// G7ChallengeSigner.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CryptoKit +import Foundation + +enum G7ChallengeSignerError: Error { + case challengeTooShort(Int) +} + +/// Answers the sensor's key challenge, the last proof of identity before it +/// agrees to bond. +/// +/// The sensor sends a 0x0C acknowledgement carrying 16 bytes to sign; we +/// return an ECDSA-P256 signature over exactly those bytes, made with the +/// fixed key whose certificate we uploaded moments earlier. +enum G7ChallengeSigner { + + private static let privateKey: P256.Signing.PrivateKey = { + // The key material is a compile-time constant, so a failure here is a + // build mistake rather than a runtime condition. + // swiftlint:disable:next force_try + try! P256.Signing.PrivateKey(rawRepresentation: G7DexcomCredentials.challengePrivateKey) + }() + + static var publicKey: Data { + G7DexcomCredentials.challengePublicKey + } + + /// Signs the 16 payload bytes of the sensor's `0C` acknowledgement, + /// returning the 64-byte raw (r ‖ s) signature. + static func sign(challengeAcknowledgement: Data) throws -> Data { + guard challengeAcknowledgement.count >= 18 else { + throw G7ChallengeSignerError.challengeTooShort(challengeAcknowledgement.count) + } + let start = challengeAcknowledgement.startIndex + 2 + let payload = challengeAcknowledgement[start ..< start + 16] + return try privateKey.signature(for: payload).rawRepresentation + } +} diff --git a/G7SensorKit/Crypto/G7DexcomCredentials.swift b/G7SensorKit/Crypto/G7DexcomCredentials.swift new file mode 100644 index 0000000..63eb57a --- /dev/null +++ b/G7SensorKit/Crypto/G7DexcomCredentials.swift @@ -0,0 +1,82 @@ +// +// G7DexcomCredentials.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Fixed credential material the sensor expects during the certificate phase +/// of pairing. +/// +/// These are protocol constants, not our secrets: a sensor only completes +/// pairing with a display presenting exactly these bytes, so they are as much +/// a part of the wire format as the service UUIDs. They were recovered from +/// Dexcom's own apps by the community reverse-engineering effort (Juggluco) +/// and are reproduced here as data. +/// +/// The leaf certificate expired 2025-04-13. Sensors do not enforce leaf +/// expiry, so this is not a bug to fix. +enum G7DexcomCredentials { + + /// The two DER-encoded X.509 certificates, in the order the sensor asks + /// for them (index 0, then index 1). + static var certificates: [Data] { + [certificate0, certificate1] + } + + /// The fixed P-256 signing key used to answer the sensor's key challenge. + /// Stored as 31 bytes; CryptoKit wants the full 32-byte big-endian + /// scalar, so it is left-padded with a zero byte. + static let challengePrivateKey: Data = { + let raw = Data(hexadecimalString: "7cfbd596f6e74477b8c0e9f6f7a174275e101ef6bf7d18caf01181d127b579")! + assert(raw.count == 31) + return Data([0x00]) + raw + }() + + /// The matching uncompressed public key (`04 || x || y`), which is also + /// the subject public key of `certificate1`. + static let challengePublicKey = Data(hexadecimalString: [ + "045118C35E9E41E7E0654FEE801C52A9C5DFC510EF09597D5CCA8461E4AF9C66", + "6714834F2BC903F16FABFC45755B0183F1A09745CDFFCB4E2F799E50BED9A6B5", + "8C" + ].joined())! + + private static let certificate0 = Data(hexadecimalString: [ + "308201EA3082018FA00302010202142F3C52B6EB08701046D45D78CE81784C9D", + "FE5240300A06082A8648CE3D04030230133111300F06035504030C0844455830", + "30504731301E170D3230313033303135353930345A170D333531303237313535", + "3930345A30133111300F06035504030C0844455830335047313059301306072A", + "8648CE3D020106082A8648CE3D03010703420004FB1ACA21D8AEEC9A4EB51F85", + "304953D977A1AD569799250FF863987F42A3CD9FA4FF571EB568BC6C396277C3", + "DCB51DEDAEE85513C80A5C4435538A19F5A96348A381C03081BD300F0603551D", + "130101FF040530030101FF301F0603551D230418301680149E0F1E36F3F276A7", + "01FE8E883A6E26A635BD6AFC305A0603551D1F04533051304FA034A032863068", + "7474703A2F2F63726C2E64702E736161732E7072696D656B65792E636F6D2F63", + "726C2F44455830305047312E63726CA217A41530133111300F06035504030C08", + "4445583030504731301D0603551D0E0416041488F61E81BC4B17F05C6B1BE299", + "1D60087CCEDD79300E0603551D0F0101FF040403020186300A06082A8648CE3D", + "0403020349003046022100AA69CD897EC663AF5F9E158187DF6851FF0756F00C", + "401624564F81A19F5A0785022100DAEBB9FDB163B731EB0661F1C0A1932871A5", + "0E399AD1C6F519EABD4C9E7BA013" + ].joined())! + + private static let certificate1 = Data(hexadecimalString: [ + "308201CD30820174A003020102021419052FCC17530BFA56E49DCAFCDACF853C", + "E5BA73300A06082A8648CE3D04030230133111300F06035504030C0844455830", + "33504731301E170D3233303431343130323831345A170D323530343133313032", + "3831335A303A3138303606035504030C2F30312C303030302C303330304C5145", + "43437A4142417741412C63696F69653356625132686C5A4D6A64556D35726741", + "3059301306072A8648CE3D020106082A8648CE3D030107034200045118C35E9E", + "41E7E0654FEE801C52A9C5DFC510EF09597D5CCA8461E4AF9C666714834F2BC9", + "03F16FABFC45755B0183F1A09745CDFFCB4E2F799E50BED9A6B58CA37F307D30", + "0C0603551D130101FF04023000301F0603551D2304183016801488F61E81BC4B", + "17F05C6B1BE2991D60087CCEDD79301D0603551D250416301406082B06010505", + "07030206082B06010505070301301D0603551D0E04160414D309E75C0725412D", + "7A7922E3AACFB27F7EBD6BE0300E0603551D0F0101FF0404030205A0300A0608", + "2A8648CE3D0403020347003044022048D4868CF393D9044101B6F07FD68D7F06", + "42805F85DA74E2FE9DE8DD3507F02702201CD1BF7C6C7EDD59435E324925FCF0", + "EBB3CAE2110D79407C77AA3B93B7BC04CB" + ].joined())! +} diff --git a/G7SensorKit/Crypto/G7JPAKE.swift b/G7SensorKit/Crypto/G7JPAKE.swift new file mode 100644 index 0000000..67af3d8 --- /dev/null +++ b/G7SensorKit/Crypto/G7JPAKE.swift @@ -0,0 +1,209 @@ +// +// G7JPAKE.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CryptoKit +import Foundation + +enum G7JPAKEError: Error { + /// `makeRound3` or `deriveSharedKey` was called before both of our own + /// ephemeral keypairs existed. + case outOfOrder +} + +/// Client side of the password-authenticated key exchange a G7-family sensor +/// runs during pairing: an EC-JPAKE over P-256 whose low-entropy secret is +/// the 4-digit code printed on the sensor's applicator. +/// +/// The exchange proves both sides know the code without either sending it, +/// and ends with a 256-bit agreed secret whose first 16 bytes become the +/// AES-128 key that authenticates every later reconnect. +/// +/// Deviations from textbook EC-JPAKE, all of them things the sensor requires: +/// the party identifiers are fixed strings, the round-3 proof uses a fixed +/// randomizer rather than a fresh one, and the transcript hash covers +/// length-prefixed uncompressed points in a specific order. +final class G7JPAKE { + + typealias RandomBytesGenerator = (Int) -> Data + + /// Party identifier attached to proofs we generate. + private static let ownParty = Array("client".utf8) + + /// Party identifier attached to proofs the sensor generates. + private static let peerParty: [UInt8] = [0x37, 0x56, 0x27, 0x67, 0x56, 0x27] + + /// The randomizer the sensor expects in our round-3 proof. Fixed, not + /// drawn fresh: the sensor reproduces this value when it verifies. + private static let round3Randomizer = G7BigUInt(bigEndianBytes: Data(hexadecimalString: + "fbc971b837e9491e45a4179ed33865c508a1e0a1d350f5af0f96370695fdc393")!) + + /// The pairing code as a big-endian integer over its ASCII digits. + private let pin: G7BigUInt + private let random: RandomBytesGenerator + + private var keyPair1: (privateKey: G7BigUInt, publicKey: G7P256Point)? + private var keyPair2: (privateKey: G7BigUInt, publicKey: G7P256Point)? + + init(pairingCode: String, random: @escaping RandomBytesGenerator = G7JPAKE.secureRandomBytes) { + pin = G7BigUInt(bigEndianBytes: Data(pairingCode.utf8)) + self.random = random + } + + static func secureRandomBytes(_ count: Int) -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) + guard status == errSecSuccess else { + // SecRandomCopyBytes does not fail in practice; if it ever did, + // silently continuing with a weak nonce would be worse than a + // crash during pairing. + preconditionFailure("SecRandomCopyBytes failed with \(status)") + } + return Data(bytes) + } + + // MARK: - Our rounds + + /// Our first ephemeral keypair, encoded for the wire. + func makeRound1() -> Data { + let pair = makeKeyPair() + keyPair1 = pair + return makeCert(base: G7P256.generator, publicKey: pair.publicKey, privateKey: pair.privateKey).encoded + } + + /// Our second ephemeral keypair, encoded for the wire. + func makeRound2() -> Data { + let pair = makeKeyPair() + keyPair2 = pair + return makeCert(base: G7P256.generator, publicKey: pair.publicKey, privateKey: pair.privateKey).encoded + } + + /// Our key-confirmation round, computed over both of the sensor's rounds. + func makeRound3(peerRound1: G7PCert, peerRound2: G7PCert) throws -> Data { + guard let keyPair1 = keyPair1, let keyPair2 = keyPair2 else { + throw G7JPAKEError.outOfOrder + } + let blindedKey = G7BigUInt.mulMod(keyPair2.privateKey, pin, G7P256.order) + let base = G7P256.add( + G7P256.add(keyPair1.publicKey, peerRound1.publicKey), + peerRound2.publicKey + ) + let publicKey = G7P256.multiply(base, by: blindedKey) + return makeCert( + base: base, + publicKey: publicKey, + privateKey: blindedKey, + randomizer: G7JPAKE.round3Randomizer + ).encoded + } + + /// The agreed secret: SHA-256 of the x-coordinate of the shared point. + /// The first 16 bytes are the AES-128 session key. + func deriveSharedSecret(peerRound2: G7PCert, peerRound3: G7PCert) throws -> Data { + guard let keyPair2 = keyPair2 else { + throw G7JPAKEError.outOfOrder + } + // Strip our own blinding from the sensor's confirmation value, then + // apply our round-2 private key to land on the shared point. + let blindedKey = G7BigUInt.mulMod(keyPair2.privateKey, pin, G7P256.order) + let unblind = G7BigUInt.subMod(.zero, blindedKey, G7P256.order) + let shared = G7P256.multiply( + G7P256.add(peerRound3.publicKey, G7P256.multiply(peerRound2.publicKey, by: unblind)), + by: keyPair2.privateKey + ) + return Data(SHA256.hash(data: shared.x.bigEndianBytes(paddedTo: 32))) + } + + // MARK: - Verifying the sensor's proofs + + /// Whether a round-1 or round-2 cert from the sensor carries a valid + /// proof. Advisory: a sensor that fails this still pairs, and the AES + /// challenge later in the handshake is the real gate, so callers log + /// rather than abort. + func validateRound1Or2(_ cert: G7PCert) -> Bool { + verifyProof(base: G7P256.generator, cert: cert, party: G7JPAKE.peerParty) + } + + /// Whether the sensor's round-3 cert carries a valid proof. Advisory, + /// same as `validateRound1Or2`. + func validateRound3(peerRound1: G7PCert, peerRound3: G7PCert) -> Bool { + guard let keyPair1 = keyPair1, let keyPair2 = keyPair2 else { + return false + } + let base = G7P256.add( + G7P256.add(keyPair1.publicKey, keyPair2.publicKey), + peerRound1.publicKey + ) + return verifyProof(base: base, cert: peerRound3, party: G7JPAKE.peerParty) + } + + // MARK: - Internals + + private func makeKeyPair() -> (privateKey: G7BigUInt, publicKey: G7P256Point) { + let privateKey = randomScalar() + return (privateKey, G7P256.multiplyGenerator(by: privateKey)) + } + + /// Uniform in [1, order - 2], the range the sensor's own implementation uses. + private func randomScalar() -> G7BigUInt { + let upper = G7P256.order - G7BigUInt(2) + return G7BigUInt(bigEndianBytes: random(32)).modulo(upper) + .one + } + + private func makeCert( + base: G7P256Point, + publicKey: G7P256Point, + privateKey: G7BigUInt, + randomizer: G7BigUInt? = nil + ) -> G7PCert { + let randomizer = randomizer ?? randomScalar() + let proofPoint = G7P256.multiply(base, by: randomizer) + let challenge = transcriptHash(base: base, proofPoint: proofPoint, publicKey: publicKey, party: G7JPAKE.ownParty) + let proof = G7BigUInt.subMod( + randomizer, + G7BigUInt.mulMod(challenge, privateKey, G7P256.order), + G7P256.order + ) + return G7PCert(publicKey: publicKey, proofPoint: proofPoint, proof: proof) + } + + /// Schnorr verification: `base * proof + publicKey * challenge` must + /// reproduce the committed proof point. + private func verifyProof(base: G7P256Point, cert: G7PCert, party: [UInt8]) -> Bool { + let challenge = transcriptHash( + base: base, + proofPoint: cert.proofPoint, + publicKey: cert.publicKey, + party: party + ) + let recomputed = G7P256.add( + G7P256.multiply(base, by: cert.proof), + G7P256.multiply(cert.publicKey, by: challenge) + ) + return recomputed == cert.proofPoint + } + + /// SHA-256 over the three points and the party identifier, each prefixed + /// with its 4-byte big-endian length, reduced into the scalar field. + private func transcriptHash( + base: G7P256Point, + proofPoint: G7P256Point, + publicKey: G7P256Point, + party: [UInt8] + ) -> G7BigUInt { + var buffer = Data() + for point in [base, proofPoint, publicKey] { + appendLengthPrefixed([UInt8](point.uncompressedBytes), to: &buffer) + } + appendLengthPrefixed(party, to: &buffer) + return G7BigUInt(bigEndianBytes: Data(SHA256.hash(data: buffer))).modulo(G7P256.order) + } + + private func appendLengthPrefixed(_ bytes: [UInt8], to buffer: inout Data) { + buffer.appendBigEndian(UInt32(bytes.count)) + buffer.append(contentsOf: bytes) + } +} diff --git a/G7SensorKit/Crypto/G7P256.swift b/G7SensorKit/Crypto/G7P256.swift new file mode 100644 index 0000000..ddab445 --- /dev/null +++ b/G7SensorKit/Crypto/G7P256.swift @@ -0,0 +1,283 @@ +// +// G7P256.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// A point on the NIST P-256 curve in affine coordinates. +struct G7P256Point: Equatable { + let x: G7BigUInt + let y: G7BigUInt + let isInfinity: Bool + + init(x: G7BigUInt, y: G7BigUInt) { + self.x = x + self.y = y + isInfinity = false + } + + private init() { + x = .zero + y = .zero + isInfinity = true + } + + static let infinity = G7P256Point() + + /// SEC1 uncompressed encoding: `04 || x || y`, each coordinate padded to + /// 32 bytes. This is the form the G7 handshake hashes and transmits. + var uncompressedBytes: Data { + guard !isInfinity else { + return Data([0x00]) + } + return Data([0x04]) + x.bigEndianBytes(paddedTo: 32) + y.bigEndianBytes(paddedTo: 32) + } +} + +/// NIST P-256 (secp256r1) arithmetic. +/// +/// Hand-rolled rather than taken from a package: see `G7BigUInt`. Everything +/// here is standard published curve math, and `G7P256Tests` pins it against +/// CryptoKit, which can independently produce `k * G` for any scalar `k`. +enum G7P256 { + + /// Field prime: 2^256 - 2^224 + 2^192 + 2^96 - 1 + static let p = G7BigUInt(bigEndianBytes: Data(hexadecimalString: + "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF")!) + + /// Group order. + static let order = G7BigUInt(bigEndianBytes: Data(hexadecimalString: + "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551")!) + + /// Curve coefficient b. (a is -3 mod p, folded into the doubling formula.) + static let b = G7BigUInt(bigEndianBytes: Data(hexadecimalString: + "5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B")!) + + static let generator = G7P256Point( + x: G7BigUInt(bigEndianBytes: Data(hexadecimalString: + "6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296")!), + y: G7BigUInt(bigEndianBytes: Data(hexadecimalString: + "4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5")!) + ) + + // MARK: - Field arithmetic + + static func fieldAdd(_ lhs: G7BigUInt, _ rhs: G7BigUInt) -> G7BigUInt { + G7BigUInt.addMod(lhs, rhs, p) + } + + static func fieldSub(_ lhs: G7BigUInt, _ rhs: G7BigUInt) -> G7BigUInt { + G7BigUInt.subMod(lhs, rhs, p) + } + + static func fieldMul(_ lhs: G7BigUInt, _ rhs: G7BigUInt) -> G7BigUInt { + reduce(lhs * rhs) + } + + static func fieldSquare(_ value: G7BigUInt) -> G7BigUInt { + reduce(value * value) + } + + static func fieldInverse(_ value: G7BigUInt) -> G7BigUInt? { + value.inverse(modulo: p) + } + + /// Solinas reduction for the P-256 prime (FIPS 186-4, D.2.3): the prime's + /// shape lets a 512-bit product be folded into nine 256-bit terms with + /// only additions and subtractions, avoiding a long division per multiply. + /// + /// `G7P256Tests` cross-checks this against generic `modulo(p)` on random + /// inputs, which is the guard against a transcription slip in the table. + static func reduce(_ product: G7BigUInt) -> G7BigUInt { + guard product.bitWidth > 256 else { + return product < p ? product : product.modulo(p) + } + + var c = product.limbs + guard c.count <= 16 else { + // Only reachable if a caller multiplies unreduced operands. + return product.modulo(p) + } + c.append(contentsOf: [UInt32](repeating: 0, count: 16 - c.count)) + + // Each row lists word indices most significant first; nil is a zero word. + let s1 = compose(c, [7, 6, 5, 4, 3, 2, 1, 0]) + let s2 = compose(c, [15, 14, 13, 12, 11, nil, nil, nil]) + let s3 = compose(c, [nil, 15, 14, 13, 12, nil, nil, nil]) + let s4 = compose(c, [15, 14, nil, nil, nil, 10, 9, 8]) + let s5 = compose(c, [8, 13, 15, 14, 13, 11, 10, 9]) + let s6 = compose(c, [10, 8, nil, nil, nil, 13, 12, 11]) + let s7 = compose(c, [11, 9, nil, nil, 15, 14, 13, 12]) + let s8 = compose(c, [12, nil, 10, 9, 8, 15, 14, 13]) + let s9 = compose(c, [13, nil, 11, 10, 9, nil, 15, 14]) + + let positive = s1 + (s2 << 1) + (s3 << 1) + s4 + s5 + let negative = s6 + s7 + s8 + s9 + + // Both sides stay under 8p, so repeated subtraction beats a division. + return G7BigUInt.subMod(smallReduce(positive), smallReduce(negative), p) + } + + /// Reduces a value known to be a small multiple of `p` above the field. + private static func smallReduce(_ value: G7BigUInt) -> G7BigUInt { + var value = value + var guardCount = 0 + while value >= p { + value = value - p + guardCount += 1 + if guardCount > 16 { + // Unreachable for Solinas inputs; fall back rather than spin. + return value.modulo(p) + } + } + return value + } + + /// Builds a 256-bit value from `c` given word indices most significant first. + private static func compose(_ c: [UInt32], _ indices: [Int?]) -> G7BigUInt { + G7BigUInt(limbs: indices.reversed().map { index in + guard let index = index else { return 0 } + return c[index] + }) + } + + // MARK: - Point arithmetic + + /// Jacobian projective coordinates: (X, Y, Z) is the affine point + /// (X/Z², Y/Z³). Used internally so a scalar multiplication needs one + /// modular inversion at the end instead of one per bit. + private struct Jacobian { + var x: G7BigUInt + var y: G7BigUInt + var z: G7BigUInt + + var isInfinity: Bool { + z.isZero + } + + static let infinity = Jacobian(x: .one, y: .one, z: .zero) + } + + private static func jacobian(from point: G7P256Point) -> Jacobian { + point.isInfinity ? .infinity : Jacobian(x: point.x, y: point.y, z: .one) + } + + private static func affine(from point: Jacobian) -> G7P256Point { + guard !point.isInfinity, let zInverse = fieldInverse(point.z) else { + return .infinity + } + let zInverse2 = fieldSquare(zInverse) + let zInverse3 = fieldMul(zInverse2, zInverse) + return G7P256Point(x: fieldMul(point.x, zInverse2), y: fieldMul(point.y, zInverse3)) + } + + /// Point doubling, using the a = -3 shortcut ("dbl-2001-b"). + private static func double(_ point: Jacobian) -> Jacobian { + guard !point.isInfinity, !point.y.isZero else { + return .infinity + } + let delta = fieldSquare(point.z) + let gamma = fieldSquare(point.y) + let beta = fieldMul(point.x, gamma) + let alpha = fieldMul( + G7BigUInt(3), + fieldMul(fieldSub(point.x, delta), fieldAdd(point.x, delta)) + ) + let eightBeta = fieldMul(G7BigUInt(8), beta) + let x = fieldSub(fieldSquare(alpha), eightBeta) + let z = fieldSub(fieldSub(fieldSquare(fieldAdd(point.y, point.z)), gamma), delta) + let y = fieldSub( + fieldMul(alpha, fieldSub(fieldMul(G7BigUInt(4), beta), x)), + fieldMul(G7BigUInt(8), fieldSquare(gamma)) + ) + return Jacobian(x: x, y: y, z: z) + } + + /// Point addition ("add-2007-bl"). + private static func add(_ lhs: Jacobian, _ rhs: Jacobian) -> Jacobian { + if lhs.isInfinity { + return rhs + } + if rhs.isInfinity { + return lhs + } + + let z1z1 = fieldSquare(lhs.z) + let z2z2 = fieldSquare(rhs.z) + let u1 = fieldMul(lhs.x, z2z2) + let u2 = fieldMul(rhs.x, z1z1) + let s1 = fieldMul(lhs.y, fieldMul(rhs.z, z2z2)) + let s2 = fieldMul(rhs.y, fieldMul(lhs.z, z1z1)) + + if u1 == u2 { + return s1 == s2 ? double(lhs) : .infinity + } + + let h = fieldSub(u2, u1) + let i = fieldSquare(fieldMul(G7BigUInt(2), h)) + let j = fieldMul(h, i) + let r = fieldMul(G7BigUInt(2), fieldSub(s2, s1)) + let v = fieldMul(u1, i) + + let x = fieldSub(fieldSub(fieldSquare(r), j), fieldMul(G7BigUInt(2), v)) + let y = fieldSub( + fieldMul(r, fieldSub(v, x)), + fieldMul(G7BigUInt(2), fieldMul(s1, j)) + ) + let z = fieldMul( + fieldSub(fieldSub(fieldSquare(fieldAdd(lhs.z, rhs.z)), z1z1), z2z2), + h + ) + return Jacobian(x: x, y: y, z: z) + } + + // MARK: - Public operations + + static func add(_ lhs: G7P256Point, _ rhs: G7P256Point) -> G7P256Point { + affine(from: add(jacobian(from: lhs), jacobian(from: rhs))) + } + + /// Left-to-right double-and-add. Not constant time: the values being + /// multiplied here are ephemeral handshake scalars on a link that is + /// already physically local, and the alternative is a much larger + /// implementation to audit. + static func multiply(_ point: G7P256Point, by scalar: G7BigUInt) -> G7P256Point { + let scalar = scalar.modulo(order) + guard !scalar.isZero, !point.isInfinity else { + return .infinity + } + + let base = jacobian(from: point) + var result = Jacobian.infinity + for index in stride(from: scalar.bitWidth - 1, through: 0, by: -1) { + result = double(result) + if scalar.bit(at: index) { + result = add(result, base) + } + } + return affine(from: result) + } + + static func multiplyGenerator(by scalar: G7BigUInt) -> G7P256Point { + multiply(generator, by: scalar) + } + + /// Whether `point` satisfies y² = x³ - 3x + b over the field. + static func isOnCurve(_ point: G7P256Point) -> Bool { + guard !point.isInfinity else { + return true + } + guard point.x < p, point.y < p else { + return false + } + let left = fieldSquare(point.y) + let right = fieldAdd( + fieldSub(fieldMul(fieldSquare(point.x), point.x), fieldMul(G7BigUInt(3), point.x)), + b + ) + return left == right + } +} diff --git a/G7SensorKit/Crypto/G7PCert.swift b/G7SensorKit/Crypto/G7PCert.swift new file mode 100644 index 0000000..c669566 --- /dev/null +++ b/G7SensorKit/Crypto/G7PCert.swift @@ -0,0 +1,56 @@ +// +// G7PCert.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +enum G7PCertError: Error { + case invalidLength(Int) +} + +/// One round of the sensor's EC-JPAKE exchange, as it appears on the wire: +/// two P-256 points plus a Schnorr proof scalar, each coordinate a 32-byte +/// big-endian value, 160 bytes in all. +/// +/// `publicKey` is the value being proven; `proofPoint` is the commitment +/// (`base * randomizer`) the verifier recomputes. +struct G7PCert: Equatable { + static let byteCount = 160 + + let publicKey: G7P256Point + let proofPoint: G7P256Point + let proof: G7BigUInt + + init(publicKey: G7P256Point, proofPoint: G7P256Point, proof: G7BigUInt) { + self.publicKey = publicKey + self.proofPoint = proofPoint + self.proof = proof + } + + init(data: Data) throws { + guard data.count == G7PCert.byteCount else { + throw G7PCertError.invalidLength(data.count) + } + let bytes = Data(data) + func coordinate(_ index: Int) -> G7BigUInt { + let start = bytes.startIndex + index * 32 + return G7BigUInt(bigEndianBytes: bytes[start ..< start + 32]) + } + publicKey = G7P256Point(x: coordinate(0), y: coordinate(1)) + proofPoint = G7P256Point(x: coordinate(2), y: coordinate(3)) + proof = coordinate(4) + } + + var encoded: Data { + var data = Data(capacity: G7PCert.byteCount) + data.append(publicKey.x.bigEndianBytes(paddedTo: 32)) + data.append(publicKey.y.bigEndianBytes(paddedTo: 32)) + data.append(proofPoint.x.bigEndianBytes(paddedTo: 32)) + data.append(proofPoint.y.bigEndianBytes(paddedTo: 32)) + data.append(proof.bigEndianBytes(paddedTo: 32)) + return data + } +} diff --git a/G7SensorKit/G7CGMManager/G7Authenticator.swift b/G7SensorKit/G7CGMManager/G7Authenticator.swift new file mode 100644 index 0000000..c2751ea --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7Authenticator.swift @@ -0,0 +1,577 @@ +// +// G7Authenticator.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// Derived from DexKit by Erik Tolboom (https://github.com/nightscout/DexKit): +// the handshake sequencing and message buffering follow its G7Authenticator. +// The cryptography is separate; see Crypto/. +// + +import CoreBluetooth +import Foundation +import os.log + +public enum G7AuthenticatorError: Error { + case timeout(step: String) + case unexpectedResponse(step: String, response: Data) + + /// The sensor completed the key exchange and then refused the session + /// anyway. `failureCode` is its reason, when it gave a known one. + /// Terminal for this connection: retrying the same way cannot help, and + /// four refusals in a row make the sensor stop accepting connections for + /// a while. `.noAppKey` is the one the session can recover from on its + /// own, by dropping the stored key and pairing again with the code. + case rejected(authStatus: UInt8, failureCode: G7AuthFailureCode?) + + /// The sensor's answer to our random challenge does not match what our + /// key produced, so we do not share a key with it: either the code + /// belongs to a different sensor, or a stored key has gone stale. + case challengeMismatch + + /// Neither a stored key nor a pairing code was available. + case noCredentials +} + +extension G7AuthenticatorError: CustomStringConvertible { + public var description: String { + switch self { + case .timeout(let step): + return "Timed out waiting for the sensor during \(step)." + case .unexpectedResponse(let step, let response): + return "Unexpected response during \(step): \(response.hexadecimalString)" + case .rejected(_, let failureCode): + switch failureCode { + case .deviceTypeRestriction: + return LocalizedString( + "The sensor is already connected to another phone or app. A sensor only works with one at a time; stop the other one from using this sensor, then try again.", + comment: "Error description when a G7 sensor's display slot is taken" + ) + case .noAppKey: + return LocalizedString( + "The sensor no longer recognizes this phone and will be paired again automatically.", + comment: "Error description when a G7 sensor has lost the app's key" + ) + case .challengeMismatch: + return LocalizedString( + "The sensor rejected this phone's key.", + comment: "Error description when a G7 sensor reports a key mismatch" + ) + case .some(.none), nil: + return LocalizedString( + "The sensor accepted the pairing code but refused the connection.", + comment: "Error description for a G7 sensor rejecting an authenticated connection with no reason given" + ) + } + case .challengeMismatch: + return LocalizedString( + "This sensor does not match the pairing code entered.", + comment: "Error description when a G7 sensor's challenge response does not match the pairing code" + ) + case .noCredentials: + return LocalizedString( + "No pairing code or saved key is available for this sensor.", + comment: "Error description when a G7 authentication is attempted with no credentials" + ) + } + } +} + +/// Runs the handshake that makes us a display the sensor will talk to. +/// +/// Two paths, decided by whether we already hold a key for this sensor: +/// +/// - **First pairing.** An EC-JPAKE exchange over the 4-digit code produces a +/// shared key, then we prove possession of it, upload Dexcom's certificates, +/// sign the sensor's key challenge, and let the sensor start BLE bonding. +/// - **Reconnect.** With the key already stored, everything above collapses +/// into one AES challenge/response. +/// +/// The whole handshake is synchronous, run on the peripheral manager's own +/// serial queue via `perform`. Blocking there is safe and deliberate: it is +/// not the queue CoreBluetooth delivers callbacks on, and the protocol is a +/// strict request/response sequence that reads far better straight-line than +/// as a dozen chained callbacks. +final class G7Authenticator { + + struct Result { + let sharedKey: Data + /// The sensor's full name, learned once the link is up. Only present + /// after a fresh handshake. + let deviceName: String? + /// Whether this run performed the EC-JPAKE exchange (as opposed to + /// reusing a stored key). + let didExchangeKeys: Bool + } + + /// Per-step response deadline for a reconnect. Short: the link is up and + /// the sensor answers immediately or not at all. + static let reconnectStepTimeout: TimeInterval = 10 + + /// Per-step deadline during first pairing. Generous, because the sensor + /// streams certificates in 20-byte chunks and a stalled step is better + /// resolved by the pairing service's own watchdog than by failing here. + static let pairingStepTimeout: TimeInterval = 60 + + /// How long to wait for the sensor's post-bond confirmation. Not fatal + /// when it never arrives; by then the key is already good. + static let bondConfirmationTimeout: TimeInterval = 20 + + private let log = OSLog(category: "G7Authenticator") + + private let pairingCode: String? + private let storedSharedKey: Data? + private let stepTimeout: TimeInterval + /// Which of the sensor's display slots this client takes. + private let displayType: G7DisplayType + + /// Receives a short description of each step as it happens, for the + /// in-app device communication log. A tester reporting "it got stuck" + /// is only diagnosable if these are visible outside Xcode. Never carries + /// the pairing code or key material. + var logHandler: ((String) -> Void)? + + init(pairingCode: String?, storedSharedKey: Data?, stepTimeout: TimeInterval, displayType: G7DisplayType = .phone) { + self.pairingCode = pairingCode + self.storedSharedKey = storedSharedKey + self.stepTimeout = stepTimeout + self.displayType = displayType + } + + /// Runs the handshake, calling `completion` exactly once on the + /// peripheral manager's queue. + func authenticate( + peripheralManager: G7PeripheralManager, + completion: @escaping (Swift.Result) -> Void + ) { + peripheralManager.perform { peripheral in + do { + completion(.success(try self.run(peripheral))) + } catch { + self.report("Authentication failed: \(error)") + peripheral.setValueUpdateHandler(for: .authentication, handler: nil) + peripheral.setValueUpdateHandler(for: .certificate, handler: nil) + completion(.failure(error)) + } + } + } + + private func run(_ peripheral: G7PeripheralManager) throws -> Result { + guard storedSharedKey != nil || pairingCode != nil else { + throw G7AuthenticatorError.noCredentials + } + + report(storedSharedKey != nil + ? "Authenticating with the saved key" + : "Pairing: running the key exchange") + + do { + try peripheral.setNotifyValue(true, for: .certificate) + try peripheral.setNotifyValue(true, for: .authentication) + } catch { + report("Could not subscribe to the sensor's characteristics: \(error)" + + (G7Authenticator.looksLikeStaleBond(error) ? ". The link could not be secured; the phone's Bluetooth bond for this sensor may be stale (Settings › Bluetooth › forget the sensor, then pair again)" : "")) + throw error + } + report("Characteristics: \(peripheral.describeCharacteristic(.authentication)); " + + "\(peripheral.describeCharacteristic(.certificate)); " + + "\(peripheral.describeCharacteristic(.control))") + + // Buffer authentication traffic for the whole handshake. Its + // acknowledgements can land while our own write is still pending, and + // a one-shot wait registered after the write would miss them. + let authentication = G7AuthMessageBuffer() + peripheral.setValueUpdateHandler(for: .authentication) { [weak authentication] message in + authentication?.append(message) + } + defer { + peripheral.setValueUpdateHandler(for: .authentication, handler: nil) + peripheral.setValueUpdateHandler(for: .certificate, handler: nil) + } + + let sharedKey: Data + let didExchangeKeys: Bool + if let storedSharedKey = storedSharedKey { + sharedKey = storedSharedKey + didExchangeKeys = false + } else { + sharedKey = try exchangeKeys(peripheral) + didExchangeKeys = true + } + + let status = try proveSharedKey(peripheral, authentication: authentication, sharedKey: sharedKey) + + // The shortcut is for a stored-key reconnect only. After a fresh key + // exchange the sensor can still say "bonded" (the OS bond from an + // earlier pairing on this phone is intact), but that is not the same + // as it holding the key we just derived: skipping the certificate + // phase on that basis left the sensor dropping the link on our first + // control write, and its reconnect challenge answered with a key we + // did not have. The official app runs the certificate phase on every + // pairing; so do we. + if !didExchangeKeys, status.isAuthenticated, status.isBonded { + report("Already authenticated and bonded") + return Result(sharedKey: sharedKey, deviceName: peripheral.peripheral.name, didExchangeKeys: didExchangeKeys) + } + + if !didExchangeKeys { + // A reconnect that got this far without being authenticated has a + // key the sensor no longer honours. Re-pairing is the only fix, + // and the caller decides whether it can do that unattended. + throw G7AuthenticatorError.unexpectedResponse( + step: "reconnect", + response: Data([0x05, status.authStatus, status.bondStatus]) + ) + } + + report("Running the certificate exchange (auth=\(status.authStatus) bond=\(status.bondStatus))") + try exchangeCertificates(peripheral, authentication: authentication) + try answerKeyChallenge(peripheral, authentication: authentication) + try finalizeAndBond(peripheral, authentication: authentication) + + return Result( + sharedKey: sharedKey, + deviceName: peripheral.peripheral.name, + didExchangeKeys: didExchangeKeys + ) + } + + // MARK: - EC-JPAKE + + private func exchangeKeys(_ peripheral: G7PeripheralManager) throws -> Data { + guard let pairingCode = pairingCode else { + throw G7AuthenticatorError.noCredentials + } + let jpake = G7JPAKE(pairingCode: pairingCode) + + let sensorRound1 = try exchangeRound(peripheral, round: 0, ours: jpake.makeRound1()) + let sensorRound2 = try exchangeRound(peripheral, round: 1, ours: jpake.makeRound2()) + let sensorRound3 = try requestSensorRound(peripheral, round: 2) + + // Advisory only: the sensor does not require us to check its proofs, + // and the AES challenge below is the real gate. On hardware they do + // not verify under the transcript format we use for our own (which + // the sensor accepts), so the sensor's side evidently hashes + // something differently; noted, not acted on. + let proofsVerified = jpake.validateRound1Or2(sensorRound1) + && jpake.validateRound1Or2(sensorRound2) + && jpake.validateRound3(peerRound1: sensorRound1, peerRound3: sensorRound3) + if !proofsVerified { + report("Key exchange: the sensor's proofs did not verify under our transcript format (advisory)") + } + + // Derive before sending our own round 3: the sensor may drop the link + // as soon as it has what it needs. + let secret = try jpake.deriveSharedSecret(peerRound2: sensorRound2, peerRound3: sensorRound3) + try peripheral.writeCertificateBytes(jpake.makeRound3(peerRound1: sensorRound1, peerRound2: sensorRound2)) + + report("Key exchange complete") + return secret.prefix(16) + } + + private func exchangeRound(_ peripheral: G7PeripheralManager, round: UInt8, ours: Data) throws -> G7PCert { + let theirs = try requestSensorRound(peripheral, round: round) + try peripheral.writeCertificateBytes(ours) + return theirs + } + + /// Asks for one round of the sensor's exchange and collects the streamed + /// reply. The buffer is installed before the request goes out: the sensor + /// starts streaming before its acknowledgement arrives. + private func requestSensorRound(_ peripheral: G7PeripheralManager, round: UInt8) throws -> G7PCert { + let step = "key exchange round \(round + 1)" + let buffer = installCertificateBuffer(peripheral) + try peripheral.writeValue(Data([0x0A, round]), for: .authentication, type: .withResponse) + let data = try waitForCertificateBytes(peripheral, buffer: buffer, count: G7PCert.byteCount, step: step) + report("\(step): received the sensor's \(data.count)-byte certificate") + return try G7PCert(data: data) + } + + // MARK: - AES challenge + + private func proveSharedKey( + _ peripheral: G7PeripheralManager, + authentication: G7AuthMessageBuffer, + sharedKey: Data + ) throws -> AuthChallengeRxMessage { + let step = "challenge" + let challenge = G7JPAKE.secureRandomBytes(8) + report("Challenge: sending ours as display type \(displayType)") + try peripheral.writeValue(Data([0x02]) + challenge + Data([displayType.rawValue]), for: .authentication, type: .withResponse) + + let response = try waitForAuthentication(authentication, prefix: 0x03, step: step) + guard response.count >= 17 else { + throw G7AuthenticatorError.unexpectedResponse(step: step, response: response) + } + + let bytes = Data(response) + let base = bytes.startIndex + guard try G7AES.encryptChallenge(challenge, key: sharedKey) == bytes[base + 1 ..< base + 9] else { + report("Challenge: the sensor's answer does not match our key") + throw G7AuthenticatorError.challengeMismatch + } + report("Challenge: the sensor's answer verified") + + let sensorChallenge = Data(bytes[base + 9 ..< base + 17]) + let answer = try G7AES.encryptChallenge(sensorChallenge, key: sharedKey) + try peripheral.writeValue(Data([0x04]) + answer, for: .authentication, type: .withResponse) + + let verdict = try waitForAuthentication(authentication, prefix: 0x05, step: step) + guard let status = AuthChallengeRxMessage(data: verdict) else { + throw G7AuthenticatorError.unexpectedResponse(step: step, response: verdict) + } + report("Challenge: verdict auth=\(status.authStatus) bond=\(status.bondStatus)") + + if status.isRejected { + throw G7AuthenticatorError.rejected(authStatus: status.authStatus, failureCode: status.failureCode) + } + return status + } + + // MARK: - Certificate exchange + + private func exchangeCertificates(_ peripheral: G7PeripheralManager, authentication: G7AuthMessageBuffer) throws { + let certificates = G7DexcomCredentials.certificates + for (index, certificate) in certificates.enumerated() { + try exchangeCertificate(peripheral, authentication: authentication, index: index, certificate: certificate) + } + + // Terminator: an entry past the last index, declared zero length. + report("Certificates: sending the terminator") + var request = Data([0x0B, UInt8(certificates.count)]) + request.append(UInt32(0).littleEndian) + try peripheral.writeValue(request, for: .authentication, type: .withResponse) + _ = try waitForAuthentication(authentication, prefix: 0x0B, step: "certificate terminator") + } + + private func exchangeCertificate( + _ peripheral: G7PeripheralManager, + authentication: G7AuthMessageBuffer, + index: Int, + certificate: Data + ) throws { + let step = "certificate \(index)" + + // Installed before the request: the sensor streams its certificate + // ahead of the acknowledgement that describes it. + let buffer = installCertificateBuffer(peripheral) + var request = Data([0x0B, UInt8(index)]) + request.append(UInt32(certificate.count).littleEndian) + try peripheral.writeValue(request, for: .authentication, type: .withResponse) + + let acknowledgement = try waitForAuthentication(authentication, prefix: 0x0B, step: step) + + // `0B 00 `. The sensor's certificates are not + // the same size as ours, so the length has to be read, not assumed. + let expectedLength: Int + if acknowledgement.count >= 5 { + let base = acknowledgement.startIndex + let declared = Int(acknowledgement[base + 3]) | Int(acknowledgement[base + 4]) << 8 + expectedLength = declared > 0 ? declared : certificate.count + } else { + report("\(step): short acknowledgement \(acknowledgement.hexadecimalString); assuming our own length") + expectedLength = certificate.count + } + + let theirs = try waitForCertificateBytes(peripheral, buffer: buffer, count: expectedLength, step: step) + report("\(step): received \(theirs.count) bytes, sending ours (\(certificate.count) bytes)") + try peripheral.writeCertificateBytes(certificate) + } + + // MARK: - Key challenge + + private func answerKeyChallenge(_ peripheral: G7PeripheralManager, authentication: G7AuthMessageBuffer) throws { + let step = "key challenge" + report("\(step): sending a nonce") + + let buffer = installCertificateBuffer(peripheral) + try peripheral.writeValue( + Data([0x0C]) + G7JPAKE.secureRandomBytes(16), + for: .authentication, + type: .withResponse + ) + + let acknowledgement = try waitForAuthentication(authentication, prefix: 0x0C, step: step) + guard acknowledgement.count >= 18 else { + peripheral.setValueUpdateHandler(for: .certificate, handler: nil) + throw G7AuthenticatorError.unexpectedResponse(step: step, response: acknowledgement) + } + + // The sensor's own 64-byte block arrives alongside. We do not verify + // it: the sensor is checking us, not the other way round, and the + // signing key it would be checked against is the one we just sent. + _ = try waitForCertificateBytes(peripheral, buffer: buffer, count: 64, step: step) + + let signature = try G7ChallengeSigner.sign(challengeAcknowledgement: acknowledgement) + report("\(step): sending our signature") + try peripheral.writeCertificateBytes(signature) + } + + // MARK: - Finalize + + private func finalizeAndBond(_ peripheral: G7PeripheralManager, authentication: G7AuthMessageBuffer) throws { + report("Finalizing") + try peripheral.writeValue(Data([0x06, 0x1E]), for: .authentication, type: .withResponse) + _ = try waitForAuthentication(authentication, prefix: 0x06, step: "finalize") + + report("Finalizing: the sensor will start BLE pairing now, so expect the system pairing prompt") + try peripheral.writeValue(Data([0x07]), for: .authentication, type: .withResponse) + _ = try waitForAuthentication(authentication, prefix: 0x07, step: "bond request") + + // The sensor confirms once the link is encrypted. Treat a silent + // sensor as success: the key and certificates are already accepted, + // and failing here would throw away a completed pairing. The sensor + // has been seen to drop the link a few seconds after 07 instead, so + // the wait is in short slices that stop as soon as the link is gone; + // the reconnect that follows uses the key just installed. + let deadline = Date().addingTimeInterval(G7Authenticator.bondConfirmationTimeout) + var confirmation: (match: Data, discarded: [Data])? + while confirmation == nil, Date() < deadline, peripheral.peripheral.state == .connected { + confirmation = authentication.waitForMessage(timeout: 1) { $0.first == 0x08 } + } + if let confirmation = confirmation { + report("Bonded (\(confirmation.match.hexadecimalString))") + } else if peripheral.peripheral.state != .connected { + report("The sensor dropped the link after the bond request; the key is installed and the next connection will use it") + } else { + report("No bond confirmation arrived; continuing, since the handshake already succeeded") + } + } + + // MARK: - Waiting + + private func waitForAuthentication( + _ buffer: G7AuthMessageBuffer, + prefix: UInt8, + step: String + ) throws -> Data { + guard let result = buffer.waitForMessage(timeout: stepTimeout, matching: { $0.first == prefix }) else { + throw G7AuthenticatorError.timeout(step: step) + } + for stale in result.discarded where stale.first != 0x0A { + // The 0A acknowledgements of our round requests arrive after the + // certificate bytes we actually wait for; skipping them is normal. + report("Skipping an unexpected message: \(stale.hexadecimalString)") + } + return result.match + } + + private func installCertificateBuffer(_ peripheral: G7PeripheralManager) -> G7ChunkBuffer { + let buffer = G7ChunkBuffer() + peripheral.setValueUpdateHandler(for: .certificate) { [weak buffer] chunk in + buffer?.append(chunk) + } + return buffer + } + + private func waitForCertificateBytes( + _ peripheral: G7PeripheralManager, + buffer: G7ChunkBuffer, + count: Int, + step: String + ) throws -> Data { + defer { + peripheral.setValueUpdateHandler(for: .certificate, handler: nil) + } + guard let data = buffer.wait(forByteCount: count, timeout: stepTimeout) else { + report("\(step): timed out with \(buffer.count) of \(count) bytes") + throw G7AuthenticatorError.timeout(step: step) + } + return data + } + + /// Logs to the system log (so it survives into a sysdiagnose) and, when + /// set, to the host's device communication log. + /// CoreBluetooth's insufficient authentication/encryption ATT errors: the + /// sensor wants an encrypted link and the phone could not provide one, + /// which is what a bond the sensor no longer recognizes looks like. + static func looksLikeStaleBond(_ error: Error) -> Bool { + guard case PeripheralManagerError.cbPeripheralError(let underlying) = error, + let attError = underlying as? CBATTError + else { + return false + } + return attError.code == .insufficientAuthentication || attError.code == .insufficientEncryption + } + + private func report(_ message: String) { + log.default("%{public}@", message) + logHandler?(message) + } +} + +// MARK: - Buffers + +/// Collects discrete messages from the authentication characteristic. +/// +/// Discrete rather than concatenated because each notification is one +/// message, and out-of-sequence arrivals need to be skipped rather than +/// misread as the message a step is waiting for. +private final class G7AuthMessageBuffer { + private let condition = NSCondition() + private var messages: [Data] = [] + + func append(_ message: Data) { + condition.lock() + messages.append(message) + condition.broadcast() + condition.unlock() + } + + /// Waits for the first message satisfying `predicate`, returning it along + /// with any older messages it skipped past. + func waitForMessage( + timeout: TimeInterval, + matching predicate: (Data) -> Bool + ) -> (match: Data, discarded: [Data])? { + let deadline = Date().addingTimeInterval(timeout) + condition.lock() + defer { condition.unlock() } + + while true { + if let index = messages.firstIndex(where: predicate) { + let discarded = Array(messages[.. Data? { + let deadline = Date().addingTimeInterval(timeout) + condition.lock() + defer { condition.unlock() } + + while storage.count < count { + guard condition.wait(until: deadline) else { + return nil + } + } + return storage + } +} diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 3edb0e2..35760d5 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -5,6 +5,10 @@ // Created by Pete Schwamb on 11/11/22. // Copyright © 2022 LoopKit Authors. All rights reserved. // +// Active-peripheral tracking, the powered-on recheck and central recreation +// are derived from DexKit by Erik Tolboom +// (https://github.com/nightscout/DexKit). +// import CoreBluetooth import Foundation @@ -47,7 +51,17 @@ protocol G7BluetoothManagerDelegate: AnyObject { - returns: PeripheralConnectionCommand indicating what should be done with this peripheral */ - func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral) -> PeripheralConnectionCommand + func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any]) -> PeripheralConnectionCommand + + /** + Asks the delegate whether peripherals restored by CoreBluetooth's state + restoration should be adopted. + + A session says yes: that is how it resumes its own sensor after a relaunch. + A pairing run says no, because a stale restored peripheral would be treated + as a candidate and crowd out the sensor actually being paired. + */ + func bluetoothManagerShouldAcceptRestoredPeripherals(_ manager: G7BluetoothManager) -> Bool /// Informs the delegate that the bluetooth manager received new data in the control characteristic /// @@ -95,6 +109,13 @@ class G7BluetoothManager: NSObject { /// Isolated to `managerQueue` private var centralManager: CBCentralManager! = nil + /// Whether the radio is usable: off, unauthorized, or on. Readable from + /// any queue; changes are announced through + /// `bluetoothManagerScanningStatusDidChange`. + var centralState: CBManagerState { + centralManager?.state ?? .unknown + } + /// Isolated to `managerQueue` private var activePeripheral: CBPeripheral? { get { @@ -112,6 +133,13 @@ class G7BluetoothManager: NSObject { } private let lockedPeripheralIdentifier: Locked = Locked(nil) + /// Targets a known peripheral directly, so a relaunch can retrieve it by + /// identifier instead of waiting for its next advertisement. Passing nil + /// reopens the search to any sensor in range. + func setActivePeripheralIdentifier(_ identifier: UUID?) { + lockedPeripheralIdentifier.value = identifier + } + /// Isolated to `managerQueue` private var activePeripheralManager: G7PeripheralManager? { didSet { @@ -124,6 +152,24 @@ class G7BluetoothManager: NSObject { private let managerQueue = DispatchQueue(label: "com.loudnate.CGMBLEKit.bluetoothManagerQueue", qos: .unspecified) + /// Whether a `.poweredOn` recheck is already pending. Confined to `managerQueue`. + private var poweredOnRecheckScheduled = false + + /// Consecutive rechecks that still saw a non-`.poweredOn` state, and how + /// often we have rebuilt the central because of it. Confined to `managerQueue`. + private var poweredOnRecheckCount = 0 + private var centralRecreationCount = 0 + + /// How long to tolerate a stuck state before rebuilding the central. + private static let poweredOnRecheckInterval: TimeInterval = 3 + private static let poweredOnRechecksBeforeRecreating = 10 + private static let maximumCentralRecreations = 5 + + /// There is exactly one of these per session, and a pairing run borrows + /// it rather than building a second: only one central per app may claim + /// the restore identifier, and sharing the central is what lets the + /// session adopt the connection pairing just authenticated instead of + /// dropping it and waiting for the sensor's next advertisement. override init() { super.init() @@ -185,6 +231,45 @@ class G7BluetoothManager: NSObject { } } + /// Makes `peripheralManager` the active peripheral, keeping its connection, + /// and drops every other managed peripheral. This is the hand-off at the + /// end of pairing: the candidate that authenticated becomes the session's + /// sensor without a disconnect in between. + func adoptAsActive(_ peripheralManager: G7PeripheralManager) { + dispatchPrecondition(condition: .notOnQueue(managerQueue)) + + managerQueue.sync { + managerQueue_stopScanning() + + for (identifier, other) in managedPeripherals where other !== peripheralManager { + centralManager.cancelPeripheralConnection(other.peripheral) + managedPeripherals.removeValue(forKey: identifier) + } + + if activePeripheralManager !== peripheralManager { + activePeripheralManager = peripheralManager + } + peripheralManager.delegate = self + peripheralManager.reclaimPeripheral() + managedPeripherals[peripheralManager.peripheral.identifier] = peripheralManager + } + } + + /// Cancels every managed peripheral's connection, not just the active one. + /// The pairing run needs this: its candidates are never made active (there + /// is no sensor ID yet), so `disconnect()` would leave them connected. + func disconnectAll() { + dispatchPrecondition(condition: .notOnQueue(managerQueue)) + + managerQueue.sync { + managerQueue_stopScanning() + + for peripheralManager in managedPeripherals.values { + centralManager.cancelPeripheralConnection(peripheralManager.peripheral) + } + } + } + func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) { managerQueue.async { if self.activePeripheralIdentifier == nil { @@ -197,10 +282,21 @@ class G7BluetoothManager: NSObject { private func managerQueue_scanForPeripheral() { dispatchPrecondition(condition: .onQueue(managerQueue)) + // `didDisconnectPeripheral` always rescans, which keeps us alive for a + // couple of seconds past release. A pairing run that has finished must + // not start scanning again in that window: the sensor it just paired + // admits one display, and the session manager is claiming it. + guard delegate != nil else { + return + } + guard centralManager.state == .poweredOn else { + schedulePoweredOnRecheck() return } + poweredOnRecheckCount = 0 + let currentState = activePeripheral?.state ?? .disconnected guard currentState != .connected else { return @@ -244,6 +340,56 @@ class G7BluetoothManager: NSObject { The sleep gives the transmitter time to shut down, but keeps the app running. */ + /// CoreBluetooth lies about its state at creation: a central built while + /// another is being torn down can report `.unknown` or even `.unsupported` + /// and then never send a corrective `didUpdateState`, leaving the manager + /// permanently convinced Bluetooth is unavailable. Poll our way out, and + /// rebuild the central if the state stays stuck. + private func schedulePoweredOnRecheck() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + + guard !poweredOnRecheckScheduled, delegate != nil else { + return + } + poweredOnRecheckScheduled = true + + managerQueue.asyncAfter(deadline: .now() + G7BluetoothManager.poweredOnRecheckInterval) { [weak self] in + guard let self = self else { return } + self.poweredOnRecheckScheduled = false + + guard self.delegate != nil else { + return + } + guard self.centralManager.state != .poweredOn else { + self.poweredOnRecheckCount = 0 + self.managerQueue_scanForPeripheral() + return + } + + self.poweredOnRecheckCount += 1 + self.log.default( + "Bluetooth still %{public}@ after %{public}d rechecks", + String(describing: self.centralManager.state.rawValue), + self.poweredOnRecheckCount + ) + + let isStuckState = self.centralManager.state == .unknown || self.centralManager.state == .unsupported + if isStuckState, + self.poweredOnRecheckCount >= G7BluetoothManager.poweredOnRechecksBeforeRecreating, + self.centralRecreationCount < G7BluetoothManager.maximumCentralRecreations, + self.managedPeripherals.isEmpty + { + self.log.error("Recreating central manager stuck at %{public}@", String(describing: self.centralManager.state.rawValue)) + self.centralRecreationCount += 1 + self.poweredOnRecheckCount = 0 + self.centralManager.delegate = nil + self.centralManager = self.makeCentralManager(queue: self.managerQueue) + } + + self.schedulePoweredOnRecheck() + } + } + fileprivate func scanAfterDelay() { DispatchQueue.global(qos: .utility).async { Thread.sleep(forTimeInterval: 2) @@ -274,37 +420,52 @@ class G7BluetoothManager: NSObject { return isConnected } - private func handleDiscoveredPeripheral(_ peripheral: CBPeripheral) { + /// The manager already attached to this peripheral, if there is one. A + /// candidate dropped from `managedPeripherals` on disconnect is still the + /// peripheral's delegate, and may still have a handshake running; a + /// second manager would take the delegate role from it, and its commands + /// would never hear back. + private func makeOrReusePeripheralManager(_ peripheral: CBPeripheral) -> G7PeripheralManager { + if let existing = peripheral.delegate as? G7PeripheralManager { + return existing + } + return G7PeripheralManager(peripheral: peripheral, configuration: .dexcomG7, centralManager: centralManager) + } + + private func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any] = [:]) { dispatchPrecondition(condition: .onQueue(managerQueue)) if let delegate = delegate { - switch delegate.bluetoothManager(self, shouldConnectPeripheral: peripheral) { + switch delegate.bluetoothManager(self, shouldConnectPeripheral: peripheral, advertisementData: advertisementData) { case .makeActive: log.default("Making peripheral active: %{public}@", peripheral.identifier.uuidString) if let peripheralManager = activePeripheralManager { peripheralManager.peripheral = peripheral } else { - activePeripheralManager = G7PeripheralManager( - peripheral: peripheral, - configuration: .dexcomG7, - centralManager: centralManager - ) + activePeripheralManager = makeOrReusePeripheralManager(peripheral) activePeripheralManager?.delegate = self } self.managedPeripherals[peripheral.identifier] = activePeripheralManager self.centralManager.connect(peripheral) case .connect: - log.default("Connecting to peripheral: %{public}@", peripheral.identifier.uuidString) - self.centralManager.connect(peripheral) - let peripheralManager = G7PeripheralManager( - peripheral: peripheral, - configuration: .dexcomG7, - centralManager: centralManager - ) - peripheralManager.delegate = self - self.managedPeripherals[peripheral.identifier] = peripheralManager + // Pairing hears repeat advertisements from the same candidate; + // building a second manager for one peripheral leaves the first + // as an orphaned delegate and loses handshake traffic. + if let existingManager = self.managedPeripherals[peripheral.identifier] { + existingManager.peripheral = peripheral + if peripheral.state != .connected, peripheral.state != .connecting { + log.default("Reconnecting to peripheral: %{public}@", peripheral.identifier.uuidString) + self.centralManager.connect(peripheral) + } + } else { + log.default("Connecting to peripheral: %{public}@", peripheral.identifier.uuidString) + let peripheralManager = makeOrReusePeripheralManager(peripheral) + peripheralManager.delegate = self + self.managedPeripherals[peripheral.identifier] = peripheralManager + self.centralManager.connect(peripheral) + } case .ignore: break } @@ -336,14 +497,19 @@ extension G7BluetoothManager: CBCentralManagerDelegate { if central.isScanning { log.default("Stopping scan on central not powered on") central.stopScan() - delegate?.bluetoothManagerScanningStatusDidChange(self) } } + delegate?.bluetoothManagerScanningStatusDidChange(self) } func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { dispatchPrecondition(condition: .onQueue(managerQueue)) + guard delegate?.bluetoothManagerShouldAcceptRestoredPeripherals(self) ?? true else { + log.default("Ignoring restored peripherals: delegate is not accepting them") + return + } + if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { for peripheral in peripherals { log.default("Restoring peripheral from state: %{public}@", peripheral.identifier.uuidString) @@ -358,7 +524,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { log.default("%{public}@: %{public}@, data = %{public}@", #function, peripheral, String(describing: advertisementData)) managerQueue.async { - self.handleDiscoveredPeripheral(peripheral) + self.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData) } } @@ -443,7 +609,9 @@ extension G7BluetoothManager: G7PeripheralManagerDelegate { } switch CGMServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString.uppercased()) { - case .none, .communication?: + case .none, .communication?, .certificate?: + // The certificate characteristic only carries handshake payloads, + // which the authenticator collects through an installed handler. return case .control?: self.delegate?.bluetoothManager(self, peripheralManager: manager, didReceiveControlResponse: value) diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index e2b7d64..21791ee 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -102,7 +102,7 @@ public class G7CGMManager: CGMManager { } public var shouldSyncToRemoteService: Bool { - return state.uploadReadings + return true } public var glucoseDisplay: GlucoseDisplayable? { @@ -171,26 +171,31 @@ public class G7CGMManager: CGMManager { return state.latestReadingTimestamp } - public var uploadReadings: Bool { - get { - return state.uploadReadings - } - set { - mutateState { state in - state.uploadReadings = newValue - } - } - } - + /// One session, and one Bluetooth central inside it, for the manager's + /// lifetime. Pairing reconfigures it in place. public let sensor: G7Sensor + /// A session is valid while there is a sensor that can still deliver + /// readings. Loop withholds closed loop without one, and refreshes its + /// status display when this changes. public var cgmManagerStatus: LoopKit.CGMManagerStatus { - return CGMManagerStatus(hasValidSensorSession: true, device: device) + let hasValidSensorSession: Bool + switch lifecycleState { + case .unpaired, .searching, .expired, .failed: + hasValidSensorSession = false + case .connecting, .warmup, .ok, .gracePeriod: + hasValidSensorSession = true + } + return CGMManagerStatus(hasValidSensorSession: hasValidSensorSession, device: device) } public var lifecycleState: G7SensorLifecycleState { if state.sensorID == nil { - return .searching + guard state.sessionMode == .direct else { + return .searching + } + let canAuthenticate = state.sharedKey != nil || state.pairingCode != nil + return canAuthenticate ? .connecting : .unpaired } if let sensorEndsAt = sensorEndsAt, sensorEndsAt.timeIntervalSinceNow < 0 { return .expired @@ -215,24 +220,155 @@ public class G7CGMManager: CGMManager { completion(.noData) } + /// Creates a manager that watches a session the Dexcom app owns. + /// + /// The fallback for someone who cannot pair, typically because they are + /// mid-session on a sensor whose code they no longer have. Prefer + /// `init(pairingCode:peripheralIdentifier:sharedKey:)`. public convenience init() { - self.init(state: G7CGMManagerState(), sensor: G7Sensor(sensorID: nil)) + self.init(sessionMode: .eavesdropping) + } + + /// A manager with no sensor yet. Created at the start of setup, so the + /// CGM exists and its device log carries the pairing from the first line; + /// `applyPairingResult` completes it. + public convenience init(sessionMode: G7SessionMode, displayType: G7DisplayType = .phone) { + var state = G7CGMManagerState() + state.sessionMode = sessionMode + self.init(state: state, sensor: G7Sensor(mode: sessionMode, credentials: state.sensorCredentials, displayType: displayType)) + } + + /// Creates a manager for a sensor that has just been paired directly. + /// + /// With a `handoff`, the session is built around the central the pairing + /// run used and takes over its authenticated connection, so the first + /// reading arrives now rather than on the sensor's next advertisement. + public convenience init(pairingCode: String, peripheralIdentifier: UUID?, sharedKey: Data?, handoff: G7PairingHandoff? = nil, displayType: G7DisplayType = .phone) { + var state = G7CGMManagerState() + state.sessionMode = .direct + state.pairingCode = pairingCode + state.peripheralIdentifier = peripheralIdentifier + state.sharedKey = sharedKey + state.pairedAt = Date() + + let sensor: G7Sensor + if let handoff = handoff { + sensor = G7Sensor(mode: .direct, credentials: state.sensorCredentials, bluetoothManager: handoff.bluetoothManager, displayType: displayType) + } else { + sensor = G7Sensor(mode: .direct, credentials: state.sensorCredentials, displayType: displayType) + } + self.init(state: state, sensor: sensor) + + if let handoff = handoff { + sensor.adoptAuthenticatedConnection(handoff.peripheralManager) + } } public required convenience init?(rawState: RawStateValue) { let state = G7CGMManagerState(rawValue: rawState) - self.init(state: state, sensor: G7Sensor(sensorID: state.sensorID)) + self.init(state: state, sensor: G7Sensor(mode: state.sessionMode, credentials: state.sensorCredentials)) sensor.needsVersionInfo = state.extendedVersion == nil } + /// Which of the sensor's display slots this app takes. A phone by + /// default; a watch app takes its own, alongside the phone's. + public var displayType: G7DisplayType { + sensor.displayType + } + init(state: G7CGMManagerState, sensor: G7Sensor) { lockedState = Locked(state) self.sensor = sensor sensor.delegate = self + sensor.latestReadingDate = state.latestReadingTimestamp + // A calibration entered before the app was last terminated is still owed to the sensor. + if let calibration = state.calibration, calibration.outcome == .pending { + sensor.calibrate(glucose: calibration.glucose, at: calibration.enteredAt) + } // A grace period may have been in flight when the app was last terminated. restorePendingSuspectedSessionEnd() } + + /// How this manager gets its readings. + public var sessionMode: G7SessionMode { + state.sessionMode + } + + /// Adopts the result of a pairing run, switching to direct mode. + /// + /// Used both to upgrade an eavesdropping session and to re-pair after + /// replacing a sensor, so it deliberately forgets the previous sensor's + /// identity: which physical sensor was just paired is not knowable until + /// it reports a reading, and the first one re-establishes activation time + /// and identity anyway. + /// + /// The session object and its Bluetooth central survive; the pairing run + /// borrowed that same central, and `handoff` carries the connection it + /// authenticated so the session can continue on it. + /// Switches the session to direct authentication with the sensor just + /// paired. `sensorName` is the paired peripheral's name, taken from the + /// hand-off when there is one; when it names the sensor already being + /// followed (an eavesdropper pairing with its own sensor), the session + /// keeps its identity, readings and alerts and only the mode changes. + /// Otherwise the current sensor is closed out and kept as the previous + /// one, and the new sensor's identity is learned from its first reading. + public func applyPairingResult(pairingCode: String, peripheralIdentifier: UUID?, sharedKey: Data?, handoff: G7PairingHandoff? = nil, sensorName: String? = nil) { + let pairedName = sensorName ?? handoff?.peripheralManager.peripheral.name + let isSameSensor: Bool + if let pairedName = pairedName, let currentID = state.sensorID, G7Sensor.isSensorName(pairedName) { + isSameSensor = pairedName.suffix(2) == currentID.suffix(2) + } else { + isSameSensor = false + } + + cancelSuspectedSessionEndScan() + + if isSameSensor { + logDeviceCommunication("Paired directly with \(state.sensorID!), the sensor already being followed; switching out of eavesdropping mode and keeping its session.", type: .connection) + } else { + logDeviceCommunication("Paired with a sensor directly; switching out of eavesdropping mode.", type: .connection) + retractAllLifecycleAlerts() + recordSensorEndIfNeeded() + archiveCurrentSensor(reason: .replaced) + } + + let newState = mutateState { state in + state.sessionMode = .direct + state.pairedAt = Date() + state.sensorFailureMessage = nil + state.sensorFailedAt = nil + state.pairingCode = pairingCode + state.peripheralIdentifier = peripheralIdentifier + state.sharedKey = sharedKey + state.lastAuthenticationFailure = nil + state.lastAuthenticationFailureDate = nil + if !isSameSensor { + state.calibration = nil + state.calibrationBounds = nil + state.calibrationBoundsDate = nil + state.sensorID = nil + state.activatedAt = nil + state.extendedVersion = nil + state.transmitterVersion = nil + state.latestReading = nil + state.latestReadingTimestamp = nil + state.lifecycleAlertsScheduledFor = nil + state.sensorFailedAlertIssuedFor = nil + state.sensorEndRecordedFor = nil + } + } + + sensor.reconfigure(mode: .direct, credentials: newState.sensorCredentials) + sensor.latestReadingDate = newState.latestReadingTimestamp + if let handoff = handoff { + assert(handoff.bluetoothManager === sensor.bluetoothManager, "pairing must borrow the session's central") + sensor.adoptAuthenticatedConnection(handoff.peripheralManager) + } else { + sensor.resumeScanning() + } + } + public var rawState: RawStateValue { return state.rawValue } @@ -245,7 +381,18 @@ public class G7CGMManager: CGMManager { "latestReading: \(String(describing: state.latestReading))", "latestReadingTimestamp: \(String(describing: state.latestReadingTimestamp))", "latestConnect: \(String(describing: state.latestConnect))", - "uploadReadings: \(String(describing: state.uploadReadings))", + "sessionMode: \(state.sessionMode.rawValue)", + "hasSharedKey: \(state.sharedKey != nil)", + "hasPairingCode: \(state.pairingCode != nil)", + "peripheralIdentifier: \(String(describing: state.peripheralIdentifier))", + "lifecycleState: \(lifecycleState)", + "lifecycleAlertsScheduledFor: \(String(describing: state.lifecycleAlertsScheduledFor))", + "sensorFailedAlertIssuedFor: \(String(describing: state.sensorFailedAlertIssuedFor))", + "lastAuthenticationFailure: \(String(describing: state.lastAuthenticationFailure))", + "hasDelegate: \(cgmManagerDelegate != nil)", + "pairedAt: \(String(describing: state.pairedAt))", + "sensorFailure: \(String(describing: state.sensorFailureMessage)) at \(String(describing: state.sensorFailedAt))", + "previousSensor: \(String(describing: state.previousSensor?.rawValue))", ] return lines.joined(separator: "\n") } @@ -257,7 +404,15 @@ public class G7CGMManager: CGMManager { public let pluginIdentifier: String = "G7CGMManager" - public let localizedTitle = LocalizedString("Dexcom G7", comment: "CGM display title") + /// The model of the sensor in use, once one is known. G7 until then; the + /// three models share one plugin and one protocol. + public var sensorModel: G7SensorModel { + state.sensorID.flatMap(G7SensorModel.init(advertisedName:)) ?? .g7 + } + + public var localizedTitle: String { + sensorModel.localizedTitle + } public let isOnboarded = true // No distinction between created and onboarded @@ -267,6 +422,9 @@ public class G7CGMManager: CGMManager { public func scanForNewSensor() { cancelSuspectedSessionEndScan() + retractAllLifecycleAlerts() + recordSensorEndIfNeeded() + archiveCurrentSensor(reason: .replaced) logDeviceCommunication("Forgetting existing sensor and starting scan for new sensor.", type: .connection) @@ -274,6 +432,18 @@ public class G7CGMManager: CGMManager { state.sensorID = nil state.activatedAt = nil state.extendedVersion = nil + state.transmitterVersion = nil + // Only ever valid for the sensor being forgotten. Keeping them + // would make every candidate fail its handshake. + state.pairingCode = nil + state.sharedKey = nil + state.peripheralIdentifier = nil + state.lifecycleAlertsScheduledFor = nil + state.sensorFailedAlertIssuedFor = nil + state.sensorEndRecordedFor = nil + state.pairedAt = nil + state.sensorFailureMessage = nil + state.sensorFailedAt = nil } sensor.scanForNewSensor() } @@ -282,16 +452,16 @@ public class G7CGMManager: CGMManager { return HKDevice( name: state.sensorID ?? "Unknown", manufacturer: "Dexcom", - model: "G7", + model: sensorModel.displayName, hardwareVersion: nil, - firmwareVersion: nil, + firmwareVersion: state.transmitterVersion?.firmwareVersion, softwareVersion: "CGMBLEKit" + String(G7SensorKitVersionNumber), localIdentifier: nil, udiDeviceIdentifier: "00386270001863" ) } - func logDeviceCommunication(_ message: String, type: DeviceLogEntryType = .send) { + public func logDeviceCommunication(_ message: String, type: DeviceLogEntryType = .send) { self.cgmManagerDelegate?.deviceManager(self, logEventForDeviceIdentifier: state.sensorID, type: type, message: message, completion: nil) } @@ -303,6 +473,153 @@ public class G7CGMManager: CGMManager { } extension G7CGMManager { + /// Tears the session down and retracts every alert before Loop drops + /// this manager. + /// + /// Alerts outlive the manager: Loop's alert store keeps them for its whole + /// cache window, and at launch it replays any past-due delayed alert as + /// immediate until the user acknowledges it. A deleted CGM's scheduled + /// expiry reminders would keep firing for weeks (LibreLoop #13). The + /// retractions are queued on the delegate queue ahead of the deletion + /// notification, so they land before Loop releases us. LoopKit's default + /// `delete` only notifies, so the notification is re-issued here. + public func delete(completion: @escaping () -> Void) { + cancelSuspectedSessionEndScan() + sensor.stopScanning() + retractAllLifecycleAlerts() + recordSensorEndIfNeeded() + archiveCurrentSensor(reason: .deleted) + notifyDelegateOfDeletion(completion: completion) + } + + // MARK: - Session events + + /// Keeps the current sensor as `previousSensor` before it is let go, the + /// way the pump plugins keep their previous pod: what it was, how and + /// when it was paired, and how it ended. + private func archiveCurrentSensor(reason: G7SensorRecord.EndReason) { + guard let sensorID = state.sensorID else { + return + } + let record = G7SensorRecord( + sensorID: sensorID, + pairingCode: state.pairingCode, + serialNumber: state.transmitterVersion?.serialNumberString, + firmwareVersion: state.transmitterVersion?.firmwareVersion, + pairedAt: state.pairedAt, + activatedAt: state.activatedAt, + sessionLength: state.extendedVersion?.sessionLength, + warmupDuration: state.extendedVersion?.warmupDuration, + endedAt: Date(), + endReason: reason, + failureMessage: state.sensorFailureMessage, + failedAt: state.sensorFailedAt + ) + mutateState { state in + state.previousSensor = record + } + } + + /// Closes the current sensor's session in Loop's CGM event history, once. + /// Paired with the `sensorStart` recorded at discovery, so the history + /// brackets each session; Loop tolerates a missing end, which is why this + /// is also safe to call speculatively when a sensor is forgotten. + private func recordSensorEndIfNeeded(failureMessage: String? = nil) { + guard let sensorID = state.sensorID, state.sensorEndRecordedFor != sensorID else { + return + } + let event = PersistedCgmEvent( + date: Date(), + type: .sensorEnd, + deviceIdentifier: sensorID, + failureMessage: failureMessage + ) + delegate.notify { delegate in + delegate?.cgmManager(self, hasNew: [event]) + } + mutateState { state in + state.sensorEndRecordedFor = sensorID + } + } + + // MARK: - Lifecycle alerts + + private func issueLifecycleAlert(_ alert: G7LifecycleAlert, trigger: Alert.Trigger = .immediate) { + let loopAlert = alert.alert(managerIdentifier: pluginIdentifier, trigger: trigger) + switch trigger { + case .delayed(let interval): + logDeviceCommunication("Scheduling alert \(alert.rawValue) in \(Int(interval))s", type: .connection) + default: + logDeviceCommunication("Issuing alert \(alert.rawValue)", type: .connection) + } + delegate.notify { delegate in + Task { + await delegate?.issueAlert(loopAlert) + } + } + } + + private func retractLifecycleAlert(_ alert: G7LifecycleAlert) { + let identifier = alert.identifier(managerIdentifier: pluginIdentifier) + delegate.notify { delegate in + Task { + await delegate?.retractAlert(identifier: identifier) + } + } + } + + private func retractAllLifecycleAlerts() { + G7LifecycleAlert.allCases.forEach(retractLifecycleAlert) + } + + /// (Re)schedules the session-timed alerts for the current sensor and + /// lifetime. Cheap to call often: nothing is issued unless the sensor or + /// its lifetime changed since the last time, which is what makes a + /// 15-day sensor's later extended-version report reschedule correctly + /// without every relaunch re-issuing the same notifications. + private func scheduleSessionTimedAlerts() { + guard let sensorID = state.sensorID, let expiresAt = sensorExpiresAt, let endsAt = sensorEndsAt else { + return + } + let key = "\(sensorID)|\(expiresAt.timeIntervalSince1970)" + guard state.lifecycleAlertsScheduledFor != key else { + return + } + + G7LifecycleAlert.sessionTimed.forEach(retractLifecycleAlert) + for (alert, delay) in G7LifecycleAlertSchedule.delays(sensorExpiresAt: expiresAt, sensorEndsAt: endsAt, now: Date()) { + issueLifecycleAlert(alert, trigger: .delayed(interval: delay)) + } + mutateState { state in + state.lifecycleAlertsScheduledFor = key + } + } + + /// Arms the signal-loss alert to fire if no further reading arrives in + /// time. Called on every reading, so it keeps being pushed back while + /// readings flow and only ever fires after they stop. + private func rearmSignalLossAlert() { + retractLifecycleAlert(.signalLoss) + issueLifecycleAlert(.signalLoss, trigger: .delayed(interval: G7LifecycleAlert.signalLossInterval)) + } + + private func raiseSensorFailedAlertIfNeeded(for message: G7GlucoseMessage) { + guard message.algorithmState.sensorFailed, let sensorID = state.sensorID, + state.sensorFailedAlertIssuedFor != sensorID + else { + return + } + issueLifecycleAlert(.sensorFailed) + // A failed sensor will not send more readings; nothing to lose signal from. + retractLifecycleAlert(.signalLoss) + mutateState { state in + state.sensorFailedAlertIssuedFor = sensorID + state.sensorFailureMessage = String(describing: message.algorithmState) + state.sensorFailedAt = Date() + } + recordSensorEndIfNeeded(failureMessage: String(describing: message.algorithmState)) + } + // MARK: - G7StateObserver public func addStateObserver(_ observer: G7StateObserver, queue: DispatchQueue) { @@ -321,9 +638,17 @@ extension G7CGMManager: G7SensorDelegate { let shouldSwitchToNewSensor = true if shouldSwitchToNewSensor { + sensor.cancelPendingCalibration() mutateState { state in state.sensorID = name state.activatedAt = activatedAt + state.calibration = nil + state.calibrationBounds = nil + state.calibrationBoundsDate = nil + state.peripheralIdentifier = sensor.credentials.peripheralIdentifier + if state.pairedAt == nil { + state.pairedAt = Date() + } } let event = PersistedCgmEvent( date: activatedAt, @@ -335,15 +660,102 @@ extension G7CGMManager: G7SensorDelegate { delegate.notify { delegate in delegate?.cgmManager(self, hasNew: [event]) } + scheduleSessionTimedAlerts() } return shouldSwitchToNewSensor } + public func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?) { + logDeviceCommunication("Authenticated with the sensor directly.", type: .connection) + mutateState { state in + state.sharedKey = sharedKey + state.peripheralIdentifier = sensor.credentials.peripheralIdentifier + state.lastAuthenticationFailure = nil + state.lastAuthenticationFailureDate = nil + } + } + + public func sensorDidInvalidateSharedKey(_ sensor: G7Sensor) { + logDeviceCommunication("The saved sensor key is no longer accepted; the next connection will pair again.", type: .connection) + mutateState { state in + state.sharedKey = nil + } + } + + public func sensor(_ sensor: G7Sensor, didReceive transmitterVersion: TransmitterVersionMessage) { + mutateState { state in + state.transmitterVersion = transmitterVersion + } + } + + // MARK: - Calibration + + /// The latest calibration entered for this sensor. + public var calibration: G7CalibrationRecord? { + state.calibration + } + + /// Whether a calibration is still waiting for the sensor's next connection. + public var hasPendingCalibration: Bool { + sensor.queuedCalibration != nil + } + + /// Whether the sensor will take a calibration right now: a direct session + /// with a live, warmed-up sensor. The sensor refuses them during warmup. + public var canCalibrate: Bool { + sessionMode == .direct && lifecycleState == .ok + } + + /// Hands a meter glucose (mg/dL, taken at `date`) to the sensor on its + /// next connection. Replaces any calibration still waiting. + public func calibrate(glucose: UInt16, at date: Date = Date()) { + logDeviceCommunication("Calibration \(glucose) mg/dL entered; queued for the sensor's next connection", type: .connection) + mutateState { state in + state.calibration = G7CalibrationRecord(glucose: glucose, enteredAt: date) + } + sensor.calibrate(glucose: glucose, at: date) + } + + public func cancelPendingCalibration() { + sensor.cancelPendingCalibration() + logDeviceCommunication("Queued calibration cancelled", type: .connection) + mutateState { state in + if state.calibration?.outcome == .pending { + state.calibration = nil + } + } + } + + public func sensor(_ sensor: G7Sensor, didReceiveCalibrationResponse response: G7CalibrateRxMessage) { + mutateState { state in + state.calibration?.outcome = response.accepted + ? .accepted(at: Date()) + : .rejected(status: response.status, at: Date()) + } + } + + public func sensor(_ sensor: G7Sensor, didReadCalibrationBounds bounds: G7CalibrationBoundsMessage) { + mutateState { state in + state.calibrationBounds = bounds + state.calibrationBoundsDate = Date() + if case .accepted = state.calibration?.outcome { + state.calibration?.processingStatus = bounds.processingStatus + } + } + // Folding a calibration in takes the sensor a reading or two; keep + // asking on each connection until it says it is done. + if bounds.processingStatus == .inProgress { + sensor.requestCalibrationBounds() + } + } + public func sensor(_ sensor: G7Sensor, didReceive extendedVersion: ExtendedVersionMessage) { mutateState { state in state.extendedVersion = extendedVersion } + // A 15-day sensor moves its expiry out; the timed alerts follow it. + scheduleSessionTimedAlerts() } public func sensorDidConnect(_ sensor: G7Sensor, name: String) { @@ -441,15 +853,52 @@ extension G7CGMManager: G7SensorDelegate { } public func sensor(_ sensor: G7Sensor, logComms comms: String) { - logDeviceCommunication("Sensor comms \(comms)", type: .receive) + logDeviceCommunication(comms, type: .receive) + } + + public func sensor(_ sensor: G7Sensor, log message: String, type: DeviceLogEntryType) { + logDeviceCommunication(message, type: type) } + /// A refusal the user can act on, as opposed to the timeouts a flaky link + /// produces every so often. + private func authenticationFailureDescription(for error: Error) -> String? { + switch error { + case G7AuthenticatorError.rejected(_, .noAppKey): + // Recovered unattended by the session; nothing for the user to do. + return nil + case G7AuthenticatorError.rejected, G7AuthenticatorError.challengeMismatch, G7AuthenticatorError.unexpectedResponse: + return String(describing: error) + default: + return nil + } + } + public func sensor(_ sensor: G7Sensor, didError error: Error) { + if let description = authenticationFailureDescription(for: error) { + let isNew = state.lastAuthenticationFailure == nil + mutateState { state in + state.lastAuthenticationFailure = description + state.lastAuthenticationFailureDate = Date() + } + if isNew { + issueLifecycleAlert(.connectionRefused) + } + } logDeviceCommunication("Sensor error \(error)", type: .error) } public func sensor(_ sensor: G7Sensor, didRead message: G7GlucoseMessage) { + if state.lastAuthenticationFailure != nil { + mutateState { state in + state.lastAuthenticationFailure = nil + state.lastAuthenticationFailureDate = nil + } + retractLifecycleAlert(.connectionRefused) + } + rearmSignalLossAlert() + raiseSensorFailedAlertIfNeeded(for: message) // Receiving any glucose message proves the session is still active. cancelSuspectedSessionEndScan() @@ -534,6 +983,20 @@ extension G7CGMManager: G7SensorDelegate { return } + // A backfill record can be the newest reading we hold, when the + // sensor's reply to the reading request itself was lost. It keeps + // the session current and the signal-loss alert armed just as a + // live reading would. + if let newest = backfill.map({ $0.timestamp }).max() { + let newestDate = activationDate.addingTimeInterval(TimeInterval(newest)) + if newestDate > (state.latestReadingTimestamp ?? .distantPast) { + mutateState { state in + state.latestReadingTimestamp = newestDate + } + rearmSignalLossAlert() + } + } + let unit = LoopUnit.milligramsPerDeciliter let samples = backfill.compactMap { entry -> NewGlucoseSample? in diff --git a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift index c51d69b..294c804 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -16,10 +16,77 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { public var sensorID: String? public var activatedAt: Date? public var extendedVersion: ExtendedVersionMessage? + /// Firmware version and serial number, asked for once per sensor right + /// after the extended version. Only for display. + public var transmitterVersion: TransmitterVersionMessage? public var latestReading: G7GlucoseMessage? public var latestReadingTimestamp: Date? public var latestConnect: Date? - public var uploadReadings: Bool = true + + /// How readings are obtained from the sensor. + /// + /// Restored state written before direct pairing existed has no value + /// here, and defaulting those to `.eavesdropping` is what keeps an + /// in-progress session working across the upgrade: the user may not have + /// the pairing code for a sensor they already applied. Pairing switches + /// this to `.direct`. + public var sessionMode: G7SessionMode = .eavesdropping + + /// The 4-digit code printed on the sensor applicator. Direct mode only. + /// Kept so a stale shared key can be recovered from without asking the + /// user again, and cleared whenever the sensor is forgotten: it is only + /// ever valid for the sensor it came with. + public var pairingCode: String? + + /// The AES key derived by the pairing handshake. Direct mode only; while + /// it is present, reconnects skip the key exchange. + /// + /// This lives in the manager's plist state rather than the keychain, + /// matching how the rest of this state is persisted. It authenticates a + /// local Bluetooth link to a disposable sensor and grants nothing beyond + /// that sensor's own readings. + public var sharedKey: Data? + + /// The paired sensor's CoreBluetooth identifier, so a relaunch can + /// retrieve it directly instead of waiting for its next advertisement. + public var peripheralIdentifier: UUID? + + /// Why the sensor last refused us, and when. Direct mode only. Without + /// this a refusal (another phone took the sensor's slot, say) is + /// indistinguishable from signal loss. Cleared by the next reading. + public var lastAuthenticationFailure: String? + public var lastAuthenticationFailureDate: Date? + + /// Identifies the sensor and lifetime the session-timed alerts were last + /// scheduled for. Loop keeps those scheduled notifications across + /// relaunches, so they are only re-issued when this changes. + public var lifecycleAlertsScheduledFor: String? + + /// The sensor a failure alert has already been raised for, so it fires + /// once per sensor rather than on every reading that repeats the state. + public var sensorFailedAlertIssuedFor: String? + + /// The sensor a `sensorEnd` event has already been recorded for, so the + /// session is closed in Loop's history exactly once. + public var sensorEndRecordedFor: String? + + /// When this app paired with, or began following, the current sensor. + public var pairedAt: Date? + + /// The current sensor's failure, if it has failed: the algorithm state + /// that said so, and when it was first seen. + public var sensorFailureMessage: String? + public var sensorFailedAt: Date? + + /// The sensor before this one, kept until the next replacement. + public var previousSensor: G7SensorRecord? + + /// The latest calibration entered for this sensor, and the sensor's last + /// account of its calibration state. Direct mode only. + public var calibration: G7CalibrationRecord? + public var calibrationBounds: G7CalibrationBoundsMessage? + public var calibrationBoundsDate: Date? + /// When a suspected session end started its grace period, or nil if none is /// pending. Persisted so a grace period survives app termination: the deferred /// scan is an in-memory work item, so without this a genuinely ended session @@ -29,6 +96,16 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { init() { } + /// The subset of this state the sensor session needs to reach its sensor. + var sensorCredentials: G7SensorCredentials { + G7SensorCredentials( + sensorID: sensorID, + pairingCode: pairingCode, + sharedKey: sharedKey, + peripheralIdentifier: peripheralIdentifier + ) + } + public init(rawValue: RawValue) { self.sensorID = rawValue["sensorID"] as? String self.activatedAt = rawValue["activatedAt"] as? Date @@ -38,9 +115,27 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { if let extendedVersionData = rawValue["extendedVersion"] as? Data { extendedVersion = ExtendedVersionMessage(data: extendedVersionData) } + if let transmitterVersionData = rawValue["transmitterVersion"] as? Data { + transmitterVersion = TransmitterVersionMessage(data: transmitterVersionData) + } self.latestReadingTimestamp = rawValue["latestReadingTimestamp"] as? Date self.latestConnect = rawValue["latestConnect"] as? Date - self.uploadReadings = rawValue["uploadReadings"] as? Bool ?? true + self.sessionMode = (rawValue["sessionMode"] as? String).flatMap(G7SessionMode.init(rawValue:)) ?? .eavesdropping + self.pairingCode = rawValue["pairingCode"] as? String + self.sharedKey = rawValue["sharedKey"] as? Data + self.peripheralIdentifier = (rawValue["peripheralIdentifier"] as? String).flatMap(UUID.init(uuidString:)) + self.lastAuthenticationFailure = rawValue["lastAuthenticationFailure"] as? String + self.lastAuthenticationFailureDate = rawValue["lastAuthenticationFailureDate"] as? Date + self.lifecycleAlertsScheduledFor = rawValue["lifecycleAlertsScheduledFor"] as? String + self.sensorFailedAlertIssuedFor = rawValue["sensorFailedAlertIssuedFor"] as? String + self.sensorEndRecordedFor = rawValue["sensorEndRecordedFor"] as? String + self.pairedAt = rawValue["pairedAt"] as? Date + self.sensorFailureMessage = rawValue["sensorFailureMessage"] as? String + self.sensorFailedAt = rawValue["sensorFailedAt"] as? Date + self.previousSensor = (rawValue["previousSensor"] as? G7SensorRecord.RawValue).flatMap(G7SensorRecord.init(rawValue:)) + self.calibration = (rawValue["calibration"] as? G7CalibrationRecord.RawValue).flatMap(G7CalibrationRecord.init(rawValue:)) + self.calibrationBounds = (rawValue["calibrationBounds"] as? Data).flatMap(G7CalibrationBoundsMessage.init(data:)) + self.calibrationBoundsDate = rawValue["calibrationBoundsDate"] as? Date self.suspectedSessionEndAt = rawValue["suspectedSessionEndAt"] as? Date } @@ -50,9 +145,25 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { rawValue["activatedAt"] = activatedAt rawValue["latestReading"] = latestReading?.data rawValue["extendedVersion"] = extendedVersion?.data + rawValue["transmitterVersion"] = transmitterVersion?.data rawValue["latestReadingTimestamp"] = latestReadingTimestamp rawValue["latestConnect"] = latestConnect - rawValue["uploadReadings"] = uploadReadings + rawValue["sessionMode"] = sessionMode.rawValue + rawValue["pairingCode"] = pairingCode + rawValue["sharedKey"] = sharedKey + rawValue["peripheralIdentifier"] = peripheralIdentifier?.uuidString + rawValue["lastAuthenticationFailure"] = lastAuthenticationFailure + rawValue["lastAuthenticationFailureDate"] = lastAuthenticationFailureDate + rawValue["lifecycleAlertsScheduledFor"] = lifecycleAlertsScheduledFor + rawValue["sensorFailedAlertIssuedFor"] = sensorFailedAlertIssuedFor + rawValue["sensorEndRecordedFor"] = sensorEndRecordedFor + rawValue["pairedAt"] = pairedAt + rawValue["sensorFailureMessage"] = sensorFailureMessage + rawValue["sensorFailedAt"] = sensorFailedAt + rawValue["previousSensor"] = previousSensor?.rawValue + rawValue["calibration"] = calibration?.rawValue + rawValue["calibrationBounds"] = calibrationBounds?.data + rawValue["calibrationBoundsDate"] = calibrationBoundsDate rawValue["suspectedSessionEndAt"] = suspectedSessionEndAt return rawValue } diff --git a/G7SensorKit/G7CGMManager/G7CalibrationRecord.swift b/G7SensorKit/G7CGMManager/G7CalibrationRecord.swift new file mode 100644 index 0000000..15eb61b --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7CalibrationRecord.swift @@ -0,0 +1,78 @@ +// +// G7CalibrationRecord.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// The most recent calibration entered for the current sensor and what +/// became of it. The sensor is the source of truth (`G7CalibrationBoundsMessage`); +/// this is what the settings screen shows while that plays out. +public struct G7CalibrationRecord: RawRepresentable, Equatable { + public typealias RawValue = [String: Any] + + public enum Outcome: Equatable { + /// Queued for the sensor's next connection, or sent and unanswered. + case pending + case accepted(at: Date) + case rejected(status: UInt16, at: Date) + } + + /// The meter value, mg/dL. + public let glucose: UInt16 + public let enteredAt: Date + public var outcome: Outcome + /// From the bounds answers that follow an accepted calibration. + public var processingStatus: G7CalibrationProcessingStatus? + + public init(glucose: UInt16, enteredAt: Date, outcome: Outcome = .pending, processingStatus: G7CalibrationProcessingStatus? = nil) { + self.glucose = glucose + self.enteredAt = enteredAt + self.outcome = outcome + self.processingStatus = processingStatus + } + + public init?(rawValue: RawValue) { + guard let glucose = rawValue["glucose"] as? UInt16 ?? (rawValue["glucose"] as? Int).map(UInt16.init), + let enteredAt = rawValue["enteredAt"] as? Date, + let outcomeName = rawValue["outcome"] as? String + else { + return nil + } + self.glucose = glucose + self.enteredAt = enteredAt + switch outcomeName { + case "accepted": + guard let at = rawValue["outcomeAt"] as? Date else { return nil } + outcome = .accepted(at: at) + case "rejected": + guard let at = rawValue["outcomeAt"] as? Date, let status = rawValue["status"] as? Int else { return nil } + outcome = .rejected(status: UInt16(truncatingIfNeeded: status), at: at) + default: + outcome = .pending + } + processingStatus = (rawValue["processingStatus"] as? Int).map { G7CalibrationProcessingStatus(byte: UInt8(truncatingIfNeeded: $0)) } + } + + public var rawValue: RawValue { + var raw: RawValue = [ + "glucose": Int(glucose), + "enteredAt": enteredAt, + ] + switch outcome { + case .pending: + raw["outcome"] = "pending" + case .accepted(let at): + raw["outcome"] = "accepted" + raw["outcomeAt"] = at + case .rejected(let status, let at): + raw["outcome"] = "rejected" + raw["outcomeAt"] = at + raw["status"] = Int(status) + } + raw["processingStatus"] = processingStatus.map { Int($0.rawValue) } + return raw + } +} diff --git a/G7SensorKit/G7CGMManager/G7LifecycleAlert.swift b/G7SensorKit/G7CGMManager/G7LifecycleAlert.swift new file mode 100644 index 0000000..b1883e6 --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7LifecycleAlert.swift @@ -0,0 +1,141 @@ +// +// G7LifecycleAlert.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +/// The sensor-lifecycle alerts this plugin raises. Glucose alerts are Loop's; +/// these cover the things only the CGM plugin knows: session timing, sensor +/// failure, loss of readings, and a sensor that refuses us. +/// +/// The time-based ones are scheduled ahead with a delayed trigger, which +/// Loop turns into a local notification, so they fire whether or not the app +/// is running when the moment comes. +enum G7LifecycleAlert: String, CaseIterable { + /// 24 hours before the nominal end of session. + case sensorExpiringSoon + /// 2 hours before the nominal end of session. + case sensorExpiringImminently + /// Nominal end of session; readings continue through the grace period. + case sensorExpired + /// End of the grace period; readings stop. + case sessionEnded + case sensorFailed + case signalLoss + /// The sensor accepted the code but refused the connection, or a stored + /// key stopped working: something the user has to act on. + case connectionRefused + + /// How long before nominal expiry each warning fires. + static let expiringSoonLeadTime = TimeInterval(hours: 24) + static let expiringImminentlyLeadTime = TimeInterval(hours: 2) + + /// How long without a reading before signal loss is raised. Readings are + /// 5 minutes apart, so this is three misses plus slack. + static let signalLossInterval = TimeInterval(minutes: 20) + + /// The alerts whose timing follows the session clock, and so are + /// (re)scheduled together whenever the sensor or its lifetime changes. + static let sessionTimed: [G7LifecycleAlert] = [.sensorExpiringSoon, .sensorExpiringImminently, .sensorExpired, .sessionEnded] + + func identifier(managerIdentifier: String) -> Alert.Identifier { + Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: rawValue) + } + + /// Reminders you can act on in your own time are `.active`; things that + /// have stopped, or are about to stop, readings interrupt. + var interruptionLevel: Alert.InterruptionLevel { + switch self { + case .sensorExpiringSoon: + return .active + case .sensorExpiringImminently, .sensorExpired, .sessionEnded, .signalLoss, .connectionRefused: + return .timeSensitive + case .sensorFailed: + return .critical + } + } + + var title: String { + switch self { + case .sensorExpiringSoon: + return LocalizedString("Sensor Expires in 24 Hours", comment: "Alert title 24 hours before sensor expiry") + case .sensorExpiringImminently: + return LocalizedString("Sensor Expires in 2 Hours", comment: "Alert title 2 hours before sensor expiry") + case .sensorExpired: + return LocalizedString("Sensor Expired", comment: "Alert title at nominal sensor expiry") + case .sessionEnded: + return LocalizedString("Sensor Session Ended", comment: "Alert title at the end of the sensor grace period") + case .sensorFailed: + return LocalizedString("Sensor Failed", comment: "Alert title for a failed sensor") + case .signalLoss: + return LocalizedString("No Sensor Readings", comment: "Alert title for signal loss") + case .connectionRefused: + return LocalizedString("Sensor Connection Refused", comment: "Alert title when the sensor refuses authentication") + } + } + + var body: String { + switch self { + case .sensorExpiringSoon: + return LocalizedString("Your sensor session ends in about a day. Have a new sensor ready.", comment: "Alert body 24 hours before sensor expiry") + case .sensorExpiringImminently: + return LocalizedString("Your sensor session ends in about 2 hours. Change your sensor soon to avoid a gap in readings.", comment: "Alert body 2 hours before sensor expiry") + case .sensorExpired: + return LocalizedString("Your sensor has reached the end of its session. Readings continue for up to 12 more hours; replace it before then.", comment: "Alert body at nominal sensor expiry") + case .sessionEnded: + return LocalizedString("Your sensor session has ended and readings have stopped. Apply and pair a new sensor.", comment: "Alert body at the end of the grace period") + case .sensorFailed: + return LocalizedString("Your sensor has stopped working and is not sending readings. Remove it and start a new sensor. Check your glucose with a meter in the meantime.", comment: "Alert body for a failed sensor") + case .signalLoss: + return LocalizedString("No readings have arrived for 20 minutes. Keep your phone within range of the sensor. If this continues, check your glucose with a meter.", comment: "Alert body for signal loss") + case .connectionRefused: + return LocalizedString("The sensor refused to connect. It may be in use by another phone or app. Open the CGM settings for details.", comment: "Alert body when the sensor refuses authentication") + } + } + + func alert(managerIdentifier: String, trigger: Alert.Trigger = .immediate) -> Alert { + let content = Alert.Content( + title: title, + body: body, + acknowledgeActionButtonLabel: LocalizedString("OK", comment: "Alert acknowledgment button label") + ) + return Alert( + identifier: identifier(managerIdentifier: managerIdentifier), + foregroundContent: content, + backgroundContent: content, + trigger: trigger, + interruptionLevel: interruptionLevel + ) + } +} + +/// Pure timing for the session-clock alerts, so it can be tested without a +/// manager: which alerts still lie ahead from `now`, and how far. +enum G7LifecycleAlertSchedule { + static func delays( + sensorExpiresAt: Date, + sensorEndsAt: Date, + now: Date + ) -> [G7LifecycleAlert: TimeInterval] { + let fireDates: [G7LifecycleAlert: Date] = [ + .sensorExpiringSoon: sensorExpiresAt.addingTimeInterval(-G7LifecycleAlert.expiringSoonLeadTime), + .sensorExpiringImminently: sensorExpiresAt.addingTimeInterval(-G7LifecycleAlert.expiringImminentlyLeadTime), + .sensorExpired: sensorExpiresAt, + .sessionEnded: sensorEndsAt + ] + var delays: [G7LifecycleAlert: TimeInterval] = [:] + for (alert, date) in fireDates { + let delay = date.timeIntervalSince(now) + // A moment already behind us is not worth an alert on its own; + // whichever later one still applies will say what matters. + if delay > 0 { + delays[alert] = delay + } + } + return delays + } +} diff --git a/G7SensorKit/G7CGMManager/G7PeripheralManager+Handshake.swift b/G7SensorKit/G7CGMManager/G7PeripheralManager+Handshake.swift new file mode 100644 index 0000000..a7fca9b --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7PeripheralManager+Handshake.swift @@ -0,0 +1,80 @@ +// +// G7PeripheralManager+Handshake.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CoreBluetooth +import Foundation + +/// Characteristic-name-addressed I/O, so the pairing handshake can talk in +/// terms of "the authentication characteristic" rather than resolving +/// `CBCharacteristic` objects at every step. +extension G7PeripheralManager { + + /// The largest payload the sensor accepts in one write on the certificate + /// characteristic. Fixed at the classic ATT default rather than negotiated: + /// the sensor reassembles by byte count, and a larger MTU buys nothing. + static let certificateChunkSize = 20 + + /// Pause between certificate chunks. Without it a long payload outruns the + /// sensor's reassembly on a write-without-response characteristic. + static let certificateChunkInterval: TimeInterval = 0.04 + + func characteristic(_ uuid: CGMServiceCharacteristicUUID) throws -> CBCharacteristic { + guard let service = peripheral.services?.itemWithUUID(SensorServiceUUID.cgmService.cbUUID) else { + throw PeripheralManagerError.invalidConfiguration + } + guard let characteristic = service.characteristics?.itemWithUUID(uuid.cbUUID) else { + throw PeripheralManagerError.unknownCharacteristic + } + return characteristic + } + + func writeValue( + _ value: Data, + for uuid: CGMServiceCharacteristicUUID, + type: CBCharacteristicWriteType, + timeout: TimeInterval = 5 + ) throws { + try writeValue(value, for: characteristic(uuid), type: type, timeout: timeout) + } + + /// Streams `data` to the certificate characteristic in the chunk size the + /// sensor expects. + func writeCertificateBytes(_ data: Data) throws { + var offset = data.startIndex + while offset < data.endIndex { + let end = data.index(offset, offsetBy: G7PeripheralManager.certificateChunkSize, limitedBy: data.endIndex) + ?? data.endIndex + try writeValue(data[offset ..< end], for: .certificate, type: .withoutResponse) + offset = end + if offset < data.endIndex { + Thread.sleep(forTimeInterval: G7PeripheralManager.certificateChunkInterval) + } + } + } + + /// A one-line summary of a characteristic's discovered properties, for the + /// pairing log. Which properties a sensor actually advertises has varied + /// across firmware, and it is the first thing worth knowing when a + /// handshake stalls on a real device. + func describeCharacteristic(_ uuid: CGMServiceCharacteristicUUID) -> String { + guard let characteristic = try? characteristic(uuid) else { + return "\(uuid) not discovered" + } + var properties = [String]() + let flags: [(CBCharacteristicProperties, String)] = [ + (.read, "read"), + (.write, "write"), + (.writeWithoutResponse, "writeWithoutResponse"), + (.notify, "notify"), + (.indicate, "indicate") + ] + for (flag, name) in flags where characteristic.properties.contains(flag) { + properties.append(name) + } + return "\(uuid) [\(properties.joined(separator: ","))] notifying=\(characteristic.isNotifying)" + } +} diff --git a/G7SensorKit/G7CGMManager/G7PeripheralManager.swift b/G7SensorKit/G7CGMManager/G7PeripheralManager.swift index 34f7d86..51aed90 100644 --- a/G7SensorKit/G7CGMManager/G7PeripheralManager.swift +++ b/G7SensorKit/G7CGMManager/G7PeripheralManager.swift @@ -42,6 +42,15 @@ class G7PeripheralManager: NSObject { } } + /// Makes this manager the peripheral's delegate again, in case another + /// manager for the same peripheral took the role in the meantime. + func reclaimPeripheral() { + if peripheral.delegate !== self { + log.error("Reclaiming peripheral %{public}@ from %{public}@", peripheral, String(describing: peripheral.delegate)) + peripheral.delegate = self + } + } + /// The dispatch queue used to serialize operations on the peripheral let queue = DispatchQueue(label: "com.loopkit.PeripheralManager.queue", qos: .unspecified) @@ -54,6 +63,14 @@ class G7PeripheralManager: NSObject { /// Any error surfaced during the active operation private var commandError: Error? + /// Persistent per-characteristic update handlers. The pairing handshake + /// installs these to collect streamed chunks, which the one-shot command + /// conditions cannot do: the sensor starts streaming on the certificate + /// characteristic before its acknowledgement lands on the authentication + /// characteristic, and acknowledgements themselves can arrive while our + /// own write is still pending. Guarded by `commandLock`. + private var valueUpdateHandlers: [CBUUID: (Data) -> Void] = [:] + private(set) weak var central: CBCentralManager? let configuration: Configuration @@ -81,6 +98,15 @@ class G7PeripheralManager: NSObject { assertConfiguration() } + /// Installs (or with a nil handler, removes) a persistent handler that + /// receives every value update for `characteristic`, ahead of the + /// unsolicited-notification path to the delegate. + func setValueUpdateHandler(for characteristic: CGMServiceCharacteristicUUID, handler: ((Data) -> Void)?) { + commandLock.lock() + valueUpdateHandlers[characteristic.cbUUID] = handler + commandLock.unlock() + } + func requestExtendedVersion() throws { self.log.default("Requesting sensor extended version"); guard let service = peripheral.services?.itemWithUUID(SensorServiceUUID.cgmService.cbUUID) else { @@ -426,9 +452,11 @@ extension G7PeripheralManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { commandLock.lock() + // On an error the state does not change, so match the characteristic + // alone; otherwise the error is lost and the command only times out. if let index = commandConditions.firstIndex(where: { (condition) -> Bool in - if case .notificationStateUpdate(characteristicUUID: characteristic.uuid, enabled: characteristic.isNotifying) = condition { - return true + if case .notificationStateUpdate(characteristicUUID: characteristic.uuid, enabled: let enabled) = condition { + return error != nil || enabled == characteristic.isNotifying } else { return false } @@ -469,6 +497,7 @@ extension G7PeripheralManager: CBPeripheralDelegate { commandLock.lock() var notifyDelegate = false + var streamedValue: (handler: (Data) -> Void, value: Data)? if let index = commandConditions.firstIndex(where: { (condition) -> Bool in if case .valueUpdate(characteristic: characteristic, matching: let matching) = condition { @@ -483,14 +512,29 @@ extension G7PeripheralManager: CBPeripheralDelegate { if commandConditions.isEmpty { commandLock.broadcast() } + } else if let handler = valueUpdateHandlers[characteristic.uuid], let value = characteristic.value { + // Deliberately ahead of the `commandConditions.isEmpty` gate below: + // handshake traffic arrives while our own writes are still pending, + // and dropping it there is what an installed handler exists to avoid. + streamedValue = (handler, value) // execute after the unlock } else if let macro = configuration.valueUpdateMacros[characteristic.uuid] { macro(self) - } else if commandConditions.isEmpty { + } else { + // Unconditionally, pending command or not. The sensor answers a + // control write with a notification a moment after the write + // response, and if the next write (a backfill request) is already + // in flight by then, gating on "no command pending" threw the + // glucose reply away. Seen in the field as signal loss while the + // sensor connected on schedule every five minutes. notifyDelegate = true // execute after the unlock } commandLock.unlock() + if let streamedValue = streamedValue { + streamedValue.handler(streamedValue.value) + } + if notifyDelegate { // If we weren't expecting this notification, pass it along to the delegate delegate?.peripheralManager(self, didUpdateValueFor: characteristic) diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index 70578c7..1a8bb53 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -8,6 +8,7 @@ import Foundation import CoreBluetooth +import LoopKit import os.log @@ -18,8 +19,13 @@ public protocol G7SensorDelegate: AnyObject { func sensor(_ sensor: G7Sensor, didError error: Error) + /// A received frame, as hex. Kept for the raw-receive lines. func sensor(_ sensor: G7Sensor, logComms comms: String) + /// A device-log entry of the given type: sends as hex, connection events, + /// handshake narration. Mirrors what the pump plugins record. + func sensor(_ sensor: G7Sensor, log message: String, type: DeviceLogEntryType) + func sensor(_ sensor: G7Sensor, didRead glucose: G7GlucoseMessage) func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) @@ -29,8 +35,25 @@ public protocol G7SensorDelegate: AnyObject { func sensor(_ sensor: G7Sensor, didReceive extendedVersion: ExtendedVersionMessage) + func sensor(_ sensor: G7Sensor, didReceive transmitterVersion: TransmitterVersionMessage) + + /// The sensor's answer to a calibration we sent. + func sensor(_ sensor: G7Sensor, didReceiveCalibrationResponse response: G7CalibrateRxMessage) + + /// The sensor's calibration state, after a calibration or on request. + func sensor(_ sensor: G7Sensor, didReadCalibrationBounds bounds: G7CalibrationBoundsMessage) + // This is triggered for connection/disconnection events, and enabling/disabling scan func sensorConnectionStatusDidUpdate(_ sensor: G7Sensor) + + /// Direct mode only: a handshake produced a new shared key, which the + /// caller must persist so later reconnects can skip the key exchange. + func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?) + + /// Direct mode only: the stored shared key is no longer accepted by the + /// sensor. It has been discarded; if a pairing code is still held, the + /// next connection will run a full handshake. + func sensorDidInvalidateSharedKey(_ sensor: G7Sensor) } public enum G7SensorError: Error { @@ -53,7 +76,16 @@ extension G7SensorError: CustomStringConvertible { } public enum G7SensorLifecycleState { + /// No sensor known and nothing to pair with: an eavesdropping session + /// waiting for the Dexcom app to start one. case searching + /// Direct mode with nothing to connect to yet: the CGM was added but no + /// sensor has been paired. + case unpaired + /// Paired, but the sensor has not reported its first reading yet. It + /// links only briefly around each 5-minute reading, so this can last a + /// few minutes after pairing. + case connecting case warmup case ok case failed @@ -61,15 +93,49 @@ public enum G7SensorLifecycleState { case expired } +/// Everything needed to reach a particular sensor. Held behind a lock: the +/// handshake completes on the peripheral manager's queue, while scanning and +/// the public setters run elsewhere. +struct G7SensorCredentials: Equatable { + /// The sensor's advertised name, which is also how a session recognizes + /// its own sensor across reconnects. + var sensorID: String? + + /// The 4-digit code printed on the applicator. Direct mode only, and only + /// valid for the sensor it came with. + var pairingCode: String? + + /// The key derived at pairing. Direct mode only; its presence is what + /// lets a reconnect skip the key exchange. + var sharedKey: Data? + + /// The paired sensor's CoreBluetooth identifier, so a relaunch can go + /// straight to it instead of waiting for an advertisement. + var peripheralIdentifier: UUID? +} + public final class G7Sensor: G7BluetoothManagerDelegate { public static let defaultLifetime = TimeInterval(hours: 10 * 24) public static let defaultWarmupDuration = TimeInterval(minutes: 27) public static let gracePeriod = TimeInterval(hours: 12) + /// How far back to ask the sensor to backfill after a direct-mode + /// reconnect when nothing is known about the gap (a fresh pairing). + static let backfillWindow = TimeInterval(hours: 3) + + /// The most a single backfill request will ask for when the gap since + /// the last reading is known. Anything the sensor no longer holds it + /// simply omits. + static let maximumBackfillWindow = TimeInterval(hours: 24) + public weak var delegate: G7SensorDelegate? - // MARK: - Passive observation state, confined to `bluetoothManager.managerQueue` + /// How readings are obtained. Changed in place by `reconfigure`, so the + /// Bluetooth central (and any connection it holds) survives a pairing. + public private(set) var mode: G7SessionMode + + // MARK: - Session state, confined to `bluetoothManager.managerQueue` /// The initial activation date of the sensor var activationDate: Date? @@ -77,43 +143,160 @@ public final class G7Sensor: G7BluetoothManagerDelegate { /// The initial activation date of the sensor var needsVersionInfo: Bool = false - /// The date of last connection - private var lastConnection: Date? - /// Used to detect connections that do not authenticate, signalling possible sensor switchover private var pendingAuth: Bool = false /// The backfill data buffer private var backfillBuffer: [G7BackfillMessage] = [] + /// When the newest reading we hold was taken. Sizes the backfill request + /// after a direct-mode reconnect. Seeded by the manager from its saved + /// state, so a relaunch asks for the gap rather than a default window. + var latestReadingDate: Date? + + /// Set when a session opens; the backfill request goes out once the + /// reading reply has arrived, so the sensor never has two of our + /// commands outstanding. Confined to `bluetoothManager.managerQueue`. + private var backfillRequestPending = false + + /// A calibration waiting for the sensor's next connection: the link is + /// only up for a few seconds around each reading, so one entered between + /// readings has to wait. Newest wins. Settable from any queue. + private let lockedPendingCalibration = Locked<(glucose: UInt16, date: Date)?>(nil) + + /// Whether to ask for the calibration state on the next connection. + private let lockedCalibrationBoundsRequestPending = Locked(false) + // MARK: - private let log = OSLog(category: "G7Sensor") - private let bluetoothManager: G7BluetoothManager + /// Shared with a pairing run, which borrows it as its central; see + /// `G7BluetoothManager.init`. + let bluetoothManager: G7BluetoothManager private let delegateQueue = DispatchQueue(label: "com.loopkit.G7Sensor.delegateQueue", qos: .unspecified) - private var sensorID: String? + private let lockedCredentials: Locked + + var credentials: G7SensorCredentials { + lockedCredentials.value + } - public convenience init(sensorID: String?) { - self.init(sensorID: sensorID, bluetoothManager: G7BluetoothManager()) + public var sensorID: String? { + lockedCredentials.value.sensorID } - init(sensorID: String?, bluetoothManager: G7BluetoothManager) { - self.sensorID = sensorID + /// Which of the sensor's display slots this session takes: a phone by + /// default; a watch app would take its own, alongside the phone's. + let displayType: G7DisplayType + + convenience init(mode: G7SessionMode, credentials: G7SensorCredentials, displayType: G7DisplayType = .phone) { + self.init(mode: mode, credentials: credentials, bluetoothManager: G7BluetoothManager(), displayType: displayType) + } + + init(mode: G7SessionMode, credentials: G7SensorCredentials, bluetoothManager: G7BluetoothManager, displayType: G7DisplayType = .phone) { + self.mode = mode + self.displayType = displayType + self.lockedCredentials = Locked(credentials) self.bluetoothManager = bluetoothManager bluetoothManager.delegate = self + bluetoothManager.setActivePeripheralIdentifier(credentials.peripheralIdentifier) + } + + private func mutateCredentials(_ changes: (inout G7SensorCredentials) -> Void) { + _ = lockedCredentials.mutate(changes) + } + + /// Whether a peripheral is the sensor these credentials describe. + /// + /// By identifier once one is known. Otherwise by the last two characters + /// of the name, because the name itself changes: a sensor advertises as + /// "DXCMxx" but reports "Dexcomxx" over GAP once connected, and a + /// peripheral retrieved for a reconnect carries the latter. Comparing + /// whole names silently stopped every reconnect after the first. + static func isSensor(identifier: UUID, name: String?, describedBy credentials: G7SensorCredentials) -> Bool { + if let known = credentials.peripheralIdentifier { + return identifier == known + } + guard let sensorID = credentials.sensorID, let name = name else { + return false + } + return name.suffix(2) == sensorID.suffix(2) + } + + /// Names a G7-family sensor uses: the model prefixes ("DXCMxx", + /// "DX02xx", "DX01xx") in advertisements, "Dexcomxx" once connected. + static func isSensorName(_ name: String) -> Bool { + G7SensorModel.isFamilyName(name) + } + + private func isOurSensor(_ peripheralManager: G7PeripheralManager) -> Bool { + G7Sensor.isSensor( + identifier: peripheralManager.peripheral.identifier, + name: peripheralManager.peripheral.name, + describedBy: lockedCredentials.value + ) + } + + private func logToDevice(_ message: String, type: DeviceLogEntryType) { + delegateQueue.async { + self.delegate?.sensor(self, log: message, type: type) + } + } + + private func logSend(_ data: Data, on characteristic: CGMServiceCharacteristicUUID) { + logToDevice("\(characteristic) \(data.hexadecimalString)", type: .send) + } + + /// Points this session at a different sensor (or a different way of + /// reaching the same one) without rebuilding it. Everything learned about + /// the previous sensor is forgotten; the first reading re-establishes it. + func reconfigure(mode: G7SessionMode, credentials: G7SensorCredentials) { + self.mode = mode + lockedCredentials.value = credentials + activationDate = nil + latestReadingDate = nil + needsVersionInfo = true + pendingAuth = false + backfillBuffer = [] + bluetoothManager.delegate = self + bluetoothManager.setActivePeripheralIdentifier(credentials.peripheralIdentifier) + } + + /// Takes over a connection on which a pairing run has just authenticated, + /// and opens the session on it straight away. Without this the sensor + /// would be dropped and reconnected on its next 5-minute advertisement, + /// leaving a gap right after "paired". + func adoptAuthenticatedConnection(_ peripheralManager: G7PeripheralManager) { + bluetoothManager.delegate = self + bluetoothManager.adoptAsActive(peripheralManager) + if let name = peripheralManager.peripheral.name { + delegateQueue.async { + self.delegate?.sensorDidConnect(self, name: name) + } + } + beginSession(peripheralManager) } public func scanForNewSensor() { - self.sensorID = nil + // The pairing code and key belong to the sensor being replaced, not to + // whatever comes next. Keeping them would make every candidate fail + // its handshake, so a replacement can only be adopted after pairing. + mutateCredentials { credentials in + credentials.sensorID = nil + credentials.pairingCode = nil + credentials.sharedKey = nil + credentials.peripheralIdentifier = nil + } + bluetoothManager.setActivePeripheralIdentifier(nil) bluetoothManager.disconnect() bluetoothManager.forgetPeripheral() bluetoothManager.scanForPeripheral() } public func resumeScanning() { + bluetoothManager.setActivePeripheralIdentifier(lockedCredentials.value.peripheralIdentifier) bluetoothManager.scanForPeripheral() } @@ -129,8 +312,80 @@ public final class G7Sensor: G7BluetoothManagerDelegate { return bluetoothManager.isConnected } + // MARK: - Calibration + + /// Queues a meter glucose for the sensor. It goes out after the reading + /// reply on the next connection, timestamped with `date` on the sensor's + /// clock, so it does not matter that the link is down right now. + public func calibrate(glucose: UInt16, at date: Date) { + lockedPendingCalibration.value = (glucose, date) + } + + public var queuedCalibration: (glucose: UInt16, date: Date)? { + lockedPendingCalibration.value + } + + /// Drops a calibration that has not gone out yet. + public func cancelPendingCalibration() { + lockedPendingCalibration.value = nil + } + + /// Asks for the sensor's calibration state on the next connection. + public func requestCalibrationBounds() { + lockedCalibrationBoundsRequestPending.value = true + } + + private func sendPendingCalibration(_ peripheral: G7PeripheralManager) { + guard let activationDate = activationDate else { + return + } + if let calibration = lockedPendingCalibration.value { + lockedPendingCalibration.value = nil + let sensorAge = UInt32(max(0, calibration.date.timeIntervalSince(activationDate))) + let request = G7CalibrateTxMessage(glucose: calibration.glucose, sensorAge: sensorAge).data + logToDevice("Sending calibration \(calibration.glucose) mg/dL taken at sensor age \(sensorAge)s", type: .connection) + logSend(request, on: .control) + do { + try peripheral.writeValue(request, for: .control, type: .withResponse) + } catch let error { + log.error("Error sending calibration: %{public}@", String(describing: error)) + logToDevice("Calibration send failed: \(error)", type: .error) + } + } + if lockedCalibrationBoundsRequestPending.value { + lockedCalibrationBoundsRequestPending.value = false + sendCalibrationBoundsRequest(peripheral) + } + } + + private func sendCalibrationBoundsRequest(_ peripheral: G7PeripheralManager) { + let request = Data([G7Opcode.calibrationBounds.rawValue]) + logSend(request, on: .control) + do { + try peripheral.writeValue(request, for: .control, type: .withResponse) + } catch let error { + log.error("Error requesting calibration bounds: %{public}@", String(describing: error)) + } + } + private func handleGlucoseMessage(message: G7GlucoseMessage, peripheralManager: G7PeripheralManager) { activationDate = Date().addingTimeInterval(-TimeInterval(message.messageTimestamp)) + let credentials = lockedCredentials.value + + if mode == .direct, credentials.sensorID != nil, isOurSensor(peripheralManager) { + peripheralManager.perform { peripheral in + self.sendPendingCalibration(peripheral) + } + } + + if backfillRequestPending { + backfillRequestPending = false + let latest = latestReadingDate + peripheralManager.perform { peripheral in + self.requestBackfillIfNeeded(peripheral, latestReadingDate: latest) + } + } + peripheralManager.perform { (peripheral) in self.log.default("Listening for backfill responses") // Subscribe to backfill updates @@ -144,9 +399,10 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } - if needsVersionInfo, let name = peripheralManager.peripheral.name, name == sensorID { + if needsVersionInfo, credentials.sensorID != nil, isOurSensor(peripheralManager) { peripheralManager.perform { (peripheral) in do { + self.logSend(Data([G7Opcode.extendedVersionTx.rawValue]), on: .control) try peripheral.requestExtendedVersion() } catch let error { self.log.error("Error trying to request extended version: %{public}@", String(describing: error)) @@ -154,21 +410,27 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } - if sensorID == nil, let name = peripheralManager.peripheral.name, let activationDate = activationDate { + if credentials.sensorID == nil, let name = peripheralManager.peripheral.name, let activationDate = activationDate { delegateQueue.async { guard let delegate = self.delegate else { return } if delegate.sensor(self, didDiscoverNewSensor: name, activatedAt: activationDate) { - self.sensorID = name + self.mutateCredentials { credentials in + credentials.sensorID = name + credentials.peripheralIdentifier = peripheralManager.peripheral.identifier + } + self.bluetoothManager.setActivePeripheralIdentifier(peripheralManager.peripheral.identifier) self.activationDate = activationDate self.needsVersionInfo = true + self.latestReadingDate = activationDate.addingTimeInterval(TimeInterval(message.messageTimestamp)) self.delegate?.sensor(self, didRead: message) self.bluetoothManager.stopScanning() - if self.needsVersionInfo, let name = peripheralManager.peripheral.name, name == self.sensorID { + if self.needsVersionInfo, self.isOurSensor(peripheralManager) { peripheralManager.perform { (peripheral) in do { + self.logSend(Data([G7Opcode.extendedVersionTx.rawValue]), on: .control) try peripheral.requestExtendedVersion() } catch let error { self.log.error("Error trying to request extended version on initial detection: %{public}@", String(describing: error)) @@ -177,7 +439,8 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } } - } else if sensorID != nil { + } else if credentials.sensorID != nil { + latestReadingDate = activationDate?.addingTimeInterval(TimeInterval(message.messageTimestamp)) delegateQueue.async { self.delegate?.sensor(self, didRead: message) } @@ -186,29 +449,203 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } + // MARK: - Direct mode + + /// Runs the handshake and, on success, opens the session. + private func authenticate(_ peripheralManager: G7PeripheralManager) { + let credentials = lockedCredentials.value + + guard credentials.sharedKey != nil || credentials.pairingCode != nil else { + // Without either, a handshake would run the key exchange against + // whatever Dexcom device is in range and fail every time. Wait for + // the user to pair instead. + log.error("Not authenticating: no shared key or pairing code") + return + } + + pendingAuth = true + + let authenticator = G7Authenticator( + pairingCode: credentials.pairingCode, + storedSharedKey: credentials.sharedKey, + stepTimeout: credentials.sharedKey == nil + ? G7Authenticator.pairingStepTimeout + : G7Authenticator.reconnectStepTimeout, + displayType: displayType + ) + authenticator.logHandler = { [weak self] message in + self?.logToDevice(message, type: .connection) + } + + authenticator.authenticate(peripheralManager: peripheralManager) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let authResult): + self.pendingAuth = false + if authResult.didExchangeKeys { + self.mutateCredentials { $0.sharedKey = authResult.sharedKey } + self.delegateQueue.async { + self.delegate?.sensor( + self, + didAuthenticateWith: authResult.sharedKey, + deviceName: authResult.deviceName + ) + } + } + self.beginSession(peripheralManager) + case .failure(let error): + self.handleAuthenticationFailure(error) + } + } + } + + /// Subscribes to the session characteristics and asks for a reading right + /// away, mirroring what the official app does after authenticating. A + /// sensor only links briefly around each 5-minute reading, so waiting for + /// an unprompted broadcast can cost a whole cycle. + private func beginSession(_ peripheralManager: G7PeripheralManager) { + peripheralManager.perform { peripheral in + // A handshake can succeed and the sensor still drop the link + // before this runs (seen after the bond request). Nothing to open + // then; the reconnect authenticates with the stored key. + guard peripheral.peripheral.state == .connected else { + self.logToDevice("Link dropped before the session could open; waiting for the sensor to reconnect", type: .connection) + return + } + do { + try peripheral.listenToCharacteristic(.control) + try peripheral.listenToCharacteristic(.backfill) + // Ask for the reading first and the backfill only once its + // reply is in: the sensor handles one command at a time, and + // a backfill request sent on top of a pending GetEgv cost us + // the reading. + self.backfillRequestPending = true + let request = Data([G7Opcode.glucoseTx.rawValue]) + self.logSend(request, on: .control) + try peripheral.writeValue(request, for: .control, type: .withResponse) + } catch let error { + self.log.error("Error opening session: %{public}@", String(describing: error)) + self.delegateQueue.async { + self.delegate?.sensor(self, didError: error) + } + return + } + } + } + + /// Asks for the readings taken while we were not connected. Nothing else + /// requests them in direct mode, so without this a gap never fills. The + /// range starts at the last reading we have, so an outage longer than the + /// default window is still asked for in full (up to the cap); the sensor + /// returns what it holds and the manager drops duplicates by timestamp. + private func requestBackfillIfNeeded(_ peripheral: G7PeripheralManager, latestReadingDate: Date?) { + guard let activationDate = activationDate else { + return + } + + let now = Date() + let gapStart: Date + if let latestReadingDate = latestReadingDate { + gapStart = latestReadingDate + } else { + gapStart = now.addingTimeInterval(-G7Sensor.backfillWindow) + } + let earliest = max(activationDate, gapStart, now.addingTimeInterval(-G7Sensor.maximumBackfillWindow)) + guard earliest < now else { + return + } + + let start = UInt32(earliest.timeIntervalSince(activationDate)) + let end = UInt32(now.timeIntervalSince(activationDate)) + guard start < end else { + return + } + + var request = Data([G7Opcode.backfillFinished.rawValue]) + request.append(start.littleEndian) + request.append(end.littleEndian) + + do { + log.default("Requesting backfill from %{public}d to %{public}d", start, end) + logSend(request, on: .control) + try peripheral.writeValue(request, for: .control, type: .withResponse) + } catch let error { + log.error("Error requesting backfill: %{public}@", String(describing: error)) + logToDevice("Backfill request failed: \(error)", type: .error) + } + } + + private func handleAuthenticationFailure(_ error: Error) { + pendingAuth = false + log.error("Authentication failed: %{public}@", String(describing: error)) + + // A stale key is recoverable without the user: drop it, and the next + // connection runs a full handshake with the pairing code we still + // hold. Two signals say the key is stale: the sensor's answer to our + // challenge not matching (caught before we ever send a wrong reply, + // so the sensor records no rejection), and the sensor saying outright + // that it has no key for us. Without a code there is nothing to fall + // back to, so leave the key in place rather than locking the sensor + // out of reconnecting. + let keyIsStale: Bool + switch error { + case G7AuthenticatorError.challengeMismatch, + G7AuthenticatorError.rejected(_, .noAppKey): + keyIsStale = true + default: + keyIsStale = false + } + if keyIsStale, + lockedCredentials.value.sharedKey != nil, + lockedCredentials.value.pairingCode != nil + { + log.default("Discarding the stored shared key; the next connection will re-run the key exchange") + mutateCredentials { $0.sharedKey = nil } + delegateQueue.async { + self.delegate?.sensorDidInvalidateSharedKey(self) + } + return + } + + delegateQueue.async { + self.delegate?.sensor(self, didError: error) + } + } + // MARK: - BluetoothManagerDelegate func bluetoothManager(_ manager: G7BluetoothManager, readied peripheralManager: G7PeripheralManager) -> Bool { var shouldStopScanning = false; + let credentials = lockedCredentials.value - if let sensorID = sensorID, sensorID == peripheralManager.peripheral.name { + // Ours by identifier once paired, even before the first reading has + // supplied a sensor ID; otherwise the hand-off connection's events + // went unlogged and its disconnect left the pending flags set. + if isOurSensor(peripheralManager) { shouldStopScanning = true + let name = credentials.sensorID ?? peripheralManager.peripheral.name ?? "sensor" delegateQueue.async { - self.delegate?.sensorDidConnect(self, name: sensorID) + self.delegate?.sensorDidConnect(self, name: name) } } - peripheralManager.perform { (peripheral) in - // .default so this survives into a sysdiagnose: info and debug are - // memory-only and are not written to the log archive, which makes the - // auth handshake invisible in field diagnostics. - self.log.default("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name)) - do { - try peripheral.listenToCharacteristic(.authentication) - self.pendingAuth = true - } catch let error { - self.delegateQueue.async { - self.delegate?.sensor(self, didError: error) + switch mode { + case .direct: + authenticate(peripheralManager) + + case .eavesdropping: + peripheralManager.perform { (peripheral) in + // .default so this survives into a sysdiagnose: info and debug are + // memory-only and are not written to the log archive, which makes the + // auth handshake invisible in field diagnostics. + self.log.default("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name)) + do { + try peripheral.listenToCharacteristic(.authentication) + self.pendingAuth = true + } catch let error { + self.delegateQueue.async { + self.delegate?.sensor(self, didError: error) + } } } } @@ -222,7 +659,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } func peripheralDidDisconnect(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, wasRemoteDisconnect: Bool) { - if let sensorID = sensorID, sensorID == peripheralManager.peripheral.name { + if isOurSensor(peripheralManager) { // Sometimes we do not receive the backfillFinished message before disconnect flushBackfillBuffer() @@ -230,12 +667,19 @@ public final class G7Sensor: G7BluetoothManagerDelegate { let suspectedEndOfSession: Bool self.log.info("Sensor disconnected: wasRemoteDisconnect:%{public}@", String(describing: wasRemoteDisconnect)) - if pendingAuth, wasRemoteDisconnect { + logToDevice(wasRemoteDisconnect ? "Disconnected by sensor" : "Disconnected by phone", type: .connection) + // Only meaningful while eavesdropping, where the only reason to see + // an authenticated session appear is the Dexcom app creating one. + // In direct mode we authenticate ourselves and the sensor drops the + // link at the end of every reading cycle, so this would fire + // constantly; session end is read from the algorithm state instead. + if mode == .eavesdropping, pendingAuth, wasRemoteDisconnect { suspectedEndOfSession = true // Normal disconnect without auth is likely that G7 app stopped this session } else { suspectedEndOfSession = false } pendingAuth = false + backfillRequestPending = false delegateQueue.async { self.delegate?.sensorDisconnected(self, suspectedEndOfSession: suspectedEndOfSession) @@ -243,33 +687,68 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } - func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral) -> PeripheralConnectionCommand { + func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any]) -> PeripheralConnectionCommand { + + let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name - guard let name = peripheral.name else { + guard let name = name else { log.debug("Not connecting to unnamed peripheral: %{public}@", String(describing: peripheral)) return .ignore } - /// The Dexcom G7 advertises a peripheral name of "DXCMxx", and later reports a full name of "Dexcomxx" - /// Dexcom One+ peripheral name start with "DX02" - if name.hasPrefix("DXCM") || name.hasPrefix("DX02"){ - // If we're following this name or if we're scanning, connect - if let sensorName = sensorID, name.suffix(2) == sensorName.suffix(2) { - return .makeActive - } else if sensorID == nil { - return .connect + let credentials = lockedCredentials.value + + // Nothing to pair with yet: no code, no key. Connecting would only + // take a slot on whatever sensor is nearby. + if mode == .direct, credentials.pairingCode == nil, credentials.sharedKey == nil, credentials.peripheralIdentifier == nil { + return .ignore + } + + // Once paired, only ever the sensor we paired with, and by identifier, + // never by name: a peripheral retrieved for a reconnect carries the + // post-connection "Dexcomxx" name, not the advertised "DXCMxx". A + // sensor admits one display at a time, so connecting to a stranger's + // sensor would take its slot, and our key would not authenticate + // there anyway. + if mode == .direct, let identifier = credentials.peripheralIdentifier { + guard peripheral.identifier == identifier else { + return .ignore } + logToDevice("Connecting to \(name)", type: .connection) + return .makeActive + } + + guard G7Sensor.isSensorName(name) else { + log.info("Not connecting to peripheral: %{public}@", name) + return .ignore + } + + // If we're following this name or if we're scanning, connect + if let sensorName = credentials.sensorID, name.suffix(2) == sensorName.suffix(2) { + logToDevice("Connecting to \(name)", type: .connection) + return .makeActive + } else if credentials.sensorID == nil { + logToDevice("Connecting to \(name) to identify it", type: .connection) + return .connect } log.info("Not connecting to peripheral: %{public}@", name) return .ignore } + func bluetoothManagerShouldAcceptRestoredPeripherals(_ manager: G7BluetoothManager) -> Bool { + // A session wants its sensor back after a relaunch. + return true + } + func bluetoothManager(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, didReceiveControlResponse response: Data) { guard response.count > 0 else { return } log.default("Received control response: %{public}@", response.hexadecimalString) + delegateQueue.async { + self.delegate?.sensor(self, logComms: "control \(response.hexadecimalString)") + } switch G7Opcode(rawValue: response[0]) { case .glucoseTx?: @@ -286,10 +765,51 @@ public final class G7Sensor: G7BluetoothManagerDelegate { delegateQueue.async { self.delegate?.sensor(self, didReceive: extendedVersionMessage) self.needsVersionInfo = false - self.delegate?.sensor(self, logComms: response.hexadecimalString) } + // The serial and firmware come from a second query, asked + // once here so a session learns them alongside its lifetime. + peripheralManager.perform { peripheral in + let request = Data([G7Opcode.transmitterVersion.rawValue]) + self.logSend(request, on: .control) + do { + try peripheral.writeValue(request, for: .control, type: .withResponse) + } catch let error { + self.log.error("Error requesting transmitter version: %{public}@", String(describing: error)) + } + } + } + case .transmitterVersion: + if let transmitterVersionMessage = TransmitterVersionMessage(data: response) { + log.default("Received %{public}@", String(describing: transmitterVersionMessage)) + delegateQueue.async { + self.delegate?.sensor(self, didReceive: transmitterVersionMessage) + } + } + case .calibrate: + if let message = G7CalibrateRxMessage(data: response) { + logToDevice("Calibration \(message.accepted ? "accepted" : "refused") by the sensor (status \(message.status))", type: .connection) + delegateQueue.async { + self.delegate?.sensor(self, didReceiveCalibrationResponse: message) + } + // Confirm what the sensor made of it while the link is up. + if message.accepted { + peripheralManager.perform { peripheral in + self.sendCalibrationBoundsRequest(peripheral) + } + } + } + case .calibrationBounds: + if let bounds = G7CalibrationBoundsMessage(data: response) { + logToDevice("Calibration state: \(bounds)", type: .connection) + delegateQueue.async { + self.delegate?.sensor(self, didReadCalibrationBounds: bounds) + } + } else { + logToDevice("Calibration state reply not understood: \(response.hexadecimalString)", type: .error) } case .backfillFinished: + // The acknowledgement arrives after the records, so it doubles as + // the end-of-stream marker. flushBackfillBuffer() default: break @@ -300,6 +820,14 @@ public final class G7Sensor: G7BluetoothManagerDelegate { if backfillBuffer.count > 0 { let backfill = backfillBuffer self.backfillBuffer = [] + // The next backfill request starts after the newest record we + // have, whichever path delivered it. + if let activationDate = activationDate, let newest = backfill.map({ $0.timestamp }).max() { + let newestDate = activationDate.addingTimeInterval(TimeInterval(newest)) + if newestDate > (latestReadingDate ?? .distantPast) { + latestReadingDate = newestDate + } + } delegateQueue.async { self.delegate?.sensor(self, didReadBackfill: backfill) } @@ -309,18 +837,36 @@ public final class G7Sensor: G7BluetoothManagerDelegate { func bluetoothManager(_ manager: G7BluetoothManager, didReceiveBackfillResponse response: Data) { log.debug("Received backfill response: %{public}@", response.hexadecimalString) + logToDevice("backfill \(response.hexadecimalString)", type: .receive) - guard response.count == 9 else { + // Records are 9 bytes each. A G7 packs two to a notification; the + // ONE+ (and the Dexcom app's own backfill, which eavesdropping + // overhears) sends one. Anything else is not a record frame. + guard response.count % 9 == 0 else { + logToDevice("Backfill frame of unexpected length \(response.count) ignored", type: .error) return } - if let msg = G7BackfillMessage(data: response) { - backfillBuffer.append(msg) + for offset in stride(from: 0, to: response.count, by: 9) { + if let msg = G7BackfillMessage(data: response.subdata(in: offset..<(offset + 9))) { + backfillBuffer.append(msg) + } } } func bluetoothManager(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, didReceiveAuthenticationResponse response: Data) { + // Direct mode drives its own handshake and collects these through the + // authenticator's handler; anything reaching here is stray. + guard mode == .eavesdropping else { + log.default("Ignoring unsolicited authentication response: %{public}@", response.hexadecimalString) + return + } + + delegateQueue.async { + self.delegate?.sensor(self, logComms: "auth \(response.hexadecimalString)") + } + if let message = AuthChallengeRxMessage(data: response), message.isBonded, message.isAuthenticated { log.default("Observed authenticated session. enabling notifications for control characteristic.") pendingAuth = false @@ -340,7 +886,10 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } func bluetoothManagerScanningStatusDidChange(_ manager: G7BluetoothManager) { + // Called on the manager's queue; `isScanning` syncs onto that queue, + // so it has to be read from somewhere else. self.delegateQueue.async { + self.delegate?.sensor(self, log: manager.isScanning ? "Scanning for sensor" : "Stopped scanning", type: .connection) self.delegate?.sensorConnectionStatusDidUpdate(self) } } diff --git a/G7SensorKit/G7CGMManager/G7SensorModel.swift b/G7SensorKit/G7CGMManager/G7SensorModel.swift new file mode 100644 index 0000000..7100ddb --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7SensorModel.swift @@ -0,0 +1,68 @@ +// +// G7SensorModel.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// The G7-family sensors, which share the protocol and differ in what they +/// call themselves over the air. A sensor advertises as `xx` and +/// reports "Dexcomxx" once connected, so the model is only knowable from the +/// advertised name, which is what a session keeps as its sensor ID. +public enum G7SensorModel: String, CaseIterable { + case g7 + case onePlus + case stelo + + /// The first four characters of the advertised name. + public var advertisedPrefix: String { + switch self { + case .g7: return "DXCM" + case .onePlus: return "DX02" + case .stelo: return "DX01" + } + } + + public var displayName: String { + switch self { + case .g7: return "G7" + case .onePlus: return "ONE+" + case .stelo: return "Stelo" + } + } + + /// The full product name, with the nominal session length once the + /// sensor has reported one: "Dexcom G7", then "Dexcom G7 15 Day". The + /// length the sensor reports includes the 12-hour grace period; the + /// nominal figure is what is printed on the box. + public func displayName(sessionLength: TimeInterval?) -> String { + guard let sessionLength = sessionLength else { + return localizedTitle + } + let nominalDays = Int(((sessionLength - G7Sensor.gracePeriod) / TimeInterval(hours: 24)).rounded()) + return String(format: LocalizedString("%1$@ %2$d Day", comment: "Sensor product name with its nominal session length (1: name, e.g. Dexcom G7, 2: days)"), localizedTitle, nominalDays) + } + + public var localizedTitle: String { + switch self { + case .g7: return LocalizedString("Dexcom G7", comment: "CGM display title") + case .onePlus: return LocalizedString("Dexcom ONE+", comment: "CGM display title for a ONE+ sensor") + case .stelo: return LocalizedString("Dexcom Stelo", comment: "CGM display title for a Stelo sensor") + } + } + + /// The model a name belongs to, or nil for a name outside the family. + public init?(advertisedName name: String) { + guard let model = G7SensorModel.allCases.first(where: { name.hasPrefix($0.advertisedPrefix) }) else { + return nil + } + self = model + } + + /// Whether `name` is one of ours, in either of its forms. + static func isFamilyName(_ name: String) -> Bool { + G7SensorModel(advertisedName: name) != nil || name.hasPrefix("Dexcom") + } +} diff --git a/G7SensorKit/G7CGMManager/G7SensorRecord.swift b/G7SensorKit/G7CGMManager/G7SensorRecord.swift new file mode 100644 index 0000000..9b02f18 --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7SensorRecord.swift @@ -0,0 +1,113 @@ +// +// G7SensorRecord.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Everything worth remembering about a sensor once it is no longer the +/// current one: identity, how it was paired, when its session ran, what it +/// reported about itself, and how it ended. Kept as `previousSensor` in the +/// manager state, the way the pump plugins keep their previous pod, so a +/// failure can still be looked at after the replacement is on. +public struct G7SensorRecord: RawRepresentable, Equatable { + public typealias RawValue = [String: Any] + + public let sensorID: String + public let pairingCode: String? + public let serialNumber: String? + public let firmwareVersion: String? + /// When this app paired with, or began following, the sensor. + public let pairedAt: Date? + /// The sensor's own session start. + public let activatedAt: Date? + /// Session length and warmup the sensor reported, if it did. + public let sessionLength: TimeInterval? + public let warmupDuration: TimeInterval? + /// When this app stopped using the sensor. + public let endedAt: Date + /// Why, in the user's terms. + public let endReason: EndReason + /// The sensor's own failure state and when it was first seen, if the + /// session ended in failure. + public let failureMessage: String? + public let failedAt: Date? + + public enum EndReason: String { + case replaced + case deleted + } + + public var model: G7SensorModel? { + G7SensorModel(advertisedName: sensorID) + } + + public init( + sensorID: String, + pairingCode: String?, + serialNumber: String?, + firmwareVersion: String?, + pairedAt: Date?, + activatedAt: Date?, + sessionLength: TimeInterval?, + warmupDuration: TimeInterval?, + endedAt: Date, + endReason: EndReason, + failureMessage: String?, + failedAt: Date? + ) { + self.sensorID = sensorID + self.pairingCode = pairingCode + self.serialNumber = serialNumber + self.firmwareVersion = firmwareVersion + self.pairedAt = pairedAt + self.activatedAt = activatedAt + self.sessionLength = sessionLength + self.warmupDuration = warmupDuration + self.endedAt = endedAt + self.endReason = endReason + self.failureMessage = failureMessage + self.failedAt = failedAt + } + + public init?(rawValue: RawValue) { + guard let sensorID = rawValue["sensorID"] as? String, + let endedAt = rawValue["endedAt"] as? Date, + let endReason = (rawValue["endReason"] as? String).flatMap(EndReason.init(rawValue:)) + else { + return nil + } + self.sensorID = sensorID + self.endedAt = endedAt + self.endReason = endReason + pairingCode = rawValue["pairingCode"] as? String + serialNumber = rawValue["serialNumber"] as? String + firmwareVersion = rawValue["firmwareVersion"] as? String + pairedAt = rawValue["pairedAt"] as? Date + activatedAt = rawValue["activatedAt"] as? Date + sessionLength = rawValue["sessionLength"] as? TimeInterval + warmupDuration = rawValue["warmupDuration"] as? TimeInterval + failureMessage = rawValue["failureMessage"] as? String + failedAt = rawValue["failedAt"] as? Date + } + + public var rawValue: RawValue { + var raw: RawValue = [ + "sensorID": sensorID, + "endedAt": endedAt, + "endReason": endReason.rawValue + ] + raw["pairingCode"] = pairingCode + raw["serialNumber"] = serialNumber + raw["firmwareVersion"] = firmwareVersion + raw["pairedAt"] = pairedAt + raw["activatedAt"] = activatedAt + raw["sessionLength"] = sessionLength + raw["warmupDuration"] = warmupDuration + raw["failureMessage"] = failureMessage + raw["failedAt"] = failedAt + return raw + } +} diff --git a/G7SensorKit/G7CGMManager/G7SessionMode.swift b/G7SensorKit/G7CGMManager/G7SessionMode.swift new file mode 100644 index 0000000..5cf51c2 --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7SessionMode.swift @@ -0,0 +1,37 @@ +// +// G7SessionMode.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// How this app gets readings out of a G7-family sensor. +public enum G7SessionMode: String, Equatable, CaseIterable { + + /// Watch a session the Dexcom app owns. + /// + /// We connect to the sensor but never authenticate; we wait until the + /// Dexcom app's own handshake completes on the link, then subscribe to + /// the readings it is already causing the sensor to broadcast. That makes + /// the Dexcom app a hard requirement: it must stay installed, and it must + /// keep the sensor's session alive. + /// + /// This is how the plugin worked before direct pairing existed, and it + /// remains for people who are mid-session on a sensor whose pairing code + /// they no longer have. New sessions should pair instead. + case eavesdropping + + /// Hold the sensor's display slot ourselves. + /// + /// A pairing code and an EC-JPAKE handshake make this app the display the + /// sensor talks to, so no Dexcom app is involved at all. Because a sensor + /// admits only one display, the Dexcom app must not also be using it. + case direct + + /// Whether the Dexcom app is required for this mode to produce readings. + public var requiresDexcomApp: Bool { + self == .eavesdropping + } +} diff --git a/G7SensorKit/GlucoseLimits.swift b/G7SensorKit/GlucoseLimits.swift index b9e7bff..e83ac74 100644 --- a/G7SensorKit/GlucoseLimits.swift +++ b/G7SensorKit/GlucoseLimits.swift @@ -8,7 +8,7 @@ import Foundation -enum GlucoseLimits { - static var minimum: UInt16 = 40 - static var maximum: UInt16 = 400 +public enum GlucoseLimits { + public static var minimum: UInt16 = 40 + public static var maximum: UInt16 = 400 } diff --git a/G7SensorKit/Messages/AuthChallengeRxMessage.swift b/G7SensorKit/Messages/AuthChallengeRxMessage.swift index 3968f77..71d4166 100644 --- a/G7SensorKit/Messages/AuthChallengeRxMessage.swift +++ b/G7SensorKit/Messages/AuthChallengeRxMessage.swift @@ -5,12 +5,60 @@ // Created by Nathan Racklyeft on 11/22/15. // Copyright © 2015 Nathan Racklyeft. All rights reserved. // +// The failure codes and the protocol notes that go with them are from DexKit +// by Erik Tolboom (https://github.com/nightscout/DexKit), recovered from +// PacketLogger captures and reverse engineering of the official app. +// import Foundation +/// Why a sensor refused an authenticated connection: byte 2 of a `05 02 xx` +/// verdict. (On success that byte is the bond state instead.) +public enum G7AuthFailureCode: UInt8 { + case none = 0 + /// Our key is not the one the sensor holds for this display. + case challengeMismatch = 1 + /// Another display of our type holds the sensor's slot. + case deviceTypeRestriction = 2 + /// The sensor no longer has a key for us at all. Recoverable without the + /// user: a fresh key exchange with the retained pairing code. + case noAppKey = 3 +} + +/// The sensor's verdict on the AES challenge: `05 `, +/// delivered on the authentication characteristic. struct AuthChallengeRxMessage: SensorMessage { - let isAuthenticated: Bool - let isBonded: Bool + let authStatus: UInt8 + let bondStatus: UInt8 + + var isAuthenticated: Bool { + authStatus == 0x1 + } + + var isBonded: Bool { + bondStatus == 0x1 + } + + /// The sensor verified our key and still refused the session. Terminal: + /// the cause is external (another display holds the sensor's single slot, + /// or the code belongs to a different sensor), so a retry cannot succeed, + /// and four refusals in a row put the sensor into a cooldown where it + /// stops accepting connections at all. + /// + /// Any other non-authenticated status means the handshake simply is not + /// finished, and the certificate exchange follows. + var isRejected: Bool { + authStatus == 0x2 + } + + /// The sensor's reason for a rejection; nil when not rejected or when the + /// byte is one we have no name for. + var failureCode: G7AuthFailureCode? { + guard isRejected else { + return nil + } + return G7AuthFailureCode(rawValue: bondStatus) + } init?(data: Data) { guard data.count >= 3 else { @@ -21,7 +69,7 @@ struct AuthChallengeRxMessage: SensorMessage { return nil } - isAuthenticated = data[1] == 0x1 - isBonded = data[2] == 0x1 + authStatus = data[data.startIndex + 1] + bondStatus = data[data.startIndex + 2] } } diff --git a/G7SensorKit/Messages/G7CalibrationMessage.swift b/G7SensorKit/Messages/G7CalibrationMessage.swift new file mode 100644 index 0000000..0e513c6 --- /dev/null +++ b/G7SensorKit/Messages/G7CalibrationMessage.swift @@ -0,0 +1,116 @@ +// +// G7CalibrationMessage.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// The processing-status values and the bounds message layout are from DexKit +// by Erik Tolboom (https://github.com/nightscout/DexKit). +// + +import Foundation + +/// Calibrate (0x34): a meter glucose and when it was taken, on the sensor's +/// seconds-since-activation clock. +public struct G7CalibrateTxMessage: Equatable { + public let glucose: UInt16 + public let sensorAge: UInt32 + + public init(glucose: UInt16, sensorAge: UInt32) { + self.glucose = glucose + self.sensorAge = sensorAge + } + + public var data: Data { + var data = Data([G7Opcode.calibrate.rawValue]) + data.append(glucose) + data.append(sensorAge) + return data + } +} + +/// The sensor's answer to a calibration: `34 xx status:u16`. 1 is accepted; +/// anything else is a refusal (warmup, or a value it will not take). +public struct G7CalibrateRxMessage: SensorMessage, Equatable { + public static let acceptedStatus: UInt16 = 1 + + public let status: UInt16 + public let data: Data + + public var accepted: Bool { + status == G7CalibrateRxMessage.acceptedStatus + } + + public init?(data: Data) { + guard data.count >= 4, data[0] == G7Opcode.calibrate.rawValue else { + return nil + } + self.data = data + status = data[2..<4].to(UInt16.self) + } +} + +/// What the sensor has done with its calibration, from the bounds answer. +/// Observed 2026-09-15: a calibration accepted right after a reading was +/// `inProgress` on that connection and the next, and `completeHigh` on the +/// second reading after it, about ten minutes later. +public enum G7CalibrationProcessingStatus: UInt8 { + case none = 0 + case factoryCalibrated = 1 + case inProgress = 2 + case completeHigh = 3 + case completeLow = 4 + case unknown = 255 + + public init(byte: UInt8) { + self = G7CalibrationProcessingStatus(rawValue: byte) ?? .unknown + } +} + +/// Calibration Bounds (0x32): the sensor's calibration state. 20 bytes: +/// `32 | status u8 | session u8 | sessionSignature u32 | lastEGV u16 | +/// lastCalibrationTime u32 | processing u8 | permitted u8 | display u8 | +/// lastProcessingUpdateTime u32`, little-endian, times in sensor seconds. +public struct G7CalibrationBoundsMessage: SensorMessage, Equatable { + public static let length = 20 + + public let status: UInt8 + public let sessionNumber: UInt8 + public let sessionSignature: UInt32 + /// The meter value of the last calibration (entered 145, read back 145 + /// with the same sensor-seconds stamp, 2026-09-15). + public let lastGlucose: UInt16 + /// Sensor seconds; 0 when the sensor has never been calibrated. + public let lastCalibrationTime: UInt32 + public let processingStatus: G7CalibrationProcessingStatus + public let calibrationsPermitted: Bool + public let lastDisplayType: G7DisplayType + public let lastProcessingUpdateTime: UInt32 + public let data: Data + + public var hasCalibration: Bool { + lastCalibrationTime > 0 + } + + public init?(data: Data) { + guard data.count >= G7CalibrationBoundsMessage.length, data[0] == G7Opcode.calibrationBounds.rawValue else { + return nil + } + self.data = data + status = data[1] + sessionNumber = data[2] + sessionSignature = data[3..<7].to(UInt32.self) + lastGlucose = data[7..<9].to(UInt16.self) + lastCalibrationTime = data[9..<13].to(UInt32.self) + processingStatus = G7CalibrationProcessingStatus(byte: data[13]) + calibrationsPermitted = data[14] == 1 + lastDisplayType = G7DisplayType(rawValue: data[15]) ?? .unknown + lastProcessingUpdateTime = data[16..<20].to(UInt32.self) + } +} + +extension G7CalibrationBoundsMessage: CustomStringConvertible { + public var description: String { + "G7CalibrationBoundsMessage(lastGlucose:\(lastGlucose) at:\(lastCalibrationTime)s processing:\(processingStatus) permitted:\(calibrationsPermitted) display:\(lastDisplayType) data:\(data.hexadecimalString))" + } +} diff --git a/G7SensorKit/Messages/G7DisplayType.swift b/G7SensorKit/Messages/G7DisplayType.swift new file mode 100644 index 0000000..084a9ee --- /dev/null +++ b/G7SensorKit/Messages/G7DisplayType.swift @@ -0,0 +1,37 @@ +// +// G7DisplayType.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// The type values are from DexKit by Erik Tolboom +// (https://github.com/nightscout/DexKit), from the official app. +// + +import Foundation + +/// What kind of display a sensor is talking to. A sensor keeps one slot per +/// display type, so a phone and a watch can both hold a session with it, +/// while two phones cannot. Sent in the authentication challenge, reported +/// in the calibration state, and the advertisement's types-in-use byte says +/// which types currently hold a slot. +public enum G7DisplayType: UInt8, CaseIterable { + case unknown = 0 + case medical = 1 + case phone = 2 + case watch = 3 + case receiver = 4 + case pump = 5 + case reader = 6 + case tool = 7 + case other = 8 + case transmitter = 9 + + /// This type's bit in the advertisement's types-in-use byte. The phone's + /// is 0x02 by observation; the rest follow the same pattern by inference + /// and have not been seen on the air. + public var typesInUseMask: UInt8 { + guard rawValue > 0, rawValue <= 8 else { return 0 } + return 1 << (rawValue - 1) + } +} diff --git a/G7SensorKit/Messages/G7GlucoseMessage.swift b/G7SensorKit/Messages/G7GlucoseMessage.swift index 93cbd10..3d2598c 100644 --- a/G7SensorKit/Messages/G7GlucoseMessage.swift +++ b/G7SensorKit/Messages/G7GlucoseMessage.swift @@ -5,6 +5,10 @@ // Created by Pete Schwamb on 9/24/22. // Copyright © 2022 LoopKit Authors. All rights reserved. // +// The alignment of the fields and trend handling with the official app is +// from DexKit by Erik Tolboom (https://github.com/nightscout/DexKit), from +// JADX reverse engineering of the app. +// import Foundation import LoopKit @@ -31,8 +35,14 @@ public struct G7GlucoseMessage: SensorMessage, Equatable { return messageTimestamp - UInt32(age) } + /// Beyond this rate the Dexcom apps show no arrow at all. The wire value + /// is Int8 tenths, so up to about 12.7 arrives; the thresholds below + /// match the official table, and this cap keeps a wild rate from being + /// drawn as a confident triple arrow. + public static let maximumTrendRateForArrow = 8.0 + public var trendType: LoopKit.GlucoseTrend? { - guard let trend = trend else { + guard let trend = trend, abs(trend) <= G7GlucoseMessage.maximumTrendRateForArrow else { return nil } diff --git a/G7SensorKit/Messages/G7Opcode.swift b/G7SensorKit/Messages/G7Opcode.swift index 4db9fe5..56d865e 100644 --- a/G7SensorKit/Messages/G7Opcode.swift +++ b/G7SensorKit/Messages/G7Opcode.swift @@ -10,7 +10,17 @@ import Foundation enum G7Opcode: UInt8 { case authChallengeRx = 0x05 + + /// Drops the BLE link for this connection cycle. Not session teardown: + /// that is `sessionStopTx`, which we deliberately never send. + case disconnect = 0x09 case sessionStopTx = 0x28 + /// The sensor's calibration state; see `G7CalibrationBoundsMessage`. + case calibrationBounds = 0x32 + /// A meter glucose for the sensor to calibrate to; see `G7CalibrateTxMessage`. + case calibrate = 0x34 + /// Firmware version and serial number; see `TransmitterVersionMessage`. + case transmitterVersion = 0x4a case glucoseTx = 0x4e case extendedVersionTx = 0x52 case extendedVersionRx = 0x53 diff --git a/G7SensorKit/Messages/TransmitterVersionMessage.swift b/G7SensorKit/Messages/TransmitterVersionMessage.swift new file mode 100644 index 0000000..230ee16 --- /dev/null +++ b/G7SensorKit/Messages/TransmitterVersionMessage.swift @@ -0,0 +1,63 @@ +// +// TransmitterVersionMessage.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// The reply to a `4A` (transmitter version) request: firmware version, +/// software number, silicon version, and the sensor's serial number. +/// +/// 20 bytes, little-endian, no CRC: +/// +/// 0 opcode echo, 0x4A +/// 1 status +/// 2-5 firmware version, four bytes (major, minor, revision, build) +/// 6-9 software number, u32 +/// 10-13 silicon version, u32 +/// 14-19 serial number, u48 +/// +/// The serial is the number printed on the applicator and encoded in the +/// package barcode, rendered in decimal. +public struct TransmitterVersionMessage: SensorMessage, Equatable { + public let status: UInt8 + public let firmwareVersion: String + public let softwareNumber: UInt32 + public let siliconVersion: UInt32 + public let serialNumber: UInt64 + + public let data: Data + + /// The serial as printed on the sensor's packaging. + public var serialNumberString: String { + String(serialNumber) + } + + init?(data: Data) { + self.data = data + + guard data.starts(with: .transmitterVersion), data.count >= 20 else { + return nil + } + + let base = data.startIndex + status = data[base + 1] + firmwareVersion = (2 ... 5).map { String(data[base + $0]) }.joined(separator: ".") + softwareNumber = data[base + 6 ..< base + 10].to(UInt32.self) + siliconVersion = data[base + 10 ..< base + 14].to(UInt32.self) + + var serial: UInt64 = 0 + for byte in data[base + 14 ..< base + 20].reversed() { + serial = serial << 8 | UInt64(byte) + } + serialNumber = serial + } +} + +extension TransmitterVersionMessage: CustomDebugStringConvertible { + public var debugDescription: String { + "TransmitterVersionMessage(firmware:\(firmwareVersion) softwareNumber:\(softwareNumber) siliconVersion:\(siliconVersion) serial:\(serialNumberString))" + } +} diff --git a/G7SensorKit/Pairing/G7Advertisement.swift b/G7SensorKit/Pairing/G7Advertisement.swift new file mode 100644 index 0000000..1c9dec7 --- /dev/null +++ b/G7SensorKit/Pairing/G7Advertisement.swift @@ -0,0 +1,122 @@ +// +// G7Advertisement.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// Derived from DexKit by Erik Tolboom (https://github.com/nightscout/DexKit). +// + +import CoreBluetooth +import Foundation + +/// What a G7-family sensor says about itself before anyone connects. +/// +/// The manufacturer-data field of its advertisement is +/// `d0 00 | CRC16-XMODEM(serial) LE | types-in-use | 04`, where the CRC is +/// over the ASCII digits of the serial printed on the package, and +/// types-in-use records which kinds of display currently hold a slot. +/// +/// Two things fall out of that without a connection: +/// +/// - Whether this sensor can be the one whose package was scanned, so pairing +/// need not try every sensor in range. +/// - Whether some display has connected in the last ~15 minutes. A sensor +/// admits one display, and it keeps advertising the slot as held for that +/// long after the display goes quiet. While held it refuses other displays, +/// and refusing four times in a row makes it stop accepting connections +/// for a while, so a held slot is a reason to try other sensors first. +struct G7Advertisement: Equatable { + + /// The name the sensor advertises before it is paired: "DXCMxx" (G7), + /// "DX02xx" (ONE+) or "DX01xx" (Stelo). The full "Dexcomxx" name only + /// appears once connected. + let name: String + + /// CRC16-XMODEM of the serial's ASCII digits, when the manufacturer data + /// was present and well formed. + let serialChecksum: UInt16? + + /// The types-in-use byte: which display types currently hold a slot. + /// Nil when the advertisement did not say. + let typesInUse: UInt8? + + /// Whether a display of this type currently holds its slot on the sensor. + func isSlotHeld(for displayType: G7DisplayType) -> Bool? { + typesInUse.map { $0 & displayType.typesInUseMask != 0 } + } + + var isPhoneSlotHeld: Bool? { + isSlotHeld(for: .phone) + } + + init?(peripheral: CBPeripheral, advertisementData: [String: Any]) { + guard let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name else { + return nil + } + self.init(name: name, manufacturerData: advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data) + } + + init(name: String, manufacturerData: Data?) { + self.name = name + + guard let data = manufacturerData.map({ Data($0) }), + data.count >= 5, + data[0] == 0xD0, data[1] == 0x00 + else { + serialChecksum = nil + typesInUse = nil + return + } + + serialChecksum = UInt16(data[2]) | UInt16(data[3]) << 8 + typesInUse = data[4] + } + + /// Whether this looks like a sensor family we know how to pair with. + var isSupportedSensor: Bool { + G7SensorModel(advertisedName: name) != nil + } + + var model: G7SensorModel? { + G7SensorModel(advertisedName: name) + } + + /// Whether this sensor could be the one with `serial` on its package. + /// + /// Unknown is treated as possible: an advertisement without the checksum + /// costs a wasted handshake at worst, while wrongly excluding the user's + /// own sensor would make pairing impossible. + func couldHaveSerial(_ serial: String) -> Bool { + guard let serialChecksum = serialChecksum, + let expected = G7Advertisement.serialChecksum(for: serial) + else { + return true + } + return serialChecksum == expected + } + + /// The checksum a sensor with `serial` advertises, or nil when `serial` + /// is not plain ASCII digits. + static func serialChecksum(for serial: String) -> UInt16? { + let digits = Array(serial.utf8) + guard !digits.isEmpty, digits.allSatisfy({ $0 >= 0x30 && $0 <= 0x39 }) else { + return nil + } + return CRC16.xmodem(digits) + } +} + +enum CRC16 { + /// CRC-16/XMODEM: polynomial 0x1021, zero initial value, no reflection. + static func xmodem(_ bytes: Bytes) -> UInt16 where Bytes.Element == UInt8 { + var crc: UInt16 = 0 + for byte in bytes { + crc ^= UInt16(byte) << 8 + for _ in 0 ..< 8 { + crc = crc & 0x8000 != 0 ? (crc << 1) ^ 0x1021 : crc << 1 + } + } + return crc + } +} diff --git a/G7SensorKit/Pairing/G7PairingPlanner.swift b/G7SensorKit/Pairing/G7PairingPlanner.swift new file mode 100644 index 0000000..82b2e2e --- /dev/null +++ b/G7SensorKit/Pairing/G7PairingPlanner.swift @@ -0,0 +1,152 @@ +// +// G7PairingPlanner.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// Derived from DexKit by Erik Tolboom (https://github.com/nightscout/DexKit): +// candidate planning follows its G7PairingPlanner. +// + +import Foundation + +/// Decides which sensor to try next, and when to stop. +/// +/// A pairing code does not identify a sensor over the air (the advertised +/// name suffix is unrelated to it), so pairing may have to try several +/// 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. +/// +/// Pure bookkeeping with no Bluetooth of its own, so the policy is testable +/// in isolation. +struct G7PairingPlanner { + + struct Candidate: Equatable { + let id: UUID + let name: String + var isPhoneSlotHeld: Bool + } + + enum Action: Equatable { + /// Try the current candidate again. + case retryCurrent + /// Move on to the next candidate. + case advanceToNext + /// Nothing left to try. + case giveUp(reason: String) + } + + /// Ordinary failures tolerated per candidate before moving on. + static let attemptsPerCandidate = 3 + + 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] = [] + + var currentCandidate: Candidate? { + currentIndex < candidates.count ? candidates[currentIndex] : nil + } + + /// The attempt number the next try will be, 1-based. + var nextAttemptNumber: Int { + attemptsOnCurrent + 1 + } + + /// 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. + @discardableResult + mutating func addCandidate(id: UUID, name: String, isPhoneSlotHeld: Bool) -> 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) + } + 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. + @discardableResult + mutating func updateSlot(id: UUID, isPhoneSlotHeld: Bool) -> Bool { + guard let index = candidates.firstIndex(where: { $0.id == id }), + candidates[index].isPhoneSlotHeld != isPhoneSlotHeld + else { + return false + } + candidates[index].isPhoneSlotHeld = isPhoneSlotHeld + + // Re-sort only the untried tail, preserving discovery order within + // each class. + let tailStart = currentIndex + 1 + guard tailStart < candidates.count else { + return true + } + let tail = candidates[tailStart...] + candidates.replaceSubrange(tailStart..., with: tail.filter { !$0.isPhoneSlotHeld } + tail.filter { $0.isPhoneSlotHeld }) + return true + } + + /// 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() + } + attemptsOnCurrent += 1 + if attemptsOnCurrent < G7PairingPlanner.attemptsPerCandidate { + return .retryCurrent + } + return advance() + } + + /// The current candidate cannot succeed (it rejected us, or it proved it + /// does not belong to this code): drop it without retrying. + mutating func abandonCurrentCandidate(reason: String) -> Action { + guard let candidate = currentCandidate else { + return giveUp() + } + abandonmentReasons.append("\(candidate.name): \(reason)") + return advance() + } + + private mutating func advance() -> Action { + currentIndex += 1 + attemptsOnCurrent = 0 + return currentCandidate != nil ? .advanceToNext : giveUp() + } + + 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" + )) + } +} diff --git a/G7SensorKit/Pairing/G7PairingService.swift b/G7SensorKit/Pairing/G7PairingService.swift new file mode 100644 index 0000000..86353e4 --- /dev/null +++ b/G7SensorKit/Pairing/G7PairingService.swift @@ -0,0 +1,581 @@ +// +// G7PairingService.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// Derived from DexKit by Erik Tolboom (https://github.com/nightscout/DexKit): +// the pairing run follows its G7PairingRunner. +// + +import CoreBluetooth +import Foundation +import os.log + +public enum G7PairingState: Equatable { + case idle + /// Looking for sensors. `candidates` names the ones found so far. + case scanning(candidates: [String]) + /// Running the handshake against `candidate`; `attempt` is 1-based. + case authenticating(candidate: String, attempt: Int) + /// Paired. `sharedKey` must be persisted: it is what lets reconnects skip + /// the key exchange. + case succeeded(peripheralIdentifier: UUID, sharedKey: Data, deviceName: String?) + case failed(reason: String) + + public var isFinished: Bool { + switch self { + case .succeeded, .failed: + return true + case .idle, .scanning, .authenticating: + return false + } + } +} + +/// What a successful pairing run leaves for the session to take over: the +/// central it used and the connection it authenticated on. +public struct G7PairingHandoff { + let bluetoothManager: G7BluetoothManager + let peripheralManager: G7PeripheralManager +} + +/// Pairs with a sensor: scan, connect to each plausible candidate in turn, +/// run the handshake, and hand back the key. +/// +/// Runs on the session's own Bluetooth central when there is one (re-pairing +/// from settings), borrowing its delegate for the duration and giving it +/// back when done. There is exactly one central per app: a second could not +/// share the state-restoration identifier, and only the same central can +/// carry the authenticated connection straight into the session. +/// +/// State changes are published on the main queue through `onStateChange`. +/// All of the service's own bookkeeping happens on the main queue too; +/// Bluetooth callbacks hop there first. That is not only for the UI's +/// benefit: `G7BluetoothManager.disconnectAll()` traps if called from the +/// Bluetooth queue, and the callbacks arrive on exactly that queue. +public final class G7PairingService { + + /// How long to look for a first candidate before giving up. A sensor + /// another display used within the last ~15 minutes advertises only in a + /// brief window around each 5-minute reading until that lease lapses, so + /// the wait has to outlast the lease with room to spare. The screen shows + /// the elapsed time and offers a way out throughout. + public static let scanTimeout: TimeInterval = 20 * 60 + + /// Connect-to-ready deadline for the candidate under trial. + static let candidateTimeout: TimeInterval = 20 + + /// Cap on one whole handshake attempt. The per-step deadline inside the + /// authenticator is generous; this bounds the sum. + static let authenticationTimeout: TimeInterval = 90 + + private let log = OSLog(category: "G7PairingService") + + private let lockedState = Locked(.idle) + + public var state: G7PairingState { + lockedState.value + } + + /// Called on the main queue after every state change. + public var onStateChange: ((G7PairingState) -> Void)? + + /// The radio's state, published on the main queue whenever it changes + /// during a run. Pairing cannot proceed while Bluetooth is off or the + /// app is not allowed to use it, and the run keeps waiting rather than + /// failing, so the screen has to say why nothing is happening. + public var onBluetoothStateChange: ((CBManagerState) -> Void)? + + public var bluetoothState: CBManagerState { + bluetoothManager?.centralState ?? .unknown + } + + /// Receives the handshake's step-by-step narration, for a device log or + /// a diagnostics view. Never carries the code or key. + public var onLog: ((String) -> Void)? + + private var pairingCode = "" + private var expectedSerial: String? + /// The sensor a session is already paired with, when re-pairing. It is + /// never the one being replaced, and trying it costs a handshake that + /// ends in a rejection. + private var excludedPeripheralIdentifier: UUID? + + /// The session's central, when re-pairing; nil during first-time setup, + /// where the run creates the central the new session will adopt. + private let borrowedBluetoothManager: G7BluetoothManager? + + /// The slot this client takes on the sensor; also which slot's lease in + /// an advertisement matters when ordering candidates. + let displayType: G7DisplayType + private weak var previousDelegate: G7BluetoothManagerDelegate? + /// The sensor the borrowed central was following, to hand back if the + /// run does not replace it. + private var previousActiveIdentifier: UUID? + + /// When scanning began, for the elapsed time on screen. + public private(set) var scanStartedAt: Date? + + private var bluetoothManager: G7BluetoothManager? + /// The candidate that authenticated, kept connected for the hand-off. + private var authenticatedPeripheralManager: G7PeripheralManager? + private var planner = G7PairingPlanner() + private var readyManagers: [UUID: G7PeripheralManager] = [:] + + private var authenticationInFlight = false + /// Bumped whenever an in-flight handshake is disowned, so its late + /// completion is ignored. + private var authenticationGeneration = 0 + + private var scanWatchdog: DispatchWorkItem? + private var candidateWatchdog: DispatchWorkItem? + private var authenticationWatchdog: DispatchWorkItem? + + /// - Parameter cgmManager: the manager being re-paired, if any. Its + /// session's central is borrowed for the run; with none, the run + /// creates the central the new session will adopt. + public convenience init(cgmManager: G7CGMManager?, displayType: G7DisplayType = .phone) { + self.init(bluetoothManager: cgmManager?.sensor.bluetoothManager, displayType: cgmManager?.displayType ?? displayType) + } + + init(bluetoothManager: G7BluetoothManager?, displayType: G7DisplayType = .phone) { + borrowedBluetoothManager = bluetoothManager + self.displayType = displayType + } + + /// After `.succeeded`: the central and connection for the session to take + /// over. Clears the service's own claim on them, so a later `cancel()` + /// does not tear down what the session is now using. Nil in the + /// simulator, where nothing was connected. + public func handOff() -> G7PairingHandoff? { + guard case .succeeded = state, + let bluetoothManager = bluetoothManager, + let peripheralManager = authenticatedPeripheralManager + else { + return nil + } + self.bluetoothManager = nil + authenticatedPeripheralManager = nil + readyManagers.removeAll() + return G7PairingHandoff(bluetoothManager: bluetoothManager, peripheralManager: peripheralManager) + } + + private func setState(_ newState: G7PairingState) { + lockedState.value = newState + DispatchQueue.main.async { [weak self] in + self?.onStateChange?(newState) + } + } + + private var isRunActive: Bool { + bluetoothManager != nil && !state.isFinished + } + + /// Always asynchronous, never inline: `scanForPeripheral()` runs the + /// manager's queue synchronously on the calling (main) thread, so a + /// callback arriving inside it is on the main thread but on the manager + /// queue, and running work inline there trips the manager's + /// not-on-queue preconditions. + private func onMain(_ work: @escaping () -> Void) { + DispatchQueue.main.async(execute: work) + } + + // MARK: - Control + + /// Whether `code` has the shape of a G7 pairing code. + public static func isValidPairingCode(_ code: String) -> Bool { + code.count == 4 && code.allSatisfy(\.isNumber) + } + + /// Starts pairing with `pairingCode`. `serial` is the package serial when + /// the code came from a scan; candidates that cannot have that serial + /// are then skipped rather than tried. + public func start(pairingCode: String, serial: String? = nil, excludingPeripheral excluded: UUID? = nil) { + cancel() + + let code = pairingCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard G7PairingService.isValidPairingCode(code) else { + setState(.failed(reason: LocalizedString( + "The pairing code is the 4-digit number printed on the sensor applicator.", + comment: "Pairing failure reason for a malformed G7 pairing code" + ))) + return + } + self.pairingCode = code + expectedSerial = serial + excludedPeripheralIdentifier = excluded + + #if targetEnvironment(simulator) + startSimulatedRun() + #else + let manager = borrowedBluetoothManager ?? G7BluetoothManager() + if manager === borrowedBluetoothManager { + previousDelegate = manager.delegate + previousActiveIdentifier = manager.activePeripheralIdentifier + // Whatever the session was following is not what we are pairing, + // and the central only scans while it has no active peripheral: + // with one still held, disconnecting alone left it neither + // retrieving nor scanning, and every re-pair "found no sensor". + manager.disconnectAll() + manager.forgetPeripheral() + } + manager.delegate = self + manager.setActivePeripheralIdentifier(nil) + bluetoothManager = manager + + scanStartedAt = Date() + setState(.scanning(candidates: [])) + onBluetoothStateChange?(manager.centralState) + manager.scanForPeripheral() + + let watchdog = DispatchWorkItem { [weak self] in + guard let self = self, case .scanning(let candidates) = self.state, candidates.isEmpty 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" + )) + } + scanWatchdog = watchdog + DispatchQueue.main.asyncAfter(deadline: .now() + G7PairingService.scanTimeout, execute: watchdog) + #endif + } + + public func cancel() { + scanWatchdog?.cancel() + scanWatchdog = nil + candidateWatchdog?.cancel() + candidateWatchdog = nil + authenticationWatchdog?.cancel() + authenticationWatchdog = nil + authenticationGeneration += 1 + authenticationInFlight = false + + releaseBluetoothManager() + planner = G7PairingPlanner() + expectedSerial = nil + excludedPeripheralIdentifier = nil + setState(.idle) + } + + /// Lets go of the central after a run that did not hand off: a borrowed + /// one goes back to the session (which resumes on its next scan), an + /// owned one is dropped. Either way every candidate is disconnected. + private func releaseBluetoothManager() { + guard let manager = bluetoothManager else { + return + } + manager.disconnectAll() + if manager === borrowedBluetoothManager { + // Hand the central back to the session and re-arm it on its own + // sensor; otherwise readings stop until something else prompts a + // scan. + manager.delegate = previousDelegate + manager.setActivePeripheralIdentifier(previousActiveIdentifier) + manager.scanForPeripheral() + } else { + manager.delegate = nil + } + bluetoothManager = nil + authenticatedPeripheralManager = nil + readyManagers.removeAll() + scanStartedAt = nil + } + + private func fail(_ reason: String) { + setState(.failed(reason: reason)) + releaseBluetoothManager() + } + + // MARK: - Simulator + + #if targetEnvironment(simulator) + /// CoreBluetooth reports `.unsupported` in the simulator. Walk the same + /// states with a stand-in sensor so onboarding can be exercised. + private func startSimulatedRun() { + setState(.scanning(candidates: [])) + let name = "DXCM" + pairingCode.suffix(2) + let authenticate = DispatchWorkItem { [weak self] in + guard let self = self, !self.state.isFinished else { return } + self.setState(.authenticating(candidate: name, attempt: 1)) + let succeed = DispatchWorkItem { [weak self] in + guard let self = self, !self.state.isFinished else { return } + self.setState(.succeeded( + peripheralIdentifier: UUID(), + sharedKey: G7JPAKE.secureRandomBytes(16), + deviceName: "Dexcom" + self.pairingCode.suffix(2) + )) + } + self.scanWatchdog = succeed + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: succeed) + } + scanWatchdog = authenticate + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: authenticate) + } + #endif + + // MARK: - Candidate handling + + private func armCandidateWatchdog() { + guard candidateWatchdog == nil, + !authenticationInFlight, + let candidate = planner.currentCandidate, + readyManagers[candidate.id] == nil + else { + return + } + let id = candidate.id + let watchdog = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.candidateWatchdog = nil + guard self.isRunActive, + !self.authenticationInFlight, + self.planner.currentCandidate?.id == id, + self.readyManagers[id] == nil + else { + return + } + self.log.default("Candidate %{public}@ did not become ready in time", id.uuidString) + self.report("Candidate \(candidate.name) did not connect in time") + self.handleCandidateFailure() + } + candidateWatchdog = watchdog + DispatchQueue.main.asyncAfter(deadline: .now() + G7PairingService.candidateTimeout, execute: watchdog) + } + + private func cancelCandidateWatchdog() { + candidateWatchdog?.cancel() + candidateWatchdog = nil + } + + private func authenticateCurrentCandidate() { + guard isRunActive, + !authenticationInFlight, + let candidate = planner.currentCandidate, + let peripheralManager = readyManagers[candidate.id] + else { + return + } + + authenticationInFlight = true + let attempt = planner.nextAttemptNumber + setState(.authenticating(candidate: candidate.name, attempt: attempt)) + report("Trying \(candidate.name), attempt \(attempt)") + + cancelCandidateWatchdog() + authenticationWatchdog?.cancel() + authenticationGeneration += 1 + let generation = authenticationGeneration + + let watchdog = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.authenticationWatchdog = nil + guard self.isRunActive, + self.authenticationInFlight, + generation == self.authenticationGeneration, + self.planner.currentCandidate?.id == candidate.id + else { + return + } + self.report("\(candidate.name) attempt \(attempt) exceeded the time limit") + self.authenticationGeneration += 1 + self.authenticationInFlight = false + self.readyManagers.removeValue(forKey: candidate.id) + self.bluetoothManager?.disconnectAll() + self.handleCandidateFailure() + } + authenticationWatchdog = watchdog + DispatchQueue.main.asyncAfter(deadline: .now() + G7PairingService.authenticationTimeout, execute: watchdog) + + let authenticator = G7Authenticator( + pairingCode: pairingCode, + storedSharedKey: nil, + stepTimeout: G7Authenticator.pairingStepTimeout, + displayType: displayType + ) + authenticator.logHandler = { [weak self] message in + self?.onMain { self?.onLog?(message) } + } + + authenticator.authenticate(peripheralManager: peripheralManager) { [weak self] result in + self?.onMain { + guard let self = self, self.isRunActive, generation == self.authenticationGeneration else { + return + } + self.authenticationWatchdog?.cancel() + self.authenticationWatchdog = nil + self.authenticationInFlight = false + + switch result { + case .success(let authResult): + // The connection stays up for the session to adopt; only + // the other candidates are let go. Terminal state first, + // so their disconnects are not scored as failures. + self.authenticatedPeripheralManager = peripheralManager + self.setState(.succeeded( + peripheralIdentifier: candidate.id, + sharedKey: authResult.sharedKey, + deviceName: authResult.deviceName + )) + self.bluetoothManager?.adoptAsActive(peripheralManager) + case .failure(let error): + self.report("\(candidate.name) attempt \(attempt) failed: \(error)") + self.handleCandidateFailure(error: error) + } + } + } + } + + private func handleCandidateFailure(error: Error? = nil) { + let action: G7PairingPlanner.Action + switch error { + case G7AuthenticatorError.rejected?: + // Terminal for this sensor, and retrying invites the lockout. + action = planner.abandonCurrentCandidate(reason: String(describing: error!)) + case G7AuthenticatorError.challengeMismatch?: + // Proof that this sensor does not belong to the entered code. + action = planner.abandonCurrentCandidate(reason: String(describing: error!)) + default: + action = planner.recordFailure() + } + + switch action { + case .retryCurrent: + guard let candidate = planner.currentCandidate, + let peripheralManager = readyManagers[candidate.id] + else { + bluetoothManager?.disconnectAll() + bluetoothManager?.scanForPeripheral() + armCandidateWatchdog() + return + } + if peripheralManager.peripheral.state == .connected { + authenticateCurrentCandidate() + } else { + readyManagers.removeValue(forKey: candidate.id) + bluetoothManager?.disconnectAll() + bluetoothManager?.scanForPeripheral() + armCandidateWatchdog() + } + + case .advanceToNext: + setState(.scanning(candidates: planner.candidates.map(\.name))) + if planner.currentCandidate.flatMap({ readyManagers[$0.id] }) != nil { + authenticateCurrentCandidate() + } else { + bluetoothManager?.scanForPeripheral() + armCandidateWatchdog() + } + + case .giveUp(let reason): + fail(reason) + } + } + + private func report(_ message: String) { + log.default("%{public}@", message) + onLog?(message) + } +} + +extension G7PairingService: G7BluetoothManagerDelegate { + + func bluetoothManager(_ manager: G7BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any]) -> PeripheralConnectionCommand { + // A finished run must never connect again: the sensor it just paired + // belongs to the session manager now. + guard !state.isFinished, + let advertisement = G7Advertisement(peripheral: peripheral, advertisementData: advertisementData), + advertisement.isSupportedSensor + else { + return .ignore + } + + if peripheral.identifier == excludedPeripheralIdentifier { + return .ignore + } + + if let serial = expectedSerial, !advertisement.couldHaveSerial(serial) { + log.debug("Skipping %{public}@: not the scanned sensor", advertisement.name) + return .ignore + } + + 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) { + 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")") + } + self.armCandidateWatchdog() + } + return .connect + } + + func bluetoothManagerShouldAcceptRestoredPeripherals(_ manager: G7BluetoothManager) -> Bool { + // A stale restored peripheral would masquerade as a candidate. + return false + } + + func bluetoothManager(_ manager: G7BluetoothManager, readied peripheralManager: G7PeripheralManager) -> Bool { + let id = peripheralManager.peripheral.identifier + onMain { [weak self] in + guard let self = self, self.isRunActive else { return } + self.readyManagers[id] = peripheralManager + if self.planner.currentCandidate?.id == id { + self.authenticateCurrentCandidate() + } + } + // Keep scanning: other candidates may still be in range. + return false + } + + func bluetoothManager(_ manager: G7BluetoothManager, readyingFailed peripheralManager: G7PeripheralManager, with error: Error) { + log.default("Candidate connection failed: %{public}@", String(describing: error)) + onMain { [weak self] in + guard let self = self, self.isRunActive, !self.authenticationInFlight else { return } + self.handleCandidateFailure() + } + } + + func peripheralDidDisconnect(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, wasRemoteDisconnect: Bool) { + let id = peripheralManager.peripheral.identifier + onMain { [weak self] in + guard let self = self, self.isRunActive, self.planner.currentCandidate?.id == id else { return } + self.readyManagers.removeValue(forKey: id) + if self.authenticationInFlight { + // The sensor drops the link itself a few seconds after the + // bond request, with the handshake complete and the key + // installed; the authenticator reports that as success once + // it notices. Its verdict decides, not the disconnect. A drop + // earlier in the handshake ends in its step timeout instead. + self.report("Link dropped during the handshake; waiting for the handshake's verdict") + return + } + self.handleCandidateFailure() + } + } + + func bluetoothManager(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, didReceiveControlResponse response: Data) {} + + func bluetoothManager(_ manager: G7BluetoothManager, didReceiveBackfillResponse response: Data) {} + + func bluetoothManager(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, didReceiveAuthenticationResponse response: Data) {} + + func bluetoothManagerScanningStatusDidChange(_ manager: G7BluetoothManager) { + // Off the manager's queue: `isScanning` syncs onto it. + onMain { [weak self] in + guard let self = self, self.isRunActive else { return } + self.report(manager.isScanning ? "Scanning for sensors" : "Stopped scanning") + self.onBluetoothStateChange?(manager.centralState) + } + } +} diff --git a/G7SensorKit/Pairing/G7SensorPackage.swift b/G7SensorKit/Pairing/G7SensorPackage.swift new file mode 100644 index 0000000..7c1c17c --- /dev/null +++ b/G7SensorKit/Pairing/G7SensorPackage.swift @@ -0,0 +1,127 @@ +// +// G7SensorPackage.swift +// G7SensorKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// What the Data Matrix on a G7-family sensor applicator says. +/// +/// The code is a GS1 element string. The parts pairing cares about are the +/// pairing code, carried in AI (240), and the sensor serial in AI (21), which +/// lets pairing skip sensors that cannot be this one. The GTIN in AI (01) +/// identifies the manufacturer. +public struct G7SensorPackage: Equatable { + + /// The leading digits of a Dexcom GTIN-14 as it appears in the barcode. + static let dexcomCompanyPrefix = "0038627" + + public let gtin: String? + public let serial: String? + public let pairingCode: String? + public let lot: String? + public let expiry: String? + + /// Whether the GTIN says Dexcom made this. + public var isDexcom: Bool { + guard let gtin = gtin, gtin.count == 14 else { + return false + } + return gtin.hasPrefix(G7SensorPackage.dexcomCompanyPrefix) + } + + /// Parses a scanned Data Matrix payload. Returns nil when nothing useful + /// was found, so a stray barcode is not mistaken for an applicator. + public init?(dataMatrix payload: String) { + let elements = GS1ElementString.parse(payload) + gtin = elements["01"] + serial = elements["21"] + lot = elements["10"] + expiry = elements["17"] + + // Only a four-digit value is a pairing code; anything else in the + // field is some other product identifier. + if let candidate = elements["240"], candidate.count == 4, candidate.allSatisfy(\.isNumber) { + pairingCode = candidate + } else { + pairingCode = nil + } + + guard gtin != nil || serial != nil || pairingCode != nil else { + return nil + } + } +} + +/// A minimal GS1 element-string parser: enough of the application identifier +/// table to read a sensor applicator. +/// +/// Fixed-length AIs run straight into the next one; variable-length AIs end +/// at a group separator (FNC1, transmitted as ASCII 0x1D) or at the end of +/// the payload. Scanners sometimes prefix the payload with a symbology +/// identifier (`]d2`) or a leading FNC1, both of which are skipped. +enum GS1ElementString { + + /// Fixed-length application identifiers and their data lengths, per the + /// GS1 General Specifications. Anything not listed is variable-length. + private static let fixedLengths: [String: Int] = [ + "00": 18, "01": 14, "02": 14, + "11": 6, "12": 6, "13": 6, "15": 6, "16": 6, "17": 6, + "20": 2, + "31": 8, "32": 8, "33": 8, "34": 8, "35": 8, "36": 8, + "41": 15 + ] + + /// Application identifiers this parser knows, longest first so that + /// "240" is matched before "24" could be mistaken for a prefix. + private static let knownIdentifiers = ["240", "241", "242", "243", "250", "251", "253", "254", "255", + "00", "01", "02", "10", "11", "12", "13", "15", "16", "17", + "20", "21", "22", "30", "37", "90", "91", "92", "93", "94", + "95", "96", "97", "98", "99"] + + private static let groupSeparator: Character = "\u{1D}" + + static func parse(_ payload: String) -> [String: String] { + var input = Substring(payload) + if input.hasPrefix("]d2") { + input = input.dropFirst(3) + } + while input.first == groupSeparator { + input = input.dropFirst() + } + + var elements = [String: String]() + while !input.isEmpty { + guard let identifier = knownIdentifiers.first(where: { input.hasPrefix($0) }) else { + // Unknown identifier: the rest cannot be framed reliably. + break + } + input = input.dropFirst(identifier.count) + + let value: Substring + if let length = fixedLengths[identifier] { + value = input.prefix(length) + input = input.dropFirst(length) + } else if let separator = input.firstIndex(of: groupSeparator) { + value = input[.. Data { + let crc = CRC16.xmodem(Array(serial.utf8)) + return Data([0xD0, 0x00, UInt8(crc & 0xFF), UInt8(crc >> 8), typesInUse, 0x04]) + } + + func testParsesSerialChecksumAndSlot() { + let advertisement = G7Advertisement(name: "DXCM12", manufacturerData: manufacturerData(serial: "123456789012", typesInUse: 0x02)) + XCTAssertEqual(advertisement.serialChecksum, CRC16.xmodem(Array("123456789012".utf8))) + XCTAssertEqual(advertisement.isPhoneSlotHeld, true) + XCTAssertEqual(advertisement.typesInUse.map { $0 & 0x02 }, 0x02) + XCTAssertEqual(G7DisplayType.phone.typesInUseMask, 0x02) + XCTAssertEqual(G7DisplayType.medical.typesInUseMask, 0x01) + XCTAssertTrue(advertisement.isSupportedSensor) + } + + func testFreeSlot() { + let advertisement = G7Advertisement(name: "DX0212", manufacturerData: manufacturerData(serial: "1", typesInUse: 0x00)) + XCTAssertEqual(advertisement.isPhoneSlotHeld, false) + XCTAssertTrue(advertisement.isSupportedSensor) + } + + func testSerialMatching() { + let advertisement = G7Advertisement(name: "DXCM12", manufacturerData: manufacturerData(serial: "123456789012", typesInUse: 0)) + XCTAssertTrue(advertisement.couldHaveSerial("123456789012")) + XCTAssertFalse(advertisement.couldHaveSerial("123456789013")) + } + + /// Missing or malformed data must never exclude a sensor: the cost of a + /// wasted handshake is small, the cost of ignoring the user's own sensor + /// is a pairing that can never succeed. + func testUnknownAdvertisementIsNotExcluded() { + for data in [nil, Data(), Data([0xD0, 0x00, 0x01]), Data([0x11, 0x22, 0x33, 0x44, 0x55, 0x66])] { + let advertisement = G7Advertisement(name: "DXCM12", manufacturerData: data) + XCTAssertNil(advertisement.serialChecksum) + XCTAssertNil(advertisement.isPhoneSlotHeld) + XCTAssertTrue(advertisement.couldHaveSerial("123456789012")) + } + } + + func testNonNumericSerialDoesNotExclude() { + let advertisement = G7Advertisement(name: "DXCM12", manufacturerData: manufacturerData(serial: "123", typesInUse: 0)) + XCTAssertTrue(advertisement.couldHaveSerial("ABC")) + XCTAssertNil(G7Advertisement.serialChecksum(for: "ABC")) + XCTAssertNil(G7Advertisement.serialChecksum(for: "")) + } + + func testUnsupportedNames() { + XCTAssertFalse(G7Advertisement(name: "Dexcom12", manufacturerData: nil).isSupportedSensor) + XCTAssertTrue(G7Advertisement(name: "DX0112", manufacturerData: nil).isSupportedSensor, "Stelo") + XCTAssertEqual(G7Advertisement(name: "DX0112", manufacturerData: nil).model, .stelo) + XCTAssertFalse(G7Advertisement(name: "Omnipod", manufacturerData: nil).isSupportedSensor) + } +} diff --git a/G7SensorKitTests/G7AuthCryptoTests.swift b/G7SensorKitTests/G7AuthCryptoTests.swift new file mode 100644 index 0000000..0261b58 --- /dev/null +++ b/G7SensorKitTests/G7AuthCryptoTests.swift @@ -0,0 +1,120 @@ +// +// G7AuthCryptoTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CryptoKit +import XCTest +@testable import G7SensorKit + +class G7AESTests: XCTestCase { + + /// Vectors generated with `openssl enc -aes-128-ecb -nopad`, so they are + /// independent of this implementation. + func testKnownAnswers() throws { + let cases: [(challenge: String, key: String, expected: String)] = [ + ("0011223344556677", "000102030405060708090a0b0c0d0e0f", "28b454bdf4d00600"), + ("a1b2c3d4e5f60718", "ffeeddccbbaa99887766554433221100", "19b22de101061935") + ] + for testCase in cases { + let result = try G7AES.encryptChallenge( + Data(hexadecimalString: testCase.challenge)!, + key: Data(hexadecimalString: testCase.key)! + ) + XCTAssertEqual(result.hexadecimalString, testCase.expected) + } + } + + func testOutputIsEightBytes() throws { + let result = try G7AES.encryptChallenge( + Data(repeating: 0xAB, count: 8), + key: Data(repeating: 0x11, count: 16) + ) + XCTAssertEqual(result.count, 8) + } + + func testRejectsWrongLengths() { + XCTAssertThrowsError(try G7AES.encryptChallenge(Data(repeating: 0, count: 7), key: Data(repeating: 0, count: 16))) + XCTAssertThrowsError(try G7AES.encryptChallenge(Data(repeating: 0, count: 8), key: Data(repeating: 0, count: 15))) + } + + /// Different keys must give different answers, which is the property the + /// handshake relies on to detect a wrong pairing code. + func testKeySensitivity() throws { + let challenge = Data(repeating: 0x5A, count: 8) + let a = try G7AES.encryptChallenge(challenge, key: Data(repeating: 0x01, count: 16)) + let b = try G7AES.encryptChallenge(challenge, key: Data(repeating: 0x02, count: 16)) + XCTAssertNotEqual(a, b) + } +} + +class G7ChallengeSignerTests: XCTestCase { + + func testEmbeddedKeyPairIsConsistent() throws { + let privateKey = try P256.Signing.PrivateKey(rawRepresentation: G7DexcomCredentials.challengePrivateKey) + XCTAssertEqual(privateKey.publicKey.x963Representation, G7DexcomCredentials.challengePublicKey) + XCTAssertEqual(G7ChallengeSigner.publicKey, G7DexcomCredentials.challengePublicKey) + } + + func testSignatureVerifiesAgainstTheEmbeddedPublicKey() throws { + // A 0x0C acknowledgement: opcode, status, then the 16 bytes to sign. + var acknowledgement = Data([0x0C, 0x00]) + acknowledgement.append(Data((0 ..< 16).map { UInt8($0) })) + + let signature = try G7ChallengeSigner.sign(challengeAcknowledgement: acknowledgement) + XCTAssertEqual(signature.count, 64) + + let publicKey = try P256.Signing.PublicKey(x963Representation: G7DexcomCredentials.challengePublicKey) + let parsed = try P256.Signing.ECDSASignature(rawRepresentation: signature) + XCTAssertTrue(publicKey.isValidSignature(parsed, for: acknowledgement[2 ..< 18])) + } + + func testSignsOnlyTheSixteenPayloadBytes() throws { + // Trailing bytes beyond the payload must not change the signature's + // validity over those 16 bytes. + var short = Data([0x0C, 0x00]) + short.append(Data((0 ..< 16).map { UInt8($0) })) + let long = short + Data([0xDE, 0xAD, 0xBE, 0xEF]) + + let publicKey = try P256.Signing.PublicKey(x963Representation: G7DexcomCredentials.challengePublicKey) + let signature = try P256.Signing.ECDSASignature( + rawRepresentation: G7ChallengeSigner.sign(challengeAcknowledgement: long) + ) + XCTAssertTrue(publicKey.isValidSignature(signature, for: short[2 ..< 18])) + } + + func testRejectsShortAcknowledgement() { + XCTAssertThrowsError(try G7ChallengeSigner.sign(challengeAcknowledgement: Data(repeating: 0, count: 17))) + } +} + +class G7DexcomCredentialsTests: XCTestCase { + + /// Guards against a transcription slip in the embedded blobs: a DER + /// certificate declares its own length in its header. + func testCertificatesAreWellFormedDER() { + let certificates = G7DexcomCredentials.certificates + XCTAssertEqual(certificates.count, 2) + XCTAssertEqual(certificates.map(\.count), [494, 465]) + + for certificate in certificates { + let bytes = [UInt8](certificate) + XCTAssertEqual(bytes[0], 0x30, "not a DER SEQUENCE") + XCTAssertEqual(bytes[1], 0x82, "expected a two-byte length") + let declared = Int(bytes[2]) << 8 | Int(bytes[3]) + XCTAssertEqual(declared + 4, certificate.count, "DER length disagrees with the blob") + } + } + + func testLeafCertificateCarriesTheChallengePublicKey() { + let leaf = G7DexcomCredentials.certificates[1].hexadecimalString + XCTAssertTrue(leaf.contains(G7DexcomCredentials.challengePublicKey.hexadecimalString)) + } + + func testPrivateKeyIsPaddedToThirtyTwoBytes() { + XCTAssertEqual(G7DexcomCredentials.challengePrivateKey.count, 32) + XCTAssertEqual(G7DexcomCredentials.challengePrivateKey.first, 0x00) + } +} diff --git a/G7SensorKitTests/G7BigUIntTests.swift b/G7SensorKitTests/G7BigUIntTests.swift new file mode 100644 index 0000000..1194cc9 --- /dev/null +++ b/G7SensorKitTests/G7BigUIntTests.swift @@ -0,0 +1,108 @@ +// +// G7BigUIntTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import G7SensorKit + +class G7BigUIntTests: XCTestCase { + + private func random(byteCount: Int) -> G7BigUInt { + G7BigUInt(bigEndianBytes: Data((0 ..< byteCount).map { _ in UInt8.random(in: 0 ... 255) })) + } + + func testBigEndianRoundTrip() { + let hex = "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF" + let value = G7BigUInt(bigEndianBytes: Data(hexadecimalString: hex)!) + XCTAssertEqual(value.bigEndianBytes(paddedTo: 32).hexadecimalString.uppercased(), hex) + } + + func testLeadingZeroesArePadded() { + let value = G7BigUInt(258) + XCTAssertEqual(value.bigEndianBytes(paddedTo: 4).hexadecimalString, "00000102") + } + + func testZero() { + XCTAssertTrue(G7BigUInt.zero.isZero) + XCTAssertTrue(G7BigUInt.zero.isEven) + XCTAssertEqual(G7BigUInt.zero.bitWidth, 0) + XCTAssertEqual(G7BigUInt.zero.bigEndianBytes(paddedTo: 4).hexadecimalString, "00000000") + XCTAssertEqual(G7BigUInt(bigEndianBytes: Data([0, 0, 0])), .zero) + } + + func testAdditionCarriesAcrossLimbs() { + let max32 = G7BigUInt(bigEndianBytes: Data(hexadecimalString: "FFFFFFFF")!) + XCTAssertEqual((max32 + .one).bigEndianBytes(paddedTo: 5).hexadecimalString, "0100000000") + } + + func testSubtractionBorrowsAcrossLimbs() { + let value = G7BigUInt(bigEndianBytes: Data(hexadecimalString: "0100000000")!) + XCTAssertEqual((value - .one).bigEndianBytes(paddedTo: 4).hexadecimalString, "ffffffff") + } + + func testShifts() { + for _ in 0 ..< 50 { + let value = random(byteCount: 24) + for shift in [1, 7, 32, 33, 64, 100] { + let shifted = value << shift + XCTAssertEqual(shifted >> shift, value, "round trip failed for shift \(shift)") + // Shifting left by n is multiplication by 2^n. + var expected = value + for _ in 0 ..< shift { + expected = expected + expected + } + XCTAssertEqual(shifted, expected, "shift \(shift) disagrees with repeated doubling") + } + } + } + + func testMultiplicationAgainstRepeatedAddition() { + for _ in 0 ..< 30 { + let a = random(byteCount: 8) + let multiplier = UInt32.random(in: 0 ... 200) + var expected = G7BigUInt.zero + for _ in 0 ..< multiplier { + expected = expected + a + } + XCTAssertEqual(a * G7BigUInt(multiplier), expected) + } + } + + func testDivisionInvariant() { + for _ in 0 ..< 40 { + let dividend = random(byteCount: 64) + let divisor = random(byteCount: 32) + guard !divisor.isZero else { continue } + let (quotient, remainder) = dividend.quotientAndRemainder(dividingBy: divisor) + XCTAssertTrue(remainder < divisor) + XCTAssertEqual(quotient * divisor + remainder, dividend) + } + } + + func testModularInverse() { + for modulus in [G7P256.p, G7P256.order] { + for _ in 0 ..< 20 { + let value = random(byteCount: 32).modulo(modulus) + guard !value.isZero else { continue } + guard let inverse = value.inverse(modulo: modulus) else { + XCTFail("no inverse for \(value)") + continue + } + XCTAssertEqual(G7BigUInt.mulMod(value, inverse, modulus), .one) + } + } + } + + func testAddModAndSubMod() { + let modulus = G7P256.order + for _ in 0 ..< 40 { + let a = random(byteCount: 32).modulo(modulus) + let b = random(byteCount: 32).modulo(modulus) + XCTAssertEqual(G7BigUInt.addMod(a, b, modulus), (a + b).modulo(modulus)) + XCTAssertEqual(G7BigUInt.addMod(G7BigUInt.subMod(a, b, modulus), b, modulus), a) + } + } +} diff --git a/G7SensorKitTests/G7CGMManagerTests.swift b/G7SensorKitTests/G7CGMManagerTests.swift index 2efad79..702b7b2 100644 --- a/G7SensorKitTests/G7CGMManagerTests.swift +++ b/G7SensorKitTests/G7CGMManagerTests.swift @@ -25,7 +25,7 @@ final class G7CGMManagerTests: XCTestCase { var state = G7CGMManagerState() state.sensorID = Self.sensorID state.activatedAt = Date(timeIntervalSinceNow: -54000) // ~15h old session - let sensor = G7Sensor(sensorID: state.sensorID, bluetoothManager: TestBluetoothManager()) + let sensor = G7Sensor(mode: state.sessionMode, credentials: state.sensorCredentials, bluetoothManager: TestBluetoothManager()) let manager = G7CGMManager(state: state, sensor: sensor) manager.suspectedSessionEndGracePeriod = gracePeriod return manager @@ -48,7 +48,7 @@ final class G7CGMManagerTests: XCTestCase { state.suspectedSessionEndAt = suspectedSessionEndAt state.latestReadingTimestamp = latestReadingTimestamp - let sensor = G7Sensor(sensorID: state.sensorID, bluetoothManager: TestBluetoothManager()) + let sensor = G7Sensor(mode: state.sessionMode, credentials: state.sensorCredentials, bluetoothManager: TestBluetoothManager()) return G7CGMManager(state: state, sensor: sensor) } diff --git a/G7SensorKitTests/G7CalibrationTests.swift b/G7SensorKitTests/G7CalibrationTests.swift new file mode 100644 index 0000000..f91e105 --- /dev/null +++ b/G7SensorKitTests/G7CalibrationTests.swift @@ -0,0 +1,121 @@ +// +// G7CalibrationTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import CoreBluetooth +@testable import G7SensorKit + +private class TestBluetoothManager: G7BluetoothManager { + override func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue) + } +} + +final class G7CalibrationTests: XCTestCase { + + func testCalibrateRequestLayout() { + let request = G7CalibrateTxMessage(glucose: 120, sensorAge: 0x00010203) + XCTAssertEqual(request.data.hexadecimalString, "34" + "7800" + "03020100") + } + + func testCalibrateReplyStatus() { + XCTAssertEqual(G7CalibrateRxMessage(data: Data(hexadecimalString: "34000100")!)?.accepted, true) + let refused = G7CalibrateRxMessage(data: Data(hexadecimalString: "34000500")!) + XCTAssertEqual(refused?.accepted, false) + XCTAssertEqual(refused?.status, 5) + XCTAssertNil(G7CalibrateRxMessage(data: Data(hexadecimalString: "3400")!)) + XCTAssertNil(G7CalibrateRxMessage(data: Data(hexadecimalString: "4e000100")!)) + } + + func testCalibrationBoundsLayout() { + // 32 | status 00 | session 03 | signature 78563412 | lastEGV 7a00 (122) | + // lastCalibrationTime 1ec0 0000 (49182) | processing 02 | permitted 01 | + // display 04 | lastProcessingUpdate 2ac1 0000 (49450) + let data = Data(hexadecimalString: "32" + "00" + "03" + "12345678" + "7a00" + "1ec00000" + "02" + "01" + "04" + "2ac10000")! + XCTAssertEqual(data.count, 20) + let bounds = G7CalibrationBoundsMessage(data: data)! + XCTAssertEqual(bounds.sessionNumber, 3) + XCTAssertEqual(bounds.sessionSignature, 0x78563412) + XCTAssertEqual(bounds.lastGlucose, 122) + XCTAssertEqual(bounds.lastCalibrationTime, 49182) + XCTAssertEqual(bounds.processingStatus, .inProgress) + XCTAssertTrue(bounds.calibrationsPermitted) + XCTAssertEqual(bounds.lastDisplayType, .receiver) + XCTAssertEqual(bounds.lastProcessingUpdateTime, 49450) + XCTAssertTrue(bounds.hasCalibration) + + let factory = G7CalibrationBoundsMessage(data: Data(hexadecimalString: "3200030000000000000000000001000000000000")!)! + XCTAssertFalse(factory.hasCalibration) + XCTAssertEqual(factory.processingStatus, .factoryCalibrated) + XCTAssertNil(G7CalibrationBoundsMessage(data: Data(hexadecimalString: "3200")!)) + } + + func testRecordRoundTrips() { + let accepted = G7CalibrationRecord(glucose: 118, enteredAt: Date(timeIntervalSince1970: 1_700_000_000), outcome: .accepted(at: Date(timeIntervalSince1970: 1_700_000_100)), processingStatus: .completeHigh) + XCTAssertEqual(G7CalibrationRecord(rawValue: accepted.rawValue), accepted) + let rejected = G7CalibrationRecord(glucose: 118, enteredAt: Date(), outcome: .rejected(status: 5, at: Date())) + XCTAssertEqual(G7CalibrationRecord(rawValue: rejected.rawValue), rejected) + let pending = G7CalibrationRecord(glucose: 118, enteredAt: Date()) + XCTAssertEqual(G7CalibrationRecord(rawValue: pending.rawValue), pending) + XCTAssertTrue(NSDictionary(dictionary: accepted.rawValue).isEqual(to: G7CalibrationRecord(rawValue: accepted.rawValue)!.rawValue)) + } + + func testSensorQueuesACalibrationUntilTheNextConnection() { + let sensor = G7Sensor(mode: .direct, credentials: G7SensorCredentials(sensorID: "DXCM99", pairingCode: "0420", sharedKey: nil, peripheralIdentifier: nil), bluetoothManager: TestBluetoothManager()) + XCTAssertNil(sensor.queuedCalibration) + sensor.calibrate(glucose: 110, at: Date()) + sensor.calibrate(glucose: 112, at: Date()) + XCTAssertEqual(sensor.queuedCalibration?.glucose, 112, "the newest calibration replaces the one still waiting") + sensor.cancelPendingCalibration() + XCTAssertNil(sensor.queuedCalibration) + } + + func testManagerTracksTheCalibrationOutcome() { + var state = G7CGMManagerState() + state.sessionMode = .direct + state.sensorID = "DXCM99" + state.pairingCode = "0420" + let sensor = G7Sensor(mode: .direct, credentials: state.sensorCredentials, bluetoothManager: TestBluetoothManager()) + let manager = G7CGMManager(state: state, sensor: sensor) + + manager.calibrate(glucose: 105) + XCTAssertEqual(manager.calibration?.glucose, 105) + XCTAssertEqual(manager.calibration?.outcome, .pending) + XCTAssertTrue(manager.hasPendingCalibration) + + manager.sensor(sensor, didReceiveCalibrationResponse: G7CalibrateRxMessage(data: Data(hexadecimalString: "34000100")!)!) + guard case .accepted? = manager.calibration?.outcome else { + return XCTFail("expected the calibration to be accepted") + } + + let inProgress = G7CalibrationBoundsMessage(data: Data(hexadecimalString: "32" + "00" + "03" + "12345678" + "7a00" + "1ec00000" + "02" + "01" + "04" + "2ac10000")!)! + manager.sensor(sensor, didReadCalibrationBounds: inProgress) + XCTAssertEqual(manager.calibration?.processingStatus, .inProgress) + XCTAssertEqual(manager.state.calibrationBounds, inProgress) + + // Persisted, and a pending one is owed to the sensor again after a relaunch. + var restored = G7CGMManagerState(rawValue: manager.rawState) + XCTAssertEqual(restored.calibration, manager.calibration) + restored.calibration = G7CalibrationRecord(glucose: 99, enteredAt: Date()) + let relaunched = G7CGMManager(state: restored, sensor: G7Sensor(mode: .direct, credentials: restored.sensorCredentials, bluetoothManager: TestBluetoothManager())) + XCTAssertEqual(relaunched.sensor.queuedCalibration?.glucose, 99) + + manager.cancelPendingCalibration() + XCTAssertNotNil(manager.calibration, "an answered calibration is not a pending one; cancelling leaves it") + } + + func testCalibrationIsForgottenWithTheSensor() { + var state = G7CGMManagerState() + state.sessionMode = .direct + state.sensorID = "DXCM99" + state.pairingCode = "0420" + state.calibration = G7CalibrationRecord(glucose: 105, enteredAt: Date()) + let manager = G7CGMManager(state: state, sensor: G7Sensor(mode: .direct, credentials: state.sensorCredentials, bluetoothManager: TestBluetoothManager())) + manager.applyPairingResult(pairingCode: "1234", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 1, count: 16), sensorName: "DXCMzz") + XCTAssertNil(manager.calibration) + } +} diff --git a/G7SensorKitTests/G7JPAKETests.swift b/G7SensorKitTests/G7JPAKETests.swift new file mode 100644 index 0000000..dcb2e7c --- /dev/null +++ b/G7SensorKitTests/G7JPAKETests.swift @@ -0,0 +1,227 @@ +// +// G7JPAKETests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CryptoKit +import XCTest +@testable import G7SensorKit + +/// Stands in for the sensor's half of the EC-JPAKE exchange so the handshake +/// can be driven end to end without hardware. It runs the mirror-image +/// algorithm: if both halves land on the same secret, our half is doing the +/// protocol and not merely agreeing with itself. +private final class SimulatedSensor { + /// The identifier the sensor attaches to its own proofs. + static let party: [UInt8] = [0x37, 0x56, 0x27, 0x67, 0x56, 0x27] + + private let pin: G7BigUInt + private let privateKey1: G7BigUInt + private let privateKey2: G7BigUInt + let publicKey1: G7P256Point + let publicKey2: G7P256Point + + init(pairingCode: String, seed: UInt8) { + pin = G7BigUInt(bigEndianBytes: Data(pairingCode.utf8)) + privateKey1 = G7BigUInt(bigEndianBytes: Data((0 ..< 32).map { UInt8(truncatingIfNeeded: $0 &+ seed) })) + .modulo(G7P256.order - G7BigUInt(2)) + .one + privateKey2 = G7BigUInt(bigEndianBytes: Data((0 ..< 32).map { UInt8(truncatingIfNeeded: $0 &* 3 &+ seed) })) + .modulo(G7P256.order - G7BigUInt(2)) + .one + publicKey1 = G7P256.multiplyGenerator(by: privateKey1) + publicKey2 = G7P256.multiplyGenerator(by: privateKey2) + } + + var round1: G7PCert { + cert(base: G7P256.generator, publicKey: publicKey1, privateKey: privateKey1, randomizer: scalar(11)) + } + + var round2: G7PCert { + cert(base: G7P256.generator, publicKey: publicKey2, privateKey: privateKey2, randomizer: scalar(23)) + } + + /// Mirror of the client's round 3: base is our two public keys plus the + /// sensor's first, blinded by the sensor's second private key times the pin. + func round3(clientRound1: G7PCert, clientRound2: G7PCert) -> G7PCert { + let base = G7P256.add(G7P256.add(clientRound1.publicKey, clientRound2.publicKey), publicKey1) + let blinded = G7BigUInt.mulMod(privateKey2, pin, G7P256.order) + return cert( + base: base, + publicKey: G7P256.multiply(base, by: blinded), + privateKey: blinded, + randomizer: scalar(37) + ) + } + + /// Mirror of the client's key derivation. + func sharedSecret(clientRound2: G7PCert, clientRound3: G7PCert) -> Data { + let blinded = G7BigUInt.mulMod(privateKey2, pin, G7P256.order) + let unblind = G7BigUInt.subMod(.zero, blinded, G7P256.order) + let shared = G7P256.multiply( + G7P256.add(clientRound3.publicKey, G7P256.multiply(clientRound2.publicKey, by: unblind)), + by: privateKey2 + ) + return Data(SHA256.hash(data: shared.x.bigEndianBytes(paddedTo: 32))) + } + + private func scalar(_ seed: UInt8) -> G7BigUInt { + G7BigUInt(bigEndianBytes: Data((0 ..< 32).map { UInt8(truncatingIfNeeded: $0 &* 7 &+ seed) })) + .modulo(G7P256.order - G7BigUInt(2)) + .one + } + + private func cert( + base: G7P256Point, + publicKey: G7P256Point, + privateKey: G7BigUInt, + randomizer: G7BigUInt + ) -> G7PCert { + let proofPoint = G7P256.multiply(base, by: randomizer) + let challenge = SimulatedSensor.transcriptHash( + base: base, + proofPoint: proofPoint, + publicKey: publicKey, + party: SimulatedSensor.party + ) + let proof = G7BigUInt.subMod( + randomizer, + G7BigUInt.mulMod(challenge, privateKey, G7P256.order), + G7P256.order + ) + return G7PCert(publicKey: publicKey, proofPoint: proofPoint, proof: proof) + } + + static func transcriptHash( + base: G7P256Point, + proofPoint: G7P256Point, + publicKey: G7P256Point, + party: [UInt8] + ) -> G7BigUInt { + var buffer = Data() + for point in [base, proofPoint, publicKey] { + let bytes = [UInt8](point.uncompressedBytes) + buffer.appendBigEndian(UInt32(bytes.count)) + buffer.append(contentsOf: bytes) + } + buffer.appendBigEndian(UInt32(party.count)) + buffer.append(contentsOf: party) + return G7BigUInt(bigEndianBytes: Data(SHA256.hash(data: buffer))).modulo(G7P256.order) + } +} + +class G7JPAKETests: XCTestCase { + + /// Runs a full exchange and returns what each side derived. + private func exchange( + clientCode: String, + sensorCode: String, + seed: UInt8 = 5 + ) throws -> (client: Data, sensor: Data, clientJPAKE: G7JPAKE, sensor1: G7PCert, sensor3: G7PCert) { + let jpake = G7JPAKE(pairingCode: clientCode) + let sensor = SimulatedSensor(pairingCode: sensorCode, seed: seed) + + let sensorRound1 = sensor.round1 + let clientRound1 = try G7PCert(data: jpake.makeRound1()) + + let sensorRound2 = sensor.round2 + let clientRound2 = try G7PCert(data: jpake.makeRound2()) + + let sensorRound3 = sensor.round3(clientRound1: clientRound1, clientRound2: clientRound2) + let clientSecret = try jpake.deriveSharedSecret(peerRound2: sensorRound2, peerRound3: sensorRound3) + let clientRound3 = try G7PCert(data: jpake.makeRound3(peerRound1: sensorRound1, peerRound2: sensorRound2)) + + let sensorSecret = sensor.sharedSecret(clientRound2: clientRound2, clientRound3: clientRound3) + return (clientSecret, sensorSecret, jpake, sensorRound1, sensorRound3) + } + + func testBothSidesDeriveTheSameSecret() throws { + let result = try exchange(clientCode: "1155", sensorCode: "1155") + XCTAssertEqual(result.client.count, 32) + XCTAssertEqual(result.client, result.sensor) + } + + func testDerivedSecretVariesWithTheSensorKeys() throws { + let first = try exchange(clientCode: "1155", sensorCode: "1155", seed: 5) + let second = try exchange(clientCode: "1155", sensorCode: "1155", seed: 99) + XCTAssertNotEqual(first.client, second.client) + } + + /// A wrong code still completes the exchange cryptographically; the two + /// sides simply land on different keys, which is why the AES challenge + /// later in the handshake is where a wrong code actually surfaces. + func testWrongCodeYieldsDisagreeingSecrets() throws { + let result = try exchange(clientCode: "1155", sensorCode: "9999") + XCTAssertEqual(result.client.count, 32) + XCTAssertNotEqual(result.client, result.sensor) + } + + func testSensorProofsValidate() throws { + let result = try exchange(clientCode: "1155", sensorCode: "1155") + XCTAssertTrue(result.clientJPAKE.validateRound1Or2(result.sensor1)) + XCTAssertTrue(result.clientJPAKE.validateRound3(peerRound1: result.sensor1, peerRound3: result.sensor3)) + } + + func testTamperedSensorProofIsRejected() throws { + let result = try exchange(clientCode: "1155", sensorCode: "1155") + let tampered = G7PCert( + publicKey: result.sensor1.publicKey, + proofPoint: result.sensor1.proofPoint, + proof: G7BigUInt.addMod(result.sensor1.proof, .one, G7P256.order) + ) + XCTAssertFalse(result.clientJPAKE.validateRound1Or2(tampered)) + } + + func testOurProofsWouldValidateAtTheSensor() throws { + // Verify our round-1 cert the way the sensor would: same Schnorr + // check, but against the "client" party identifier. + let jpake = G7JPAKE(pairingCode: "1155") + let ourRound1 = try G7PCert(data: jpake.makeRound1()) + let challenge = SimulatedSensor.transcriptHash( + base: G7P256.generator, + proofPoint: ourRound1.proofPoint, + publicKey: ourRound1.publicKey, + party: Array("client".utf8) + ) + let recomputed = G7P256.add( + G7P256.multiply(G7P256.generator, by: ourRound1.proof), + G7P256.multiply(ourRound1.publicKey, by: challenge) + ) + XCTAssertEqual(recomputed, ourRound1.proofPoint) + } + + func testRound3UsesTheFixedRandomizer() throws { + // The sensor reproduces our round-3 commitment, so it must come from + // the fixed randomizer rather than a fresh draw: two runs with the + // same keys must produce the same proof point. + let makeRound3: () throws -> G7PCert = { + let jpake = G7JPAKE(pairingCode: "1155", random: { count in Data(repeating: 0x42, count: count) }) + let sensor = SimulatedSensor(pairingCode: "1155", seed: 5) + _ = jpake.makeRound1() + _ = jpake.makeRound2() + return try G7PCert(data: jpake.makeRound3(peerRound1: sensor.round1, peerRound2: sensor.round2)) + } + XCTAssertEqual(try makeRound3().proofPoint, try makeRound3().proofPoint) + } + + func testCertEncodingRoundTrip() throws { + let jpake = G7JPAKE(pairingCode: "1155") + let encoded = jpake.makeRound1() + XCTAssertEqual(encoded.count, G7PCert.byteCount) + let decoded = try G7PCert(data: encoded) + XCTAssertEqual(decoded.encoded, encoded) + XCTAssertTrue(G7P256.isOnCurve(decoded.publicKey)) + XCTAssertTrue(G7P256.isOnCurve(decoded.proofPoint)) + } + + func testCertRejectsWrongLength() { + XCTAssertThrowsError(try G7PCert(data: Data(repeating: 0, count: 159))) + XCTAssertThrowsError(try G7PCert(data: Data(repeating: 0, count: 161))) + } + + func testOutOfOrderUse() { + let jpake = G7JPAKE(pairingCode: "1155") + let sensor = SimulatedSensor(pairingCode: "1155", seed: 5) + XCTAssertThrowsError(try jpake.makeRound3(peerRound1: sensor.round1, peerRound2: sensor.round2)) + XCTAssertFalse(jpake.validateRound3(peerRound1: sensor.round1, peerRound3: sensor.round1)) + } +} diff --git a/G7SensorKitTests/G7LifecycleAlertTests.swift b/G7SensorKitTests/G7LifecycleAlertTests.swift new file mode 100644 index 0000000..28cdd46 --- /dev/null +++ b/G7SensorKitTests/G7LifecycleAlertTests.swift @@ -0,0 +1,247 @@ +// +// G7LifecycleAlertTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import CoreBluetooth +import LoopKit +@testable import G7SensorKit + +private class TestBluetoothManager: G7BluetoothManager { + override func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue) + } +} + +/// Records what the manager asks Loop to do with alerts. Main-actor bound, +/// like the issuing calls themselves. +@MainActor +private final class RecordingDelegate: CGMManagerDelegate { + var issued: [Alert] = [] + var retracted: [Alert.Identifier] = [] + /// Ordered record of what happened, for ordering assertions. + var events: [String] = [] + + func issueAlert(_ alert: Alert) async { + issued.append(alert) + events.append("issue:" + alert.identifier.alertIdentifier) + } + + func retractAlert(identifier: Alert.Identifier) async { + retracted.append(identifier) + events.append("retract:" + identifier.alertIdentifier) + } + + func cgmManagerWantsDeletion(_ manager: CGMManager) async { + events.append("delete") + } + + nonisolated func doesIssuedAlertExist(identifier: Alert.Identifier) async throws -> Bool { false } + nonisolated func lookupAllUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] } + nonisolated func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] } + nonisolated func recordRetractedAlert(_ alert: Alert, at date: Date) async throws {} + nonisolated func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) {} + nonisolated func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) {} + nonisolated func startDateToFilterNewData(for manager: CGMManager) -> Date? { nil } + nonisolated func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) {} + nonisolated func cgmManager(_ manager: CGMManager, hasNew events: [PersistedCgmEvent]) {} + nonisolated func cgmManagerDidUpdateState(_ manager: CGMManager) {} + nonisolated func credentialStoragePrefix(for manager: CGMManager) -> String { "test" } +} + +class G7LifecycleAlertScheduleTests: XCTestCase { + + private let day = TimeInterval(hours: 24) + + func testFreshSensorSchedulesEverything() { + let now = Date() + let expires = now.addingTimeInterval(10 * day) + let ends = expires.addingTimeInterval(12 * 3600) + let delays = G7LifecycleAlertSchedule.delays(sensorExpiresAt: expires, sensorEndsAt: ends, now: now) + + XCTAssertEqual(delays[.sensorExpiringSoon] ?? -1, 9 * day, accuracy: 1) + XCTAssertEqual(delays[.sensorExpiringImminently] ?? -1, 10 * day - 2 * 3600, accuracy: 1) + XCTAssertEqual(delays[.sensorExpired] ?? -1, 10 * day, accuracy: 1) + XCTAssertEqual(delays[.sessionEnded] ?? -1, 10 * day + 12 * 3600, accuracy: 1) + } + + /// Adopting a sensor that is already most of the way through its + /// session must not fire warnings for moments that have passed. + func testPastMomentsAreSkipped() { + let now = Date() + let expires = now.addingTimeInterval(3600) // an hour left + let ends = expires.addingTimeInterval(12 * 3600) + let delays = G7LifecycleAlertSchedule.delays(sensorExpiresAt: expires, sensorEndsAt: ends, now: now) + + XCTAssertNil(delays[.sensorExpiringSoon]) + XCTAssertNil(delays[.sensorExpiringImminently]) + XCTAssertEqual(delays[.sensorExpired] ?? -1, 3600, accuracy: 1) + XCTAssertNotNil(delays[.sessionEnded]) + } + + func testEverySessionTimedAlertHasAScheduleEntry() { + let now = Date() + let delays = G7LifecycleAlertSchedule.delays( + sensorExpiresAt: now.addingTimeInterval(10 * day), + sensorEndsAt: now.addingTimeInterval(10.5 * day), + now: now + ) + XCTAssertEqual(Set(delays.keys), Set(G7LifecycleAlert.sessionTimed)) + } + + func testAlertContentIsComplete() { + for alert in G7LifecycleAlert.allCases { + let loopAlert = alert.alert(managerIdentifier: "G7CGMManager") + XCTAssertEqual(loopAlert.identifier.alertIdentifier, alert.rawValue) + XCTAssertFalse(loopAlert.foregroundContent?.title.isEmpty ?? true) + XCTAssertFalse(loopAlert.foregroundContent?.body.isEmpty ?? true) + } + XCTAssertEqual(G7LifecycleAlert.sensorFailed.interruptionLevel, .critical) + } +} + +@MainActor +class G7LifecycleAlertManagerTests: XCTestCase { + + private var recorder: RecordingDelegate! + + private func makeManager(state: G7CGMManagerState = G7CGMManagerState()) -> G7CGMManager { + let sensor = G7Sensor(mode: state.sessionMode, credentials: state.sensorCredentials, bluetoothManager: TestBluetoothManager()) + let manager = G7CGMManager(state: state, sensor: sensor) + recorder = RecordingDelegate() + manager.cgmManagerDelegate = recorder + manager.delegateQueue = DispatchQueue.main + return manager + } + + /// Delegate calls are dispatched; give them a moment to land. + private func settle() { + let expectation = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { expectation.fulfill() } + wait(for: [expectation], timeout: 2) + } + + private var okReading: G7GlucoseMessage { + G7GlucoseMessage(data: Data(hexadecimalString: "4e00c35501002601000106008a00060187000f")!)! + } + + func testDiscoveringASensorSchedulesTheSessionTimedAlerts() { + let manager = makeManager() + _ = manager.sensor(manager.sensor, didDiscoverNewSensor: "DXCM99", activatedAt: Date()) + settle() + + let scheduled = recorder.issued.filter { alert in + if case .delayed = alert.trigger { return true } + return false + } + XCTAssertEqual( + Set(scheduled.map(\.identifier.alertIdentifier)), + Set(G7LifecycleAlert.sessionTimed.map(\.rawValue)) + ) + XCTAssertNotNil(manager.state.lifecycleAlertsScheduledFor) + } + + func testReschedulingIsIdempotentPerSensorAndLifetime() { + let manager = makeManager() + _ = manager.sensor(manager.sensor, didDiscoverNewSensor: "DXCM99", activatedAt: Date()) + settle() + let issuedOnce = recorder.issued.count + + // Same sensor, same lifetime: a relaunch-style repeat must not + // duplicate notifications Loop already holds. + _ = manager.sensor(manager.sensor, didDiscoverNewSensor: "DXCM99", activatedAt: manager.state.activatedAt!) + settle() + XCTAssertEqual(recorder.issued.count, issuedOnce) + + // A 15-day sensor reports a longer session: the alerts move. + let fifteenDay = ExtendedVersionMessage(data: Data(hexadecimalString: "5200406f1400880e00010a04ff1100")!)! + manager.sensor(manager.sensor, didReceive: fifteenDay) + settle() + XCTAssertGreaterThan(recorder.issued.count, issuedOnce) + XCTAssertTrue(recorder.retracted.contains { $0.alertIdentifier == G7LifecycleAlert.sensorExpired.rawValue }) + } + + func testReadingsRearmSignalLoss() { + var state = G7CGMManagerState() + state.sensorID = "DXCM99" + state.activatedAt = Date(timeIntervalSinceNow: -3600) + let manager = makeManager(state: state) + + manager.sensor(manager.sensor, didRead: okReading) + settle() + + let signalLoss = recorder.issued.filter { $0.identifier.alertIdentifier == G7LifecycleAlert.signalLoss.rawValue } + XCTAssertEqual(signalLoss.count, 1) + guard case .delayed(let interval) = signalLoss[0].trigger else { + return XCTFail("signal loss must be scheduled ahead, not raised now") + } + XCTAssertEqual(interval, G7LifecycleAlert.signalLossInterval) + XCTAssertTrue(recorder.retracted.contains { $0.alertIdentifier == G7LifecycleAlert.signalLoss.rawValue }, "the previous arming is cleared first") + } + + func testRefusalRaisesOnceAndClearsOnReading() { + var state = G7CGMManagerState() + state.sessionMode = .direct + state.sensorID = "DXCM99" + state.activatedAt = Date(timeIntervalSinceNow: -3600) + state.pairingCode = "1155" + let manager = makeManager(state: state) + + manager.sensor(manager.sensor, didError: G7AuthenticatorError.rejected(authStatus: 2, failureCode: .deviceTypeRestriction)) + manager.sensor(manager.sensor, didError: G7AuthenticatorError.rejected(authStatus: 2, failureCode: .deviceTypeRestriction)) + settle() + XCTAssertEqual(recorder.issued.filter { $0.identifier.alertIdentifier == G7LifecycleAlert.connectionRefused.rawValue }.count, 1) + + manager.sensor(manager.sensor, didRead: okReading) + settle() + XCTAssertTrue(recorder.retracted.contains { $0.alertIdentifier == G7LifecycleAlert.connectionRefused.rawValue }) + } + + /// LibreLoop #13: alerts left standing when the CGM is deleted are + /// replayed by Loop at every launch until acknowledged. Deleting must + /// retract every alert this manager can issue, and must do so before the + /// deletion notification that makes Loop release the manager. + func testDeletingTheCGMRetractsEveryAlertBeforeNotifying() { + var state = G7CGMManagerState() + state.sensorID = "DXCM99" + state.activatedAt = Date() + state.lifecycleAlertsScheduledFor = "scheduled" + let manager = makeManager(state: state) + + let done = expectation(description: "delete completed") + manager.delete { done.fulfill() } + wait(for: [done], timeout: 2) + settle() + + XCTAssertEqual( + Set(recorder.retracted.map(\.alertIdentifier)), + Set(G7LifecycleAlert.allCases.map(\.rawValue)), + "every alert the manager can issue must be retracted" + ) + let deleteIndex = recorder.events.firstIndex(of: "delete") + XCTAssertNotNil(deleteIndex) + for (index, event) in recorder.events.enumerated() where event.hasPrefix("retract:") { + XCTAssertLessThan(index, deleteIndex ?? -1, "\(event) must land before Loop is told to delete") + } + } + + func testForgettingTheSensorRetractsEverything() { + var state = G7CGMManagerState() + state.sensorID = "DXCM99" + state.activatedAt = Date() + state.lifecycleAlertsScheduledFor = "stale" + let manager = makeManager(state: state) + + manager.scanForNewSensor() + settle() + + XCTAssertEqual( + Set(recorder.retracted.map(\.alertIdentifier)), + Set(G7LifecycleAlert.allCases.map(\.rawValue)) + ) + XCTAssertNil(manager.state.lifecycleAlertsScheduledFor) + } +} diff --git a/G7SensorKitTests/G7P256Tests.swift b/G7SensorKitTests/G7P256Tests.swift new file mode 100644 index 0000000..de19d76 --- /dev/null +++ b/G7SensorKitTests/G7P256Tests.swift @@ -0,0 +1,135 @@ +// +// G7P256Tests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CryptoKit +import XCTest +@testable import G7SensorKit + +/// CryptoKit can produce `k * G` for any scalar `k` (that is exactly what +/// deriving a P-256 public key is), which makes it an independent oracle for +/// this file's hand-rolled curve arithmetic. +class G7P256Tests: XCTestCase { + + /// A scalar in [1, n-1], plus the public key CryptoKit derives from it. + private func randomScalarWithOracle() -> (scalar: G7BigUInt, x: G7BigUInt, y: G7BigUInt) { + while true { + let bytes = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) }) + guard let key = try? P256.Signing.PrivateKey(rawRepresentation: bytes) else { + continue + } + let publicKey = key.publicKey.rawRepresentation + return ( + G7BigUInt(bigEndianBytes: bytes), + G7BigUInt(bigEndianBytes: publicKey.prefix(32)), + G7BigUInt(bigEndianBytes: publicKey.suffix(32)) + ) + } + } + + func testGeneratorIsOnCurve() { + XCTAssertTrue(G7P256.isOnCurve(G7P256.generator)) + } + + func testKnownSmallMultiples() { + // Published P-256 test vectors for 2G and 3G. + let two = G7P256.multiplyGenerator(by: G7BigUInt(2)) + XCTAssertEqual( + two.x.bigEndianBytes(paddedTo: 32).hexadecimalString.uppercased(), + "7CF27B188D034F7E8A52380304B51AC3C08969E277F21B35A60B48FC47669978" + ) + XCTAssertEqual( + two.y.bigEndianBytes(paddedTo: 32).hexadecimalString.uppercased(), + "07775510DB8ED040293D9AC69F7430DBBA7DADE63CE982299E04B79D227873D1" + ) + + let three = G7P256.multiplyGenerator(by: G7BigUInt(3)) + XCTAssertEqual( + three.x.bigEndianBytes(paddedTo: 32).hexadecimalString.uppercased(), + "5ECBE4D1A6330A44C8F7EF951D4BF165E6C6B721EFADA985FB41661BC6E7FD6C" + ) + XCTAssertEqual( + three.y.bigEndianBytes(paddedTo: 32).hexadecimalString.uppercased(), + "8734640C4998FF7E374B06CE1A64A2ECD82AB036384FB83D9A79B127A27D5032" + ) + } + + func testScalarMultiplicationMatchesCryptoKit() { + for _ in 0 ..< 12 { + let (scalar, x, y) = randomScalarWithOracle() + let point = G7P256.multiplyGenerator(by: scalar) + XCTAssertFalse(point.isInfinity) + XCTAssertEqual(point.x, x) + XCTAssertEqual(point.y, y) + XCTAssertTrue(G7P256.isOnCurve(point)) + } + } + + func testPointAdditionIsScalarAdditionInTheExponent() { + for _ in 0 ..< 8 { + let (a, _, _) = randomScalarWithOracle() + let (b, _, _) = randomScalarWithOracle() + let sum = G7BigUInt.addMod(a.modulo(G7P256.order), b.modulo(G7P256.order), G7P256.order) + + let combined = G7P256.add(G7P256.multiplyGenerator(by: a), G7P256.multiplyGenerator(by: b)) + XCTAssertEqual(combined, G7P256.multiplyGenerator(by: sum)) + } + } + + func testAddingAPointToItselfDoubles() { + let (a, _, _) = randomScalarWithOracle() + let point = G7P256.multiplyGenerator(by: a) + XCTAssertEqual(G7P256.add(point, point), G7P256.multiply(point, by: G7BigUInt(2))) + } + + func testMultiplyingByOrderYieldsInfinity() { + XCTAssertTrue(G7P256.multiplyGenerator(by: G7P256.order).isInfinity) + XCTAssertTrue(G7P256.multiplyGenerator(by: .zero).isInfinity) + } + + func testAddingInversePointsYieldsInfinity() { + let (a, _, _) = randomScalarWithOracle() + let point = G7P256.multiplyGenerator(by: a) + let negated = G7P256Point(x: point.x, y: G7BigUInt.subMod(.zero, point.y, G7P256.p)) + XCTAssertTrue(G7P256.add(point, negated).isInfinity) + } + + func testScalarMultiplicationOfAnArbitraryPoint() { + // (a * b) * G should equal b * (a * G). + for _ in 0 ..< 5 { + let (a, _, _) = randomScalarWithOracle() + let (b, _, _) = randomScalarWithOracle() + let product = G7BigUInt.mulMod(a, b, G7P256.order) + XCTAssertEqual( + G7P256.multiply(G7P256.multiplyGenerator(by: a), by: b), + G7P256.multiplyGenerator(by: product) + ) + } + } + + /// The reduction table is the one part of this file that a transcription + /// slip could break in a way the curve tests might still pass by luck. + func testSolinasReductionMatchesLongDivision() { + for _ in 0 ..< 200 { + let value = G7BigUInt(bigEndianBytes: Data((0 ..< 64).map { _ in UInt8.random(in: 0 ... 255) })) + XCTAssertEqual(G7P256.reduce(value), value.modulo(G7P256.p)) + } + // Boundary values the random draw is unlikely to hit. + for value in [G7P256.p, G7P256.p - .one, G7P256.p + .one, G7P256.p * G7P256.p] { + XCTAssertEqual(G7P256.reduce(value), value.modulo(G7P256.p)) + } + } + + func testUncompressedEncoding() { + let encoded = G7P256.generator.uncompressedBytes + XCTAssertEqual(encoded.count, 65) + XCTAssertEqual(encoded.first, 0x04) + XCTAssertEqual( + encoded.dropFirst().prefix(32).hexadecimalString.uppercased(), + "6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296" + ) + } +} diff --git a/G7SensorKitTests/G7PairingPlannerTests.swift b/G7SensorKitTests/G7PairingPlannerTests.swift new file mode 100644 index 0000000..3ef3da3 --- /dev/null +++ b/G7SensorKitTests/G7PairingPlannerTests.swift @@ -0,0 +1,125 @@ +// +// G7PairingPlannerTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import G7SensorKit + +class G7PairingPlannerTests: XCTestCase { + + private let a = UUID() + private let b = UUID() + private let c = UUID() + + func testFirstCandidateBecomesCurrent() { + var planner = G7PairingPlanner() + XCTAssertNil(planner.currentCandidate) + XCTAssertTrue(planner.addCandidate(id: a, name: "DXCM01", isPhoneSlotHeld: false)) + XCTAssertEqual(planner.currentCandidate?.id, a) + XCTAssertEqual(planner.nextAttemptNumber, 1) + } + + func testDuplicateDiscoveryIsIgnored() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "DXCM01", isPhoneSlotHeld: false) + XCTAssertFalse(planner.addCandidate(id: a, name: "DXCM01", isPhoneSlotHeld: false)) + XCTAssertEqual(planner.candidates.count, 1) + } + + /// A sensor another phone is using will reject us, and rejections count + /// toward a lockout, so free sensors are tried first. + func testFreeSensorsAreTriedBeforeHeldOnes() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "held", isPhoneSlotHeld: true) + planner.addCandidate(id: b, name: "free", isPhoneSlotHeld: false) + // `a` was already current when `b` arrived, so it keeps its turn... + XCTAssertEqual(planner.currentCandidate?.name, "held") + + // ...but among the untried tail, free ones jump ahead of held ones. + planner.addCandidate(id: c, name: "held2", isPhoneSlotHeld: true) + let d = UUID() + planner.addCandidate(id: d, name: "free2", isPhoneSlotHeld: false) + XCTAssertEqual(planner.candidates.map(\.name), ["held", "free", "free2", "held2"]) + } + + func testHeldCandidateIsDeferredNotDropped() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "free", isPhoneSlotHeld: false) + planner.addCandidate(id: b, name: "held", isPhoneSlotHeld: true) + XCTAssertEqual(planner.abandonCurrentCandidate(reason: "rejected"), .advanceToNext) + XCTAssertEqual(planner.currentCandidate?.name, "held", "a held sensor may be our own; it still gets a turn") + } + + func testOrdinaryFailuresRetryThenAdvance() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "A", isPhoneSlotHeld: false) + planner.addCandidate(id: b, name: "B", isPhoneSlotHeld: false) + + for attempt in 1 ..< G7PairingPlanner.attemptsPerCandidate { + XCTAssertEqual(planner.nextAttemptNumber, attempt) + XCTAssertEqual(planner.recordFailure(), .retryCurrent) + } + XCTAssertEqual(planner.recordFailure(), .advanceToNext) + XCTAssertEqual(planner.currentCandidate?.id, b) + XCTAssertEqual(planner.nextAttemptNumber, 1, "attempts reset for the next candidate") + } + + /// A rejection is terminal for that sensor, and retrying it invites the + /// lockout, so it is dropped on the first one. + func testRejectionAbandonsImmediately() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "A", isPhoneSlotHeld: false) + planner.addCandidate(id: b, name: "B", isPhoneSlotHeld: false) + + XCTAssertEqual(planner.abandonCurrentCandidate(reason: "rejected"), .advanceToNext) + XCTAssertEqual(planner.currentCandidate?.id, b) + } + + func testGiveUpAfterLastCandidate() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "A", isPhoneSlotHeld: false) + + guard case .giveUp(let reason) = planner.abandonCurrentCandidate(reason: "wrong code") else { + return XCTFail("expected give up") + } + XCTAssertTrue(reason.contains("A: wrong code"), "the user should learn why each sensor was skipped") + XCTAssertNil(planner.currentCandidate) + } + + func testGiveUpWithNoCandidatesExplainsNothingWasFound() { + var planner = G7PairingPlanner() + guard case .giveUp(let reason) = planner.recordFailure() else { + return XCTFail("expected give up") + } + XCTAssertTrue(reason.lowercased().contains("no sensor")) + } + + /// The held slot expires after ~15 minutes of silence, so a repeat + /// advertisement can move a deferred candidate forward. + func testSlotUpdateReordersUntriedCandidates() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "current", isPhoneSlotHeld: false) + planner.addCandidate(id: b, name: "held", isPhoneSlotHeld: true) + planner.addCandidate(id: c, name: "free", isPhoneSlotHeld: false) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "free", "held"]) + + XCTAssertTrue(planner.updateSlot(id: b, isPhoneSlotHeld: false)) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "free", "held"], "discovery order holds within a class") + + XCTAssertTrue(planner.updateSlot(id: c, isPhoneSlotHeld: true)) + XCTAssertEqual(planner.candidates.map(\.name), ["current", "held", "free"]) + + XCTAssertFalse(planner.updateSlot(id: c, isPhoneSlotHeld: true), "no change reports no change") + } + + func testSlotUpdateNeverMovesTheCurrentCandidate() { + var planner = G7PairingPlanner() + planner.addCandidate(id: a, name: "current", isPhoneSlotHeld: false) + planner.addCandidate(id: b, name: "other", isPhoneSlotHeld: false) + planner.updateSlot(id: a, isPhoneSlotHeld: true) + XCTAssertEqual(planner.currentCandidate?.id, a, "a candidate mid-handshake must stay put") + } +} diff --git a/G7SensorKitTests/G7SensorPackageTests.swift b/G7SensorKitTests/G7SensorPackageTests.swift new file mode 100644 index 0000000..97bc6a6 --- /dev/null +++ b/G7SensorKitTests/G7SensorPackageTests.swift @@ -0,0 +1,78 @@ +// +// G7SensorPackageTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import G7SensorKit + +class G7SensorPackageTests: XCTestCase { + + private let gs = "\u{1D}" + + /// GTIN, expiry, lot, serial, pairing code: the fields on a sensor box, + /// with the variable-length ones separated by FNC1. + private var samplePayload: String { + "0100386270001863" + "17260531" + "10LOT42" + gs + "21123456789012" + gs + "2401155" + } + + func testParsesASensorBox() throws { + let package = try XCTUnwrap(G7SensorPackage(dataMatrix: samplePayload)) + XCTAssertEqual(package.gtin, "00386270001863") + XCTAssertEqual(package.expiry, "260531") + XCTAssertEqual(package.lot, "LOT42") + XCTAssertEqual(package.serial, "123456789012") + XCTAssertEqual(package.pairingCode, "1155") + XCTAssertTrue(package.isDexcom) + } + + func testSymbologyIdentifierAndLeadingSeparatorAreSkipped() throws { + let package = try XCTUnwrap(G7SensorPackage(dataMatrix: "]d2" + gs + samplePayload)) + XCTAssertEqual(package.pairingCode, "1155") + XCTAssertEqual(package.serial, "123456789012") + } + + func testFieldOrderDoesNotMatter() throws { + let package = try XCTUnwrap(G7SensorPackage(dataMatrix: "2401155" + gs + "21123456789012" + gs + "0100386270001863")) + XCTAssertEqual(package.pairingCode, "1155") + XCTAssertEqual(package.serial, "123456789012") + XCTAssertEqual(package.gtin, "00386270001863") + } + + func testPairingCodeFieldAtEndNeedsNoSeparator() throws { + let package = try XCTUnwrap(G7SensorPackage(dataMatrix: "21123456789012" + gs + "2400420")) + XCTAssertEqual(package.pairingCode, "0420") + } + + /// AI 240 is a general product identifier; only a four-digit value is a + /// pairing code. + func testNonCodeValueInAI240IsIgnored() throws { + let package = try XCTUnwrap(G7SensorPackage(dataMatrix: "0100386270001863" + "240ABC123")) + XCTAssertNil(package.pairingCode) + XCTAssertEqual(package.gtin, "00386270001863") + } + + func testOtherManufacturer() throws { + let package = try XCTUnwrap(G7SensorPackage(dataMatrix: "0100123456789012")) + XCTAssertFalse(package.isDexcom) + } + + func testUnrelatedBarcodeIsRejected() { + XCTAssertNil(G7SensorPackage(dataMatrix: "https://example.com")) + XCTAssertNil(G7SensorPackage(dataMatrix: "")) + } + + func testUnknownIdentifierStopsParsingWithoutLosingEarlierFields() throws { + // "42" is not an identifier this parser knows. + let package = try XCTUnwrap(G7SensorPackage(dataMatrix: "2401155" + gs + "42junk")) + XCTAssertEqual(package.pairingCode, "1155") + } + + func testLongestIdentifierWins() { + // "240..." must not be read as AI 24 (unknown) or AI 2 (nonexistent). + XCTAssertEqual(GS1ElementString.parse("2401155")["240"], "1155") + XCTAssertNil(GS1ElementString.parse("2401155")["24"]) + } +} diff --git a/G7SensorKitTests/G7SessionModeMigrationTests.swift b/G7SensorKitTests/G7SessionModeMigrationTests.swift new file mode 100644 index 0000000..c70afab --- /dev/null +++ b/G7SensorKitTests/G7SessionModeMigrationTests.swift @@ -0,0 +1,496 @@ +// +// G7SessionModeMigrationTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import CoreBluetooth +import LoopKit +@testable import G7SensorKit + +/// CBCentralManager with the state restoration option raises an exception in a +/// test bundle, which lacks the bluetooth-central background mode. +private class TestBluetoothManager: G7BluetoothManager { + override func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue) + } +} + +/// Someone upgrading mid-session may have applied their sensor days ago and no +/// longer have its pairing code, so the upgrade must not strand them: restored +/// state that predates direct pairing has to keep working exactly as before. +final class G7SessionModeMigrationTests: XCTestCase { + + /// Raw state as written by a version of the plugin with no notion of + /// pairing: no mode, no code, no key. + private var legacyRawState: G7CGMManagerState.RawValue { + [ + "sensorID": "DXCM99", + "activatedAt": Date(timeIntervalSinceNow: -54000), + "latestConnect": Date(timeIntervalSinceNow: -300), + "uploadReadings": true + ] + } + + /// Every manager here goes through the internal initializer with the + /// bluetooth seam: the public ones build a real central manager, which + /// CoreBluetooth refuses to do in a test bundle. + private func makeManager(state: G7CGMManagerState) -> G7CGMManager { + G7CGMManager(state: state, sensor: makeSensor(state.sessionMode, state.sensorCredentials)) + } + + private func makeSensor(_ mode: G7SessionMode, _ credentials: G7SensorCredentials) -> G7Sensor { + G7Sensor(mode: mode, credentials: credentials, bluetoothManager: TestBluetoothManager()) + } + + /// The state `G7CGMManager(pairingCode:peripheralIdentifier:sharedKey:)` starts from. + private func pairedState(pairingCode: String, peripheralIdentifier: UUID, sharedKey: Data?) -> G7CGMManagerState { + var state = G7CGMManagerState() + state.sessionMode = .direct + state.pairingCode = pairingCode + state.peripheralIdentifier = peripheralIdentifier + state.sharedKey = sharedKey + return state + } + + // MARK: - State + + func testLegacyStateRestoresAsEavesdropping() { + let state = G7CGMManagerState(rawValue: legacyRawState) + XCTAssertEqual(state.sessionMode, .eavesdropping) + XCTAssertNil(state.pairingCode) + XCTAssertNil(state.sharedKey) + XCTAssertNil(state.peripheralIdentifier) + // The rest of the session survives untouched. + XCTAssertEqual(state.sensorID, "DXCM99") + } + + func testEavesdroppingModeRequiresTheDexcomApp() { + XCTAssertTrue(G7SessionMode.eavesdropping.requiresDexcomApp) + XCTAssertFalse(G7SessionMode.direct.requiresDexcomApp) + } + + func testDirectStateRoundTrips() { + let identifier = UUID() + var state = G7CGMManagerState() + state.sessionMode = .direct + state.pairingCode = "1155" + state.sharedKey = Data(repeating: 0xAB, count: 16) + state.peripheralIdentifier = identifier + state.sensorID = "DXCM99" + + let restored = G7CGMManagerState(rawValue: state.rawValue) + XCTAssertEqual(restored.sessionMode, .direct) + XCTAssertEqual(restored.pairingCode, "1155") + XCTAssertEqual(restored.sharedKey, Data(repeating: 0xAB, count: 16)) + XCTAssertEqual(restored.peripheralIdentifier, identifier) + XCTAssertEqual(restored, state) + } + + /// The raw state goes into a plist, so every value has to be a property + /// list type or the whole manager fails to persist. + func testRawStateIsPropertyListCompatible() { + var state = G7CGMManagerState() + state.sessionMode = .direct + state.pairingCode = "1155" + state.sharedKey = Data(repeating: 0xAB, count: 16) + state.peripheralIdentifier = UUID() + state.sensorID = "DXCM99" + state.activatedAt = Date() + + XCTAssertTrue(PropertyListSerialization.propertyList(state.rawValue, isValidFor: .binary)) + } + + // MARK: - Manager + + func testLegacyManagerRestoresIntoEavesdroppingMode() { + let manager = makeManager(state: G7CGMManagerState(rawValue: legacyRawState)) + XCTAssertEqual(manager.sessionMode, .eavesdropping) + XCTAssertEqual(manager.sensor.mode, .eavesdropping) + XCTAssertEqual(manager.state.sensorID, "DXCM99") + } + + func testFreshStateIsEavesdropping() { + let manager = makeManager(state: G7CGMManagerState()) + XCTAssertEqual(manager.sessionMode, .eavesdropping) + XCTAssertEqual(manager.sensor.mode, .eavesdropping) + } + + func testPairedStateIsDirect() { + let identifier = UUID() + let key = Data(repeating: 0x11, count: 16) + let manager = makeManager(state: pairedState(pairingCode: "1155", peripheralIdentifier: identifier, sharedKey: key)) + + XCTAssertEqual(manager.sessionMode, .direct) + XCTAssertEqual(manager.sensor.mode, .direct) + XCTAssertEqual(manager.state.pairingCode, "1155") + XCTAssertEqual(manager.state.sharedKey, key) + XCTAssertEqual(manager.state.peripheralIdentifier, identifier) + XCTAssertEqual(manager.sensor.credentials, manager.state.sensorCredentials) + } + + /// The upgrade path: an eavesdropping session pairs, and from then on the + /// Dexcom app is not involved. + func testApplyPairingResultUpgradesAnEavesdroppingSession() { + var state = G7CGMManagerState(rawValue: legacyRawState) + let manager = makeManager(state: state) + let before = manager.sensor + XCTAssertEqual(manager.sensor.mode, .eavesdropping) + + let identifier = UUID() + let key = Data(repeating: 0x22, count: 16) + manager.applyPairingResult(pairingCode: "0420", peripheralIdentifier: identifier, sharedKey: key) + + XCTAssertEqual(manager.sessionMode, .direct) + XCTAssertEqual(manager.sensor.mode, .direct) + XCTAssertTrue(manager.sensor === before, "the session and its Bluetooth central survive; only the mode changes") + XCTAssertEqual(manager.sensor.credentials, manager.state.sensorCredentials) + XCTAssertEqual(manager.state.pairingCode, "0420") + XCTAssertEqual(manager.state.sharedKey, key) + XCTAssertEqual(manager.state.peripheralIdentifier, identifier) + + // Without a name for the paired sensor its identity is dropped: which + // sensor was actually paired is only knowable once it reports a reading. + XCTAssertNil(manager.state.sensorID) + XCTAssertNil(manager.state.activatedAt) + XCTAssertNil(manager.state.latestReading) + XCTAssertEqual(manager.state.previousSensor?.sensorID, "DXCM99") + } + + func testPairingWithTheSensorAlreadyFollowedKeepsItsSession() { + var state = G7CGMManagerState(rawValue: legacyRawState) + state.latestReading = G7GlucoseMessage(data: Data(hexadecimalString: "4e0098a400008e000001f500700006016f000f")!) + state.latestReadingTimestamp = Date(timeIntervalSinceNow: -120) + state.lifecycleAlertsScheduledFor = "DXCM99|1" + state.sensorEndRecordedFor = nil + let manager = makeManager(state: state) + let activatedAt = state.activatedAt + + // Once connected the sensor calls itself "Dexcom99", not "DXCM99". + manager.applyPairingResult(pairingCode: "0420", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 3, count: 16), sensorName: "Dexcom99") + + XCTAssertEqual(manager.sessionMode, .direct) + XCTAssertEqual(manager.state.sensorID, "DXCM99") + XCTAssertEqual(manager.state.activatedAt, activatedAt) + XCTAssertNotNil(manager.state.latestReading) + XCTAssertEqual(manager.state.lifecycleAlertsScheduledFor, "DXCM99|1", "the alerts already scheduled for this sensor still stand") + XCTAssertNil(manager.state.previousSensor, "the same sensor is not its own predecessor") + XCTAssertNil(manager.state.sensorEndRecordedFor, "its session did not end") + XCTAssertNotNil(manager.state.pairedAt) + } + + func testBackfillFramesCarryOneOrTwoRecords() { + let sensor = makeSensor(.direct, G7SensorCredentials(sensorID: "DXCM99", pairingCode: "0420", sharedKey: nil, peripheralIdentifier: nil)) + let delegate = BackfillRecordingDelegate() + sensor.delegate = delegate + + // A G7 packs two 9-byte records into one notification; the ONE+ sends one. + sensor.bluetoothManager(sensor.bluetoothManager, didReceiveBackfillResponse: Data(hexadecimalString: "cf5802008f00060f10" + "f20e0d00ba00060ffb")!) + sensor.bluetoothManager(sensor.bluetoothManager, didReceiveBackfillResponse: Data(hexadecimalString: "f63d00008500061efe")!) + // Not a record frame. + sensor.bluetoothManager(sensor.bluetoothManager, didReceiveBackfillResponse: Data(hexadecimalString: "0102030405")!) + sensor.flushBackfillBuffer() + + wait(for: [delegate.backfillArrived], timeout: 2) + XCTAssertEqual(delegate.backfill.map(\.timestamp), [153807, 855794, 15862]) + } + + func testPairingWithADifferentSensorReplacesTheFollowedOne() { + let state = G7CGMManagerState(rawValue: legacyRawState) + let manager = makeManager(state: state) + + manager.applyPairingResult(pairingCode: "0420", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 3, count: 16), sensorName: "DXCMzz") + + XCTAssertNil(manager.state.sensorID) + XCTAssertEqual(manager.state.previousSensor?.sensorID, "DXCM99") + XCTAssertEqual(manager.state.previousSensor?.endReason, .replaced) + } + + func testApplyPairingResultCancelsAPendingSessionEndGracePeriod() { + var state = G7CGMManagerState(rawValue: legacyRawState) + state.suspectedSessionEndAt = Date() + let manager = makeManager(state: state) + + manager.applyPairingResult(pairingCode: "0420", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 1, count: 16)) + + XCTAssertNil(manager.state.suspectedSessionEndAt) + } + + /// A code and key only ever authenticate the sensor they came from, so + /// looking for a new one has to discard them; otherwise every candidate + /// fails its handshake and the sensor is never adopted. + func testScanningForANewSensorDiscardsPairingCredentials() { + let manager = makeManager(state: pairedState( + pairingCode: "1155", + peripheralIdentifier: UUID(), + sharedKey: Data(repeating: 0x33, count: 16) + )) + + manager.scanForNewSensor() + + XCTAssertNil(manager.state.pairingCode) + XCTAssertNil(manager.state.sharedKey) + XCTAssertNil(manager.state.peripheralIdentifier) + XCTAssertNil(manager.state.sensorID) + // Still a direct-mode manager: the user has not gone back to the + // Dexcom app, they just need to pair the replacement. + XCTAssertEqual(manager.sessionMode, .direct) + } + + func testInvalidatingTheSharedKeyKeepsThePairingCode() { + let manager = makeManager(state: pairedState( + pairingCode: "1155", + peripheralIdentifier: UUID(), + sharedKey: Data(repeating: 0x44, count: 16) + )) + + manager.sensorDidInvalidateSharedKey(manager.sensor) + + XCTAssertNil(manager.state.sharedKey, "a rejected key must not be retried") + XCTAssertEqual(manager.state.pairingCode, "1155", "the code is what lets us recover unattended") + } + + func testAuthenticationFailureIsRecordedAndClearedByAReading() { + let manager = makeManager(state: pairedState(pairingCode: "1155", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 0x66, count: 16))) + + manager.sensor(manager.sensor, didError: G7AuthenticatorError.rejected(authStatus: 2, failureCode: .deviceTypeRestriction)) + XCTAssertNotNil(manager.state.lastAuthenticationFailure) + XCTAssertNotNil(manager.state.lastAuthenticationFailureDate) + XCTAssertEqual(manager.lifecycleState, .connecting, "paired but no reading yet") + + // Timeouts happen on flaky links and are not worth alarming over. + let cleared = makeManager(state: pairedState(pairingCode: "1155", peripheralIdentifier: UUID(), sharedKey: nil)) + cleared.sensor(cleared.sensor, didError: G7AuthenticatorError.timeout(step: "challenge")) + XCTAssertNil(cleared.state.lastAuthenticationFailure) + + // A successful handshake clears the record. + manager.sensor(manager.sensor, didAuthenticateWith: Data(repeating: 0x77, count: 16), deviceName: nil) + XCTAssertNil(manager.state.lastAuthenticationFailure) + } + + func testFailureFieldsRoundTrip() { + var state = G7CGMManagerState() + state.lastAuthenticationFailure = "refused" + state.lastAuthenticationFailureDate = Date(timeIntervalSince1970: 1_700_000_000) + let restored = G7CGMManagerState(rawValue: state.rawValue) + XCTAssertEqual(restored.lastAuthenticationFailure, "refused") + XCTAssertEqual(restored.lastAuthenticationFailureDate, state.lastAuthenticationFailureDate) + } + + func testAnUnpairedDirectManagerWaitsForPairing() { + var state = G7CGMManagerState() + state.sessionMode = .direct + let manager = makeManager(state: state) + XCTAssertEqual(manager.lifecycleState, .unpaired) + XCTAssertFalse(manager.cgmManagerStatus.hasValidSensorSession) + + manager.applyPairingResult(pairingCode: "0420", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 1, count: 16), sensorName: "DXCM99") + XCTAssertEqual(manager.lifecycleState, .connecting) + XCTAssertTrue(manager.cgmManagerStatus.hasValidSensorSession) + XCTAssertNil(manager.state.previousSensor, "there was no sensor before") + } + + func testLegacySessionIsSearchingNotConnecting() { + var state = G7CGMManagerState(rawValue: legacyRawState) + state.sensorID = nil + XCTAssertEqual(makeManager(state: state).lifecycleState, .searching) + } + + /// The sensor advertises as "DXCMxx" but reports "Dexcomxx" once + /// connected, and a reconnect retrieves it under the latter name. In the + /// field, whole-name comparison made every reconnect after the first + /// fail silently. + func testSensorIdentityIgnoresTheNameChange() { + let id = UUID() + let paired = G7SensorCredentials(sensorID: "DXCMka", pairingCode: "1155", sharedKey: nil, peripheralIdentifier: id) + XCTAssertTrue(G7Sensor.isSensor(identifier: id, name: "Dexcomka", describedBy: paired)) + XCTAssertTrue(G7Sensor.isSensor(identifier: id, name: nil, describedBy: paired)) + XCTAssertFalse(G7Sensor.isSensor(identifier: UUID(), name: "DXCMka", describedBy: paired), "a different peripheral with the same name is not ours") + + let byNameOnly = G7SensorCredentials(sensorID: "DXCMka", pairingCode: nil, sharedKey: nil, peripheralIdentifier: nil) + XCTAssertTrue(G7Sensor.isSensor(identifier: UUID(), name: "Dexcomka", describedBy: byNameOnly)) + XCTAssertTrue(G7Sensor.isSensor(identifier: UUID(), name: "DXCMka", describedBy: byNameOnly)) + XCTAssertFalse(G7Sensor.isSensor(identifier: UUID(), name: "DXCMzz", describedBy: byNameOnly)) + XCTAssertFalse(G7Sensor.isSensor(identifier: UUID(), name: nil, describedBy: byNameOnly)) + + XCTAssertFalse(G7Sensor.isSensor(identifier: UUID(), name: "DXCMka", describedBy: G7SensorCredentials()), "nothing known means nothing matches") + } + + func testSensorNameFamilies() { + XCTAssertTrue(G7Sensor.isSensorName("DXCMka")) + XCTAssertTrue(G7Sensor.isSensorName("DX02ka")) + XCTAssertTrue(G7Sensor.isSensorName("Dexcomka")) + XCTAssertTrue(G7Sensor.isSensorName("DX01ka"), "Stelo") + XCTAssertFalse(G7Sensor.isSensorName("Omnipod")) + } + + func testModelNameCarriesTheNominalSessionLength() { + let tenDay = ExtendedVersionMessage(data: Data(hexadecimalString: "5200c0d70d00540600020404ff0c00")!)! + let fifteenDay = ExtendedVersionMessage(data: Data(hexadecimalString: "5200406f1400880e00010a04ff1100")!)! + XCTAssertEqual(G7SensorModel.g7.displayName(sessionLength: tenDay.sessionLength), "Dexcom G7 10 Day") + XCTAssertEqual(G7SensorModel.g7.displayName(sessionLength: fifteenDay.sessionLength), "Dexcom G7 15 Day") + XCTAssertEqual(G7SensorModel.stelo.displayName(sessionLength: fifteenDay.sessionLength), "Dexcom Stelo 15 Day") + XCTAssertEqual(G7SensorModel.g7.displayName(sessionLength: nil), "Dexcom G7", "nothing reported yet") + } + + func testSensorModelFromAdvertisedName() { + XCTAssertEqual(G7SensorModel(advertisedName: "DXCMka"), .g7) + XCTAssertEqual(G7SensorModel(advertisedName: "DX02ka"), .onePlus) + XCTAssertEqual(G7SensorModel(advertisedName: "DX01ka"), .stelo) + XCTAssertNil(G7SensorModel(advertisedName: "Dexcomka"), "the connected-form name does not say which model") + XCTAssertNil(G7SensorModel(advertisedName: "Omnipod")) + + var state = G7CGMManagerState() + state.sensorID = "DX01ka" + let manager = makeManager(state: state) + XCTAssertEqual(manager.sensorModel, .stelo) + XCTAssertEqual(manager.localizedTitle, "Dexcom Stelo") + XCTAssertEqual(makeManager(state: G7CGMManagerState()).sensorModel, .g7, "G7 until a sensor is known") + } + + func testAuthVerdictFailureCodes() { + XCTAssertEqual(AuthChallengeRxMessage(data: Data([0x05, 0x02, 0x03]))?.failureCode, .noAppKey) + XCTAssertEqual(AuthChallengeRxMessage(data: Data([0x05, 0x02, 0x02]))?.failureCode, .deviceTypeRestriction) + XCTAssertEqual(AuthChallengeRxMessage(data: Data([0x05, 0x02, 0x01]))?.failureCode, .challengeMismatch) + XCTAssertNil(AuthChallengeRxMessage(data: Data([0x05, 0x02, 0x7f]))?.failureCode, "unknown byte, no name") + // On success byte 2 is the bond state, not a failure code. + let bonded = AuthChallengeRxMessage(data: Data([0x05, 0x01, 0x01])) + XCTAssertNil(bonded?.failureCode) + XCTAssertTrue(bonded?.isAuthenticated ?? false) + XCTAssertTrue(bonded?.isBonded ?? false) + } + + /// `noAppKey` is the sensor saying it has no key for us; the session + /// recovers unattended by pairing again with the retained code, so it is + /// not something to alarm the user about. + func testNoAppKeyIsNotRecordedAsARefusal() { + let manager = makeManager(state: pairedState(pairingCode: "1155", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 0x66, count: 16))) + manager.sensor(manager.sensor, didError: G7AuthenticatorError.rejected(authStatus: 2, failureCode: .noAppKey)) + XCTAssertNil(manager.state.lastAuthenticationFailure) + + manager.sensor(manager.sensor, didError: G7AuthenticatorError.rejected(authStatus: 2, failureCode: .deviceTypeRestriction)) + XCTAssertNotNil(manager.state.lastAuthenticationFailure) + } + + func testForgettingASensorClosesItsSessionOnce() { + var state = G7CGMManagerState(rawValue: legacyRawState) + state.sensorID = "DXCM99" + let manager = makeManager(state: state) + + manager.scanForNewSensor() + XCTAssertNil(manager.state.sensorID) + XCTAssertNil(manager.state.sensorEndRecordedFor, "bookkeeping resets with the identity") + + // A second forget with no sensor known records nothing. + manager.scanForNewSensor() + XCTAssertNil(manager.state.sensorEndRecordedFor) + } + + func testNewerFirmwareFailureStatesCountAsFailed() { + for raw: UInt8 in [27, 28, 29] { + XCTAssertTrue(AlgorithmState(rawValue: raw).sensorFailed, "state \(raw)") + } + XCTAssertFalse(AlgorithmState(rawValue: 1).sensorFailed, "stopped is the pre-start state, not a failure") + } + + /// The official apps draw no arrow above 8 mg/dL/min; a triple arrow + /// there overstated a rate the sensor itself flags as out of range. + func testTrendArrowIsDroppedBeyondTheOfficialLimit() { + // Sample from G7GlucoseMessageTests with the trend byte (offset 15) replaced. + func message(trendTenths: Int8) -> G7GlucoseMessage { + var data = Data(hexadecimalString: "4e00c35501002601000106008a00060187000f")! + data[15] = UInt8(bitPattern: trendTenths) + return G7GlucoseMessage(data: data)! + } + XCTAssertEqual(message(trendTenths: 35).trendType, .upUpUp) + XCTAssertEqual(message(trendTenths: -35).trendType, .downDownDown) + XCTAssertEqual(message(trendTenths: 80).trendType, .upUpUp, "8.0 is the last rate with an arrow") + XCTAssertNil(message(trendTenths: 81).trendType) + XCTAssertNil(message(trendTenths: -90).trendType) + } + + func testSensorRecordRoundTrips() { + let record = G7SensorRecord( + sensorID: "DXCMka", pairingCode: "1155", serialNumber: "771958849216", firmwareVersion: "44.192.105.72", + pairedAt: Date(timeIntervalSince1970: 1_700_000_000), activatedAt: Date(timeIntervalSince1970: 1_700_000_100), + sessionLength: 1_339_200, warmupDuration: 3_720, + endedAt: Date(timeIntervalSince1970: 1_701_000_000), endReason: .replaced, + failureMessage: "known(sensorFailed)", failedAt: Date(timeIntervalSince1970: 1_700_900_000) + ) + XCTAssertEqual(G7SensorRecord(rawValue: record.rawValue), record) + XCTAssertEqual(record.model, .g7) + XCTAssertTrue(PropertyListSerialization.propertyList(record.rawValue, isValidFor: .binary)) + XCTAssertNil(G7SensorRecord(rawValue: ["sensorID": "x"]), "an end date and reason are required") + } + + /// Replacing a sensor keeps the old one, with how and when it ended; + /// a failure seen while it was current rides along. + func testReplacingASensorKeepsItAsPrevious() { + var state = G7CGMManagerState(rawValue: legacyRawState) + state.pairedAt = Date(timeIntervalSinceNow: -60_000) + state.pairingCode = "1155" + state.sensorFailureMessage = "known(sensorFailed)" + state.sensorFailedAt = Date(timeIntervalSinceNow: -600) + let manager = makeManager(state: state) + + manager.applyPairingResult(pairingCode: "0420", peripheralIdentifier: UUID(), sharedKey: Data(repeating: 1, count: 16)) + + let previous = manager.state.previousSensor + XCTAssertEqual(previous?.sensorID, "DXCM99") + XCTAssertEqual(previous?.pairingCode, "1155") + XCTAssertEqual(previous?.endReason, .replaced) + XCTAssertEqual(previous?.failureMessage, "known(sensorFailed)") + XCTAssertNotNil(previous?.failedAt) + XCTAssertEqual(previous?.pairedAt, state.pairedAt) + + // The new sensor starts clean, with its own pairing date. + XCTAssertNil(manager.state.sensorFailureMessage) + XCTAssertNotNil(manager.state.pairedAt) + XCTAssertGreaterThan(manager.state.pairedAt!, state.pairedAt!) + } + + func testDeletingKeepsThePreviousSensorAsRemoved() { + var state = G7CGMManagerState(rawValue: legacyRawState) + state.sensorID = "DXCM99" + let manager = makeManager(state: state) + let done = expectation(description: "deleted") + manager.delete { done.fulfill() } + wait(for: [done], timeout: 2) + XCTAssertEqual(manager.state.previousSensor?.endReason, .deleted) + } + + func testAuthenticationPersistsTheDerivedKey() { + let manager = makeManager(state: pairedState(pairingCode: "1155", peripheralIdentifier: UUID(), sharedKey: nil)) + let key = Data(repeating: 0x55, count: 16) + + manager.sensor(manager.sensor, didAuthenticateWith: key, deviceName: "Dexcom99") + + XCTAssertEqual(manager.state.sharedKey, key) + } +} + +private final class BackfillRecordingDelegate: G7SensorDelegate { + let backfillArrived = XCTestExpectation(description: "backfill delivered") + var backfill: [G7BackfillMessage] = [] + + func sensorDidConnect(_ sensor: G7Sensor, name: String) {} + func sensorDisconnected(_ sensor: G7Sensor, suspectedEndOfSession: Bool) {} + func sensor(_ sensor: G7Sensor, didError error: Error) {} + func sensor(_ sensor: G7Sensor, logComms comms: String) {} + func sensor(_ sensor: G7Sensor, log message: String, type: DeviceLogEntryType) {} + func sensor(_ sensor: G7Sensor, didRead glucose: G7GlucoseMessage) {} + func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) { + self.backfill = backfill + backfillArrived.fulfill() + } + func sensor(_ sensor: G7Sensor, didDiscoverNewSensor name: String, activatedAt: Date) -> Bool { false } + func sensor(_ sensor: G7Sensor, didReceive extendedVersion: ExtendedVersionMessage) {} + func sensor(_ sensor: G7Sensor, didReceive transmitterVersion: TransmitterVersionMessage) {} + func sensor(_ sensor: G7Sensor, didReceiveCalibrationResponse response: G7CalibrateRxMessage) {} + func sensor(_ sensor: G7Sensor, didReadCalibrationBounds bounds: G7CalibrationBoundsMessage) {} + func sensorConnectionStatusDidUpdate(_ sensor: G7Sensor) {} + func sensor(_ sensor: G7Sensor, didAuthenticateWith sharedKey: Data, deviceName: String?) {} + func sensorDidInvalidateSharedKey(_ sensor: G7Sensor) {} +} diff --git a/G7SensorKitTests/TransmitterVersionMessageTests.swift b/G7SensorKitTests/TransmitterVersionMessageTests.swift new file mode 100644 index 0000000..992befd --- /dev/null +++ b/G7SensorKitTests/TransmitterVersionMessageTests.swift @@ -0,0 +1,43 @@ +// +// TransmitterVersionMessageTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import G7SensorKit + +class TransmitterVersionMessageTests: XCTestCase { + + func testSyntheticLayout() throws { + // 4A | status 00 | version 25 c0 69 5e | sw 04030201 | silicon ddccbbaa | serial 060504030201 + let message = try XCTUnwrap(TransmitterVersionMessage(data: Data(hexadecimalString: "4a0025c0695e04030201ddccbbaa060504030201")!)) + XCTAssertEqual(message.status, 0) + XCTAssertEqual(message.firmwareVersion, "37.192.105.94") + XCTAssertEqual(message.softwareNumber, 0x0102_0304) + XCTAssertEqual(message.siliconVersion, 0xAABB_CCDD) + XCTAssertEqual(message.serialNumber, 0x0102_0304_0506) + } + + /// A reply captured from a sensor. + func testCapturedReply() throws { + let message = try XCTUnwrap(TransmitterVersionMessage(data: Data(hexadecimalString: "4a002cc069489c37000031474141c03e55bcb300")!)) + XCTAssertEqual(message.firmwareVersion, "44.192.105.72") + XCTAssertEqual(message.softwareNumber, 0x0000_379C) + XCTAssertEqual(message.serialNumberString, String(0x00B3_BC55_3EC0 as UInt64)) + } + + func testRejectsOtherOpcodesAndShortReplies() { + XCTAssertNil(TransmitterVersionMessage(data: Data(hexadecimalString: "4e0025c0695e04030201ddccbbaa060504030201")!)) + XCTAssertNil(TransmitterVersionMessage(data: Data(hexadecimalString: "4a0025c0695e04030201ddccbbaa0605040302")!)) + } + + func testRoundTripsThroughState() { + var state = G7CGMManagerState() + state.transmitterVersion = TransmitterVersionMessage(data: Data(hexadecimalString: "4a002cc069489c37000031474141c03e55bcb300")!) + let restored = G7CGMManagerState(rawValue: state.rawValue) + XCTAssertEqual(restored.transmitterVersion, state.transmitterVersion) + XCTAssertEqual(restored.transmitterVersion?.firmwareVersion, "44.192.105.72") + } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7CleanDry.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7CleanDry.imageset/Contents.json new file mode 100644 index 0000000..9771859 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7CleanDry.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7CleanDry.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7CleanDry.imageset/G7CleanDry.png b/G7SensorKitUI/Assets.xcassets/G7CleanDry.imageset/G7CleanDry.png new file mode 100644 index 0000000..e6985b9 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7CleanDry.imageset/G7CleanDry.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7InTheBox.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7InTheBox.imageset/Contents.json new file mode 100644 index 0000000..96e29e4 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7InTheBox.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7InTheBox.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7InTheBox.imageset/G7InTheBox.png b/G7SensorKitUI/Assets.xcassets/G7InTheBox.imageset/G7InTheBox.png new file mode 100644 index 0000000..bb10e43 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7InTheBox.imageset/G7InTheBox.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7InsertSensor.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7InsertSensor.imageset/Contents.json new file mode 100644 index 0000000..4b79e96 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7InsertSensor.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7InsertSensor.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7InsertSensor.imageset/G7InsertSensor.png b/G7SensorKitUI/Assets.xcassets/G7InsertSensor.imageset/G7InsertSensor.png new file mode 100644 index 0000000..8224a84 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7InsertSensor.imageset/G7InsertSensor.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchA.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7OverpatchA.imageset/Contents.json new file mode 100644 index 0000000..37d38ec --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7OverpatchA.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7OverpatchA.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchA.imageset/G7OverpatchA.png b/G7SensorKitUI/Assets.xcassets/G7OverpatchA.imageset/G7OverpatchA.png new file mode 100644 index 0000000..4c8c773 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7OverpatchA.imageset/G7OverpatchA.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchB.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7OverpatchB.imageset/Contents.json new file mode 100644 index 0000000..531223b --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7OverpatchB.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7OverpatchB.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchB.imageset/G7OverpatchB.png b/G7SensorKitUI/Assets.xcassets/G7OverpatchB.imageset/G7OverpatchB.png new file mode 100644 index 0000000..06c10d3 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7OverpatchB.imageset/G7OverpatchB.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchC.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7OverpatchC.imageset/Contents.json new file mode 100644 index 0000000..be0119b --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7OverpatchC.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7OverpatchC.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchC.imageset/G7OverpatchC.png b/G7SensorKitUI/Assets.xcassets/G7OverpatchC.imageset/G7OverpatchC.png new file mode 100644 index 0000000..1b394d8 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7OverpatchC.imageset/G7OverpatchC.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchD.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7OverpatchD.imageset/Contents.json new file mode 100644 index 0000000..c9b131f --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7OverpatchD.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7OverpatchD.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchD.imageset/G7OverpatchD.png b/G7SensorKitUI/Assets.xcassets/G7OverpatchD.imageset/G7OverpatchD.png new file mode 100644 index 0000000..50b1462 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7OverpatchD.imageset/G7OverpatchD.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchE.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7OverpatchE.imageset/Contents.json new file mode 100644 index 0000000..e7d2882 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7OverpatchE.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7OverpatchE.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7OverpatchE.imageset/G7OverpatchE.png b/G7SensorKitUI/Assets.xcassets/G7OverpatchE.imageset/G7OverpatchE.png new file mode 100644 index 0000000..4ea3fb1 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7OverpatchE.imageset/G7OverpatchE.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7PushOn.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7PushOn.imageset/Contents.json new file mode 100644 index 0000000..25fcc3f --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7PushOn.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7PushOn.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7PushOn.imageset/G7PushOn.png b/G7SensorKitUI/Assets.xcassets/G7PushOn.imageset/G7PushOn.png new file mode 100644 index 0000000..5d6ce21 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7PushOn.imageset/G7PushOn.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7RemoveApplicator.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7RemoveApplicator.imageset/Contents.json new file mode 100644 index 0000000..2234fbb --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7RemoveApplicator.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7RemoveApplicator.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7RemoveApplicator.imageset/G7RemoveApplicator.png b/G7SensorKitUI/Assets.xcassets/G7RemoveApplicator.imageset/G7RemoveApplicator.png new file mode 100644 index 0000000..6fc59cf Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7RemoveApplicator.imageset/G7RemoveApplicator.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7RubPatch.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7RubPatch.imageset/Contents.json new file mode 100644 index 0000000..c7d4b7b --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7RubPatch.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7RubPatch.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7RubPatch.imageset/G7RubPatch.png b/G7SensorKitUI/Assets.xcassets/G7RubPatch.imageset/G7RubPatch.png new file mode 100644 index 0000000..98d2fb2 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7RubPatch.imageset/G7RubPatch.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7SiteAdult.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7SiteAdult.imageset/Contents.json new file mode 100644 index 0000000..75b4f18 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7SiteAdult.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7SiteAdult.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7SiteAdult.imageset/G7SiteAdult.png b/G7SensorKitUI/Assets.xcassets/G7SiteAdult.imageset/G7SiteAdult.png new file mode 100644 index 0000000..2602b8b Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7SiteAdult.imageset/G7SiteAdult.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7SiteChild.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7SiteChild.imageset/Contents.json new file mode 100644 index 0000000..4ce2710 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7SiteChild.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7SiteChild.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7SiteChild.imageset/G7SiteChild.png b/G7SensorKitUI/Assets.xcassets/G7SiteChild.imageset/G7SiteChild.png new file mode 100644 index 0000000..d5a472c Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7SiteChild.imageset/G7SiteChild.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7UnscrewCap.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7UnscrewCap.imageset/Contents.json new file mode 100644 index 0000000..4979b86 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7UnscrewCap.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7UnscrewCap.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7UnscrewCap.imageset/G7UnscrewCap.png b/G7SensorKitUI/Assets.xcassets/G7UnscrewCap.imageset/G7UnscrewCap.png new file mode 100644 index 0000000..9af8499 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7UnscrewCap.imageset/G7UnscrewCap.png differ diff --git a/G7SensorKitUI/Assets.xcassets/G7WashHands.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/G7WashHands.imageset/Contents.json new file mode 100644 index 0000000..1d67d4d --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/G7WashHands.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "G7WashHands.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/G7SensorKitUI/Assets.xcassets/G7WashHands.imageset/G7WashHands.png b/G7SensorKitUI/Assets.xcassets/G7WashHands.imageset/G7WashHands.png new file mode 100644 index 0000000..49e5305 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/G7WashHands.imageset/G7WashHands.png differ diff --git a/G7SensorKitUI/Assets.xcassets/oneplus.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/oneplus.imageset/Contents.json new file mode 100644 index 0000000..190145b --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/oneplus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "oneplus.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/G7SensorKitUI/Assets.xcassets/oneplus.imageset/oneplus.png b/G7SensorKitUI/Assets.xcassets/oneplus.imageset/oneplus.png new file mode 100644 index 0000000..80a4cb8 Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/oneplus.imageset/oneplus.png differ diff --git a/G7SensorKitUI/Assets.xcassets/stelo.imageset/Contents.json b/G7SensorKitUI/Assets.xcassets/stelo.imageset/Contents.json new file mode 100644 index 0000000..d31e199 --- /dev/null +++ b/G7SensorKitUI/Assets.xcassets/stelo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "stelo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/G7SensorKitUI/Assets.xcassets/stelo.imageset/stelo.png b/G7SensorKitUI/Assets.xcassets/stelo.imageset/stelo.png new file mode 100644 index 0000000..23b003f Binary files /dev/null and b/G7SensorKitUI/Assets.xcassets/stelo.imageset/stelo.png differ diff --git a/G7SensorKitUI/Extensions/G7SensorModel+Image.swift b/G7SensorKitUI/Extensions/G7SensorModel+Image.swift new file mode 100644 index 0000000..5030be3 --- /dev/null +++ b/G7SensorKitUI/Extensions/G7SensorModel+Image.swift @@ -0,0 +1,28 @@ +// +// G7SensorModel+Image.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import G7SensorKit +import SwiftUI + +extension G7SensorModel { + /// The product image for this model in the framework's asset catalog. + var imageName: String { + switch self { + case .g7: return "g7" + case .onePlus: return "oneplus" + case .stelo: return "stelo" + } + } + + var image: Image { + Image(frameworkImage: imageName) + } + + var uiImage: UIImage? { + UIImage(named: imageName, in: FrameworkBundle.main, compatibleWith: nil) + } +} diff --git a/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift b/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift index 54f218d..30ba076 100644 --- a/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift +++ b/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift @@ -40,12 +40,19 @@ extension G7CGMManager: CGMManagerUI { } public var smallImage: UIImage? { - UIImage(named: "g7", in: Bundle(for: G7SettingsViewModel.self), compatibleWith: nil)! + sensorModel.uiImage ?? G7SensorModel.g7.uiImage } // TODO Placeholder. public var cgmStatusHighlight: DeviceStatusHighlight? { + if lifecycleState == .unpaired { + return G7DeviceStatusHighlight( + localizedMessage: LocalizedString("Pair\nSensor", comment: "G7 Status highlight text when the CGM has been added but no sensor paired"), + imageName: "plus.circle", + state: .normalCGM) + } + if lifecycleState == .searching { return G7DeviceStatusHighlight( localizedMessage: LocalizedString("Searching for\nSensor", comment: "G7 Status highlight text for searching for sensor"), @@ -53,7 +60,34 @@ extension G7CGMManager: CGMManagerUI { state: .normalCGM) } + if lifecycleState == .connecting { + return G7DeviceStatusHighlight( + localizedMessage: LocalizedString("Waiting for\nSensor", comment: "G7 Status highlight text after pairing, before the first reading"), + imageName: "dot.radiowaves.left.and.right", + state: .normalCGM) + } + + // A refusal newer than the last reading is why there is no data; say + // so instead of letting it look like signal loss. + if state.sessionMode == .direct, + let failureDate = state.lastAuthenticationFailureDate, + failureDate > (state.latestReadingTimestamp ?? .distantPast) + { + return G7DeviceStatusHighlight( + localizedMessage: LocalizedString("Connection\nRefused", comment: "G7 Status highlight text after the sensor refused authentication"), + imageName: "exclamationmark.circle.fill", + state: .critical) + } + if lifecycleState == .expired { + // In direct mode nothing happens until the user pairs the next + // sensor, so the highlight should send them there. + if state.sessionMode == .direct { + return G7DeviceStatusHighlight( + localizedMessage: LocalizedString("Pair New\nSensor", comment: "G7 Status highlight text when an expired direct-mode sensor needs replacing"), + imageName: "plus.circle", + state: .critical) + } return G7DeviceStatusHighlight( localizedMessage: LocalizedString("Sensor\nExpired", comment: "G7 Status highlight text for sensor expired"), imageName: "clock", @@ -61,6 +95,12 @@ extension G7CGMManager: CGMManagerUI { } if lifecycleState == .failed { + if state.sessionMode == .direct { + return G7DeviceStatusHighlight( + localizedMessage: LocalizedString("Pair New\nSensor", comment: "G7 Status highlight text when an expired direct-mode sensor needs replacing"), + imageName: "plus.circle", + state: .critical) + } return G7DeviceStatusHighlight( localizedMessage: LocalizedString("Sensor\nFailed", comment: "G7 Status highlight text for sensor failed"), imageName: "exclamationmark.circle.fill", diff --git a/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift b/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift index 0906587..fcc6db0 100644 --- a/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift +++ b/G7SensorKitUI/G7CGMManager/G7UICoordinator.swift @@ -9,6 +9,25 @@ import Foundation import LoopKitUI import G7SensorKit +import SwiftUI + +private enum G7Screen { + case startup + /// Shown ahead of everything else whenever the Dexcom G7 app is installed. + case dexcomAppWarning + /// Placement and application guidance. Only for a sensor that is not on + /// yet; an eavesdropping session moving to direct already has one on. + case applySensor + /// For an eavesdropping session moving to direct: the alerts the Dexcom + /// app used to raise now have to come from Loop, and the phone has to + /// let them through. + case alertsFromLoop + case notificationPermissions + case enterCode + case pairing(code: String, serial: String?) + case pairingSuccess(deviceName: String?) + case settings +} class G7UICoordinator: UINavigationController, CGMManagerOnboarding, CompletionNotifying, UINavigationControllerDelegate { var cgmManagerOnboardingDelegate: LoopKitUI.CGMManagerOnboardingDelegate? @@ -18,12 +37,19 @@ class G7UICoordinator: UINavigationController, CGMManagerOnboarding, CompletionN var colorPalette: LoopUIColorPalette + private let isInitialSetup: Bool + private var screenStack = [G7Screen]() + + /// Whether the pairing flow in progress is for a sensor not yet applied. + private var isPairingNewSensor = true + init(cgmManager: G7CGMManager? = nil, colorPalette: LoopUIColorPalette, displayGlucosePreference: DisplayGlucosePreference, allowDebugFeatures: Bool) { self.cgmManager = cgmManager + self.isInitialSetup = cgmManager == nil self.colorPalette = colorPalette self.displayGlucosePreference = displayGlucosePreference super.init(navigationBarClass: UINavigationBar.self, toolbarClass: UIToolbar.self) @@ -40,26 +66,96 @@ class G7UICoordinator: UINavigationController, CGMManagerOnboarding, CompletionN navigationBar.prefersLargeTitles = true // Ensure nav bar text is displayed correctly - let viewController = initialView() - setViewControllers([viewController], animated: false) + let start: G7Screen = isInitialSetup ? .startup : .settings + screenStack = [start] + setViewControllers([viewController(for: start)], animated: false) } - private func initialView() -> UIViewController { - if cgmManager == nil { - let rootView = G7StartupView( - didContinue: { [weak self] in self?.completeSetup() }, + private func hostingController(_ content: Content, largeTitle: Bool = true) -> UIViewController { + let hostingController = DismissibleHostingController( + content: content.environment(\.appName, Bundle.main.bundleDisplayName), + colorPalette: colorPalette + ) + hostingController.navigationItem.largeTitleDisplayMode = largeTitle ? .automatic : .never + return hostingController + } + + /// Whether the sensor being paired is the one an eavesdropping session + /// has been reading through the Dexcom app. + private var isReplacingDexcomAppSession: Bool { + !isPairingNewSensor && cgmManager?.sessionMode == .eavesdropping + } + + private func viewController(for screen: G7Screen) -> UIViewController { + switch screen { + case .startup: + let view = G7StartupView( + didChoosePairing: { [weak self] in self?.beginPairingFlow(newSensor: true) }, + didChooseDexcomApp: { [weak self] in self?.completeLegacySetup() }, didCancel: { [weak self] in if let self = self { self.completionDelegate?.completionNotifyingDidComplete(self) } } ) - .environment(\.appName, Bundle.main.bundleDisplayName) - let hostingController = DismissibleHostingController(content: rootView, colorPalette: colorPalette) - hostingController.navigationItem.largeTitleDisplayMode = .never - hostingController.title = nil - return hostingController - } else { + let controller = hostingController(view, largeTitle: false) + controller.title = nil + return controller + + case .dexcomAppWarning: + let view = G7DexcomAppWarningView( + isReplacingDexcomAppSession: isReplacingDexcomAppSession, + isDexcomAppInstalled: { G7DexcomApp.isAnyInstalled }, + didContinue: { [weak self] in self?.continueAfterDexcomAppCheck() } + ) + return hostingController(view, largeTitle: false) + + case .applySensor: + let view = G7ApplySensorView { [weak self] in self?.navigate(to: .enterCode) } + return hostingController(view, largeTitle: false) + + case .alertsFromLoop: + let view = G7AlertsFromLoopView { [weak self] in self?.navigate(to: .notificationPermissions) } + return hostingController(view, largeTitle: false) + + case .notificationPermissions: + let view = G7NotificationPermissionsView { [weak self] in self?.navigate(to: .enterCode) } + return hostingController(view, largeTitle: false) + + case .enterCode: + let view = G7EnterCodeView { [weak self] code, serial in + self?.navigate(to: .pairing(code: code, serial: serial)) + } + return hostingController(view, largeTitle: false) + + case .pairing(let code, let serial): + let viewModel = G7PairingViewModel( + pairingCode: code, + serial: serial, + cgmManager: cgmManager, + onLog: { [weak self] message in + self?.recordPairingLog(message) + }, + onSuccess: { [weak self] peripheralIdentifier, sharedKey, deviceName, handoff in + self?.pairingSucceeded( + code: code, + peripheralIdentifier: peripheralIdentifier, + sharedKey: sharedKey, + deviceName: deviceName, + handoff: handoff + ) + } + ) + let view = G7PairingView(viewModel: viewModel, didEditCode: { [weak self] in self?.popScreen() }) + return hostingController(view, largeTitle: false) + + case .pairingSuccess(let deviceName): + let view = G7PairingSuccessView(deviceName: deviceName) { [weak self] in + self?.finishPairingFlow() + } + return hostingController(view, largeTitle: false) + + case .settings: let view = G7SettingsView( didFinish: { [weak self] in if let self = self { @@ -67,7 +163,10 @@ class G7UICoordinator: UINavigationController, CGMManagerOnboarding, CompletionN } }, deleteCGM: { [ weak self] in - self?.cgmManager?.notifyDelegateOfDeletion { + // `delete`, not `notifyDelegateOfDeletion`: the manager's + // own teardown retracts its standing alerts first, and Loop + // replays anything left behind at every launch. + self?.cgmManager?.delete { DispatchQueue.main.async { if let self = self { self.completionDelegate?.completionNotifyingDidComplete(self) @@ -76,17 +175,100 @@ class G7UICoordinator: UINavigationController, CGMManagerOnboarding, CompletionN } } }, + pairNewSensor: { [weak self] in self?.beginPairingFlow(newSensor: true) }, + pairCurrentSensor: { [weak self] in self?.beginPairingFlow(newSensor: false) }, viewModel: G7SettingsViewModel(cgmManager: cgmManager!, displayGlucosePreference: displayGlucosePreference) ) - let hostingController = DismissibleHostingController(content: view, colorPalette: colorPalette) - return hostingController + return hostingController(view) } } - func completeSetup() { - cgmManager = G7CGMManager() - cgmManagerOnboardingDelegate?.cgmManagerOnboarding(didCreateCGMManager: cgmManager!) - cgmManagerOnboardingDelegate?.cgmManagerOnboarding(didOnboardCGMManager: cgmManager!) + // MARK: - Flows + + /// - Parameter newSensor: whether the sensor still has to be applied. + /// An eavesdropping session moving to direct pairs the sensor already + /// on the arm, so it skips the application guide. + private func beginPairingFlow(newSensor: Bool) { + isPairingNewSensor = newSensor + if cgmManager == nil { + // The CGM exists from here on, paired or not: its device log + // carries the pairing, and a run that does not finish can be + // picked up again from settings. + let manager = G7CGMManager(sessionMode: .direct) + cgmManager = manager + cgmManagerOnboardingDelegate?.cgmManagerOnboarding(didCreateCGMManager: manager) + cgmManagerOnboardingDelegate?.cgmManagerOnboarding(didOnboardCGMManager: manager) + } + if G7DexcomApp.isAnyInstalled { + navigate(to: .dexcomAppWarning) + } else { + continueAfterDexcomAppCheck() + } + } + + private func continueAfterDexcomAppCheck() { + if isPairingNewSensor { + navigate(to: .applySensor) + } else if isReplacingDexcomAppSession { + navigate(to: .alertsFromLoop) + } else { + navigate(to: .enterCode) + } + } + + /// The pre-pairing setup, kept for someone who cannot pair the sensor + /// they are already wearing. + private func completeLegacySetup() { + let manager = G7CGMManager() + cgmManager = manager + cgmManagerOnboardingDelegate?.cgmManagerOnboarding(didCreateCGMManager: manager) + cgmManagerOnboardingDelegate?.cgmManagerOnboarding(didOnboardCGMManager: manager) completionDelegate?.completionNotifyingDidComplete(self) } + + private func recordPairingLog(_ message: String) { + cgmManager?.logDeviceCommunication("[pairing] " + message, type: .connection) + } + + private func pairingSucceeded(code: String, peripheralIdentifier: UUID, sharedKey: Data, deviceName: String?, handoff: G7PairingHandoff?) { + cgmManager?.applyPairingResult(pairingCode: code, peripheralIdentifier: peripheralIdentifier, sharedKey: sharedKey, handoff: handoff) + navigate(to: .pairingSuccess(deviceName: deviceName)) + } + + private func finishPairingFlow() { + if isInitialSetup { + completionDelegate?.completionNotifyingDidComplete(self) + } else { + // Back to settings, which observes the manager and already shows + // the new session. + screenStack = [.settings] + popToRootViewController(animated: true) + } + } + + // MARK: - Navigation + + private func navigate(to screen: G7Screen) { + screenStack.append(screen) + pushViewController(viewController(for: screen), animated: true) + } + + private func popScreen() { + if !screenStack.isEmpty { + screenStack.removeLast() + } + popViewController(animated: true) + } + + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + // Keep the stack honest when the user pops with the back button. + let shown = navigationController.viewControllers.count + if screenStack.count > shown { + screenStack.removeLast(screenStack.count - shown) + } + // Resume the session if pairing was abandoned from settings. + if !isInitialSetup, shown == 1 { + cgmManager?.sensor.resumeScanning() + } + } } diff --git a/G7SensorKitUI/G7DexcomApp.swift b/G7SensorKitUI/G7DexcomApp.swift new file mode 100644 index 0000000..22b38e7 --- /dev/null +++ b/G7SensorKitUI/G7DexcomApp.swift @@ -0,0 +1,105 @@ +// +// G7DexcomApp.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import UIKit + +/// The Dexcom apps that can hold a G7-family sensor's display slot: the G7 +/// app, the ONE+ app and the Stelo app. +/// +/// Relevant in both directions. An eavesdropping session cannot work without +/// one of them. A direct session cannot work reliably with one installed: a +/// sensor admits one display, and the Dexcom app will keep trying to be it. +/// +/// Detection is by URL scheme, the only means iOS offers, and only works for +/// schemes the host app lists under `LSApplicationQueriesSchemes` (Loop +/// lists all of these); otherwise `canOpenURL` answers false for everything +/// and the app is reported absent. All three schemes are confirmed against +/// the apps (G7 and Stelo by probe on a phone with both installed, +/// 2026-09-15; ONE+ from its app). `describeProbe()` logs what answered, +/// which is how a new scheme would be settled if Dexcom ever changes one. +enum G7DexcomApp: CaseIterable { + case g7 + case onePlus + case stelo + + var displayName: String { + switch self { + case .g7: return "Dexcom G7" + case .onePlus: return "Dexcom ONE+" + case .stelo: return "Stelo" + } + } + + /// The scheme each app registers. A list, so a renamed scheme can be + /// carried alongside the old one for a release. + var schemes: [String] { + switch self { + case .g7: return ["dexcomg7"] + case .onePlus: return ["dexcomoneplus"] + case .stelo: return ["stelo"] + } + } + + /// The scheme the phone answers for, if any. + var installedScheme: String? { + schemes.first { scheme in + URL(string: scheme + "://").map(UIApplication.shared.canOpenURL) ?? false + } + } + + var isInstalled: Bool { + installedScheme != nil + } + + /// Every Dexcom app found on this phone. + static var installedApps: [G7DexcomApp] { + allCases.filter(\.isInstalled) + } + + static var isAnyInstalled: Bool { + !installedApps.isEmpty + } + + /// "Dexcom G7", "Dexcom G7 and Stelo", for the warnings that name what + /// has to be deleted. Falls back to a generic name if nothing is found, + /// for callers that show the warning on other grounds. + static var installedAppNames: String { + let names = installedApps.map(\.displayName) + switch names.count { + case 0: return "Dexcom" + case 1: return names[0] + default: return names.dropLast().joined(separator: ", ") + " " + LocalizedString("and", comment: "Conjunction between the last two app names in a list") + " " + names.last! + } + } + + /// The G7 app, kept for the settings button that opens it. + static var isInstalled: Bool { + isAnyInstalled + } + + static let url = URL(string: "dexcomg7://")! + + /// Opens whichever Dexcom app is installed, preferring the G7 app. + static func open() { + let scheme = installedApps.first?.installedScheme ?? "dexcomg7" + if let url = URL(string: scheme + "://") { + UIApplication.shared.open(url) + } + } + + /// A one-line account of every probe, for the device log: which schemes + /// answered and which did not. + static func describeProbe() -> String { + allCases.map { app in + let answered = app.schemes.map { scheme in + let ok = URL(string: scheme + "://").map(UIApplication.shared.canOpenURL) ?? false + return "\(scheme)=\(ok ? "yes" : "no")" + } + return "\(app.displayName): " + answered.joined(separator: " ") + }.joined(separator: "; ") + } +} diff --git a/G7SensorKitUI/ViewModels/G7PairingViewModel.swift b/G7SensorKitUI/ViewModels/G7PairingViewModel.swift new file mode 100644 index 0000000..5cf6a18 --- /dev/null +++ b/G7SensorKitUI/ViewModels/G7PairingViewModel.swift @@ -0,0 +1,147 @@ +// +// G7PairingViewModel.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import CoreBluetooth +import Foundation +import G7SensorKit + +/// Drives one pairing attempt for the pairing screen. +final class G7PairingViewModel: ObservableObject { + + @Published private(set) var state: G7PairingState = .idle + + /// Assumed fine until the radio says otherwise, so the screen does not + /// flash a warning before the central has reported in. + @Published private(set) var bluetoothState: CBManagerState = .poweredOn + + let pairingCode: String + let serial: String? + private let excludedPeripheral: UUID? + + private let service: G7PairingService + private let onSuccess: (_ peripheralIdentifier: UUID, _ sharedKey: Data, _ deviceName: String?, _ handoff: G7PairingHandoff?) -> Void + private let onLog: ((String) -> Void)? + + /// - Parameter cgmManager: the manager being re-paired, if any. Its + /// session's Bluetooth central is borrowed for the run. + init( + pairingCode: String, + serial: String?, + cgmManager: G7CGMManager?, + onLog: ((String) -> Void)? = nil, + onSuccess: @escaping (_ peripheralIdentifier: UUID, _ sharedKey: Data, _ deviceName: String?, _ handoff: G7PairingHandoff?) -> Void + ) { + self.pairingCode = pairingCode + // The sensor a session already holds is not the one being replaced; + // trying it with the new code just earns a rejection. The same code + // entered again means the same sensor, though: re-pairing it, so it + // is the one to look for, by serial when the session has learned it. + let isCurrentSensor = cgmManager?.state.pairingCode == pairingCode + self.serial = serial ?? (isCurrentSensor ? cgmManager?.state.transmitterVersion?.serialNumberString : nil) + self.excludedPeripheral = isCurrentSensor ? nil : cgmManager?.state.peripheralIdentifier + self.onLog = onLog + self.onSuccess = onSuccess + service = G7PairingService(cgmManager: cgmManager) + + service.onLog = onLog + service.onBluetoothStateChange = { [weak self] state in + self?.bluetoothState = state + } + service.onStateChange = { [weak self] state in + guard let self = self else { return } + self.state = state + if case .succeeded(let peripheralIdentifier, let sharedKey, let deviceName) = state { + self.onSuccess(peripheralIdentifier, sharedKey, deviceName, self.service.handOff()) + } + } + } + + func start() { + service.start(pairingCode: pairingCode, serial: serial, excludingPeripheral: excludedPeripheral) + } + + func retry() { + start() + } + + func cancel() { + service.cancel() + } + + var scanStartedAt: Date? { + service.scanStartedAt + } + + /// Why pairing cannot make progress right now, if the radio is the reason. + var bluetoothProblem: String? { + guard isWorking else { return nil } + switch bluetoothState { + case .poweredOff: + return LocalizedString("Bluetooth is off. Turn it on in Settings or Control Center to pair.", comment: "Pairing screen notice when Bluetooth is powered off") + case .unauthorized: + return LocalizedString("Bluetooth access is not allowed. Turn it on for this app in Settings › Privacy & Security › Bluetooth.", comment: "Pairing screen notice when the app lacks Bluetooth permission") + default: + return nil + } + } + + var isWorking: Bool { + switch state { + case .scanning, .authenticating: + return true + case .idle, .succeeded, .failed: + return false + } + } + + var statusTitle: String { + switch state { + case .idle: + return LocalizedString("Preparing…", comment: "Pairing status before the scan starts") + case .scanning(let candidates) where candidates.isEmpty: + return LocalizedString("Searching for sensor…", comment: "Pairing status while scanning with no sensor found yet") + case .scanning: + return LocalizedString("Connecting…", comment: "Pairing status once a sensor has been found") + case .authenticating: + return LocalizedString("Pairing…", comment: "Pairing status during the handshake") + case .succeeded: + return LocalizedString("Paired", comment: "Pairing status on success") + case .failed: + return LocalizedString("Pairing Failed", comment: "Pairing status on failure") + } + } + + var statusDetail: String? { + switch state { + case .idle: + return nil + case .scanning(let candidates) where candidates.isEmpty: + return LocalizedString( + "Keep your phone near the sensor. A sensor that was recently used by the Dexcom app or another phone can take up to 15 minutes to become available; this screen will keep looking.", + comment: "Pairing guidance while scanning" + ) + case .scanning(let candidates): + return String( + format: LocalizedString("Found %@", comment: "Pairing detail listing discovered sensors (1: comma-separated names)"), + candidates.joined(separator: ", ") + ) + case .authenticating(let candidate, let attempt): + if attempt > 1 { + return String( + format: LocalizedString("%1$@, attempt %2$d", comment: "Pairing detail for a retry (1: sensor name, 2: attempt number)"), + candidate, + attempt + ) + } + return candidate + case .succeeded(_, _, let deviceName): + return deviceName + case .failed(let reason): + return reason + } + } +} diff --git a/G7SensorKitUI/Views/Calibration/G7CalibrationFlowView.swift b/G7SensorKitUI/Views/Calibration/G7CalibrationFlowView.swift new file mode 100644 index 0000000..f5c8666 --- /dev/null +++ b/G7SensorKitUI/Views/Calibration/G7CalibrationFlowView.swift @@ -0,0 +1,226 @@ +// +// G7CalibrationFlowView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import G7SensorKit +import LoopKitUI +import SwiftUI + +/// Calibration in two pages: when it is a good idea, then the value. +struct G7CalibrationFlowView: View { + @ObservedObject var viewModel: G7SettingsViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + // Inside the pushed pages `dismiss` would only pop; the sheet's own + // dismiss is handed down for when the calibration is sent. + NavigationView { + G7CalibrationAdviceView(viewModel: viewModel, didFinish: { dismiss() }) + .navigationBarItems(leading: Button(LocalizedString("Cancel", comment: "Button text to cancel G7 setup")) { dismiss() }) + } + } +} + +/// Why and when to calibrate, before the number is asked for. The sensor +/// trails blood glucose by several minutes, so a calibration taken while +/// glucose is moving teaches it the wrong value; and every reading Loop +/// doses on shifts with it. +struct G7CalibrationAdviceView: View { + @ObservedObject var viewModel: G7SettingsViewModel + var didFinish: () -> Void + + @Environment(\.guidanceColors) private var guidanceColors + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "drop.fill") + .font(.largeTitle) + .foregroundColor(.accentColor) + Text(LocalizedString("Before You Calibrate", comment: "Title of the calibration advice page")) + .font(.title2) + .fontWeight(.semibold) + } + + Text(LocalizedString("The G7 is factory calibrated and does not need calibrating. Calibrating tells the sensor to trust your meter over itself, so it is only worth doing when a small, steady offset has been there for hours and you have confirmed it with more than one fingerstick.", comment: "Calibration advice: calibration is optional")) + .fixedSize(horizontal: false, vertical: true) + + advice( + icon: "exclamationmark.triangle", + title: LocalizedString("A big difference is a reason for caution, not a reason to calibrate", comment: "Calibration advice heading: large differences"), + body: LocalizedString("A large gap is usually temporary: pressure on the sensor while you sleep, a new sensor still settling, or a fast change the sensor has not caught up with. Calibrating to it makes a big correction to the algorithm, and when the cause passes the readings swing just as far the other way. Wait it out and check again; if the gap stays large, replace the sensor.", comment: "Calibration advice: large differences") + ) + + advice( + icon: "arrow.right", + title: LocalizedString("Only when glucose is stable", comment: "Calibration advice heading: stability"), + body: LocalizedString("A flat trend, and nothing that will move it: no meal, insulin, correction or exercise in the last hour or so, and not while treating a low. The sensor lags blood glucose by several minutes, so a calibration taken while glucose is changing teaches it the wrong number.", comment: "Calibration advice: stability") + ) + + advice( + icon: "clock", + title: LocalizedString("Not on the first day", comment: "Calibration advice heading: timing"), + body: LocalizedString("The sensor refuses calibrations during warmup, and readings are still settling for the first 12 to 24 hours. Give it a day before deciding it is off.", comment: "Calibration advice: timing") + ) + + advice( + icon: "hand.raised", + title: LocalizedString("Wash your hands first", comment: "Calibration advice heading: clean hands"), + body: LocalizedString("Clean and dry your hands thoroughly with soap and water before testing. Sugar or lotion on a finger gives a false meter reading, and the calibration would carry it into the sensor.", comment: "Calibration advice: clean hands") + ) + + advice( + icon: "drop.triangle", + title: LocalizedString("Fingersticks only", comment: "Calibration advice heading: fingersticks only"), + body: LocalizedString("Calibrate only with a blood glucose meter, and enter the value within five minutes of the test. Never calibrate from another CGM's reading.", comment: "Calibration advice: fingersticks only") + ) + + if let stabilityNote = stabilityNote { + Label(stabilityNote, systemImage: "exclamationmark.triangle.fill") + .foregroundColor(guidanceColors.warning) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + NavigationLink(destination: G7CalibrationEntryView(viewModel: viewModel, didFinish: didFinish)) { + Text(LocalizedString("Continue", comment: "Button title to continue")) + .actionButtonStyle(.primary) + } + .padding() + .background(Color(.systemBackground)) + } + .navigationBarTitle(Text(LocalizedString("Calibrate", comment: "Navigation title of the calibration pages")), displayMode: .inline) + } + + /// Whether the last reading says now is a bad moment. + private var stabilityNote: String? { + guard let trend = viewModel.lastTrendMgdlPerMinute else { return nil } + if abs(trend) >= 1 { + return LocalizedString("Your glucose is changing right now. Wait until the trend is flat.", comment: "Calibration advice warning: trend is not flat") + } + return nil + } + + private func advice(icon: String, title: String, body: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundColor(.accentColor) + .frame(width: 28) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(body) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +/// The meter value, checked against the sensor's reading before it is sent. +struct G7CalibrationEntryView: View { + @ObservedObject var viewModel: G7SettingsViewModel + var didFinish: () -> Void + + @Environment(\.guidanceColors) private var guidanceColors + + @State private var text = "" + @State private var showingLargeDifferenceWarning = false + @FocusState private var fieldFocused: Bool + + /// Difference from the sensor at which the warning steps in (mg/dL). + static let largeDifferenceMgdl: Double = 40 + + private var enteredMgdl: Double? { + guard let value = Double(text.replacingOccurrences(of: ",", with: ".")) else { return nil } + return viewModel.mgdl(fromDisplayValue: value) + } + + private var isValid: Bool { + guard let mgdl = enteredMgdl else { return false } + return (Double(GlucoseLimits.minimum)...Double(GlucoseLimits.maximum)).contains(mgdl) + } + + private var differenceMgdl: Double? { + guard let entered = enteredMgdl, let current = viewModel.lastGlucoseMgdl else { return nil } + return entered - current + } + + var body: some View { + List { + Section { + HStack { + Text(LocalizedString("Sensor Reading", comment: "Calibration entry row: the sensor's current value")) + Spacer() + Text(viewModel.lastGlucoseMgdl.map { viewModel.formatGlucose(mgdl: $0) } ?? "–") + .foregroundColor(.secondary) + if !viewModel.lastGlucoseTrendString.isEmpty { + Text(viewModel.lastGlucoseTrendString) + .foregroundColor(.secondary) + } + } + HStack { + Text(LocalizedString("Meter Value", comment: "Calibration entry row: the fingerstick value field")) + Spacer() + TextField(viewModel.glucoseUnitString, text: $text) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .focused($fieldFocused) + } + if let difference = differenceMgdl, isValid { + HStack { + Text(LocalizedString("Difference", comment: "Calibration entry row: meter minus sensor")) + Spacer() + Text((difference >= 0 ? "+" : "−") + viewModel.formatGlucose(mgdl: abs(difference))) + .foregroundColor(abs(difference) >= G7CalibrationEntryView.largeDifferenceMgdl ? guidanceColors.warning : .secondary) + } + } + } footer: { + Text(LocalizedString("The sensor connects for a few seconds around each reading, so the calibration is sent at the next one, within about five minutes. Until then you can cancel it from the settings screen.", comment: "Calibration entry footer: when the value is sent")) + } + } + .insetGroupedListStyle() + .safeAreaInset(edge: .bottom) { + Button(action: submitTapped) { + Text(LocalizedString("Calibrate", comment: "Button title to send the calibration")) + .actionButtonStyle(.primary) + } + .disabled(!isValid) + .padding() + .background(Color(.systemBackground)) + } + .onAppear { fieldFocused = true } + .alert( + LocalizedString("Large Difference", comment: "Title of the alert for a calibration far from the sensor reading"), + isPresented: $showingLargeDifferenceWarning + ) { + Button(LocalizedString("Cancel", comment: "Button text to cancel G7 setup"), role: .cancel) {} + Button(LocalizedString("Calibrate Anyway", comment: "Button title to send a calibration despite the large difference")) { submit() } + } message: { + Text(String(format: LocalizedString("The meter value is %1$@ from the sensor reading. A gap this large is usually temporary, and calibrating to it makes a large correction that swings the readings the other way once the cause passes. Wait and check again; keep any calibration within %2$@ of the reading, or replace the sensor if the gap stays this large.", comment: "Message of the large difference alert (1: difference with unit, 2: recommended limit with unit)"), viewModel.formatGlucose(mgdl: abs(differenceMgdl ?? 0)), viewModel.formatGlucose(mgdl: G7CalibrationEntryView.largeDifferenceMgdl))) + } + .navigationBarTitle(Text(LocalizedString("Meter Value", comment: "Navigation title of the calibration entry page")), displayMode: .inline) + } + + private func submitTapped() { + if let difference = differenceMgdl, abs(difference) >= G7CalibrationEntryView.largeDifferenceMgdl { + showingLargeDifferenceWarning = true + } else { + submit() + } + } + + private func submit() { + guard let mgdl = enteredMgdl else { return } + viewModel.calibrate(mgdl: mgdl) + didFinish() + } +} diff --git a/G7SensorKitUI/Views/G7LifecycleBar.swift b/G7SensorKitUI/Views/G7LifecycleBar.swift new file mode 100644 index 0000000..b432de9 --- /dev/null +++ b/G7SensorKitUI/Views/G7LifecycleBar.swift @@ -0,0 +1,34 @@ +// +// G7LifecycleBar.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +/// The session progress bar: the pump plugins' 8pt bar, drawn with capsules. +/// +/// LoopKitUI's ProgressView rounds two rectangles with a corner radius of +/// half the bar height, and a radius clamps to half the shape's smaller +/// side; a fill a few points wide, as at the start of a session, comes out +/// as a square nub. Capsules always round fully, and the fill is never +/// narrower than the bar is tall, so it reads as a dot growing into a pill. +struct G7LifecycleBar: View { + var progress: Double + var color: Color + var height: CGFloat = 8 + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.1)) + Capsule() + .fill(color) + .frame(width: max(height, geometry.size.width * CGFloat(min(max(progress, 0), 1)))) + } + } + .frame(height: height) + } +} diff --git a/G7SensorKitUI/Views/G7PreviousSensorView.swift b/G7SensorKitUI/Views/G7PreviousSensorView.swift new file mode 100644 index 0000000..0281aca --- /dev/null +++ b/G7SensorKitUI/Views/G7PreviousSensorView.swift @@ -0,0 +1,130 @@ +// +// G7PreviousSensorView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import G7SensorKit +import LoopKitUI +import SwiftUI + +/// The sensor before the current one: what it was, when it ran, how it ended. +struct G7PreviousSensorView: View { + let record: G7SensorRecord + + @Environment(\.guidanceColors) private var guidanceColors + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + private let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute] + formatter.unitsStyle = .full + formatter.maximumUnitCount = 2 + return formatter + }() + + var body: some View { + List { + if let failureMessage = record.failureMessage { + Section { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(guidanceColors.critical) + .font(.title3) + VStack(alignment: .leading, spacing: 4) { + Text(LocalizedString("Sensor Failed", comment: "Title of the failure notice on the previous sensor page")) + .font(.headline) + Text(failureMessage) + .font(.subheadline) + .foregroundColor(.secondary) + if let failedAt = record.failedAt { + Text(dateFormatter.string(from: failedAt)) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + .padding(.vertical, 4) + } + } + + Section(header: Text(LocalizedString("Sensor", comment: "Section header for sensor details"))) { + LabeledValueView( + label: LocalizedString("Model", comment: "Row label for the sensor model"), + value: record.model?.displayName(sessionLength: record.sessionLength) ?? record.sensorID + ) + LabeledValueView( + label: LocalizedString("Name", comment: "title for g7 settings row showing BLE Name"), + value: record.sensorID + ) + if let serialNumber = record.serialNumber { + LabeledValueView( + label: LocalizedString("Serial Number", comment: "title for g7 settings row showing the sensor serial number"), + value: serialNumber + ) + } + if let pairingCode = record.pairingCode { + LabeledValueView( + label: LocalizedString("Pairing Code", comment: "Row label for the sensor's pairing code"), + value: pairingCode + ) + } + if let firmwareVersion = record.firmwareVersion { + LabeledValueView( + label: LocalizedString("Firmware", comment: "title for g7 settings row showing the sensor firmware version"), + value: firmwareVersion + ) + } + if let sessionLength = record.sessionLength { + LabeledValueView( + label: LocalizedString("Session Length", comment: "Row label for the sensor's session length"), + value: durationFormatter.string(from: sessionLength) ?? "" + ) + } + } + + Section(header: Text(LocalizedString("Session", comment: "Section header for the previous sensor's session dates"))) { + if let pairedAt = record.pairedAt { + LabeledValueView( + label: LocalizedString("Paired", comment: "Row label for when the sensor was paired"), + value: dateFormatter.string(from: pairedAt) + ) + } + if let activatedAt = record.activatedAt { + LabeledValueView( + label: LocalizedString("Sensor Start", comment: "title for g7 settings row showing sensor start time"), + value: dateFormatter.string(from: activatedAt) + ) + } + LabeledValueView( + label: endLabel, + value: dateFormatter.string(from: record.endedAt) + ) + if let activatedAt = record.activatedAt { + LabeledValueView( + label: LocalizedString("Worn For", comment: "Row label for how long the previous sensor was in use"), + value: durationFormatter.string(from: record.endedAt.timeIntervalSince(activatedAt)) ?? "" + ) + } + } + } + .insetGroupedListStyle() + .navigationBarTitle(Text(LocalizedString("Previous Sensor", comment: "Navigation title of the previous sensor page")), displayMode: .inline) + } + + private var endLabel: String { + switch record.endReason { + case .replaced: + return LocalizedString("Replaced", comment: "Row label for when the previous sensor was replaced") + case .deleted: + return LocalizedString("Removed", comment: "Row label for when the previous sensor's CGM was deleted") + } + } +} diff --git a/G7SensorKitUI/Views/G7ProgressBarState.swift b/G7SensorKitUI/Views/G7ProgressBarState.swift index 652a611..3889832 100644 --- a/G7SensorKitUI/Views/G7ProgressBarState.swift +++ b/G7SensorKitUI/Views/G7ProgressBarState.swift @@ -14,11 +14,14 @@ enum G7ProgressBarState { case sensorFailed case sensorExpired case searchingForSensor + case connecting var label: String { switch self { case .searchingForSensor: return LocalizedString("Searching for sensor", comment: "G7 Progress bar label when searching for sensor") + case .connecting: + return LocalizedString("Waiting for first reading", comment: "G7 Progress bar label after pairing, before the first reading") case .sensorExpired: return LocalizedString("Sensor expired", comment: "G7 Progress bar label when sensor expired") case .warmupProgress: diff --git a/G7SensorKitUI/Views/G7SettingsView.swift b/G7SensorKitUI/Views/G7SettingsView.swift index ea71de9..fcd7fa8 100644 --- a/G7SensorKitUI/Views/G7SettingsView.swift +++ b/G7SensorKitUI/Views/G7SettingsView.swift @@ -26,13 +26,24 @@ struct G7SettingsView: View { var didFinish: (() -> Void) var deleteCGM: (() -> Void) + /// Pair a sensor that still has to be applied (replacement, or first + /// direct sensor after eavesdropping ends). + var pairNewSensor: (() -> Void) + /// Pair the sensor already on the arm: the eavesdropping upgrade path. + var pairCurrentSensor: (() -> Void) @ObservedObject var viewModel: G7SettingsViewModel + @Environment(\.appName) private var appName + @Environment(\.scenePhase) private var scenePhase + @State private var showingDeletionSheet = false + @State private var showingCalibration = false - init(didFinish: @escaping () -> Void, deleteCGM: @escaping () -> Void, viewModel: G7SettingsViewModel) { + init(didFinish: @escaping () -> Void, deleteCGM: @escaping () -> Void, pairNewSensor: @escaping () -> Void, pairCurrentSensor: @escaping () -> Void, viewModel: G7SettingsViewModel) { self.didFinish = didFinish self.deleteCGM = deleteCGM + self.pairNewSensor = pairNewSensor + self.pairCurrentSensor = pairCurrentSensor self.viewModel = viewModel } @@ -50,11 +61,15 @@ struct G7SettingsView: View { var body: some View { List { Section() { - VStack { - headerImage - progressBar + sensorCard + if let message = sessionMessage { + Text(message) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) } } + + sensorSection if let activatedAt = viewModel.activatedAt { HStack { Text(LocalizedString("Sensor Start", comment: "title for g7 settings row showing sensor start time")) @@ -76,16 +91,6 @@ struct G7SettingsView: View { } } - Section("Last Reading") { - LabeledValueView(label: LocalizedString("Glucose", comment: "Field label"), - value: viewModel.lastGlucoseString) - LabeledDateView(label: LocalizedString("Time", comment: "Field label"), - date: viewModel.latestReadingTimestamp, - dateFormatter: viewModel.dateFormatter) - LabeledValueView(label: LocalizedString("Trend", comment: "Field label"), - value: viewModel.lastGlucoseTrendString) - } - Section("Bluetooth") { if let name = viewModel.sensorName { HStack { @@ -104,6 +109,10 @@ struct G7SettingsView: View { } else { if viewModel.connected { Text(LocalizedString("Connected", comment: "title for g7 settings connection status when connected")) + } else if viewModel.sessionMode == .direct { + // The sensor drops the link between readings; a spinner + // here would spin ~95% of the time and read as broken. + Text(LocalizedString("Waiting for next reading", comment: "title for g7 settings connection status between readings in direct mode")) } else { HStack { Text(LocalizedString("Connecting", comment: "title for g7 settings connection status when connecting")) @@ -118,25 +127,28 @@ struct G7SettingsView: View { } } - Section("Configuration") { - HStack { - Toggle(LocalizedString("Upload Readings", comment: "title for g7 config settings to upload readings"), isOn: $viewModel.uploadReadings) +if viewModel.sessionMode == .eavesdropping { + Section () { + Button(LocalizedString("Open Dexcom App", comment:"Opens the dexcom G7 app to allow users to manage active sensors"), action: { + G7DexcomApp.open() + }) } } - - Section () { - Button(LocalizedString("Open Dexcom App", comment:"Opens the dexcom G7 app to allow users to manage active sensors"), action: { - if let appURL = URL(string: "dexcomg7://") { - UIApplication.shared.open(appURL) - } - }) + + if viewModel.sessionMode == .direct { + calibrationSection } Section () { - if !self.viewModel.scanning { - Button("Scan for new sensor", action: { - self.viewModel.scanForNewSensor() - }) + switch viewModel.sessionMode { + case .direct: + Button(LocalizedString("Pair New Sensor", comment: "Button title in settings to pair a replacement sensor"), action: pairNewSensor) + case .eavesdropping: + if !self.viewModel.scanning { + Button("Scan for new sensor", action: { + self.viewModel.scanForNewSensor() + }) + } } deleteCGMButton @@ -144,7 +156,270 @@ struct G7SettingsView: View { } .insetGroupedListStyle() .navigationBarItems(trailing: doneButton) - .navigationBarTitle(LocalizedString("Dexcom G7", comment: "Navigation bar title for G7SettingsView")) + .navigationBarTitle(viewModel.title) + .sheet(isPresented: $showingCalibration) { + G7CalibrationFlowView(viewModel: viewModel) + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + viewModel.refreshEnvironment() + } + } + } + + /// How readings reach the app, and whatever the Dexcom app's presence + /// means for that. + @ViewBuilder + /// What we know about the sensor itself: model, identity, and what it + /// reported about its own firmware and session. Mode-specific notices + /// and the pairing actions live here too. + private var sensorSection: some View { + Section(header: Text(LocalizedString("Sensor", comment: "Section header for sensor details"))) { + // Eavesdropping sessions lead with the way out of them. + if viewModel.sessionMode == .eavesdropping { + if viewModel.isDexcomAppInstalled { + warning( + title: LocalizedString("Direct Connection Available", comment: "Title of the settings notice offering to pair directly"), + message: String(format: LocalizedString("%1$@ is currently reading glucose through the Dexcom app's session. Pair the sensor directly to stop depending on the Dexcom app. You will need the sensor's 4-digit pairing code, and you must delete the Dexcom app first.", comment: "Body of the settings notice offering to pair directly (1: appName)"), appName), + style: .informational + ) + } else { + warning( + title: LocalizedString("Dexcom App Not Found", comment: "Title of the settings warning when the Dexcom app is missing in eavesdropping mode"), + message: String(format: LocalizedString("In this mode %1$@ can only read glucose while the Dexcom G7 app is installed and running a session. Pair the sensor directly instead, or reinstall the Dexcom app.", comment: "Body of the settings warning when the Dexcom app is missing in eavesdropping mode (1: appName)"), appName), + style: .critical + ) + } + Button(LocalizedString("Pair Sensor Directly", comment: "Button title in settings to upgrade an eavesdropping session to direct pairing"), action: pairCurrentSensor) + } + LabeledValueView( + label: LocalizedString("Model", comment: "Row label for the sensor model"), + value: viewModel.sensorModelName + ) + if let serialNumber = viewModel.serialNumber { + copyableRow( + label: LocalizedString("Serial Number", comment: "title for g7 settings row showing the sensor serial number"), + value: serialNumber + ) + } + if let pairingCode = viewModel.pairingCode { + // The applicator gets thrown away; this is where the code + // lives afterwards, for pairing the same sensor elsewhere. + copyableRow( + label: LocalizedString("Pairing Code", comment: "Row label for the sensor's pairing code"), + value: pairingCode + ) + } + if let firmwareVersion = viewModel.firmwareVersion { + LabeledValueView( + label: LocalizedString("Firmware", comment: "title for g7 settings row showing the sensor firmware version"), + value: firmwareVersion + ) + } + if let softwareNumber = viewModel.softwareNumber { + LabeledValueView( + label: LocalizedString("Software Number", comment: "Row label for the sensor's software number"), + value: softwareNumber + ) + } + if let hardwareVersion = viewModel.hardwareVersion { + LabeledValueView( + label: LocalizedString("Hardware Version", comment: "Row label for the sensor's hardware version"), + value: hardwareVersion + ) + } + if let siliconVersion = viewModel.siliconVersion { + LabeledValueView( + label: LocalizedString("Silicon Version", comment: "Row label for the sensor's silicon version"), + value: siliconVersion + ) + } + if let algorithmVersion = viewModel.algorithmVersion { + LabeledValueView( + label: LocalizedString("Algorithm Version", comment: "Row label for the sensor's algorithm version"), + value: algorithmVersion + ) + } + if viewModel.hasReportedLifetime { + LabeledValueView( + label: LocalizedString("Session Length", comment: "Row label for the sensor's session length"), + value: sessionLengthFormatter.string(from: viewModel.lifetime) ?? "" + ) + LabeledValueView( + label: LocalizedString("Warmup", comment: "Row label for the sensor's warmup duration"), + value: sessionLengthFormatter.string(from: viewModel.warmupDuration) ?? "" + ) + } + if let pairedAt = viewModel.pairedAt { + LabeledValueView( + label: LocalizedString("Paired", comment: "Row label for when the sensor was paired"), + value: timeFormatter.string(from: pairedAt) + ) + } + if let previousSensor = viewModel.previousSensor { + NavigationLink(destination: G7PreviousSensorView(record: previousSensor)) { + HStack { + Text(LocalizedString("Previous Sensor", comment: "Row label linking to the previous sensor page")) + Spacer() + if previousSensor.failureMessage != nil { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(guidanceColors.critical) + } + Text(previousSensor.model?.displayName(sessionLength: previousSensor.sessionLength) ?? previousSensor.sensorID) + .foregroundColor(.secondary) + } + } + } + + if viewModel.sessionMode == .direct { + if viewModel.needsNewSensor { + // The one thing to do now; the same button also lives at + // the bottom of the screen, but this is where the eye lands + // after tapping an "expired" status. + Button(action: pairNewSensor) { + Label(LocalizedString("Pair New Sensor", comment: "Button title in settings to pair a replacement sensor"), systemImage: "plus.circle.fill") + .font(.headline) + } + } + if let failure = viewModel.lastAuthenticationFailure { + warning( + title: LocalizedString("Sensor Refused Connection", comment: "Title of the settings warning after the sensor refused authentication"), + message: failure + (viewModel.lastAuthenticationFailureDate.map { " (" + timeFormatter.string(from: $0) + ")" } ?? ""), + style: .critical + ) + } + if viewModel.isDexcomAppInstalled { + warning( + title: String(format: LocalizedString("Delete the %@ App", comment: "Title of the settings warning when a Dexcom app is installed in direct mode (1: app name)"), G7DexcomApp.installedAppNames), + message: String(format: LocalizedString("%1$@ is connected to the sensor directly and does not need the %2$@ app. A sensor works with only one app at a time, so it will interfere with readings while it is installed.", comment: "Body of the settings warning when a Dexcom app is installed in direct mode (1: appName, 2: Dexcom app name)"), appName, G7DexcomApp.installedAppNames), + style: .critical + ) + } + + } + } + } + + @State private var copiedValue: String? + + /// A value row that copies on tap (and offers Copy in its context menu), + /// confirming briefly in place. For things like the serial number that + /// end up typed into support forms and Dexcom's website. + private func copyableRow(label: String, value: String) -> some View { + Button(action: { + UIPasteboard.general.string = value + withAnimation { copiedValue = value } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + if copiedValue == value { + copiedValue = nil + } + } + } + }) { + HStack { + Text(label) + .foregroundColor(.primary) + Spacer() + if copiedValue == value { + Text(LocalizedString("Copied", comment: "Confirmation shown briefly after copying a settings value")) + .foregroundColor(.secondary) + } else { + Text(value) + .foregroundColor(.secondary) + Image(systemName: "doc.on.doc") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + .contextMenu { + Button(action: { UIPasteboard.general.string = value }) { + Label(LocalizedString("Copy", comment: "Context menu action to copy a settings value"), systemImage: "doc.on.doc") + } + } + } + + /// Calibration is a direct-mode command; an eavesdropper can only listen. + private var calibrationSection: some View { + Section(header: Text(LocalizedString("Calibration", comment: "Section header for sensor calibration"))) { + if let calibration = viewModel.calibration { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(LocalizedString("Last Calibration", comment: "Row label for the last calibration entered")) + Spacer() + Text(viewModel.formatGlucose(mgdl: Double(calibration.glucose))) + .foregroundColor(.secondary) + } + Text(calibrationOutcomeText(calibration)) + .font(.footnote) + .foregroundColor(.secondary) + } + } else if let bounds = viewModel.calibrationBounds, bounds.hasCalibration { + // Calibrated by another display (the Dexcom app, before the switch). + LabeledValueView( + label: LocalizedString("Last Calibration", comment: "Row label for the last calibration entered"), + value: LocalizedString("By another app", comment: "Last calibration value when the sensor reports one this app did not enter") + ) + } else { + LabeledValueView( + label: LocalizedString("Last Calibration", comment: "Row label for the last calibration entered"), + value: LocalizedString("None", comment: "Last calibration value when the sensor has not been calibrated") + ) + } + + if viewModel.hasPendingCalibration { + Button(action: viewModel.cancelPendingCalibration) { + Text(LocalizedString("Cancel Pending Calibration", comment: "Button title to drop a calibration not yet sent")) + .foregroundColor(guidanceColors.critical) + } + } else { + Button(action: { showingCalibration = true }) { + Text(LocalizedString("Calibrate", comment: "Button title to start calibrating the sensor")) + } + .disabled(!viewModel.canCalibrate) + } + } + } + + private func calibrationOutcomeText(_ calibration: G7CalibrationRecord) -> String { + let entered = timeFormatter.string(from: calibration.enteredAt) + switch calibration.outcome { + case .pending: + return String(format: LocalizedString("Entered %@, waiting for the sensor's next connection", comment: "Calibration outcome: pending (1: time entered)"), entered) + case .rejected(let status, _): + return String(format: LocalizedString("Entered %1$@, refused by the sensor (status %2$d)", comment: "Calibration outcome: rejected (1: time entered, 2: status code)"), entered, Int(status)) + case .accepted(let at): + switch calibration.processingStatus { + case .inProgress?: + return String(format: LocalizedString("Accepted %@; the sensor is still applying it", comment: "Calibration outcome: accepted, processing (1: time accepted)"), timeFormatter.string(from: at)) + case .completeHigh?, .completeLow?: + return String(format: LocalizedString("Accepted %@ and applied", comment: "Calibration outcome: accepted and applied (1: time accepted)"), timeFormatter.string(from: at)) + default: + return String(format: LocalizedString("Accepted %@", comment: "Calibration outcome: accepted (1: time accepted)"), timeFormatter.string(from: at)) + } + } + } + + private enum WarningStyle { + case informational, critical + } + + private func warning(title: String, message: String, style: WarningStyle) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: style == .critical ? "exclamationmark.triangle.fill" : "info.circle.fill") + .foregroundColor(style == .critical ? guidanceColors.critical : .accentColor) + .font(.title3) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.vertical, 4) } private var deleteCGMButton: some View { @@ -156,6 +431,9 @@ struct G7SettingsView: View { }).actionSheet(isPresented: $showingDeletionSheet) { ActionSheet( title: Text("Are you sure you want to delete this CGM?"), + message: viewModel.sessionMode == .direct + ? Text(LocalizedString("The sensor stays reserved for this phone for about 15 minutes. To use it with another app, wait that long, then forget the sensor under Settings > Bluetooth before pairing there.", comment: "Delete CGM sheet message in direct mode about the sensor lease and iOS bond")) + : nil, buttons: [ .destructive(Text("Delete CGM")) { self.deleteCGM() @@ -166,33 +444,267 @@ struct G7SettingsView: View { } } - private var headerImage: some View { - VStack(alignment: .center) { - Image(frameworkImage: "g7") + /// Whether the session is over and the sensor needs replacing: the card + /// dims the image and the reading row turns into a call to action. + private var sessionIsOver: Bool { + switch viewModel.lifecycleState { + case .expired, .failed: + return true + case .unpaired, .searching, .connecting, .warmup, .ok, .gracePeriod: + return false + } + } + + private var sensorCard: some View { + VStack(alignment: .leading, spacing: 12) { + viewModel.sensorModel.image .resizable() - .aspectRatio(contentMode: ContentMode.fit) + .aspectRatio(contentMode: .fit) .frame(height: 150) + .frame(maxWidth: .infinity) .padding(.horizontal) - }.frame(maxWidth: .infinity) + .opacity(sessionIsOver || viewModel.lifecycleState == .searching ? 0.4 : 1) + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(progressLabel) + .foregroundColor(progressLabelColor) + Spacer() + remainingTime + } + G7LifecycleBar( + progress: viewModel.progressBarProgress, + color: sessionIsOver ? Color(.systemGray3) : color(for: viewModel.progressBarColorStyle) + ) + } + + VStack(alignment: .leading, spacing: 6) { + Text(LocalizedString("Last reading", comment: "Label above the last reading row on the sensor card")) + .font(.subheadline) + .foregroundColor(.secondary) + HStack(alignment: .center) { + lastReadingValue + Spacer() + lastReadingAge + } + } + } + .padding(.vertical, 4) } + // MARK: Progress line + + private var progressLabel: String { + switch viewModel.lifecycleState { + case .unpaired: + return LocalizedString("No sensor paired", comment: "Sensor card label when the CGM has been added but no sensor paired") + case .searching: + return LocalizedString("Searching for sensor", comment: "Sensor card label while searching") + case .connecting: + return LocalizedString("Waiting for first reading", comment: "Sensor card label after pairing, before the first reading") + case .warmup: + return LocalizedString("Warmup completes in", comment: "Sensor card label during warmup, followed by the remaining time") + case .ok: + return LocalizedString("Sensor expires in", comment: "Sensor card label during the session, followed by the remaining time") + case .gracePeriod: + return LocalizedString("Sensor expired", comment: "Sensor card label during the grace period") + case .expired: + if let endsAt = viewModel.sensorEndsAt { + return String(format: LocalizedString("Session ended at %@", comment: "Sensor card label once the session is over (1: end time)"), sessionEndFormatter.string(from: endsAt)) + } + return LocalizedString("Session ended", comment: "Sensor card label once the session is over") + case .failed: + return LocalizedString("Sensor failed", comment: "Sensor card label after a sensor failure") + } + } + + private var progressLabelColor: Color { + switch viewModel.lifecycleState { + case .gracePeriod, .failed: + return guidanceColors.critical + case .unpaired, .searching, .connecting, .expired, .warmup, .ok: + return .secondary + } + } + + /// The remaining time as big numbers with small units: the two largest + /// nonzero units abbreviated ("1 hr 50 min", "9 days 3 hr"), or a single + /// unit spelled out ("2 hours", "30 mins"). @ViewBuilder - private var progressBar: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline) { - Text(viewModel.progressBarState.label) - .font(.system(size: 17)) - .foregroundColor(color(for: viewModel.progressBarState.labelColor)) + private var remainingTime: some View { + switch viewModel.lifecycleState { + case .warmup, .ok: + if let remaining = viewModel.progressValue { + HStack(alignment: .firstTextBaseline, spacing: 4) { + ForEach(Array(remainingComponents(remaining).enumerated()), id: \.offset) { _, component in + Text(component.value) + .font(.system(size: 28, weight: .bold)) + Text(component.unit) + .foregroundColor(.secondary) + } + } + } + case .unpaired, .searching, .connecting, .gracePeriod, .expired, .failed: + EmptyView() + } + } - Spacer() - if let referenceDate = viewModel.progressReferenceDate { - Text(sessionLengthFormatter.string(from: referenceDate.timeIntervalSince(Date())) ?? "") + private func remainingComponents(_ interval: TimeInterval) -> [(value: String, unit: String)] { + let totalMinutes = max(0, Int((interval / 60).rounded(.up))) + let days = totalMinutes / (24 * 60) + let hours = (totalMinutes % (24 * 60)) / 60 + let minutes = totalMinutes % 60 + + if days > 0 { + if hours > 0 { + return [ + (String(days), LocalizedString("days", comment: "Abbreviated days unit after a remaining-time number")), + (String(hours), LocalizedString("hr", comment: "Abbreviated hours unit after a remaining-time number")) + ] + } + return [(String(days), days == 1 + ? LocalizedString("day", comment: "Unit after a remaining-time number, singular") + : LocalizedString("days", comment: "Unit after a remaining-time number, plural"))] + } + if hours > 0 { + if minutes > 0 { + return [ + (String(hours), LocalizedString("hr", comment: "Abbreviated hours unit after a remaining-time number")), + (String(minutes), LocalizedString("min", comment: "Abbreviated minutes unit after a remaining-time number")) + ] + } + return [(String(hours), hours == 1 + ? LocalizedString("hour", comment: "Unit after a remaining-time number, singular") + : LocalizedString("hours", comment: "Unit after a remaining-time number, plural"))] + } + return [(String(minutes), minutes == 1 + ? LocalizedString("min", comment: "Unit after a remaining-time number, singular") + : LocalizedString("mins", comment: "Unit after a remaining-time number, plural"))] + } + + private var sessionEndFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + if let endsAt = viewModel.sensorEndsAt, !Calendar.current.isDateInToday(endsAt) { + formatter.dateStyle = .short + } + return formatter + } + + // MARK: Last reading row + + @ViewBuilder + private var lastReadingValue: some View { + if sessionIsOver { + HStack(spacing: 8) { + badge(systemName: "exclamationmark", color: guidanceColors.critical) + Text(LocalizedString("Replace Sensor", comment: "Last reading row text once the session is over")) + .font(.headline) + .lineLimit(2) + } + } else if viewModel.hasLastGlucose { + HStack(spacing: 8) { + if let trend = viewModel.lastGlucoseTrend { + badge(text: trend.symbol, color: glucoseTintColor) + } else { + badge(systemName: "minus", color: glucoseTintColor) + } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(viewModel.lastGlucoseValueString) + .font(.system(size: 28, weight: .bold)) + Text(viewModel.displayGlucosePreference.unit.shortLocalizedUnitString()) .foregroundColor(.secondary) } } - ProgressView(value: viewModel.progressBarProgress) - .accentColor(color(for: viewModel.progressBarColorStyle)) + } else if viewModel.lifecycleState == .warmup { + HStack(spacing: 8) { + outlinedBadge(systemName: "clock", color: glucoseTintColor) + Text(LocalizedString("Sensor Warmup", comment: "Last reading row text during warmup")) + .font(.headline) + .lineLimit(2) + } + } else { + Text(LocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)")) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.secondary) + } + } + + /// Minutes since the last reading, live. Readings are 5 minutes apart, + /// so past 10 something is late and past 20 something is wrong. + @ViewBuilder + private var lastReadingAge: some View { + if let latest = viewModel.latestReadingTimestamp { + TimelineView(.everyMinute) { context in + let minutes = max(0, Int(context.date.timeIntervalSince(latest) / 60)) + let color: Color = minutes > 20 ? guidanceColors.critical : (minutes > 10 ? guidanceColors.warning : glucoseTintColor) + HStack(spacing: 8) { + badge(systemName: "arrow.triangle.2.circlepath", color: color) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(minutes < 90 ? String(minutes) : String(minutes / 60)) + .font(.system(size: 28, weight: .bold)) + Text(minutes < 90 + ? LocalizedString("min", comment: "Unit after the minutes-since-last-reading number") + : LocalizedString("hr", comment: "Unit after the hours-since-last-reading number")) + .foregroundColor(.secondary) + } + } + } + } + } + + private func badge(systemName: String, color: Color) -> some View { + Image(systemName: systemName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(Circle().fill(color)) + } + + /// A hollow variant, for states that are pending rather than alarming. + private func outlinedBadge(systemName: String, color: Color) -> some View { + Image(systemName: systemName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(color) + .frame(width: 28, height: 28) + .overlay(Circle().stroke(color, lineWidth: 2)) + } + + private func badge(text: String, color: Color) -> some View { + Text(text) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(Circle().fill(color)) + } + + // MARK: Message + + private var sessionMessage: String? { + switch viewModel.lifecycleState { + case .gracePeriod: + return LocalizedString("Your sensor has reached the end of its session. Readings continue for up to 12 hours; replace your sensor before then.", comment: "Sensor card message during the grace period") + case .expired: + return LocalizedString("Replace your sensor now. You will not receive glucose readings until you do.", comment: "Sensor card message once the session is over") + case .failed: + return LocalizedString("Your sensor has stopped working. Remove it and replace it now; you will not receive glucose readings until you do.", comment: "Sensor card message after a sensor failure") + case .warmup: + return String(format: LocalizedString("Your sensor is warming up. You will not receive alerts, alarms, or glucose readings during the %@ warmup.", comment: "Sensor card message during warmup (1: warmup duration, e.g. 30-minute)"), warmupDurationString) + case .unpaired: + return LocalizedString("Pair a sensor to start receiving glucose readings.", comment: "Sensor card message when the CGM has been added but no sensor paired") + case .searching, .connecting, .ok: + return nil + } + } + + /// "30-minute" or "1-hour", for the warmup message. + private var warmupDurationString: String { + let minutes = Int((viewModel.warmupDuration / 60).rounded()) + if minutes % 60 == 0 { + let hours = minutes / 60 + return String(format: LocalizedString("%d-hour", comment: "Warmup duration in hours, adjectival (1: hours)"), hours) } + return String(format: LocalizedString("%d-minute", comment: "Warmup duration in minutes, adjectival (1: minutes)"), minutes) } private func color(for colorStyle: ColorStyle) -> Color { diff --git a/G7SensorKitUI/Views/G7SettingsViewModel.swift b/G7SensorKitUI/Views/G7SettingsViewModel.swift index 5db9064..0694789 100644 --- a/G7SensorKitUI/Views/G7SettingsViewModel.swift +++ b/G7SensorKitUI/Views/G7SettingsViewModel.swift @@ -8,6 +8,7 @@ import Foundation import G7SensorKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -24,11 +25,33 @@ class G7SettingsViewModel: ObservableObject { @Published private(set) var lifetime: TimeInterval @Published private(set) var warmupDuration: TimeInterval @Published private(set) var latestReadingTimestamp: Date? - @Published var uploadReadings: Bool = true { - didSet { - cgmManager.uploadReadings = uploadReadings - } - } + @Published private(set) var sessionMode: G7SessionMode = .eavesdropping + @Published private(set) var isDexcomAppInstalled: Bool = false + @Published private(set) var lifecycleState: G7SensorLifecycleState = .searching + @Published private(set) var title: String = "" + @Published private(set) var pairedAt: Date? + @Published private(set) var previousSensor: G7SensorRecord? + @Published private(set) var lastGlucoseTrend: GlucoseTrend? + @Published private(set) var sensorEndsAt: Date? + @Published private(set) var sensorModel: G7SensorModel = .g7 + /// "G7 15 Day" once the sensor has said, "G7" before. + @Published private(set) var sensorModelName: String = "" + @Published private(set) var pairingCode: String? + @Published private(set) var serialNumber: String? + @Published private(set) var firmwareVersion: String? + @Published private(set) var softwareNumber: String? + @Published private(set) var siliconVersion: String? + @Published private(set) var hardwareVersion: String? + @Published private(set) var algorithmVersion: String? + /// Whether the sensor has reported its lifetime; until then the defaults + /// are in use and not worth presenting as the sensor's own. + @Published private(set) var hasReportedLifetime: Bool = false + @Published private(set) var lastAuthenticationFailure: String? + @Published private(set) var lastAuthenticationFailureDate: Date? + @Published private(set) var calibration: G7CalibrationRecord? + @Published private(set) var calibrationBounds: G7CalibrationBoundsMessage? + @Published private(set) var hasPendingCalibration: Bool = false + @Published private(set) var canCalibrate: Bool = false let displayGlucosePreference: DisplayGlucosePreference @@ -45,8 +68,10 @@ class G7SettingsViewModel: ObservableObject { var progressBarState: G7ProgressBarState { switch cgmManager.lifecycleState { - case .searching: + case .searching, .unpaired: return .searchingForSensor + case .connecting: + return .connecting case .ok: return .lifetimeRemaining case .warmup: @@ -66,6 +91,8 @@ class G7SettingsViewModel: ObservableObject { self.lifetime = cgmManager.lifetime self.warmupDuration = cgmManager.warmupDuration updateValues() + // Once per visit to settings: which Dexcom apps the phone admits to. + cgmManager.logDeviceCommunication("Dexcom app probe: " + G7DexcomApp.describeProbe(), type: .connection) self.cgmManager.addStateObserver(self, queue: DispatchQueue.main) } @@ -78,16 +105,102 @@ class G7SettingsViewModel: ObservableObject { lastConnect = cgmManager.lastConnect lastReading = cgmManager.latestReading latestReadingTimestamp = cgmManager.latestReadingTimestamp - uploadReadings = cgmManager.state.uploadReadings lifetime = cgmManager.lifetime warmupDuration = cgmManager.warmupDuration + sessionMode = cgmManager.sessionMode + isDexcomAppInstalled = G7DexcomApp.isAnyInstalled + lifecycleState = cgmManager.lifecycleState + title = cgmManager.localizedTitle + pairedAt = cgmManager.state.pairedAt + previousSensor = cgmManager.state.previousSensor + lastGlucoseTrend = cgmManager.latestReading?.hasReliableGlucose == true ? cgmManager.latestReading?.trendType : nil + sensorEndsAt = cgmManager.sensorEndsAt + sensorModel = cgmManager.sensorModel + sensorModelName = cgmManager.sensorModel.displayName(sessionLength: cgmManager.state.extendedVersion?.sessionLength) + pairingCode = cgmManager.state.pairingCode + serialNumber = cgmManager.state.transmitterVersion?.serialNumberString + firmwareVersion = cgmManager.state.transmitterVersion?.firmwareVersion + softwareNumber = cgmManager.state.transmitterVersion.map { String($0.softwareNumber) } + siliconVersion = cgmManager.state.transmitterVersion.map { String($0.siliconVersion) } + hardwareVersion = cgmManager.state.extendedVersion.map { String($0.hardwareVersion) } + algorithmVersion = cgmManager.state.extendedVersion.map { String($0.algorithmVersion) } + hasReportedLifetime = cgmManager.state.extendedVersion != nil + lastAuthenticationFailure = cgmManager.state.lastAuthenticationFailure + lastAuthenticationFailureDate = cgmManager.state.lastAuthenticationFailureDate + calibration = cgmManager.calibration + calibrationBounds = cgmManager.state.calibrationBounds + hasPendingCalibration = cgmManager.hasPendingCalibration + canCalibrate = cgmManager.canCalibrate + } + + // MARK: - Calibration + + /// The last reliable reading in mg/dL, for the calibration entry to + /// compare against. + var lastGlucoseMgdl: Double? { + guard let lastReading = lastReading, lastReading.hasReliableGlucose, let quantity = lastReading.glucoseQuantity else { + return nil + } + return quantity.doubleValue(for: .milligramsPerDeciliter) + } + + /// The last trend in mg/dL/min, for the "is glucose stable" check. + var lastTrendMgdlPerMinute: Double? { + guard let lastReading = lastReading, lastReading.hasReliableGlucose else { + return nil + } + return lastReading.trend + } + + var glucoseUnit: LoopUnit { + displayGlucosePreference.unit + } + + var glucoseUnitString: String { + displayGlucosePreference.formatter.localizedUnitStringWithPlurality() + } + + func formatGlucose(mgdl: Double, includeUnit: Bool = true) -> String { + displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl), includeUnit: includeUnit) + } + + /// A value typed in the display unit, as mg/dL. + func mgdl(fromDisplayValue value: Double) -> Double { + LoopQuantity(unit: displayGlucosePreference.unit, doubleValue: value).doubleValue(for: .milligramsPerDeciliter) + } + + func calibrate(mgdl: Double) { + cgmManager.calibrate(glucose: UInt16(mgdl.rounded())) + updateValues() + } + + func cancelPendingCalibration() { + cgmManager.cancelPendingCalibration() + updateValues() + } + + /// Whether the session has run its course and the next thing to do is + /// put on and pair a new sensor. + var needsNewSensor: Bool { + switch lifecycleState { + case .expired, .failed, .gracePeriod, .unpaired: + return true + case .searching, .connecting, .warmup, .ok: + return false + } + } + + /// Re-check things that change outside the manager, such as the Dexcom + /// app being deleted while this screen was in the background. + func refreshEnvironment() { + isDexcomAppInstalled = G7DexcomApp.isAnyInstalled } var progressBarColorStyle: ColorStyle { switch progressBarState { case .warmupProgress: return .glucose - case .searchingForSensor: + case .searchingForSensor, .connecting: return .dimmed case .sensorExpired, .sensorFailed: return .critical @@ -107,7 +220,7 @@ class G7SettingsViewModel: ObservableObject { var progressBarProgress: Double { switch progressBarState { - case .searchingForSensor: + case .searchingForSensor, .connecting: return 0 case .warmupProgress: guard let value = progressValue, value > 0 else { @@ -131,7 +244,7 @@ class G7SettingsViewModel: ObservableObject { var progressReferenceDate: Date? { switch progressBarState { - case .searchingForSensor: + case .searchingForSensor, .connecting: return nil case .sensorExpired, .gracePeriodRemaining: return cgmManager.sensorEndsAt @@ -146,7 +259,7 @@ class G7SettingsViewModel: ObservableObject { var progressValue: TimeInterval? { switch progressBarState { - case .sensorExpired, .sensorFailed, .searchingForSensor: + case .sensorExpired, .sensorFailed, .searchingForSensor, .connecting: guard let sensorEndsAt = cgmManager.sensorEndsAt else { return nil } @@ -173,6 +286,31 @@ class G7SettingsViewModel: ObservableObject { cgmManager.scanForNewSensor() } + /// Whether the last reading carries a glucose value worth showing. + var hasLastGlucose: Bool { + guard let lastReading = lastReading, lastReading.hasReliableGlucose else { + return false + } + return lastReading.glucoseQuantity != nil + } + + /// The last glucose without its unit, for a layout that sets the unit + /// separately. LOW/HIGH stand in for out-of-range values as usual. + var lastGlucoseValueString: String { + guard let lastReading = lastReading, lastReading.hasReliableGlucose, let quantity = lastReading.glucoseQuantity else { + return LocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)") + } + + switch lastReading.glucoseRangeCategory { + case .some(.belowRange): + return LocalizedString("LOW", comment: "String displayed instead of a glucose value below the CGM range") + case .some(.aboveRange): + return LocalizedString("HIGH", comment: "String displayed instead of a glucose value above the CGM range") + default: + return displayGlucosePreference.formatter.string(from: quantity, includeUnit: false) ?? "" + } + } + var lastGlucoseString: String { guard let lastReading = lastReading, lastReading.hasReliableGlucose, let quantity = lastReading.glucoseQuantity else { return LocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)") diff --git a/G7SensorKitUI/Views/G7StartupView.swift b/G7SensorKitUI/Views/G7StartupView.swift deleted file mode 100644 index b7a4d07..0000000 --- a/G7SensorKitUI/Views/G7StartupView.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// G7StartupView.swift -// CGMBLEKitUI -// -// Created by Pete Schwamb on 9/24/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import Foundation -import SwiftUI - -struct G7StartupView: View { - var didContinue: (() -> Void)? - var didCancel: (() -> Void)? - - @Environment(\.appName) private var appName - - var body: some View { - VStack(alignment: .center, spacing: 20) { - Spacer() - Text(LocalizedString("Dexcom G7", comment: "Title on WelcomeView")) - .font(.largeTitle) - .fontWeight(.semibold) - VStack(alignment: .center) { - Image(frameworkImage: "g7") - .resizable() - .aspectRatio(contentMode: ContentMode.fit) - .frame(height: 120) - .padding(.horizontal) - }.frame(maxWidth: .infinity) - Text(String(format: LocalizedString("%1$@ can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.", comment: "Descriptive text on G7StartupView (1: appName)"), self.appName)) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.secondary) - Spacer() - Button(action: { self.didContinue?() }) { - Text(LocalizedString("Continue", comment:"Button title for starting setup")) - .actionButtonStyle(.primary) - } - Button(action: { self.didCancel?() } ) { - Text(LocalizedString("Cancel", comment: "Button text to cancel G7 setup")).padding(.top, 20) - } - } - .padding() - .environment(\.horizontalSizeClass, .compact) - .navigationBarTitle("") - .navigationBarHidden(true) - } -} - -struct WelcomeView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - G7StartupView() - } - .previewDevice("iPod touch (7th generation)") - } -} diff --git a/G7SensorKitUI/Views/Onboarding/G7AlertsFromLoopView.swift b/G7SensorKitUI/Views/Onboarding/G7AlertsFromLoopView.swift new file mode 100644 index 0000000..19ec976 --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7AlertsFromLoopView.swift @@ -0,0 +1,86 @@ +// +// G7AlertsFromLoopView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +/// Shown while an eavesdropping session moves to direct: with the Dexcom app +/// gone, every alert the user is used to now has to come from Loop. +struct G7AlertsFromLoopView: View { + var didContinue: () -> Void + + @Environment(\.appName) private var appName + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "bell.badge.fill") + .font(.largeTitle) + .foregroundColor(.accentColor) + Text(String(format: LocalizedString("Alerts Now Come From %@", comment: "Title of the alerts hand-off page shown when moving to a direct connection (1: appName)"), appName)) + .font(.title2) + .fontWeight(.semibold) + } + + Text(String(format: LocalizedString("Until now the Dexcom app has been alerting you. Once %1$@ connects to the sensor directly, the Dexcom app is out of the picture, and so are its alerts.", comment: "First paragraph of the alerts hand-off page (1: appName)"), appName)) + .fixedSize(horizontal: false, vertical: true) + + section( + title: LocalizedString("Glucose Alerts", comment: "Heading for the glucose alerts part of the alerts hand-off page"), + icon: "waveform.path.ecg", + body: String(format: LocalizedString("High, low and urgent low glucose alerts are issued by %1$@ according to its own alert settings. Review them so they match what you had in the Dexcom app.", comment: "Glucose alerts paragraph of the alerts hand-off page (1: appName)"), appName) + ) + + section( + title: LocalizedString("Sensor Alerts", comment: "Heading for the sensor alerts part of the alerts hand-off page"), + icon: "sensor.fill", + body: String(format: LocalizedString("The G7 integration raises these itself and delivers them as %1$@ notifications:", comment: "Sensor alerts paragraph of the alerts hand-off page (1: appName)"), appName), + bullets: [ + LocalizedString("Sensor expiring, 24 hours and again 2 hours before", comment: "Sensor alert list item: expiring"), + LocalizedString("Sensor expired and session ended", comment: "Sensor alert list item: expired"), + LocalizedString("Sensor failed", comment: "Sensor alert list item: failed"), + LocalizedString("Signal loss, after 20 minutes without a reading", comment: "Sensor alert list item: signal loss"), + ] + ) + + Text(LocalizedString("Anything else you had set up in the Dexcom app, such as rising or falling rate alerts, is not carried over.", comment: "Closing note of the alerts hand-off page")) + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + } + .safeAreaInset(edge: .bottom) { + Button(action: didContinue) { + Text(LocalizedString("Continue", comment: "Button title to continue")) + .actionButtonStyle(.primary) + } + .padding() + .background(Color(.systemBackground)) + } + .navigationBarTitle(Text(LocalizedString("Alerts", comment: "Navigation title of the alerts hand-off page")), displayMode: .inline) + } + + private func section(title: String, icon: String, body: String, bullets: [String] = []) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label(title, systemImage: icon) + .font(.headline) + Text(body) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + ForEach(bullets, id: \.self) { bullet in + HStack(alignment: .top, spacing: 8) { + Text("•") + Text(bullet) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(.secondary) + .padding(.leading, 4) + } + } + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7ApplySensorView.swift b/G7SensorKitUI/Views/Onboarding/G7ApplySensorView.swift new file mode 100644 index 0000000..06aa96b --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7ApplySensorView.swift @@ -0,0 +1,238 @@ +// +// G7ApplySensorView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// The application steps and their images are from DexKit by Erik Tolboom +// (https://github.com/nightscout/DexKit). +// + +import SwiftUI + +/// One illustrated step of putting on a sensor. +struct G7ApplyStep: Identifiable { + let id: Int + let title: String + let section: String + let assetName: String + let body: String + let note: String? +} + +/// The walkthrough, in the order the applicator box lays it out: insert the +/// sensor, then apply the overpatch. +enum G7ApplySteps { + private static let insertSection = LocalizedString("INSERT SENSOR", comment: "Section label above the insert-sensor steps") + private static let overpatchSection = LocalizedString("APPLY OVERPATCH", comment: "Section label above the overpatch steps") + + private static func insertTitle(_ number: Int) -> String { + String(format: LocalizedString("Step %d of 7", comment: "Insert-sensor step counter (1: step number)"), number) + } + + private static func overpatchTitle(_ letter: String) -> String { + String(format: LocalizedString("Step 7%@ of 7", comment: "Overpatch step counter (1: letter A to E)"), letter) + } + + static var all: [G7ApplyStep] { + [ + G7ApplyStep( + id: 1, title: insertTitle(1), section: insertSection, assetName: "G7SiteAdult", + body: LocalizedString("Choose a site. Adults: the back of the upper arm. Ages 7 and up may also use the abdomen; ages 2 to 6 may also use the upper buttocks.", comment: "Apply step 1: choosing a site"), + note: LocalizedString("Stay clear of loose skin, scars, tattoos, your waistband, and anywhere you inject insulin or wear a pump.", comment: "Apply step 1 note: sites to avoid") + ), + G7ApplyStep( + id: 2, title: insertTitle(2), section: insertSection, assetName: "G7CleanDry", + body: LocalizedString("Wash and dry your hands. Clean the site with an alcohol wipe and let it dry completely.", comment: "Apply step 2: clean the site"), + note: LocalizedString("Adhesive will not hold on damp skin.", comment: "Apply step 2 note") + ), + G7ApplyStep( + id: 3, title: insertTitle(3), section: insertSection, assetName: "G7UnscrewCap", + body: LocalizedString("Hold the applicator by its narrow end and unscrew the wide cap.", comment: "Apply step 3: unscrew the cap"), + note: LocalizedString("Do not use a damaged applicator, and keep fingers away from the needle end.", comment: "Apply step 3 note") + ), + G7ApplyStep( + id: 4, title: insertTitle(4), section: insertSection, assetName: "G7InsertSensor", + body: LocalizedString("Relax the muscles at the site. Press the applicator flat against your skin until the clear ring disappears, then press the button.", comment: "Apply step 4: insert the sensor"), + note: nil + ), + G7ApplyStep( + id: 5, title: insertTitle(5), section: insertSection, assetName: "G7RemoveApplicator", + body: LocalizedString("Lift the applicator straight off. The sensor stays on your skin.", comment: "Apply step 5: remove the applicator"), + note: LocalizedString("Keep the applicator until you have paired: the 4-digit pairing code is printed on it. Dispose of it as sharps afterwards.", comment: "Apply step 5 note: keep the applicator for its code") + ), + G7ApplyStep( + id: 6, title: insertTitle(6), section: insertSection, assetName: "G7PushOn", + body: LocalizedString("Hold the sensor down for 10 seconds, then rub firmly around the patch three times.", comment: "Apply step 6: secure the patch"), + note: LocalizedString("Keeping the patch dry for the first 12 hours helps it last.", comment: "Apply step 6 note") + ), + G7ApplyStep( + id: 7, title: overpatchTitle("A"), section: overpatchSection, assetName: "G7OverpatchA", + body: LocalizedString("Peel off both clear liners, one at a time, without touching the white adhesive.", comment: "Overpatch step A"), + note: nil + ), + G7ApplyStep( + id: 8, title: overpatchTitle("B"), section: overpatchSection, assetName: "G7OverpatchB", + body: LocalizedString("Holding the colored tab, center the overpatch over the sensor and press it on.", comment: "Overpatch step B"), + note: nil + ), + G7ApplyStep( + id: 9, title: overpatchTitle("C"), section: overpatchSection, assetName: "G7OverpatchC", + body: LocalizedString("Rub all the way around the overpatch.", comment: "Overpatch step C"), + note: nil + ), + G7ApplyStep( + id: 10, title: overpatchTitle("D"), section: overpatchSection, assetName: "G7OverpatchD", + body: LocalizedString("Pull the tab to remove the colored top liner, leaving the overpatch in place.", comment: "Overpatch step D"), + note: nil + ), + G7ApplyStep( + id: 11, title: overpatchTitle("E"), section: overpatchSection, assetName: "G7OverpatchE", + body: LocalizedString("Rub around the overpatch once more.", comment: "Overpatch step E"), + note: LocalizedString("That's it. Next, enter the pairing code from the applicator.", comment: "Overpatch step E note: pairing is next") + ) + ] + } +} + +/// A walkthrough figure. Falls back to a symbol when the artwork is not in +/// the bundle, so a missing asset degrades to a plainer page rather than a +/// blank one. +struct G7StepFigure: View { + let assetName: String + var height: CGFloat = 220 + + var body: some View { + Group { + if let image = UIImage(named: assetName, in: FrameworkBundle.main, compatibleWith: nil) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "sensor.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary) + .padding(40) + } + } + .frame(maxWidth: .infinity) + .frame(height: height) + } +} + +/// The screen before code entry for a new sensor: where it goes, what is in +/// the box, and a way into the step-by-step walkthrough. +struct G7ApplySensorView: View { + var didContinue: () -> Void + + @Environment(\.appName) private var appName + @State private var showingSteps = false + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + G7StepFigure(assetName: "G7InTheBox", height: 180) + .padding(.top, 16) + + Text(LocalizedString("Apply the Sensor", comment: "Title of the apply-sensor screen")) + .font(.title2) + .fontWeight(.semibold) + + Text(LocalizedString("Adults wear the sensor on the back of the upper arm. Children aged 2 to 17 can also use the abdomen or upper buttocks.", comment: "Apply-sensor screen: where the sensor goes")) + .fixedSize(horizontal: false, vertical: true) + + Text(LocalizedString("The box holds the applicator and an overpatch. The applicator inserts the sensor in one press; keep it afterwards, because the pairing code is printed on it.", comment: "Apply-sensor screen: box contents and the code")) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle.fill") + .foregroundColor(.accentColor) + Text(String(format: LocalizedString("You do not need the Dexcom app. Inserting the sensor starts its session on its own; pairing it with %1$@ on the next screen is all that is left to do.", comment: "Apply-sensor screen: the Dexcom app is not part of starting a sensor (1: appName)"), appName)) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + + Button(action: { showingSteps = true }) { + Label(LocalizedString("How to Apply a Sensor", comment: "Button title opening the step-by-step application guide"), systemImage: "questionmark.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .padding() + } + + Button(action: didContinue) { + Text(LocalizedString("Sensor Is On, Continue", comment: "Button title to proceed from the apply-sensor screen to code entry")) + .actionButtonStyle(.primary) + } + .padding() + } + .sheet(isPresented: $showingSteps) { + G7ApplyStepsView(didFinish: { showingSteps = false }) + } + .navigationBarTitle(Text(LocalizedString("New Sensor", comment: "Navigation title of the apply-sensor screen")), displayMode: .inline) + } +} + +/// The step-by-step walkthrough, one page per step. +struct G7ApplyStepsView: View { + var didFinish: () -> Void + + private let steps = G7ApplySteps.all + + var body: some View { + NavigationView { + TabView { + ForEach(steps) { step in + G7ApplyStepCard(step: step) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + .navigationBarTitle(Text(LocalizedString("How to Apply a Sensor", comment: "Navigation title of the step-by-step application guide")), displayMode: .inline) + .navigationBarItems(trailing: Button(LocalizedString("Done", comment: "Button title to finish setup"), action: didFinish)) + } + .navigationViewStyle(.stack) + } +} + +struct G7ApplyStepCard: View { + let step: G7ApplyStep + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + G7StepFigure(assetName: step.assetName) + .padding(.top, 8) + + Text(step.section) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + Text(step.title) + .font(.title3) + .fontWeight(.semibold) + + Text(step.body) + .fixedSize(horizontal: false, vertical: true) + + if let note = step.note { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text(note) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding() + .padding(.bottom, 40) // room for the page indicator + } + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7DexcomAppWarningView.swift b/G7SensorKitUI/Views/Onboarding/G7DexcomAppWarningView.swift new file mode 100644 index 0000000..db464c7 --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7DexcomAppWarningView.swift @@ -0,0 +1,88 @@ +// +// G7DexcomAppWarningView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +/// Shown before pairing when the Dexcom G7 app is installed. +/// +/// A sensor admits one display at a time and the Dexcom app will keep trying +/// to be it, so pairing while it is installed either fails outright or +/// produces a session the two apps fight over. The user has to remove it. +struct G7DexcomAppWarningView: View { + /// Whether the sensor being paired is one the Dexcom app has been + /// connected to (an eavesdropping session moving to direct). That is + /// when the sensor's 15-minute display lease matters, and when readings + /// stop until pairing completes. + var isReplacingDexcomAppSession: Bool + + /// Re-checks whether the app is still installed; the screen refuses to + /// move on while it is. + var isDexcomAppInstalled: () -> Bool + var didContinue: () -> Void + + @Environment(\.appName) private var appName + @Environment(\.guidanceColors) private var guidanceColors + + @State private var stillInstalled = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundColor(guidanceColors.warning) + Text(String(format: LocalizedString("Delete the %@ App", comment: "Title of the Dexcom app warning shown before pairing (1: app name, e.g. Dexcom G7 or Stelo)"), G7DexcomApp.installedAppNames)) + .font(.title2) + .fontWeight(.semibold) + } + + Text(String(format: LocalizedString("The %@ app is installed on this phone. You must delete it before pairing.", comment: "First paragraph of the Dexcom app warning (1: app name)"), G7DexcomApp.installedAppNames)) + .fixedSize(horizontal: false, vertical: true) + + Text(String(format: LocalizedString("A G7 sensor works with only one app at a time. If the Dexcom app stays installed it will keep connecting to the sensor, and %1$@ will lose readings or fail to pair at all.", comment: "Second paragraph of the Dexcom app warning (1: appName)"), appName)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + + if isReplacingDexcomAppSession { + VStack(alignment: .leading, spacing: 12) { + Text(String(format: LocalizedString("Because the Dexcom app has been using this sensor, wait about 15 minutes after deleting it before pairing; the sensor holds onto its last app for that long. %1$@ will not receive readings until pairing completes.", comment: "Dexcom app warning: lease wait and reading gap when moving an existing session (1: appName)"), appName)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + Text(LocalizedString("You will need this sensor's 4-digit pairing code, printed on its applicator.", comment: "Dexcom app warning: reminder that the code for the current sensor is needed")) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + } + } else { + Text(LocalizedString("A new sensor that the Dexcom app has never connected to can be paired right away.", comment: "Dexcom app warning: no wait needed for a fresh sensor")) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + } + + Spacer() + + if stillInstalled { + Text(String(format: LocalizedString("The %@ app is still installed.", comment: "Message when the user tries to continue with the Dexcom app still present (1: app name)"), G7DexcomApp.installedAppNames)) + .font(.footnote) + .foregroundColor(guidanceColors.critical) + .frame(maxWidth: .infinity) + } + + Button(action: { + if isDexcomAppInstalled() { + stillInstalled = true + } else { + didContinue() + } + }) { + Text(LocalizedString("I've Deleted It", comment: "Button title to confirm the Dexcom app was removed")) + .actionButtonStyle(.primary) + } + } + .padding() + .navigationBarTitle(Text(LocalizedString("Before You Pair", comment: "Navigation title of the Dexcom app warning screen")), displayMode: .inline) + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7EnterCodeView.swift b/G7SensorKitUI/Views/Onboarding/G7EnterCodeView.swift new file mode 100644 index 0000000..692ef04 --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7EnterCodeView.swift @@ -0,0 +1,151 @@ +// +// G7EnterCodeView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import AVFoundation +import G7SensorKit +import SwiftUI + +/// Collects the 4-digit pairing code, by typing or by scanning the +/// applicator's Data Matrix. +struct G7EnterCodeView: View { + var didEnterCode: (_ code: String, _ serial: String?) -> Void + + @State private var code = "" + /// The code and serial from a scanned applicator. The serial only rides + /// along while the code still matches what was scanned: it belongs to that + /// applicator, + /// not to whatever gets typed afterwards. + @State private var scannedCode: String? + @State private var scannedSerial: String? + @State private var showingScanner = false + @State private var showingCameraDenied = false + @State private var scanMessage: String? + + @FocusState private var codeFieldFocused: Bool + + private var isValid: Bool { + G7PairingService.isValidPairingCode(code) + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text(LocalizedString("Enter the 4-digit pairing code printed on the sensor applicator, or scan the applicator's barcode.", comment: "Instructions on the pairing code entry screen")) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + + Text(LocalizedString("There is nothing to do in the Dexcom app first; the sensor is ready to pair as soon as it is on.", comment: "Reminder on the code entry screen that the Dexcom app is not involved")) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + + TextField(LocalizedString("Pairing Code", comment: "Placeholder for the pairing code field"), text: $code) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .font(.system(size: 34, weight: .semibold, design: .monospaced)) + .multilineTextAlignment(.center) + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(10) + .focused($codeFieldFocused) + .onChange(of: code) { _, newValue in + let digits = newValue.filter(\.isNumber) + let trimmed = String(digits.prefix(4)) + if trimmed != newValue { + code = trimmed + } + if trimmed != scannedCode { + scannedSerial = nil + } + } + + if G7PackageScannerView.isAvailable { + Button(action: scanTapped) { + Label(LocalizedString("Scan Applicator", comment: "Button title to scan the applicator barcode"), systemImage: "qrcode.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + + if let scanMessage = scanMessage { + Text(scanMessage) + .font(.footnote) + .foregroundColor(.secondary) + } + + Spacer() + + Button(action: { didEnterCode(code, scannedSerial) }) { + Text(LocalizedString("Continue", comment: "Button title for starting setup")) + .actionButtonStyle(.primary) + } + .disabled(!isValid) + } + .padding() + .onAppear { codeFieldFocused = true } + .sheet(isPresented: $showingScanner) { + NavigationView { + G7PackageScannerView { package in + showingScanner = false + handleScannedPackage(package) + } + .navigationBarTitle(Text(LocalizedString("Scan Applicator", comment: "Navigation title of the applicator scanner")), displayMode: .inline) + .navigationBarItems(trailing: Button(LocalizedString("Cancel", comment: "Button text to cancel G7 setup")) { + showingScanner = false + }) + } + } + .alert( + LocalizedString("Camera Access Is Off", comment: "Title of the alert shown when camera permission is denied"), + isPresented: $showingCameraDenied + ) { + Button(LocalizedString("Open Settings", comment: "Button title to open the iOS Settings app")) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button(LocalizedString("Cancel", comment: "Button text to cancel G7 setup"), role: .cancel) {} + } message: { + Text(LocalizedString("Allow camera access in Settings to scan the code on the applicator, or type the 4 digits instead.", comment: "Message of the alert shown when camera permission is denied")) + } + .navigationBarTitle(Text(LocalizedString("Pairing Code", comment: "Navigation title of the pairing code entry screen")), displayMode: .inline) + } + + /// The scanner shows a blank view without camera access, so ask first. + private func scanTapped() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + showingScanner = true + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { + showingScanner = true + } else { + showingCameraDenied = true + } + } + } + default: + showingCameraDenied = true + } + } + + private func handleScannedPackage(_ package: G7SensorPackage) { + guard let pairingCode = package.pairingCode else { + scanMessage = package.isDexcom + ? LocalizedString("That barcode has no pairing code. Enter the code from the applicator instead.", comment: "Message after scanning a Dexcom barcode without a pairing code") + : LocalizedString("That doesn't look like a Dexcom applicator.", comment: "Message after scanning a non-Dexcom barcode") + return + } + scannedCode = pairingCode + scannedSerial = package.serial + code = pairingCode + scanMessage = package.serial.map { serial in + String(format: LocalizedString("Scanned sensor %@", comment: "Message after a successful package scan (1: serial number)"), serial) + } + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7NotificationPermissionsView.swift b/G7SensorKitUI/Views/Onboarding/G7NotificationPermissionsView.swift new file mode 100644 index 0000000..728d195 --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7NotificationPermissionsView.swift @@ -0,0 +1,170 @@ +// +// G7NotificationPermissionsView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import UserNotifications + +/// What the phone will let Loop's alerts do: notify at all, break through +/// Silent mode and Focus as Critical Alerts, or at least as Time Sensitive +/// ones. `criticalAlerts == .notSupported` is how a build without Apple's +/// Critical Alerts entitlement shows up, which is the case that needs the +/// most advice. +struct G7NotificationStatus: Equatable { + var authorized: Bool + var criticalAlerts: UNNotificationSetting + var timeSensitive: UNNotificationSetting + + static func fetch(_ completion: @escaping (G7NotificationStatus) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + let status = G7NotificationStatus( + authorized: settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, + criticalAlerts: settings.criticalAlertSetting, + timeSensitive: settings.timeSensitiveSetting + ) + DispatchQueue.main.async { completion(status) } + } + } +} + +/// Shown after `G7AlertsFromLoopView`: checks the notification settings that +/// decide whether those alerts will actually be heard, and says what to +/// change. Re-checks whenever the app comes back from Settings. +struct G7NotificationPermissionsView: View { + var didContinue: () -> Void + + @Environment(\.appName) private var appName + @Environment(\.guidanceColors) private var guidanceColors + + @State private var status: G7NotificationStatus? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "iphone.radiowaves.left.and.right") + .font(.largeTitle) + .foregroundColor(.accentColor) + Text(LocalizedString("Make Sure Alerts Reach You", comment: "Title of the notification permissions page shown when moving to a direct connection")) + .font(.title2) + .fontWeight(.semibold) + } + + Text(String(format: LocalizedString("An alert is only useful if your phone lets it through. Here is how %1$@ stands right now.", comment: "First paragraph of the notification permissions page (1: appName)"), appName)) + .fixedSize(horizontal: false, vertical: true) + + if let status = status { + statusRows(status) + } else { + ProgressView() + .frame(maxWidth: .infinity) + } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 8) { + if status.map(needsSettings) ?? false { + Button(action: openSettings) { + Text(LocalizedString("Open Settings", comment: "Button title to open the iOS Settings app")) + .actionButtonStyle(.secondary) + } + } + Button(action: didContinue) { + Text(LocalizedString("Continue", comment: "Button title to continue")) + .actionButtonStyle(.primary) + } + } + .padding() + .background(Color(.systemBackground)) + } + .navigationBarTitle(Text(LocalizedString("Notifications", comment: "Navigation title of the notification permissions page")), displayMode: .inline) + .onAppear(perform: refresh) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in refresh() } + } + + @ViewBuilder + private func statusRows(_ status: G7NotificationStatus) -> some View { + row( + ok: status.authorized, + title: LocalizedString("Notifications", comment: "Status row title: notification permission"), + detail: status.authorized + ? LocalizedString("Allowed.", comment: "Status row detail: notifications allowed") + : String(format: LocalizedString("Turned off. Allow notifications for %1$@ in Settings, or no alert will appear at all.", comment: "Status row detail: notifications denied (1: appName)"), appName) + ) + + switch status.criticalAlerts { + case .enabled: + row( + ok: true, + title: LocalizedString("Critical Alerts", comment: "Status row title: critical alerts"), + detail: LocalizedString("On. Urgent alerts will sound even when your phone is silenced or in a Focus.", comment: "Status row detail: critical alerts enabled") + ) + case .disabled: + row( + ok: false, + title: LocalizedString("Critical Alerts", comment: "Status row title: critical alerts"), + detail: String(format: LocalizedString("Turned off. Turn on Critical Alerts for %1$@ in Settings so urgent alerts sound through Silent mode and Focus.", comment: "Status row detail: critical alerts disabled (1: appName)"), appName) + ) + default: + row( + ok: false, + title: LocalizedString("Critical Alerts", comment: "Status row title: critical alerts"), + detail: String(format: LocalizedString("Not available. This build of %1$@ does not have Apple's Critical Alerts entitlement, so its alerts cannot override Silent mode or a Focus on their own. To make sure they still get through:", comment: "Status row detail: critical alerts not supported (1: appName)"), appName), + bullets: [ + String(format: LocalizedString("In Settings › Notifications › %1$@, turn on Time Sensitive Notifications.", comment: "Advice bullet: time sensitive setting (1: appName)"), appName), + String(format: LocalizedString("In Settings › Focus, open each Focus you use and add %1$@ to its allowed apps.", comment: "Advice bullet: focus allowed apps (1: appName)"), appName), + LocalizedString("Keep your ringer on. Silent mode mutes every sound that is not a Critical Alert.", comment: "Advice bullet: ringer"), + ] + ) + if status.timeSensitive == .disabled { + row( + ok: false, + title: LocalizedString("Time Sensitive Notifications", comment: "Status row title: time sensitive notifications"), + detail: String(format: LocalizedString("Turned off for %1$@. These are what let an alert break through a Focus without Critical Alerts.", comment: "Status row detail: time sensitive disabled (1: appName)"), appName) + ) + } + } + } + + private func row(ok: Bool, title: String, detail: String, bullets: [String] = []) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: ok ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.title3) + .foregroundColor(ok ? guidanceColors.acceptable : guidanceColors.warning) + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + Text(detail) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + ForEach(bullets, id: \.self) { bullet in + HStack(alignment: .top, spacing: 8) { + Text("•") + Text(bullet) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(.secondary) + .padding(.leading, 4) + } + } + } + } + + private func needsSettings(_ status: G7NotificationStatus) -> Bool { + !status.authorized || status.criticalAlerts != .enabled || status.timeSensitive == .disabled + } + + private func refresh() { + G7NotificationStatus.fetch { status = $0 } + } + + private func openSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7PackageScannerView.swift b/G7SensorKitUI/Views/Onboarding/G7PackageScannerView.swift new file mode 100644 index 0000000..e29310a --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7PackageScannerView.swift @@ -0,0 +1,75 @@ +// +// G7PackageScannerView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// +// Derived from DexKit by Erik Tolboom (https://github.com/nightscout/DexKit). +// + +import G7SensorKit +import SwiftUI +import VisionKit + +/// Reads the GS1 Data Matrix on a sensor applicator with the camera. +struct G7PackageScannerView: UIViewControllerRepresentable { + var didScan: (G7SensorPackage) -> Void + + /// Whether scanning can be offered at all. Requires a device with a + /// camera and a host app that declares camera usage: asking for camera + /// access without `NSCameraUsageDescription` terminates the app. + static var isAvailable: Bool { + DataScannerViewController.isSupported + && DataScannerViewController.isAvailable + && Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription") != nil + } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.dataMatrix])], + qualityLevel: .accurate, + isHighlightingEnabled: true + ) + scanner.delegate = context.coordinator + return scanner + } + + func updateUIViewController(_ scanner: DataScannerViewController, context: Context) { + if !scanner.isScanning { + try? scanner.startScanning() + } + } + + static func dismantleUIViewController(_ scanner: DataScannerViewController, coordinator: Coordinator) { + scanner.stopScanning() + } + + func makeCoordinator() -> Coordinator { + Coordinator(didScan: didScan) + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + private let didScan: (G7SensorPackage) -> Void + private var handled = false + + init(didScan: @escaping (G7SensorPackage) -> Void) { + self.didScan = didScan + } + + func dataScanner(_ scanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { + guard !handled else { return } + for item in addedItems { + guard case .barcode(let barcode) = item, + let payload = barcode.payloadStringValue, + let package = G7SensorPackage(dataMatrix: payload) + else { + continue + } + handled = true + scanner.stopScanning() + didScan(package) + return + } + } + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7PairingSuccessView.swift b/G7SensorKitUI/Views/Onboarding/G7PairingSuccessView.swift new file mode 100644 index 0000000..7cd3191 --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7PairingSuccessView.swift @@ -0,0 +1,55 @@ +// +// G7PairingSuccessView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct G7PairingSuccessView: View { + var deviceName: String? + var didFinish: () -> Void + + @Environment(\.appName) private var appName + @Environment(\.guidanceColors) private var guidanceColors + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(guidanceColors.acceptable) + + Text(LocalizedString("Sensor Paired", comment: "Title of the pairing success screen")) + .font(.title2) + .fontWeight(.semibold) + + if let deviceName = deviceName { + Text(deviceName) + .foregroundColor(.secondary) + } + + Text(String(format: LocalizedString("%1$@ is now connected to the sensor directly. Readings arrive every 5 minutes; a new sensor needs about 30 minutes to warm up first.", comment: "Body of the pairing success screen (1: appName)"), appName)) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(LocalizedString("Do not install or use the Dexcom G7 app with this sensor.", comment: "Reminder on the pairing success screen")) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + Spacer() + + Button(action: didFinish) { + Text(LocalizedString("Done", comment: "Button title to finish setup")) + .actionButtonStyle(.primary) + } + } + .padding() + .navigationBarBackButtonHidden(true) + .navigationBarTitle("", displayMode: .inline) + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7PairingView.swift b/G7SensorKitUI/Views/Onboarding/G7PairingView.swift new file mode 100644 index 0000000..64b7768 --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7PairingView.swift @@ -0,0 +1,99 @@ +// +// G7PairingView.swift +// G7SensorKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import G7SensorKit +import SwiftUI + +/// Shows the pairing run's progress and lets the user retry or go back to +/// the code on failure. +struct G7PairingView: View { + @ObservedObject var viewModel: G7PairingViewModel + var didEditCode: () -> Void + + @Environment(\.guidanceColors) private var guidanceColors + + var body: some View { + VStack(spacing: 24) { + Spacer() + + statusIcon + .frame(height: 80) + + Text(viewModel.statusTitle) + .font(.title2) + .fontWeight(.semibold) + + if let problem = viewModel.bluetoothProblem { + Label(problem, systemImage: "exclamationmark.triangle.fill") + .multilineTextAlignment(.leading) + .foregroundColor(guidanceColors.critical) + .fixedSize(horizontal: false, vertical: true) + } else if let detail = viewModel.statusDetail { + Text(detail) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if case .scanning(let candidates) = viewModel.state, candidates.isEmpty, let startedAt = viewModel.scanStartedAt { + TimelineView(.periodic(from: startedAt, by: 1)) { context in + let elapsed = max(0, Int(context.date.timeIntervalSince(startedAt))) + Text(String(format: LocalizedString("Looking for %d:%02d", comment: "Elapsed scan time while pairing (1: minutes, 2: seconds)"), elapsed / 60, elapsed % 60)) + .font(.footnote.monospacedDigit()) + .foregroundColor(.secondary) + } + } + + if viewModel.isWorking { + Text(LocalizedString("If iOS asks to pair with the sensor, tap Pair.", comment: "Hint about the system Bluetooth pairing prompt during G7 pairing")) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + } + + Spacer() + + if case .failed = viewModel.state { + Button(action: { viewModel.retry() }) { + Text(LocalizedString("Try Again", comment: "Button title to retry pairing")) + .actionButtonStyle(.primary) + } + Button(action: didEditCode) { + Text(LocalizedString("Change Code", comment: "Button title to go back and edit the pairing code")) + .actionButtonStyle(.secondary) + } + } else if viewModel.isWorking { + Button(action: didEditCode) { + Text(LocalizedString("Cancel", comment: "Button text to cancel G7 setup")) + .actionButtonStyle(.secondary) + } + } + } + .padding() + .navigationBarTitle(Text(LocalizedString("Pairing", comment: "Navigation title of the pairing progress screen")), displayMode: .inline) + .navigationBarBackButtonHidden(viewModel.isWorking) + .onAppear { viewModel.start() } + .onDisappear { viewModel.cancel() } + } + + @ViewBuilder + private var statusIcon: some View { + switch viewModel.state { + case .idle, .scanning, .authenticating: + ProgressView() + .scaleEffect(2) + case .succeeded: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(guidanceColors.acceptable) + case .failed: + Image(systemName: "xmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(guidanceColors.critical) + } + } +} diff --git a/G7SensorKitUI/Views/Onboarding/G7StartupView.swift b/G7SensorKitUI/Views/Onboarding/G7StartupView.swift new file mode 100644 index 0000000..d201458 --- /dev/null +++ b/G7SensorKitUI/Views/Onboarding/G7StartupView.swift @@ -0,0 +1,80 @@ +// +// G7StartupView.swift +// CGMBLEKitUI +// +// Created by Pete Schwamb on 9/24/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI + +/// First screen of setup. Two ways in: pair directly (the normal path), or +/// keep relying on the Dexcom app, which exists for someone mid-session on a +/// sensor whose pairing code they no longer have. +struct G7StartupView: View { + var didChoosePairing: (() -> Void)? + var didChooseDexcomApp: (() -> Void)? + var didCancel: (() -> Void)? + + @Environment(\.appName) private var appName + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text(LocalizedString("Dexcom G7", comment: "Title on WelcomeView")) + .font(.largeTitle) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + VStack(alignment: .center) { + Image(frameworkImage: "g7") + .resizable() + .aspectRatio(contentMode: ContentMode.fit) + .frame(height: 120) + .padding(.horizontal) + }.frame(maxWidth: .infinity) + + Text(String(format: LocalizedString("%1$@ connects to your G7, ONE+ or Stelo sensor directly. Pair it with the 4-digit code printed on the sensor applicator, and the Dexcom app is not needed.", comment: "Descriptive text on G7StartupView (1: appName)"), self.appName)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + + Spacer() + + Button(action: { self.didChoosePairing?() }) { + Text(LocalizedString("Pair Sensor", comment: "Button title to start direct pairing")) + .actionButtonStyle(.primary) + } + + VStack(alignment: .leading, spacing: 8) { + Text(LocalizedString("Already wearing a sensor and don't have its code?", comment: "Heading above the legacy Dexcom-app setup option")) + .font(.subheadline) + .foregroundColor(.secondary) + Button(action: { self.didChooseDexcomApp?() }) { + Text(LocalizedString("Use with the Dexcom App Instead", comment: "Button title to set up in eavesdropping mode alongside the Dexcom app")) + .actionButtonStyle(.secondary) + } + Text(String(format: LocalizedString("%1$@ will read glucose from the Dexcom app's session until you pair a sensor. The Dexcom app must stay installed for this to work.", comment: "Explanation of eavesdropping mode on G7StartupView (1: appName)"), self.appName)) + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Button(action: { self.didCancel?() } ) { + Text(LocalizedString("Cancel", comment: "Button text to cancel G7 setup")) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } + } + .padding() + .environment(\.horizontalSizeClass, .compact) + .navigationBarTitle("") + .navigationBarHidden(true) + } +} + +struct WelcomeView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + G7StartupView() + } + } +} diff --git a/README.md b/README.md index 9737119..066f9e7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ -# Loop Plugin for G7 Sensor -Requires use of official G7 app \ No newline at end of file +# G7SensorKit + +A Loop plugin for the Dexcom G7, ONE+ and Stelo sensors. + +The plugin connects to the sensor directly over Bluetooth. Pair it with the 4-digit code printed on the sensor applicator (typed or scanned), and readings, backfill, sensor status and lifecycle alerts come straight from the sensor. The Dexcom app is not needed, and must not be installed alongside: a sensor admits one display at a time. + +For a sensor already in use with the Dexcom app whose code is not available, the plugin can instead read alongside the Dexcom app's own session until the next sensor is paired directly. + +## Features + +- Direct pairing and session management for G7, ONE+ and Stelo, including 15-day sensors +- Sensor application guide and pairing flow +- Gap backfill after any time out of range +- Lifecycle alerts: sensor expiring, expired, session ended, sensor failed, signal loss, connection refused +- Calibration, with guidance on when it is appropriate +- Sensor details in settings: model, serial number, pairing code, firmware, session length, previous sensor + +## Credits + +- Knowledge of the sensor's authentication protocol comes from [Juggluco](https://github.com/j-kaltes/Juggluco) and [xDrip](https://github.com/NightscoutFoundation/xDrip). +- The pairing code, pairing UI and sensor application graphics are derived from [DexKit](https://github.com/nightscout/DexKit) by Erik Tolboom. + +## License + +MIT; see [LICENSE](LICENSE).