diff --git a/Apps/G6SensorHarness/Sources/ContentView.swift b/Apps/G6SensorHarness/Sources/ContentView.swift index 8cd93c6..28d7abd 100644 --- a/Apps/G6SensorHarness/Sources/ContentView.swift +++ b/Apps/G6SensorHarness/Sources/ContentView.swift @@ -21,6 +21,9 @@ struct ContentView: View { .textInputAutocapitalization(.characters) .disabled(model.isRunning) + Toggle("Passive mode (listen to Dexcom app)", isOn: $model.passiveModeEnabled) + .disabled(model.isRunning) + if model.isRunning { Button("Stop", role: .destructive) { model.stop() } } else { @@ -31,26 +34,28 @@ struct ContentView: View { row("Last connect", model.lastConnect.map { $0.formatted(date: .omitted, time: .standard) } ?? "—") } - Section("Session commands (sent on next connection)") { - HStack { - TextField("Sensor code (blank = no code)", text: $sensorCode) - .keyboardType(.numberPad) - Button("Start sensor") { - model.enqueueStartSensor(code: sensorCode.isEmpty ? nil : sensorCode) + if !model.passiveModeEnabled { + Section("Session commands (sent on next connection)") { + HStack { + TextField("Sensor code (blank = no code)", text: $sensorCode) + .keyboardType(.numberPad) + Button("Start sensor") { + model.enqueueStartSensor(code: sensorCode.isEmpty ? nil : sensorCode) + } } - } - Button("Stop sensor") { model.enqueueStopSensor() } - HStack { - TextField("Calibration mg/dL", text: $calibrationValue) - .keyboardType(.numberPad) - Button("Calibrate") { - if let value = Double(calibrationValue) { - model.enqueueCalibration(mgDL: value) + Button("Stop sensor") { model.enqueueStopSensor() } + HStack { + TextField("Calibration mg/dL", text: $calibrationValue) + .keyboardType(.numberPad) + Button("Calibrate") { + if let value = Double(calibrationValue) { + model.enqueueCalibration(mgDL: value) + } } } + Button("Read battery") { model.requestBattery() } + Button("Backfill last 3h") { model.requestBackfill(hours: 3) } } - Button("Read battery") { model.requestBattery() } - Button("Backfill last 3h") { model.requestBackfill(hours: 3) } } Section("Log") { diff --git a/Apps/G6SensorHarness/Sources/HarnessViewModel.swift b/Apps/G6SensorHarness/Sources/HarnessViewModel.swift index c31d1ab..9ca3d0b 100644 --- a/Apps/G6SensorHarness/Sources/HarnessViewModel.swift +++ b/Apps/G6SensorHarness/Sources/HarnessViewModel.swift @@ -4,10 +4,11 @@ // // Copyright © 2026 Nightscout Foundation. MIT License. // -// Drives a TransmitterSession directly for on-device validation of the +// Drives a transmitter session directly for on-device validation of the // core: pairing, per-cycle reads, session start/stop with sensor codes, -// calibration, battery, and backfill — with an in-app event log so -// background behavior can be inspected without a debugger. +// calibration, battery, and backfill in direct mode — or passive listening +// alongside the Dexcom app — with an in-app event log so background +// behavior can be inspected without a debugger. // import Foundation @@ -23,13 +24,14 @@ struct LogLine: Identifiable { final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, TransmitterCommandSource { @Published var transmitterID: String = UserDefaults.standard.string(forKey: "harness.transmitterID") ?? "" + @Published var passiveModeEnabled: Bool = UserDefaults.standard.bool(forKey: "harness.passiveModeEnabled") @Published var isRunning = false @Published var latestReading: String = "—" @Published var lastConnect: Date? @Published var log: [LogLine] = [] @Published var pendingCommands: [Command] = [] - private var session: TransmitterSession? + private var session: (any TransmitterSessioning)? private var peripheralIdentifier: UUID? { get { UserDefaults.standard.string(forKey: "harness.peripheralIdentifier").flatMap(UUID.init) } @@ -49,14 +51,23 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran } UserDefaults.standard.set(transmitterID, forKey: "harness.transmitterID") - - let session = TransmitterSession(id: transmitterID, peripheralIdentifier: peripheralIdentifier) + UserDefaults.standard.set(passiveModeEnabled, forKey: "harness.passiveModeEnabled") + + let session: any TransmitterSessioning + if passiveModeEnabled { + session = PassiveTransmitterSession(id: transmitterID, peripheralIdentifier: peripheralIdentifier) + } else { + let active = TransmitterSession(id: transmitterID, peripheralIdentifier: peripheralIdentifier) + active.commandSource = self + session = active + } session.delegate = self - session.commandSource = self self.session = session session.start() isRunning = true - append("Session armed for \(transmitterID); waiting for transmitter advertisement (~5 min cycle)") + append(passiveModeEnabled + ? "Passive session armed for \(transmitterID); listening for the Dexcom app's traffic" + : "Session armed for \(transmitterID); waiting for transmitter advertisement (~5 min cycle)") } func stop() { @@ -87,17 +98,29 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran } func requestBattery() { - session?.shouldReadBattery = true + guard let session = session as? TransmitterSession else { + append("Battery reads require direct mode") + return + } + session.shouldReadBattery = true append("Battery read queued for next connection") } func requestBackfill(hours: Double) { + guard let session = session as? TransmitterSession else { + append("Backfill requests require direct mode") + return + } let end = Date() - session?.requestedBackfillWindow = DateInterval(start: end.addingTimeInterval(-hours * 3600), end: end) + session.requestedBackfillWindow = DateInterval(start: end.addingTimeInterval(-hours * 3600), end: end) append("Backfill queued for last \(Int(hours))h") } private func enqueue(_ command: Command) { + guard !passiveModeEnabled else { + append("Commands require direct mode") + return + } DispatchQueue.main.async { self.pendingCommands.append(command) self.append("Queued: \(String(describing: command))") @@ -115,49 +138,53 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran // MARK: - TransmitterSessionDelegate (background queue) - func transmitterSessionDidConnect(_ session: TransmitterSession) { + func transmitterSessionDidConnect(_ session: any TransmitterSessioning) { DispatchQueue.main.async { self.lastConnect = Date() } append("Connected") } - func transmitterSession(_ session: TransmitterSession, didError error: Error) { + func transmitterSession(_ session: any TransmitterSessioning, didError error: Error) { append("Error: \(error)") } - func transmitterSession(_ session: TransmitterSession, didRead glucose: Glucose) { + func transmitterSession(_ session: any TransmitterSessioning, didRead glucose: Glucose) { let value = glucose.glucoseMgDL.map { "\(Int($0)) mg/dL" } ?? "unreliable (\(glucose.state))" DispatchQueue.main.async { self.latestReading = value } append("Glucose: \(value), state: \(glucose.state), trend: \(glucose.trend)") } - func transmitterSession(_ session: TransmitterSession, didReadBackfill glucose: [Glucose]) { + func transmitterSession(_ session: any TransmitterSessioning, didReadBackfill glucose: [Glucose]) { append("Backfill: \(glucose.count) readings") } - func transmitterSession(_ session: TransmitterSession, didReadTransmitterVersion message: TransmitterVersionRxMessage) { + func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterVersion message: TransmitterVersionRxMessage) { append("Version: fw \(message.firmwareVersion.map(String.init).joined(separator: ".")), expiry \(message.transmitterExpiryInDays)d\(message.isAnubis ? " (Anubis)" : "")") } - func transmitterSession(_ session: TransmitterSession, didReadBattery message: BatteryStatusRxMessage) { + func transmitterSession(_ session: any TransmitterSessioning, didReadBattery message: BatteryStatusRxMessage) { append("Battery: A \(Double(message.voltageA)/100)V, B \(Double(message.voltageB)/100)V") } - func transmitterSession(_ session: TransmitterSession, didReadUnknownData data: Data) { + func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterTime activationDate: Date) { + append("Transmitter clock: activation \(activationDate.formatted(date: .abbreviated, time: .standard))") + } + + func transmitterSession(_ session: any TransmitterSessioning, didReadUnknownData data: Data) { append("Unknown data: \(data.map { String(format: "%02x", $0) }.joined())") } - func transmitterSession(_ session: TransmitterSession, didUpdatePeripheralIdentifier identifier: UUID?) { + func transmitterSession(_ session: any TransmitterSessioning, didUpdatePeripheralIdentifier identifier: UUID?) { DispatchQueue.main.async { self.peripheralIdentifier = identifier } append("Peripheral identifier: \(identifier?.uuidString ?? "nil")") } - func transmitterSessionDidRequestBond(_ session: TransmitterSession) { + func transmitterSessionDidRequestBond(_ session: any TransmitterSessioning) { append("⚠️ Accept the iOS pairing prompt within 60 seconds") } // MARK: - TransmitterCommandSource (background queue) - func dequeuePendingCommand(for session: TransmitterSession) -> Command? { + func dequeuePendingCommand(for session: any TransmitterSessioning) -> Command? { var command: Command? DispatchQueue.main.sync { if !pendingCommands.isEmpty { @@ -167,11 +194,11 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran return command } - func transmitterSession(_ session: TransmitterSession, didFail command: Command, with error: Error) { + func transmitterSession(_ session: any TransmitterSessioning, didFail command: Command, with error: Error) { append("Command failed: \(String(describing: command)) — \(error)") } - func transmitterSession(_ session: TransmitterSession, didComplete command: Command, response: TransmitterRxMessage?) { + func transmitterSession(_ session: any TransmitterSessioning, didComplete command: Command, response: TransmitterRxMessage?) { append("Command completed: \(String(describing: command))") } } diff --git a/Apps/G6SensorHarness/Sources/Info.plist b/Apps/G6SensorHarness/Sources/Info.plist new file mode 100644 index 0000000..d244bb4 --- /dev/null +++ b/Apps/G6SensorHarness/Sources/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSBluetoothAlwaysUsageDescription + G6SensorHarness connects to your Dexcom transmitter over Bluetooth to read glucose values. + UIBackgroundModes + + bluetooth-central + + UILaunchScreen + + + diff --git a/Apps/G6SensorHarness/project.yml b/Apps/G6SensorHarness/project.yml index 6c95f82..de16e5d 100644 --- a/Apps/G6SensorHarness/project.yml +++ b/Apps/G6SensorHarness/project.yml @@ -8,7 +8,7 @@ name: G6SensorHarness options: bundleIdPrefix: org.nightscout deploymentTarget: - iOS: "15.0" + iOS: "17.0" settings: TARGETED_DEVICE_FAMILY: "1" SWIFT_VERSION: "5.9" diff --git a/Common/Locked.swift b/Common/Locked.swift index 206b2a5..849328d 100644 --- a/Common/Locked.swift +++ b/Common/Locked.swift @@ -31,6 +31,7 @@ public class Locked { } } + @discardableResult public func mutate(_ changes: (_ value: inout T) -> Void) -> T { os_unfair_lock_lock(&lock) defer { os_unfair_lock_unlock(&lock) } diff --git a/Docs/CGMBLEKitDeviations.md b/Docs/CGMBLEKitDeviations.md index 87073e6..e54c56d 100644 --- a/Docs/CGMBLEKitDeviations.md +++ b/Docs/CGMBLEKitDeviations.md @@ -18,9 +18,10 @@ Nothing in the BLE layer comes from xDrip4iOS. | Ours | From | Changed | |---|---|---| | `Transport/PeripheralManager.swift` | `PeripheralManager.swift` | **0 lines of 471** — byte-identical | -| `Transport/PeripheralManager+G5.swift` | `PeripheralManager+G5.swift` | +61 lines, **0 deletions** — purely additive | -| `Transport/TransmitterConnection.swift` | `BluetoothManager.swift` | 350 → 352 lines; mostly renames, 2 substantive | -| `Session/TransmitterSession.swift` | `Transmitter.swift` | heavily restructured — see below | +| `Transport/PeripheralManager+G5.swift` | `PeripheralManager+G5.swift` | +75 lines, **0 deletions** — purely additive | +| `Transport/TransmitterConnection.swift` | `BluetoothManager.swift` | mostly renames, 3 substantive deviations | +| `Session/TransmitterSession.swift` | `Transmitter.swift` (active path) | heavily restructured — see below | +| `Session/PassiveTransmitterSession.swift` | `Transmitter.swift` (passive path) | clean-room reimplementation — see below | `PeripheralManager.swift` is where the connection intricacies actually live — `runCommand`, the condition list, `commandLock`, the delegate callbacks. It @@ -50,7 +51,7 @@ refreshes displayed text and touches nothing BLE-related. For reference, CGMBLEKit itself does use a `DispatchSourceTimer`, in `TransmitterManager`'s simulated sample generator. That file is not vendored. -## The two deviations in `TransmitterConnection` +## The deviations in `TransmitterConnection` Everything else in that file is a rename (`BluetoothManager` → `TransmitterConnection` and its delegate methods) or added logging. @@ -75,6 +76,14 @@ match can fall back to `CBAdvertisementDataLocalNameKey` when persisted and passed back into `init`, so a known transmitter can be reconnected directly across launches. +**3. Unsolicited control and authentication values reach the delegate.** +CGMBLEKit routes them to `Transmitter`'s passive listener. Here they were +initially dropped (only backfill was forwarded); passive mode re-adds two +delegate callbacks, `didReceiveControlResponse` and +`didReceiveAuthenticationResponse`. Values claimed by the condition +machinery — an active session's own request/response traffic — still never +reach these callbacks. + Also: the configuration constant changed from `.dexcomG5` to `.dexcomG6`, and the authentication-response delegate callback was dropped because auth responses are now awaited inline (below). @@ -131,23 +140,46 @@ left on the characteristic could otherwise satisfy a bare type match. ## The session layer `TransmitterSession` is where the real divergence is, and it is policy rather -than connection handling. +than connection handling. It covers the **active** path only; the passive +path is a separate class (below). -- **Removed:** the passive path entirely. G6SensorKit always owns the - transmitter; it never observes another app's session. Also gone: +- **Removed:** the passive path from this class (it lives in + `PassiveTransmitterSession` now). Also gone: `resumeScanning`, `stopScanning`, `transmitterDidConnect`; `computeHash` moved to `TransmitterID`. - **Added:** `start`/`stop`/`retarget`, `requestBackfill`/`drainBackfill`, `readBatteryStatus`. - **Kept:** `authenticate`, `requestBond`, `enableNotify`, - `listenToCharacteristic`, `sendCommand`, `dequeuePendingCommand`, + `listenToCharacteristic` (moved to `PeripheralManager+G5.swift`, shared + with the passive session), `sendCommand`, `dequeuePendingCommand`, `readGlucose`, `readTimeMessage`, `readCalibrationData`, `readTransmitterVersion`, `disconnect`. -Backfill is a request in our version. In CGMBLEKit backfill arrives as +Backfill is a request in the active session. In CGMBLEKit backfill arrives as notifications and there is no request function, because the Dexcom app drives the session. +## The passive session + +`PassiveTransmitterSession` + `PassiveMessageHandler` reimplement CGMBLEKit's +passive path without its accumulated workarounds: + +- **Subscribing is done once, at connect, for all three characteristics** + (authentication, control, backfill), and subscriptions are never toggled + for the life of the connection. CGMBLEKit subscribes to control only after + observing an authenticated session (commit `dec4ff0`), because toggling + subscriptions mid-exchange confused CoreBluetooth — a workaround this + design sidesteps rather than replicates. If field testing shows missed + traffic, the fallback is to gate the control subscription on the observed + `AuthChallengeRxMessage`, which the handler already parses. +- **No writes exist in scope at all** — no auth, bond, commands, active reads + or disconnect request. CGMBLEKit's passive path shares a class with the + active path, so writes were reachable from it. +- Message handling is a pure type (`PassiveMessageHandler`, no CoreBluetooth) + that turns observed frames into events, so the whole decode path — + time-frame caching, glucose dating, backfill frame reassembly and CRC + validation against the observed acknowledgement — is unit-tested. + ## What came from xDrip4iOS Four things, none of them code, none in the BLE layer: diff --git a/Docs/HostIntegration.md b/Docs/HostIntegration.md index e557db9..b04861a 100644 --- a/Docs/HostIntegration.md +++ b/Docs/HostIntegration.md @@ -86,12 +86,52 @@ building against `LoopKit/LoopKit`. Fork differences belong in `Common/` shims — never in `G6SensorCore`, which imports no LoopKit at all. A CI matrix building against both hosts at pinned SHAs is the only reliable guard. +## Connection modes + +The manager runs in one of two modes, persisted as `passiveModeEnabled` in the +manager state, chosen during onboarding and switchable afterwards under +Transmitter details: + +- **Direct (default)**: G6SensorKit owns the transmitter — authentication, + bonding, session start/stop, calibrations, active backfill requests. The + manufacturer's app must not be connected to the same transmitter. +- **Passive**: G6SensorKit never writes to the transmitter. It subscribes to + the authentication/control/backfill characteristics and decodes the traffic + of the session the manufacturer's app (or another bonded client on the same + phone) drives. Session commands, calibrations, battery reads and backfill + requests are unavailable; `enqueue(_:)` drops commands. + +`providesBLEHeartbeat` is `true` in direct mode and `false` in passive mode: +in passive the manufacturer's app drives the cadence, so the host's own fetch +timer must keep ticking. + +## Migration from CGMBLEKit + +`G6CGMManager.init?(rawState:)` accepts a rawState dictionary persisted by +CGMBLEKit's `TransmitterManagerState` directly. A state is recognized as +CGMBLEKit-shaped when `passiveModeEnabled` and `sensorLifeDays` are both +absent (every G6SensorKit state carries both). Legacy key handling: + +| CGMBLEKit key | Handling | +|---|---| +| `transmitterID` | Required, same key | +| `transmitterStartDate` | Same key | +| `sensorStartOffset` (seconds since activation) | Mapped to `sensorStartDate = transmitterStartDate + offset` | +| `transmitterExpiryInDays` | Same key (Int) | +| `shouldSyncToRemoteService` | Same key | +| *(absent)* | `passiveModeEnabled = true`, `isOnboarded = true` — CGMBLEKit ran passive-only, so migrated users keep exactly the behavior they had and never see onboarding | + +`peripheralIdentifier` starts nil and is learned on first connection, so the +first launch after migration does one name-filtered scan. + ## Safety notes for host maintainers -- G6SensorKit owns the transmitter connection. Users must not run the - manufacturer's app against the same transmitter. -- The manufacturer's app is not watching glucose while this driver is in use, - so high/low alerting is entirely the host's responsibility. Do not ship this - plugin in a host without working glucose alerts. -- `providesBLEHeartbeat` is `true`. In Loop this suppresses the pump's timer - tick, so regressions in BLE wake-up affect loop cadence, not just CGM data. +- In direct mode G6SensorKit owns the transmitter connection. Users must not + run the manufacturer's app against the same transmitter. In passive mode the + manufacturer's app is **required** on the same phone. +- The manufacturer's app is not watching glucose while this driver is in use + in direct mode, so high/low alerting is entirely the host's responsibility. + Do not ship this plugin in a host without working glucose alerts. +- `providesBLEHeartbeat` is `true` in direct mode. In Loop this suppresses + the pump's timer tick, so regressions in BLE wake-up affect loop cadence, + not just CGM data. diff --git a/G6SensorKit.xcodeproj/project.pbxproj b/G6SensorKit.xcodeproj/project.pbxproj index 1fcbec7..7fbbf6a 100644 --- a/G6SensorKit.xcodeproj/project.pbxproj +++ b/G6SensorKit.xcodeproj/project.pbxproj @@ -7,81 +7,82 @@ objects = { /* Begin PBXBuildFile section */ - 0012909C39D62B9E08C9B064 /* G6SensorKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */; }; - 04A2B67E2547E628203F1358 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 429E660585726604E67AECFD /* Localizable.xcstrings */; }; - 04B8053FDEAE43229A81457E /* G6CGMManager+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3CDBC2872D089CCFC608914 /* G6CGMManager+UI.swift */; }; - 1E4F189DD2133923C1AFE92E /* G6OnboardingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A74B38BAA7E148D962602A /* G6OnboardingViews.swift */; }; - 1F73E2F944A55A9AC25A90D2 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */; }; - 20ADEEB69E1914A498A8288C /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E317B6199E7FDA3530EB43 /* LocalizedString.swift */; }; - 2B177BC4AA7B5AC20A0C1CF0 /* G6SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3226DB4412DBC72DFFA90E /* G6SettingsView.swift */; }; - 348D78E88E7AB61A3EA8B5A6 /* G6DetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F3FBE9FF2D0785F9F703D83 /* G6DetailViews.swift */; }; - 3B345FE82B0B128320B404C0 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BE9888BF3A30CE450E970741 /* Localizable.xcstrings */; }; - 3D9CC651E9C531C097620F89 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 253838547314B57604F7AD40 /* OSLog.swift */; }; - 43A3BB61380CE04F8ADF97F5 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */; }; - 43CCBACB96EC5B4551CE5E7A /* G6ReadingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC5A15D4A759F997F7FC541 /* G6ReadingDetailView.swift */; }; - 44180D8F790DF0B7AD6858A6 /* G6CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED4B45A9BCAFD7FD14DE50A /* G6CGMManager.swift */; }; - 5124B64274AC3FE6A2C96D55 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; }; - 55F5DF80BE7C11261D02F92D /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */; }; - 5C0BDAD9F8F46B1894220D75 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = B534C9F9F094DA2E641E1F56 /* HKUnit.swift */; }; - 60CA6A4323498F0AA5733921 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F64DEB4B031D1C5CFE809317 /* Foundation.framework */; }; - 6B46867393D9926E754A0B1C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F64DEB4B031D1C5CFE809317 /* Foundation.framework */; }; - 6B717441841D031D0B28D767 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F64DEB4B031D1C5CFE809317 /* Foundation.framework */; }; - 75FB2E8B0FA7123C2ED8DAE4 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; }; - 80A0E78EA46BE2777673109D /* G6SensorKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 8BB678C1F2BD1D88FA1CFBBC /* G6SimulatedSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74783BF907EE99207B30B578 /* G6SimulatedSession.swift */; }; - 8FF638A85A7E67CA3BC5D9F9 /* G6Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03906F90369E7A2BF4DED672 /* G6Alert.swift */; }; - A69AF218C10E883AEB62C3BD /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */; }; - A81FAA3A42066C630B773A7A /* G6SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - A8E3935009CCE36B2536A64C /* G6ApplySensorViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76466FB9DD611978BC655632 /* G6ApplySensorViews.swift */; }; - A9E3573682AB3B40EC374CA7 /* G6CalibrationGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE94E843AB267DF79DBE2E3 /* G6CalibrationGuideView.swift */; }; - AF6D1CDCC2147D486CC666B0 /* G6AlertEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58664EFC566ECB3D78CCFAF /* G6AlertEvaluator.swift */; }; - B379B66CE81079E346AC7C74 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCBBEB66782BF39C6C015CB /* LocalizedString.swift */; }; - B62C7DB071276B15C354E424 /* G6ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5694A019EAD8454DABAA13 /* G6ActivityViewController.swift */; }; - B920AFAF7C31F1E389D677D6 /* G6UICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB91998500832B7983C5050 /* G6UICoordinator.swift */; }; - B974B8BBB65603583E2AA9B6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D79C9A64B80B4CC5B3DE78 /* HKUnit.swift */; }; - C2A26CF0F1BB14468B041B94 /* G6Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD441CA0C87A3361D4ABC996 /* G6Graphics.swift */; }; - C402E99C66F01260013FBB1A /* G6ShareCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C22FE5054AF7D52948DDD28 /* G6ShareCredentialStore.swift */; }; - CDBF362147EF86005600539C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF491D549C3EF35172BD575E /* Assets.xcassets */; }; - E733B5AF9AC6DDC38B92A330 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */; }; - EB7246F1D6D512CC2D28B53C /* G6CGMManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFDEDB952EC717B78C87578 /* G6CGMManagerState.swift */; }; - F605B5DD6F6B98C7BA18BE26 /* G6SensorKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7985708466B1C12CA524C462 /* G6SensorKitPlugin.swift */; }; - F63FF74AB43CFAC6FA5BB9F1 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2737F7376B1AEBD38191DE68 /* Locked.swift */; }; - FCEF70D7853D669835A2C2A2 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEFF2008F7747472D563331E /* TimeInterval.swift */; }; + 068F65E11AF1419388CBD953 /* G6SensorKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */; }; + 14589281DC7760165D5590E3 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */; }; + 1BFC8B0012423024BCEE84C4 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695593AE5C331A21B37C828 /* Locked.swift */; }; + 226AE1C008E3432034691E58 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAF739DBBA35C93627B2A12C /* Assets.xcassets */; }; + 25B13BDAFD609EC61E01ED42 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CADE8EBD6A8DBD34E5F08E /* LocalizedString.swift */; }; + 2921FB176CBA1E3FACF8C998 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8C69491E1F3F7F01BACF348A /* Localizable.xcstrings */; }; + 2A9D142A81AE191360B058B0 /* G6Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC51720925E26FFF9526D8 /* G6Alert.swift */; }; + 2C77CCC0A4FC873D451B1D9C /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2803D463F94F749B9B4C4480 /* Localizable.xcstrings */; }; + 2E9270FAE984E3B653790956 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */; }; + 332BA57D80F0D8A629C0881A /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F95502372EC14A32117B5F92 /* Foundation.framework */; }; + 3342E51E140EB78C3406DCFC /* G6SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8B421AE8197339D306979939 /* G6SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 39603B3649182CA37CE8237A /* G6CGMManager+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2C05844010D28B27D4E02D /* G6CGMManager+UI.swift */; }; + 3A518597A56D377383B5D384 /* G6CGMManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650CB121B3A64293DDF66491 /* G6CGMManagerState.swift */; }; + 3A7D53148400D95D5EBE9B20 /* G6Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC82866E887A9C466610864B /* G6Graphics.swift */; }; + 3B2B08D9C8ACC49731771581 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */; }; + 3D85A309CDAA70C948DB7D06 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C199CCC759EB31B7459FAF02 /* HKUnit.swift */; }; + 405A216F8D1752F1421D4887 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4847E689C846CC527559717B /* LocalizedString.swift */; }; + 4159FE6F9D1C67AA7D355705 /* G6SimulatedSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFB4FF079E0E059B0A2514C4 /* G6SimulatedSession.swift */; }; + 51AA80EED323BB1051CCF352 /* G6SensorKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5623B9849DDAC97251E7B413 /* G6CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB56C95B7CD81513D98B212 /* G6CGMManager.swift */; }; + 6C6A5725B781F8EC0283A2E3 /* G6ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20A6B1AD620BB43E52CA138 /* G6ActivityViewController.swift */; }; + 77CD64A55933B10835781892 /* G6UICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60451EC9CB88AAED72C7B4C0 /* G6UICoordinator.swift */; }; + 7A17394DCB757B79A35A9401 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F66577CA895DC0539B94DB /* LoopKitUI.framework */; }; + 7B236774AE21607067257858 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B421AE8197339D306979939 /* G6SensorKit.framework */; }; + 81CC3E50E4370774F5954A78 /* G6SensorKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15C3F478B37D28F487EA970 /* G6SensorKitPlugin.swift */; }; + 83FDE18A177B5C10B0504485 /* G6ShareCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9DBE6B77523AE6CF47FE179 /* G6ShareCredentialStore.swift */; }; + 84E98310287C4DAF8D376F5C /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B421AE8197339D306979939 /* G6SensorKit.framework */; }; + 8587D2CC46C9C9CB94B2D3FA /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E067B8007DAFB29BE2F03F48 /* HKUnit.swift */; }; + 85D8C77F66A1FF1AF57A8F17 /* G6ApplySensorViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5C11477C372E423D1838A5 /* G6ApplySensorViews.swift */; }; + 9068DC2E9303B2620EC57AF2 /* G6AlertEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF9D322E96EE881F0910FD /* G6AlertEvaluator.swift */; }; + 9068DD5A9EB5430807D21AE5 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F95502372EC14A32117B5F92 /* Foundation.framework */; }; + 934EFF804E6452CD19F40C04 /* G6CalibrationGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F99764DE6442A33728920B /* G6CalibrationGuideView.swift */; }; + 9D23800E9E278C62602AAF06 /* G6SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76778EA2C3FE465AE8FC8F /* G6SettingsView.swift */; }; + ABC18A66F55CAC750FAAA781 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED9872057F9AB73CCE989CF /* TimeInterval.swift */; }; + CF81E72F011FD1971AA15AA2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F95502372EC14A32117B5F92 /* Foundation.framework */; }; + D48D98C13312034AB8C11BA8 /* G6DetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB63314B843491AD67FAFB8 /* G6DetailViews.swift */; }; + DA3B54D9184C892B5F9A7B96 /* G6OnboardingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCCCBFAEC5AC32DCB17C0E0 /* G6OnboardingViews.swift */; }; + E5928066DDD984FCE765E888 /* G6PackageScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8E7A171900BF409A482908 /* G6PackageScannerView.swift */; }; + EE9148D1635A031059CB0474 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2202DFC274BBEC075ADF5E /* OSLog.swift */; }; + F3BF96B43DC44EAB997C9556 /* G6ReadingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9773F6BB69F46104A715B09 /* G6ReadingDetailView.swift */; }; + F5B695198564172CA054D830 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F66577CA895DC0539B94DB /* LoopKitUI.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 393AD5FC1C4D642198DBF3BA /* PBXContainerItemProxy */ = { + 1CA7CA75216CC94C77001C8A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = 05E8929C0E445F2264A8A045 /* Project object */; proxyType = 1; - remoteGlobalIDString = 3ADDB0CD055A33C01C7B54FE; + remoteGlobalIDString = 8F3461A960A54D2320B21FE4; remoteInfo = G6SensorKitUI; }; - 5DCC41D3B8AE0AF30CAAD2E3 /* PBXContainerItemProxy */ = { + 8C5FBFBE5966E33ED892663C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = 05E8929C0E445F2264A8A045 /* Project object */; proxyType = 1; - remoteGlobalIDString = 1B26058BD31E40741B0058A5; + remoteGlobalIDString = 215CD1147465A2E980A94D63; remoteInfo = G6SensorKit; }; - CC0AE83086E9B970A7244CCE /* PBXContainerItemProxy */ = { + AB91E4CF703C2153542F72B0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = 05E8929C0E445F2264A8A045 /* Project object */; proxyType = 1; - remoteGlobalIDString = 1B26058BD31E40741B0058A5; + remoteGlobalIDString = 215CD1147465A2E980A94D63; remoteInfo = G6SensorKit; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 4FFBC74F2EB40DC4B5ED7514 /* Embed Frameworks */ = { + D0449C47C05DB7A8F1FC65C4 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - A81FAA3A42066C630B773A7A /* G6SensorKit.framework in Embed Frameworks */, - 80A0E78EA46BE2777673109D /* G6SensorKitUI.framework in Embed Frameworks */, + 3342E51E140EB78C3406DCFC /* G6SensorKit.framework in Embed Frameworks */, + 51AA80EED323BB1051CCF352 /* G6SensorKitUI.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -89,194 +90,196 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 03906F90369E7A2BF4DED672 /* G6Alert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6Alert.swift; sourceTree = ""; }; - 0BC5A15D4A759F997F7FC541 /* G6ReadingDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ReadingDetailView.swift; path = Views/G6ReadingDetailView.swift; sourceTree = ""; }; - 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 1DB91998500832B7983C5050 /* G6UICoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6UICoordinator.swift; sourceTree = ""; }; - 253838547314B57604F7AD40 /* OSLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; - 2737F7376B1AEBD38191DE68 /* Locked.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; - 2C5694A019EAD8454DABAA13 /* G6ActivityViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ActivityViewController.swift; path = Views/G6ActivityViewController.swift; sourceTree = ""; }; - 429E660585726604E67AECFD /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; - 48E317B6199E7FDA3530EB43 /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; - 4F3226DB4412DBC72DFFA90E /* G6SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6SettingsView.swift; path = Views/G6SettingsView.swift; sourceTree = ""; }; - 5F3FBE9FF2D0785F9F703D83 /* G6DetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6DetailViews.swift; path = Views/G6DetailViews.swift; sourceTree = ""; }; - 5FCBBEB66782BF39C6C015CB /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; - 6ED4B45A9BCAFD7FD14DE50A /* G6CGMManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManager.swift; sourceTree = ""; }; - 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 74783BF907EE99207B30B578 /* G6SimulatedSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SimulatedSession.swift; sourceTree = ""; }; - 76466FB9DD611978BC655632 /* G6ApplySensorViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ApplySensorViews.swift; path = Views/G6ApplySensorViews.swift; sourceTree = ""; }; - 7985708466B1C12CA524C462 /* G6SensorKitPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SensorKitPlugin.swift; sourceTree = ""; }; - 89D79C9A64B80B4CC5B3DE78 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; - 8C22FE5054AF7D52948DDD28 /* G6ShareCredentialStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6ShareCredentialStore.swift; sourceTree = ""; }; - A9A74B38BAA7E148D962602A /* G6OnboardingViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6OnboardingViews.swift; path = Views/G6OnboardingViews.swift; sourceTree = ""; }; - ADFDEDB952EC717B78C87578 /* G6CGMManagerState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManagerState.swift; sourceTree = ""; }; - B534C9F9F094DA2E641E1F56 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; - B58664EFC566ECB3D78CCFAF /* G6AlertEvaluator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6AlertEvaluator.swift; sourceTree = ""; }; - BE9888BF3A30CE450E970741 /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; - BEFF2008F7747472D563331E /* TimeInterval.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimeInterval.swift; sourceTree = ""; }; - C3CDBC2872D089CCFC608914 /* G6CGMManager+UI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G6CGMManager+UI.swift"; sourceTree = ""; }; - CFE94E843AB267DF79DBE2E3 /* G6CalibrationGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6CalibrationGuideView.swift; path = Views/G6CalibrationGuideView.swift; sourceTree = ""; }; - F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitPlugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F64DEB4B031D1C5CFE809317 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - FD441CA0C87A3361D4ABC996 /* G6Graphics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6Graphics.swift; path = Views/G6Graphics.swift; sourceTree = ""; }; - FF491D549C3EF35172BD575E /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 14F99764DE6442A33728920B /* G6CalibrationGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6CalibrationGuideView.swift; path = Views/G6CalibrationGuideView.swift; sourceTree = ""; }; + 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2803D463F94F749B9B4C4480 /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; + 2AB56C95B7CD81513D98B212 /* G6CGMManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManager.swift; sourceTree = ""; }; + 3695593AE5C331A21B37C828 /* Locked.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; + 3B2202DFC274BBEC075ADF5E /* OSLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + 3C2C05844010D28B27D4E02D /* G6CGMManager+UI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G6CGMManager+UI.swift"; sourceTree = ""; }; + 4847E689C846CC527559717B /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + 54CADE8EBD6A8DBD34E5F08E /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + 5BFF9D322E96EE881F0910FD /* G6AlertEvaluator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6AlertEvaluator.swift; sourceTree = ""; }; + 60451EC9CB88AAED72C7B4C0 /* G6UICoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6UICoordinator.swift; sourceTree = ""; }; + 650CB121B3A64293DDF66491 /* G6CGMManagerState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManagerState.swift; sourceTree = ""; }; + 6BCCCBFAEC5AC32DCB17C0E0 /* G6OnboardingViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6OnboardingViews.swift; path = Views/G6OnboardingViews.swift; sourceTree = ""; }; + 6D5C11477C372E423D1838A5 /* G6ApplySensorViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ApplySensorViews.swift; path = Views/G6ApplySensorViews.swift; sourceTree = ""; }; + 6ED9872057F9AB73CCE989CF /* TimeInterval.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimeInterval.swift; sourceTree = ""; }; + 7F8E7A171900BF409A482908 /* G6PackageScannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6PackageScannerView.swift; path = Views/G6PackageScannerView.swift; sourceTree = ""; }; + 8B421AE8197339D306979939 /* G6SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8C69491E1F3F7F01BACF348A /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; + 93F66577CA895DC0539B94DB /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A15C3F478B37D28F487EA970 /* G6SensorKitPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SensorKitPlugin.swift; sourceTree = ""; }; + A1A4A5B6A02A6007C52230EE /* G6SensorKitPlugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitPlugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AAFC51720925E26FFF9526D8 /* G6Alert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6Alert.swift; sourceTree = ""; }; + ABB63314B843491AD67FAFB8 /* G6DetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6DetailViews.swift; path = Views/G6DetailViews.swift; sourceTree = ""; }; + AFB4FF079E0E059B0A2514C4 /* G6SimulatedSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SimulatedSession.swift; sourceTree = ""; }; + B9DBE6B77523AE6CF47FE179 /* G6ShareCredentialStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6ShareCredentialStore.swift; sourceTree = ""; }; + BC82866E887A9C466610864B /* G6Graphics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6Graphics.swift; path = Views/G6Graphics.swift; sourceTree = ""; }; + C199CCC759EB31B7459FAF02 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + D20A6B1AD620BB43E52CA138 /* G6ActivityViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ActivityViewController.swift; path = Views/G6ActivityViewController.swift; sourceTree = ""; }; + DAF739DBBA35C93627B2A12C /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DB76778EA2C3FE465AE8FC8F /* G6SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6SettingsView.swift; path = Views/G6SettingsView.swift; sourceTree = ""; }; + E067B8007DAFB29BE2F03F48 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + E9773F6BB69F46104A715B09 /* G6ReadingDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ReadingDetailView.swift; path = Views/G6ReadingDetailView.swift; sourceTree = ""; }; + F95502372EC14A32117B5F92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 08CFF0A2C0AF7A01359571D3 /* Frameworks */ = { + 29459DDF79C2F6E84E026198 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6B46867393D9926E754A0B1C /* Foundation.framework in Frameworks */, - A69AF218C10E883AEB62C3BD /* LoopKit.framework in Frameworks */, - 55F5DF80BE7C11261D02F92D /* LoopKitUI.framework in Frameworks */, - 5124B64274AC3FE6A2C96D55 /* G6SensorKit.framework in Frameworks */, - 0012909C39D62B9E08C9B064 /* G6SensorKitUI.framework in Frameworks */, + 332BA57D80F0D8A629C0881A /* Foundation.framework in Frameworks */, + 3B2B08D9C8ACC49731771581 /* LoopKit.framework in Frameworks */, + F5B695198564172CA054D830 /* LoopKitUI.framework in Frameworks */, + 84E98310287C4DAF8D376F5C /* G6SensorKit.framework in Frameworks */, + 068F65E11AF1419388CBD953 /* G6SensorKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 4BBD731E15A4EBB02214C814 /* Frameworks */ = { + 7892D299DD1C4456611A5D26 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 60CA6A4323498F0AA5733921 /* Foundation.framework in Frameworks */, - E733B5AF9AC6DDC38B92A330 /* LoopKit.framework in Frameworks */, + CF81E72F011FD1971AA15AA2 /* Foundation.framework in Frameworks */, + 2E9270FAE984E3B653790956 /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 7034F2C68438154861EB8766 /* Frameworks */ = { + CC4E9DF835B45B2D2994E5A0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6B717441841D031D0B28D767 /* Foundation.framework in Frameworks */, - 1F73E2F944A55A9AC25A90D2 /* LoopKit.framework in Frameworks */, - 43A3BB61380CE04F8ADF97F5 /* LoopKitUI.framework in Frameworks */, - 75FB2E8B0FA7123C2ED8DAE4 /* G6SensorKit.framework in Frameworks */, + 9068DD5A9EB5430807D21AE5 /* Foundation.framework in Frameworks */, + 14589281DC7760165D5590E3 /* LoopKit.framework in Frameworks */, + 7A17394DCB757B79A35A9401 /* LoopKitUI.framework in Frameworks */, + 7B236774AE21607067257858 /* G6SensorKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0B2EBDB688CC9F72D51B8750 /* Products */ = { + 35E638A87A48A6CBC246BF6B /* Products */ = { isa = PBXGroup; children = ( - F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */, - 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */, - F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */, + 8B421AE8197339D306979939 /* G6SensorKit.framework */, + 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */, + A1A4A5B6A02A6007C52230EE /* G6SensorKitPlugin.framework */, ); name = Products; sourceTree = ""; }; - 1DCBFFA30E3C8A0058AA3F9E /* Common */ = { + 7CAD88E09B3DB8C98B60678D /* iOS */ = { isa = PBXGroup; children = ( - 89D79C9A64B80B4CC5B3DE78 /* HKUnit.swift */, - 48E317B6199E7FDA3530EB43 /* LocalizedString.swift */, - 2737F7376B1AEBD38191DE68 /* Locked.swift */, - 253838547314B57604F7AD40 /* OSLog.swift */, - BEFF2008F7747472D563331E /* TimeInterval.swift */, - B534C9F9F094DA2E641E1F56 /* HKUnit.swift */, + F95502372EC14A32117B5F92 /* Foundation.framework */, ); - name = Common; - path = Common; + name = iOS; sourceTree = ""; }; - 473EA52459AC5B5F384D87B9 /* iOS */ = { + 82F360FB55C90EC211EF8CA1 /* Common */ = { isa = PBXGroup; children = ( - F64DEB4B031D1C5CFE809317 /* Foundation.framework */, + C199CCC759EB31B7459FAF02 /* HKUnit.swift */, + 54CADE8EBD6A8DBD34E5F08E /* LocalizedString.swift */, + 3695593AE5C331A21B37C828 /* Locked.swift */, + 3B2202DFC274BBEC075ADF5E /* OSLog.swift */, + 6ED9872057F9AB73CCE989CF /* TimeInterval.swift */, + E067B8007DAFB29BE2F03F48 /* HKUnit.swift */, ); - name = iOS; + name = Common; + path = Common; sourceTree = ""; }; - 96B57708CA5A86D6435C7B27 /* Frameworks */ = { + B59CE774003B80A203AAC25E /* G6SensorKitUI */ = { isa = PBXGroup; children = ( - F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */, - 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */, - 473EA52459AC5B5F384D87B9 /* iOS */, + 3C2C05844010D28B27D4E02D /* G6CGMManager+UI.swift */, + 60451EC9CB88AAED72C7B4C0 /* G6UICoordinator.swift */, + 4847E689C846CC527559717B /* LocalizedString.swift */, + D20A6B1AD620BB43E52CA138 /* G6ActivityViewController.swift */, + 6D5C11477C372E423D1838A5 /* G6ApplySensorViews.swift */, + 14F99764DE6442A33728920B /* G6CalibrationGuideView.swift */, + ABB63314B843491AD67FAFB8 /* G6DetailViews.swift */, + BC82866E887A9C466610864B /* G6Graphics.swift */, + 6BCCCBFAEC5AC32DCB17C0E0 /* G6OnboardingViews.swift */, + 7F8E7A171900BF409A482908 /* G6PackageScannerView.swift */, + E9773F6BB69F46104A715B09 /* G6ReadingDetailView.swift */, + DB76778EA2C3FE465AE8FC8F /* G6SettingsView.swift */, + 8C69491E1F3F7F01BACF348A /* Localizable.xcstrings */, + DAF739DBBA35C93627B2A12C /* Assets.xcassets */, ); - name = Frameworks; + name = G6SensorKitUI; + path = G6SensorKitUI; sourceTree = ""; }; - BC0A237992092A7C2BE9B0C1 /* G6SensorKit */ = { + BF936AEBB1ADE9BD907B2665 /* G6SensorKit */ = { isa = PBXGroup; children = ( - 03906F90369E7A2BF4DED672 /* G6Alert.swift */, - B58664EFC566ECB3D78CCFAF /* G6AlertEvaluator.swift */, - 6ED4B45A9BCAFD7FD14DE50A /* G6CGMManager.swift */, - ADFDEDB952EC717B78C87578 /* G6CGMManagerState.swift */, - 8C22FE5054AF7D52948DDD28 /* G6ShareCredentialStore.swift */, - 74783BF907EE99207B30B578 /* G6SimulatedSession.swift */, - BE9888BF3A30CE450E970741 /* Localizable.xcstrings */, + AAFC51720925E26FFF9526D8 /* G6Alert.swift */, + 5BFF9D322E96EE881F0910FD /* G6AlertEvaluator.swift */, + 2AB56C95B7CD81513D98B212 /* G6CGMManager.swift */, + 650CB121B3A64293DDF66491 /* G6CGMManagerState.swift */, + B9DBE6B77523AE6CF47FE179 /* G6ShareCredentialStore.swift */, + AFB4FF079E0E059B0A2514C4 /* G6SimulatedSession.swift */, + 2803D463F94F749B9B4C4480 /* Localizable.xcstrings */, ); name = G6SensorKit; path = G6SensorKit; sourceTree = ""; }; - C15E860FAD47DBE3D41E4EC3 /* G6SensorKitPlugin */ = { + C8A84B9B66E84F3EA74CEF1D /* Frameworks */ = { isa = PBXGroup; children = ( - 7985708466B1C12CA524C462 /* G6SensorKitPlugin.swift */, + 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */, + 93F66577CA895DC0539B94DB /* LoopKitUI.framework */, + 7CAD88E09B3DB8C98B60678D /* iOS */, ); - name = G6SensorKitPlugin; - path = G6SensorKitPlugin; + name = Frameworks; sourceTree = ""; }; - CC50F2BDBEBFB7D6BD059422 /* G6SensorKitUI */ = { + CC1502118BD5CBA5F06AC98B /* G6SensorKitPlugin */ = { isa = PBXGroup; children = ( - C3CDBC2872D089CCFC608914 /* G6CGMManager+UI.swift */, - 1DB91998500832B7983C5050 /* G6UICoordinator.swift */, - 5FCBBEB66782BF39C6C015CB /* LocalizedString.swift */, - 2C5694A019EAD8454DABAA13 /* G6ActivityViewController.swift */, - 76466FB9DD611978BC655632 /* G6ApplySensorViews.swift */, - CFE94E843AB267DF79DBE2E3 /* G6CalibrationGuideView.swift */, - 5F3FBE9FF2D0785F9F703D83 /* G6DetailViews.swift */, - FD441CA0C87A3361D4ABC996 /* G6Graphics.swift */, - A9A74B38BAA7E148D962602A /* G6OnboardingViews.swift */, - 0BC5A15D4A759F997F7FC541 /* G6ReadingDetailView.swift */, - 4F3226DB4412DBC72DFFA90E /* G6SettingsView.swift */, - 429E660585726604E67AECFD /* Localizable.xcstrings */, - FF491D549C3EF35172BD575E /* Assets.xcassets */, + A15C3F478B37D28F487EA970 /* G6SensorKitPlugin.swift */, ); - name = G6SensorKitUI; - path = G6SensorKitUI; + name = G6SensorKitPlugin; + path = G6SensorKitPlugin; sourceTree = ""; }; - FA22E5F750CC96151439CB10 = { + D96C91ED9D8FC6BFCEDD36AC = { isa = PBXGroup; children = ( - BC0A237992092A7C2BE9B0C1 /* G6SensorKit */, - 1DCBFFA30E3C8A0058AA3F9E /* Common */, - CC50F2BDBEBFB7D6BD059422 /* G6SensorKitUI */, - C15E860FAD47DBE3D41E4EC3 /* G6SensorKitPlugin */, - 0B2EBDB688CC9F72D51B8750 /* Products */, - 96B57708CA5A86D6435C7B27 /* Frameworks */, + BF936AEBB1ADE9BD907B2665 /* G6SensorKit */, + 82F360FB55C90EC211EF8CA1 /* Common */, + B59CE774003B80A203AAC25E /* G6SensorKitUI */, + CC1502118BD5CBA5F06AC98B /* G6SensorKitPlugin */, + 35E638A87A48A6CBC246BF6B /* Products */, + C8A84B9B66E84F3EA74CEF1D /* Frameworks */, ); sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ - 46CF0A3AEA3068EC92793F50 /* Headers */ = { + 07EAC52738A519CDC1D4C8D4 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 8871D9EC41336D32DC93F0A1 /* Headers */ = { + 7ADBA84EA66B2BBAFD4A6288 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - D0DCBEE1011D380E0DAB9521 /* Headers */ = { + FF4AC7DA0EA20F2B20BE3902 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( @@ -286,14 +289,14 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ - 1B26058BD31E40741B0058A5 /* G6SensorKit */ = { + 215CD1147465A2E980A94D63 /* G6SensorKit */ = { isa = PBXNativeTarget; - buildConfigurationList = 3C02D248834BA3228A2A0985 /* Build configuration list for PBXNativeTarget "G6SensorKit" */; + buildConfigurationList = DD280BDB51D34244226439F4 /* Build configuration list for PBXNativeTarget "G6SensorKit" */; buildPhases = ( - D0DCBEE1011D380E0DAB9521 /* Headers */, - 8BC23E60728EBBC538BC019C /* Sources */, - 4BBD731E15A4EBB02214C814 /* Frameworks */, - 840346A90686729F6FA69249 /* Resources */, + 7ADBA84EA66B2BBAFD4A6288 /* Headers */, + CF5459A40A7FD204384A3827 /* Sources */, + 7892D299DD1C4456611A5D26 /* Frameworks */, + BBB2CD01CD25C8772A7D6484 /* Resources */, ); buildRules = ( ); @@ -301,62 +304,62 @@ ); name = G6SensorKit; packageProductDependencies = ( - 7B648B7A7143DAA0CE2F4FF9 /* G6SensorCore */, + 0EC90A7F507FA56DA0FAAC1D /* G6SensorCore */, ); productName = G6SensorKit; - productReference = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; + productReference = 8B421AE8197339D306979939 /* G6SensorKit.framework */; productType = "com.apple.product-type.framework"; }; - 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */ = { + 8F3461A960A54D2320B21FE4 /* G6SensorKitUI */ = { isa = PBXNativeTarget; - buildConfigurationList = 0871B09F7297CFA1E6F3ADCB /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */; + buildConfigurationList = 072C8C59C81653E06FA00EB4 /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */; buildPhases = ( - 8871D9EC41336D32DC93F0A1 /* Headers */, - 488FB4793D06FDDE73E64813 /* Sources */, - 7034F2C68438154861EB8766 /* Frameworks */, - FF4CE3D3A177620D8B97B7A2 /* Resources */, + 07EAC52738A519CDC1D4C8D4 /* Headers */, + 30F8F65AC6C7D52F848495B9 /* Sources */, + CC4E9DF835B45B2D2994E5A0 /* Frameworks */, + 6D20D7C6745342F8C65404B0 /* Resources */, ); buildRules = ( ); dependencies = ( - 17B55B286FE0A660013A668D /* PBXTargetDependency */, + 2B67866B45ABC4CCDF08A03B /* PBXTargetDependency */, ); name = G6SensorKitUI; productName = G6SensorKitUI; - productReference = 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */; + productReference = 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */; productType = "com.apple.product-type.framework"; }; - EDCCBEADE99E54A732DD551F /* G6SensorKitPlugin */ = { + D09D95523AA824744122C1CB /* G6SensorKitPlugin */ = { isa = PBXNativeTarget; - buildConfigurationList = 5C9202DE59969D7F24D99477 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */; + buildConfigurationList = FCDA3AF29E0740002157A96C /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */; buildPhases = ( - 46CF0A3AEA3068EC92793F50 /* Headers */, - A4FF0114F6295250522E88B0 /* Sources */, - 08CFF0A2C0AF7A01359571D3 /* Frameworks */, - BD1DA6A29C055EAE3F534D12 /* Resources */, - 4FFBC74F2EB40DC4B5ED7514 /* Embed Frameworks */, + FF4AC7DA0EA20F2B20BE3902 /* Headers */, + D91BC325094ACAC7D108B6A0 /* Sources */, + 29459DDF79C2F6E84E026198 /* Frameworks */, + BE615E0FBD1840B4E6C34D02 /* Resources */, + D0449C47C05DB7A8F1FC65C4 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( - 07F9FA8A5643278CD0C1E3DC /* PBXTargetDependency */, - 53F05EC0188A4EBC5A8F5A4F /* PBXTargetDependency */, + 627FBE52365C3C03931C7886 /* PBXTargetDependency */, + F6FFF5B09BC08B51699968C7 /* PBXTargetDependency */, ); name = G6SensorKitPlugin; productName = G6SensorKitPlugin; - productReference = F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */; + productReference = A1A4A5B6A02A6007C52230EE /* G6SensorKitPlugin.framework */; productType = "com.apple.product-type.framework"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - 9254155A3D36B6309735ED9B /* Project object */ = { + 05E8929C0E445F2264A8A045 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; }; - buildConfigurationList = 1F831DF837A28B0445B1871F /* Build configuration list for PBXProject "G6SensorKit" */; + buildConfigurationList = 8B609048B49027655DD83E61 /* Build configuration list for PBXProject "G6SensorKit" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; @@ -364,121 +367,122 @@ en, Base, ); - mainGroup = FA22E5F750CC96151439CB10; + mainGroup = D96C91ED9D8FC6BFCEDD36AC; minimizedProjectReferenceProxies = 0; packageReferences = ( - 17A8E591A82F95E6476AA1C9 /* XCLocalSwiftPackageReference "." */, + 222FDCD3CB726F70DF5E5FEA /* XCLocalSwiftPackageReference "." */, ); - preferredProjectObjectVersion = 77; - productRefGroup = 0B2EBDB688CC9F72D51B8750 /* Products */; + preferredProjectObjectVersion = 100; + productRefGroup = 35E638A87A48A6CBC246BF6B /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 1B26058BD31E40741B0058A5 /* G6SensorKit */, - 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */, - EDCCBEADE99E54A732DD551F /* G6SensorKitPlugin */, + 215CD1147465A2E980A94D63 /* G6SensorKit */, + 8F3461A960A54D2320B21FE4 /* G6SensorKitUI */, + D09D95523AA824744122C1CB /* G6SensorKitPlugin */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 840346A90686729F6FA69249 /* Resources */ = { + 6D20D7C6745342F8C65404B0 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3B345FE82B0B128320B404C0 /* Localizable.xcstrings in Resources */, + 2921FB176CBA1E3FACF8C998 /* Localizable.xcstrings in Resources */, + 226AE1C008E3432034691E58 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - BD1DA6A29C055EAE3F534D12 /* Resources */ = { + BBB2CD01CD25C8772A7D6484 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2C77CCC0A4FC873D451B1D9C /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - FF4CE3D3A177620D8B97B7A2 /* Resources */ = { + BE615E0FBD1840B4E6C34D02 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 04A2B67E2547E628203F1358 /* Localizable.xcstrings in Resources */, - CDBF362147EF86005600539C /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 488FB4793D06FDDE73E64813 /* Sources */ = { + 30F8F65AC6C7D52F848495B9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 04B8053FDEAE43229A81457E /* G6CGMManager+UI.swift in Sources */, - B920AFAF7C31F1E389D677D6 /* G6UICoordinator.swift in Sources */, - B379B66CE81079E346AC7C74 /* LocalizedString.swift in Sources */, - B62C7DB071276B15C354E424 /* G6ActivityViewController.swift in Sources */, - A8E3935009CCE36B2536A64C /* G6ApplySensorViews.swift in Sources */, - A9E3573682AB3B40EC374CA7 /* G6CalibrationGuideView.swift in Sources */, - 348D78E88E7AB61A3EA8B5A6 /* G6DetailViews.swift in Sources */, - C2A26CF0F1BB14468B041B94 /* G6Graphics.swift in Sources */, - 1E4F189DD2133923C1AFE92E /* G6OnboardingViews.swift in Sources */, - 43CCBACB96EC5B4551CE5E7A /* G6ReadingDetailView.swift in Sources */, - 2B177BC4AA7B5AC20A0C1CF0 /* G6SettingsView.swift in Sources */, - 5C0BDAD9F8F46B1894220D75 /* HKUnit.swift in Sources */, + 39603B3649182CA37CE8237A /* G6CGMManager+UI.swift in Sources */, + 77CD64A55933B10835781892 /* G6UICoordinator.swift in Sources */, + 405A216F8D1752F1421D4887 /* LocalizedString.swift in Sources */, + 6C6A5725B781F8EC0283A2E3 /* G6ActivityViewController.swift in Sources */, + 85D8C77F66A1FF1AF57A8F17 /* G6ApplySensorViews.swift in Sources */, + 934EFF804E6452CD19F40C04 /* G6CalibrationGuideView.swift in Sources */, + D48D98C13312034AB8C11BA8 /* G6DetailViews.swift in Sources */, + 3A7D53148400D95D5EBE9B20 /* G6Graphics.swift in Sources */, + DA3B54D9184C892B5F9A7B96 /* G6OnboardingViews.swift in Sources */, + E5928066DDD984FCE765E888 /* G6PackageScannerView.swift in Sources */, + F3BF96B43DC44EAB997C9556 /* G6ReadingDetailView.swift in Sources */, + 9D23800E9E278C62602AAF06 /* G6SettingsView.swift in Sources */, + 8587D2CC46C9C9CB94B2D3FA /* HKUnit.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 8BC23E60728EBBC538BC019C /* Sources */ = { + CF5459A40A7FD204384A3827 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8FF638A85A7E67CA3BC5D9F9 /* G6Alert.swift in Sources */, - AF6D1CDCC2147D486CC666B0 /* G6AlertEvaluator.swift in Sources */, - 44180D8F790DF0B7AD6858A6 /* G6CGMManager.swift in Sources */, - EB7246F1D6D512CC2D28B53C /* G6CGMManagerState.swift in Sources */, - C402E99C66F01260013FBB1A /* G6ShareCredentialStore.swift in Sources */, - 8BB678C1F2BD1D88FA1CFBBC /* G6SimulatedSession.swift in Sources */, - B974B8BBB65603583E2AA9B6 /* HKUnit.swift in Sources */, - 20ADEEB69E1914A498A8288C /* LocalizedString.swift in Sources */, - F63FF74AB43CFAC6FA5BB9F1 /* Locked.swift in Sources */, - 3D9CC651E9C531C097620F89 /* OSLog.swift in Sources */, - FCEF70D7853D669835A2C2A2 /* TimeInterval.swift in Sources */, + 2A9D142A81AE191360B058B0 /* G6Alert.swift in Sources */, + 9068DC2E9303B2620EC57AF2 /* G6AlertEvaluator.swift in Sources */, + 5623B9849DDAC97251E7B413 /* G6CGMManager.swift in Sources */, + 3A518597A56D377383B5D384 /* G6CGMManagerState.swift in Sources */, + 83FDE18A177B5C10B0504485 /* G6ShareCredentialStore.swift in Sources */, + 4159FE6F9D1C67AA7D355705 /* G6SimulatedSession.swift in Sources */, + 3D85A309CDAA70C948DB7D06 /* HKUnit.swift in Sources */, + 25B13BDAFD609EC61E01ED42 /* LocalizedString.swift in Sources */, + 1BFC8B0012423024BCEE84C4 /* Locked.swift in Sources */, + EE9148D1635A031059CB0474 /* OSLog.swift in Sources */, + ABC18A66F55CAC750FAAA781 /* TimeInterval.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - A4FF0114F6295250522E88B0 /* Sources */ = { + D91BC325094ACAC7D108B6A0 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F605B5DD6F6B98C7BA18BE26 /* G6SensorKitPlugin.swift in Sources */, + 81CC3E50E4370774F5954A78 /* G6SensorKitPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 07F9FA8A5643278CD0C1E3DC /* PBXTargetDependency */ = { + 2B67866B45ABC4CCDF08A03B /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKit; - target = 1B26058BD31E40741B0058A5 /* G6SensorKit */; - targetProxy = 5DCC41D3B8AE0AF30CAAD2E3 /* PBXContainerItemProxy */; + target = 215CD1147465A2E980A94D63 /* G6SensorKit */; + targetProxy = 8C5FBFBE5966E33ED892663C /* PBXContainerItemProxy */; }; - 17B55B286FE0A660013A668D /* PBXTargetDependency */ = { + 627FBE52365C3C03931C7886 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKit; - target = 1B26058BD31E40741B0058A5 /* G6SensorKit */; - targetProxy = CC0AE83086E9B970A7244CCE /* PBXContainerItemProxy */; + target = 215CD1147465A2E980A94D63 /* G6SensorKit */; + targetProxy = AB91E4CF703C2153542F72B0 /* PBXContainerItemProxy */; }; - 53F05EC0188A4EBC5A8F5A4F /* PBXTargetDependency */ = { + F6FFF5B09BC08B51699968C7 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKitUI; - target = 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */; - targetProxy = 393AD5FC1C4D642198DBF3BA /* PBXContainerItemProxy */; + target = 8F3461A960A54D2320B21FE4 /* G6SensorKitUI */; + targetProxy = 1CA7CA75216CC94C77001C8A /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 000A188CB4AC3C534F7DEDA5 /* Debug */ = { + 3A87E12D403C48B6C4E16BCA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -498,7 +502,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = G6SensorKitPlugin/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; @@ -508,13 +512,14 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; WRAPPER_EXTENSION = loopplugin; }; - name = Debug; + name = Release; }; - 0A0532B628C24522B116C2FD /* Release */ = { + 3B66BBA892E68EDF726B4480 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -548,29 +553,35 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; + DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; - MTL_ENABLE_DEBUG_INFO = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.9; - VALIDATE_PRODUCT = YES; }; - name = Release; + name = Debug; }; - 1AE1D80C0367AD875A928588 /* Release */ = { + 6965477C499968CA83551725 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -587,87 +598,24 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKitUI/Info.plist; + INFOPLIST_FILE = G6SensorKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKit; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; - VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; - name = Release; - }; - 222C333070D0F8651A5DDC09 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.9; - }; name = Debug; }; - 98F6F8109B911D276CB9A52E /* Debug */ = { + 6EC800F9252B079EBE43E8D0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -684,24 +632,25 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKit/Info.plist; + INFOPLIST_FILE = G6SensorKitUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKit; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; - name = Debug; + name = Release; }; - D9853CD9CF2489E939E9B1B7 /* Debug */ = { + 9342504C39FA6A5C36B3F153 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -718,24 +667,25 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKitUI/Info.plist; + INFOPLIST_FILE = G6SensorKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKit; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; - name = Debug; + name = Release; }; - F5F1150AA306D577B24CACB2 /* Release */ = { + C2EF29AC766E13A496E2D70A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -743,7 +693,7 @@ CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = NO; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; + DEFINES_MODULE = NO; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -752,25 +702,82 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKit/Info.plist; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = G6SensorKitPlugin/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKit; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; - VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WRAPPER_EXTENSION = loopplugin; + }; + name = Debug; + }; + E1A1853C1186E503AB2BC172 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + VALIDATE_PRODUCT = YES; }; name = Release; }; - FC3AF832A0B14A9957D5966C /* Release */ = { + F3ADA5588B68CDB0DC02C9F6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -778,7 +785,7 @@ CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = NO; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = NO; + DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -787,61 +794,58 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = G6SensorKitPlugin/Info.plist; + INFOPLIST_FILE = G6SensorKitUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; - VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WRAPPER_EXTENSION = loopplugin; }; - name = Release; + name = Debug; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0871B09F7297CFA1E6F3ADCB /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */ = { + 072C8C59C81653E06FA00EB4 /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1AE1D80C0367AD875A928588 /* Release */, - D9853CD9CF2489E939E9B1B7 /* Debug */, + 6EC800F9252B079EBE43E8D0 /* Release */, + F3ADA5588B68CDB0DC02C9F6 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 1F831DF837A28B0445B1871F /* Build configuration list for PBXProject "G6SensorKit" */ = { + 8B609048B49027655DD83E61 /* Build configuration list for PBXProject "G6SensorKit" */ = { isa = XCConfigurationList; buildConfigurations = ( - 222C333070D0F8651A5DDC09 /* Debug */, - 0A0532B628C24522B116C2FD /* Release */, + 3B66BBA892E68EDF726B4480 /* Debug */, + E1A1853C1186E503AB2BC172 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 3C02D248834BA3228A2A0985 /* Build configuration list for PBXNativeTarget "G6SensorKit" */ = { + DD280BDB51D34244226439F4 /* Build configuration list for PBXNativeTarget "G6SensorKit" */ = { isa = XCConfigurationList; buildConfigurations = ( - F5F1150AA306D577B24CACB2 /* Release */, - 98F6F8109B911D276CB9A52E /* Debug */, + 9342504C39FA6A5C36B3F153 /* Release */, + 6965477C499968CA83551725 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5C9202DE59969D7F24D99477 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */ = { + FCDA3AF29E0740002157A96C /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( - FC3AF832A0B14A9957D5966C /* Release */, - 000A188CB4AC3C534F7DEDA5 /* Debug */, + 3A87E12D403C48B6C4E16BCA /* Release */, + C2EF29AC766E13A496E2D70A /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -849,18 +853,18 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 17A8E591A82F95E6476AA1C9 /* XCLocalSwiftPackageReference "." */ = { + 222FDCD3CB726F70DF5E5FEA /* XCLocalSwiftPackageReference "." */ = { isa = XCLocalSwiftPackageReference; relativePath = .; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 7B648B7A7143DAA0CE2F4FF9 /* G6SensorCore */ = { + 0EC90A7F507FA56DA0FAAC1D /* G6SensorCore */ = { isa = XCSwiftPackageProductDependency; productName = G6SensorCore; }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = 9254155A3D36B6309735ED9B /* Project object */; + rootObject = 05E8929C0E445F2264A8A045 /* Project object */; } diff --git a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme index 6c3c107..2a070a1 100644 --- a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme +++ b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> diff --git a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme index 7fedca5..aacd34c 100644 --- a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme +++ b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> diff --git a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme index ad990c8..1c91f21 100644 --- a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme +++ b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> diff --git a/G6SensorKit/G6CGMManager.swift b/G6SensorKit/G6CGMManager.swift index 93d4516..c84270c 100644 --- a/G6SensorKit/G6CGMManager.swift +++ b/G6SensorKit/G6CGMManager.swift @@ -4,11 +4,11 @@ // // Copyright © 2026 Nightscout Foundation. MIT License. // -// LoopKit CGMManager conformance over G6SensorCore's owned, active -// transmitter session. Unlike CGMBLEKit (which in Loop always piggybacks -// the Dexcom app's session) this manager owns the transmitter: it -// authenticates, starts and stops sessions, sends calibrations, and -// requests backfill. +// LoopKit CGMManager conformance over G6SensorCore's transmitter sessions. +// Two modes: direct (the manager owns the transmitter — it authenticates, +// starts and stops sessions, sends calibrations, requests backfill) and +// passive (the manager observes the session another client, such as the +// Dexcom app, drives — like CGMBLEKit). // import Foundation @@ -66,12 +66,10 @@ public final class G6CGMManager: CGMManager { private let lockedState: Locked - public private(set) var session: TransmitterSession? + public private(set) var session: (any TransmitterSessioning)? /// Commands queued for the next connection cycle. - private let lockedPendingCommands: Locked<[Command]> = Locked([]) - - private var lastBatteryReadDate: Date? + private let lockedCommandQueue: Locked = Locked(CommandQueue()) /// Response code from a session start awaiting confirmation. The reply /// alone does not say whether a session began, so it is held until the @@ -134,7 +132,7 @@ public final class G6CGMManager: CGMManager { public init(state: G6CGMManagerState) { self.lockedState = Locked(state) - lockedPendingCommands.mutate { $0 = state.pendingCommands.compactMap(Command.init(rawValue:)) } + lockedCommandQueue.mutate { $0 = CommandQueue(rawValues: state.pendingCommands) } startSession() } @@ -151,7 +149,7 @@ public final class G6CGMManager: CGMManager { } self.lockedState = Locked(state) - lockedPendingCommands.mutate { $0 = state.pendingCommands.compactMap(Command.init(rawValue:)) } + lockedCommandQueue.mutate { $0 = CommandQueue(rawValues: state.pendingCommands) } startSession() } @@ -188,6 +186,7 @@ public final class G6CGMManager: CGMManager { "## G6CGMManager", "transmitterID: \(state.transmitterID)", "model: \(state.deviceModel)", + "mode: \(state.passiveModeEnabled ? "passive" : "direct")", "firmware: \(state.firmwareVersion ?? "unknown")", "isAnubis: \(state.isAnubis)", "sensorStartDate: \(String(describing: state.sensorStartDate))", @@ -289,9 +288,19 @@ public final class G6CGMManager: CGMManager { return #else connectionPhase = .searching - let session = TransmitterSession(id: state.transmitterID, peripheralIdentifier: state.peripheralIdentifier) + let session: any TransmitterSessioning + if state.passiveModeEnabled { + session = PassiveTransmitterSession( + id: state.transmitterID, + peripheralIdentifier: state.peripheralIdentifier, + activationDate: state.transmitterStartDate + ) + } else { + let active = TransmitterSession(id: state.transmitterID, peripheralIdentifier: state.peripheralIdentifier) + active.commandSource = self + session = active + } session.delegate = self - session.commandSource = self self.session = session session.start() #endif @@ -299,16 +308,21 @@ public final class G6CGMManager: CGMManager { /// Queues a device command for the next connection cycle. public func enqueue(_ command: Command) { + // A passive session never writes, so a command could never be + // delivered. The UI hides the command rows in passive mode; this is + // the backstop. + guard !state.passiveModeEnabled else { + log.error("Dropping command %{public}@: passive mode cannot write to the transmitter", String(describing: command)) + return + } + // Newest wins. Repeated trips through setup used to stack session // starts — seven went out in one connection — and because the queue // is persisted, a stale code-less start could reach the transmitter // ahead of the one carrying the sensor code the user actually typed. var superseded = 0 - lockedPendingCommands.mutate { commands in - let before = commands.count - commands.removeAll { $0.supersededBy(command) } - superseded = before - commands.count - commands.append(command) + lockedCommandQueue.mutate { queue in + superseded = queue.enqueue(command) } if superseded > 0 { @@ -316,10 +330,7 @@ public final class G6CGMManager: CGMManager { } mutateState { state in - state.pendingCommands.removeAll { raw in - Command(rawValue: raw).map { $0.supersededBy(command) } ?? true - } - state.pendingCommands.append(command.rawValue) + state.pendingCommands = self.lockedCommandQueue.value.rawValues // Reflect the entered code straight away. Waiting until the // transmitter answered meant the screen said "Not used" for as @@ -332,6 +343,38 @@ public final class G6CGMManager: CGMManager { // Nudge the connection in case it is idle. session?.start() + + #if targetEnvironment(simulator) + // No link will ever drain the queue in the simulator; apply the + // command to state directly, mirroring didComplete on hardware, + // so End Sensor / Start New Sensor work end-to-end there. + lockedCommandQueue.mutate { _ = $0.dequeue() } + if case .stopSensor = command, state.sensorStartDate != nil { + let sensorEnded = PersistedCgmEvent( + date: Date(), + type: .sensorEnd, + deviceIdentifier: state.transmitterID, + failureMessage: LocalizedString("Stopped by user", comment: "Reason recorded when a session ends because the user stopped it") + ) + delegate.notify { delegate in + delegate?.cgmManager(self, hasNew: [sensorEnded]) + } + } + mutateState { state in + state.pendingCommands = self.lockedCommandQueue.value.rawValues + switch command { + case .stopSensor: + state.sensorStartDate = nil + state.sensorCode = nil + case .startSensor(let date, let sensorCode): + state.sensorCode = sensorCode.carriesParameters ? sensorCode.code : nil + state.sensorStartDate = date + .addingTimeInterval(-(state.warmupPeriod - G6CGMManager.simulatedWarmupRemaining)) + case .calibrateSensor, .resetTransmitter: + break + } + } + #endif } public func setSensorLifeDays(_ days: Int) { @@ -352,6 +395,33 @@ public final class G6CGMManager: CGMManager { } } + /// Switches between passive (observe the Dexcom app's session) and direct + /// (own the transmitter) mode. The live session can't change nature, so + /// it is torn down and rebuilt as the other kind. + public func setPassiveModeEnabled(_ enabled: Bool) { + guard enabled != state.passiveModeEnabled else { + return + } + + log.default("Switching to %{public}@ mode", enabled ? "passive" : "direct") + + session?.stop() + session = nil + + // Queued commands belong to the mode they were queued in. Passive + // mode can't deliver them, and carrying them back into direct mode + // would replay a stale stop or start — possibly days later, against + // a session the Dexcom app has since started. + lockedCommandQueue.mutate { $0.removeAll() } + + mutateState { state in + state.passiveModeEnabled = enabled + state.pendingCommands = [] + } + + startSession() + } + /// Switches to a different transmitter without tearing down the CGM. /// /// Everything scoped to the old transmitter is cleared — its clock, @@ -379,11 +449,12 @@ public final class G6CGMManager: CGMManager { // Best effort: if the old transmitter happens to be connected // right now, tell it to stop. Once it is off the sensor there is - // nothing to send to, so this is not worth waiting on. - if connectionPhase == .connected { + // nothing to send to, so this is not worth waiting on. A passive + // session cannot send anything at all. + if !state.passiveModeEnabled, connectionPhase == .connected, let active = session as? TransmitterSession { log.default("Old transmitter still connected; sending session stop before switching") - session?.commandSource = self - lockedPendingCommands.mutate { $0 = [.stopSensor(at: Date())] } + active.commandSource = self + lockedCommandQueue.mutate { $0 = CommandQueue([.stopSensor(at: Date())]) } } else { log.default("Old transmitter not connected; its session is left as-is") } @@ -407,7 +478,7 @@ public final class G6CGMManager: CGMManager { // middle of the state reset. The session itself is kept and pointed at // the new transmitter — the radio does not need rebuilding for a swap. session?.stop() - lockedPendingCommands.mutate { $0.removeAll() } + lockedCommandQueue.mutate { $0.removeAll() } raisedAlerts = [] mutateState { state in @@ -464,10 +535,12 @@ public final class G6CGMManager: CGMManager { // MARK: - CGMManager public var providesBLEHeartbeat: Bool { - // The owned connection wakes the app roughly every 5 minutes. + // Direct mode: the owned connection wakes the app roughly every + // 5 minutes. Passive mode: the Dexcom app drives the cadence, so the + // host's own timer must keep ticking. // Loop uses this to suppress the pump's timer tick; Trio dev reads it // through FetchGlucoseManager. - return true + return !state.passiveModeEnabled } public var shouldSyncToRemoteService: Bool { @@ -521,19 +594,24 @@ public final class G6CGMManager: CGMManager { // session delegate on BLE events. session?.start() - if let lastDate = state.latestReading?.date { - let gap = Date().timeIntervalSince(lastDate) - if gap > Self.backfillGapThreshold { - let end = Date() - let start = max(lastDate, end.addingTimeInterval(-Self.backfillWindow)) - session?.requestedBackfillWindow = DateInterval(start: start, end: end) + // Backfill requests and battery reads are writes; passive mode + // cannot perform them and picks up whatever the driving client asks + // for instead. + if let session = session as? TransmitterSession { + if let lastDate = state.latestReading?.date { + let gap = Date().timeIntervalSince(lastDate) + if gap > Self.backfillGapThreshold { + let end = Date() + let start = max(lastDate, end.addingTimeInterval(-Self.backfillWindow)) + session.requestedBackfillWindow = DateInterval(start: start, end: end) + } } - } - if let last = lastBatteryReadDate, Date().timeIntervalSince(last) < Self.batteryReadInterval { - // Battery was read recently. - } else { - session?.shouldReadBattery = true + let batteryReadRecently = state.lastBatteryReadDate + .map { Date().timeIntervalSince($0) < Self.batteryReadInterval } ?? false + if !batteryReadRecently { + session.shouldReadBattery = true + } } completion(.noData) @@ -562,7 +640,7 @@ public final class G6CGMManager: CGMManager { extension G6CGMManager: TransmitterSessionDelegate { - public func transmitterSessionDidConnect(_ session: TransmitterSession) { + public func transmitterSessionDidConnect(_ session: any TransmitterSessioning) { log.default("Connected to transmitter") connectionPhase = .connected // Expiry and signal loss are time-based: without this they would only @@ -570,7 +648,7 @@ extension G6CGMManager: TransmitterSessionDelegate { evaluateAlerts() } - public func transmitterSession(_ session: TransmitterSession, didError error: Error) { + public func transmitterSession(_ session: any TransmitterSessioning, didError error: Error) { log.error("Session error: %{public}@", String(describing: error)) connectionPhase = .searching delegate.notify { delegate in @@ -578,7 +656,7 @@ extension G6CGMManager: TransmitterSessionDelegate { } } - public func transmitterSession(_ session: TransmitterSession, didRead glucose: Glucose) { + public func transmitterSession(_ session: any TransmitterSessioning, didRead glucose: Glucose) { reconcileSession(with: glucose) guard let sample = newGlucoseSample(from: glucose) else { @@ -595,7 +673,7 @@ extension G6CGMManager: TransmitterSessionDelegate { deliver([sample]) } - public func transmitterSession(_ session: TransmitterSession, didReadBackfill glucose: [Glucose]) { + public func transmitterSession(_ session: any TransmitterSessioning, didReadBackfill glucose: [Glucose]) { let samples = glucose.compactMap { newGlucoseSample(from: $0) } guard !samples.isEmpty else { @@ -606,7 +684,7 @@ extension G6CGMManager: TransmitterSessionDelegate { deliver(samples) } - public func transmitterSession(_ session: TransmitterSession, didReadTransmitterVersion message: TransmitterVersionRxMessage) { + public func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterVersion message: TransmitterVersionRxMessage) { let firmware = message.firmwareVersion.map(String.init).joined(separator: ".") let wasAnubis = state.isAnubis @@ -620,10 +698,12 @@ extension G6CGMManager: TransmitterSessionDelegate { } } - public func transmitterSession(_ session: TransmitterSession, didReadTransmitterTime activationDate: Date) { - // Authentication succeeded. Record it now rather than waiting for a - // glucose reading: a stopped or failed sensor never produces one, and - // pairing would appear to hang even though the transmitter is talking. + public func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterTime activationDate: Date) { + // In direct mode this is the proof authentication succeeded; in + // passive mode it is the first observed time frame. Record it now + // rather than waiting for a glucose reading: a stopped or failed + // sensor never produces one, and pairing would appear to hang even + // though the transmitter is talking. log.default("Transmitter clock read; activation %{public}@", String(describing: activationDate)) mutateState { state in @@ -631,8 +711,7 @@ extension G6CGMManager: TransmitterSessionDelegate { } } - public func transmitterSession(_ session: TransmitterSession, didReadBattery message: BatteryStatusRxMessage) { - lastBatteryReadDate = Date() + public func transmitterSession(_ session: any TransmitterSessioning, didReadBattery message: BatteryStatusRxMessage) { log.default("Battery: A %d, B %d, resist %d", Int(message.voltageA), Int(message.voltageB), Int(message.resist)) mutateState { state in @@ -647,17 +726,17 @@ extension G6CGMManager: TransmitterSessionDelegate { evaluateAlerts() } - public func transmitterSession(_ session: TransmitterSession, didReadUnknownData data: Data) { + public func transmitterSession(_ session: any TransmitterSessioning, didReadUnknownData data: Data) { log.error("Unknown data received (%d bytes)", data.count) } - public func transmitterSession(_ session: TransmitterSession, didUpdatePeripheralIdentifier identifier: UUID?) { + public func transmitterSession(_ session: any TransmitterSessioning, didUpdatePeripheralIdentifier identifier: UUID?) { mutateState { state in state.peripheralIdentifier = identifier } } - public func transmitterSessionDidRequestBond(_ session: TransmitterSession) { + public func transmitterSessionDidRequestBond(_ session: any TransmitterSessioning) { log.default("Bond requested; user must accept the pairing prompt") connectionPhase = .awaitingPairing } @@ -814,27 +893,10 @@ extension G6CGMManager: TransmitterSessionDelegate { } private func glucoseTrend(for glucose: Glucose) -> GlucoseTrend? { - guard let rate = glucose.trendRateMgDLPerMinute else { + guard let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: glucose.trendRateMgDLPerMinute) else { return nil } - - // Standard Dexcom trend arrow thresholds in (mg/dL)/min. - switch rate { - case ..<(-3): - return .downDownDown - case ..<(-2): - return .downDown - case ..<(-1): - return .down - case ..<1: - return .flat - case ..<2: - return .up - case ..<3: - return .upUp - default: - return .upUpUp - } + return GlucoseTrend(rawValue: arrow.rawValue) } } @@ -843,46 +905,27 @@ extension G6CGMManager: TransmitterSessionDelegate { extension G6CGMManager: TransmitterCommandSource { - public func dequeuePendingCommand(for session: TransmitterSession) -> Command? { + public func dequeuePendingCommand(for session: any TransmitterSessioning) -> Command? { var next: Command? var dropped: [Command] = [] - lockedPendingCommands.mutate { commands in - // A calibration is only meaningful next to the fingerstick it came - // from; xDrip uses the same five-minute window. Sending a stale one - // would teach the sensor from a value that no longer holds. - while let candidate = commands.first { - commands.removeFirst() - if case .calibrateSensor(_, let date) = candidate, - Date().timeIntervalSince(date) > .minutes(5) { - dropped.append(candidate) - continue - } - next = candidate - break - } + lockedCommandQueue.mutate { queue in + (next, dropped) = queue.dequeue() } for command in dropped { log.default("Dropping stale command: %{public}@", String(describing: command)) - mutateState { state in - if !state.pendingCommands.isEmpty { - state.pendingCommands.removeFirst() - } - } } - if next != nil { + if next != nil || !dropped.isEmpty { mutateState { state in - if !state.pendingCommands.isEmpty { - state.pendingCommands.removeFirst() - } + state.pendingCommands = self.lockedCommandQueue.value.rawValues } } return next } - public func transmitterSession(_ session: TransmitterSession, didFail command: Command, with error: Error) { + public func transmitterSession(_ session: any TransmitterSessioning, didFail command: Command, with error: Error) { log.error("Command failed: %{public}@ — %{public}@", String(describing: command), String(describing: error)) if case .startSensor = command { @@ -895,7 +938,7 @@ extension G6CGMManager: TransmitterCommandSource { } } - public func transmitterSession(_ session: TransmitterSession, didComplete command: Command, response: TransmitterRxMessage?) { + public func transmitterSession(_ session: any TransmitterSessioning, didComplete command: Command, response: TransmitterRxMessage?) { log.default("Command completed: %{public}@", String(describing: command)) switch command { @@ -911,6 +954,17 @@ extension G6CGMManager: TransmitterCommandSource { state.lastSessionStartFailure = nil } case .stopSensor: + if state.sensorStartDate != nil { + let sensorEnded = PersistedCgmEvent( + date: Date(), + type: .sensorEnd, + deviceIdentifier: state.transmitterID, + failureMessage: LocalizedString("Stopped by user", comment: "Reason recorded when a session ends because the user stopped it") + ) + delegate.notify { delegate in + delegate?.cgmManager(self, hasNew: [sensorEnded]) + } + } mutateState { state in state.sensorStartDate = nil state.sensorCode = nil @@ -933,18 +987,10 @@ struct G6GlucoseDisplay: GlucoseDisplayable { } var trendType: GlucoseTrend? { - guard let rate = reading.trendRateMgDLPerMinute else { + guard let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: reading.trendRateMgDLPerMinute) else { return nil } - switch rate { - case ..<(-3): return .downDownDown - case ..<(-2): return .downDown - case ..<(-1): return .down - case ..<1: return .flat - case ..<2: return .up - case ..<3: return .upUp - default: return .upUpUp - } + return GlucoseTrend(rawValue: arrow.rawValue) } var trendRate: HKQuantity? { diff --git a/G6SensorKit/G6CGMManagerState.swift b/G6SensorKit/G6CGMManagerState.swift index 40b6039..85e84ba 100644 --- a/G6SensorKit/G6CGMManagerState.swift +++ b/G6SensorKit/G6CGMManagerState.swift @@ -19,6 +19,7 @@ import G6SensorCore /// cannot live in memory. public enum G6SetupStep: String { case transmitterID + case connectionMode case placement case sensorCode case pairing @@ -56,6 +57,12 @@ public struct G6CGMManagerState: RawRepresentable { public var shouldSyncToRemoteService: Bool + /// Passive mode observes the Dexcom app's session instead of owning the + /// transmitter: no auth, no bonding, no commands, no active reads. Direct + /// (active) mode is the default; passive is chosen by the user or by + /// migration from CGMBLEKit, which ran passive-only. + public var passiveModeEnabled: Bool + /// Transmitter-reported lifetime in days (90 stock, 180 Anubis); nil until /// the first version-rx frame arrives. public var transmitterExpiryInDays: UInt16? @@ -131,6 +138,24 @@ public struct G6CGMManagerState: RawRepresentable { return millivolts <= Self.batteryVeryLowMillivolts } + /// Headline battery level for the settings row. + public enum BatteryLevel { + case unknown, high, low, veryLow + } + + public var batteryLevel: BatteryLevel { + guard let millivolts = batteryVoltageBMillivolts else { + return .unknown + } + if millivolts <= Self.batteryVeryLowMillivolts { + return .veryLow + } + if millivolts <= Self.batteryLowMillivolts { + return .low + } + return .high + } + public init( transmitterID: String, peripheralIdentifier: UUID? = nil, @@ -138,6 +163,7 @@ public struct G6CGMManagerState: RawRepresentable { sensorStartDate: Date? = nil, sensorCode: String? = nil, shouldSyncToRemoteService: Bool = true, + passiveModeEnabled: Bool = false, transmitterExpiryInDays: UInt16? = nil, sensorLifeDays: Int = TransmitterManagerState.defaultSensorLifeDays, latestReading: G6StoredReading? = nil, @@ -156,6 +182,7 @@ public struct G6CGMManagerState: RawRepresentable { self.sensorStartDate = sensorStartDate self.sensorCode = sensorCode self.shouldSyncToRemoteService = shouldSyncToRemoteService + self.passiveModeEnabled = passiveModeEnabled self.transmitterExpiryInDays = transmitterExpiryInDays self.sensorLifeDays = TransmitterManagerState.clampedSensorLifeDays(sensorLifeDays) self.latestReading = latestReading @@ -174,24 +201,45 @@ public struct G6CGMManagerState: RawRepresentable { return nil } + // Migration from CGMBLEKit: its TransmitterManagerState persists + // `sensorStartOffset` and never `sensorLifeDays` or + // `passiveModeEnabled`, while every G6SensorKit state carries + // `sensorLifeDays`. CGMBLEKit ran passive-only, so a legacy-shaped + // state migrates into passive mode with onboarding already complete. + let isLegacyCGMBLEKitState = rawValue["sensorLifeDays"] == nil && rawValue["passiveModeEnabled"] == nil + // UInt16 cannot round-trip through a property list, so accept Int too. let expiry = (rawValue["transmitterExpiryInDays"] as? UInt16) ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16(clamping: $0) } + let transmitterStartDate = rawValue["transmitterStartDate"] as? Date + + // CGMBLEKit stores the session start as an offset from transmitter + // activation; G6SensorKit stores the date itself. + var sensorStartDate = rawValue["sensorStartDate"] as? Date + if sensorStartDate == nil, let transmitterStartDate = transmitterStartDate { + let offset = (rawValue["sensorStartOffset"] as? UInt32) + ?? (rawValue["sensorStartOffset"] as? Int).map { UInt32(clamping: $0) } + if let offset = offset { + sensorStartDate = transmitterStartDate.addingTimeInterval(TimeInterval(offset)) + } + } + self.init( transmitterID: transmitterID, peripheralIdentifier: (rawValue["peripheralIdentifier"] as? String).flatMap(UUID.init(uuidString:)), - transmitterStartDate: rawValue["transmitterStartDate"] as? Date, - sensorStartDate: rawValue["sensorStartDate"] as? Date, + transmitterStartDate: transmitterStartDate, + sensorStartDate: sensorStartDate, sensorCode: rawValue["sensorCode"] as? String, shouldSyncToRemoteService: rawValue["shouldSyncToRemoteService"] as? Bool ?? true, + passiveModeEnabled: rawValue["passiveModeEnabled"] as? Bool ?? isLegacyCGMBLEKitState, transmitterExpiryInDays: expiry, sensorLifeDays: rawValue["sensorLifeDays"] as? Int ?? TransmitterManagerState.defaultSensorLifeDays, latestReading: (rawValue["latestReading"] as? G6StoredReading.RawValue).flatMap(G6StoredReading.init(rawValue:)), recentReadings: (rawValue["recentReadings"] as? [G6StoredReading.RawValue])?.compactMap(G6StoredReading.init(rawValue:)) ?? [], firmwareVersion: rawValue["firmwareVersion"] as? String, shareUploadEnabled: rawValue["shareUploadEnabled"] as? Bool ?? false, - isOnboarded: rawValue["isOnboarded"] as? Bool ?? false, + isOnboarded: rawValue["isOnboarded"] as? Bool ?? isLegacyCGMBLEKitState, setupStep: (rawValue["setupStep"] as? String).flatMap(G6SetupStep.init(rawValue:)), pendingCommands: rawValue["pendingCommands"] as? [Command.RawValue] ?? [], lastSessionStartFailure: rawValue["lastSessionStartFailure"] as? String, @@ -211,6 +259,9 @@ public struct G6CGMManagerState: RawRepresentable { "transmitterID": transmitterID, "shouldSyncToRemoteService": shouldSyncToRemoteService, "sensorLifeDays": sensorLifeDays, + // Written unconditionally: its absence is the migration signal + // for CGMBLEKit-shaped states (see init?(rawValue:)). + "passiveModeEnabled": passiveModeEnabled, ] raw["peripheralIdentifier"] = peripheralIdentifier?.uuidString @@ -308,7 +359,14 @@ public struct G6CGMManagerState: RawRepresentable { return sensorStartDate?.addingTimeInterval(sensorLife) } + /// Unknown until the first version read: the fallback constants in + /// `transmitterLifetime` are guesses (a 180-day Anubis would alert as + /// "expired" against the stock 90 days), so no expiry is reported before + /// the transmitter's own value is in. public var transmitterExpirationDate: Date? { + guard transmitterExpiryInDays != nil else { + return nil + } return transmitterStartDate?.addingTimeInterval(transmitterLifetime) } @@ -338,11 +396,9 @@ public struct G6CGMManagerState: RawRepresentable { /// The device model implied by the transmitter ID prefix. G6 transmitters /// use `8…`; Dexcom ONE uses `5…` or `C…`. public var deviceModel: String { - switch transmitterID.first { - case "5", "C", "c": - return "Dexcom ONE" - default: - return "Dexcom G6" + switch G6TransmitterModel(transmitterID: transmitterID) { + case .g6: return "Dexcom G6" + case .one: return "Dexcom ONE" } } } @@ -416,6 +472,7 @@ extension G6CGMManagerState: Equatable { && lhs.sensorStartDate == rhs.sensorStartDate && lhs.sensorCode == rhs.sensorCode && lhs.shouldSyncToRemoteService == rhs.shouldSyncToRemoteService + && lhs.passiveModeEnabled == rhs.passiveModeEnabled && lhs.transmitterExpiryInDays == rhs.transmitterExpiryInDays && lhs.sensorLifeDays == rhs.sensorLifeDays && lhs.latestReading == rhs.latestReading diff --git a/G6SensorKit/G6SimulatedSession.swift b/G6SensorKit/G6SimulatedSession.swift index cc886c3..c9df672 100644 --- a/G6SensorKit/G6SimulatedSession.swift +++ b/G6SensorKit/G6SimulatedSession.swift @@ -43,9 +43,19 @@ extension G6CGMManager { // warming up. state.transmitterStartDate = now.addingTimeInterval(-.hours(24 * 12)) state.sensorStartDate = now.addingTimeInterval(-(state.warmupPeriod - Self.simulatedWarmupRemaining)) - state.transmitterExpiryInDays = 90 + // 180 days so the simulator presents as an Anubis, making the + // longer session length and expiry visible in the UI. + state.transmitterExpiryInDays = 180 state.firmwareVersion = "1.0.0.0 (simulated)" state.peripheralIdentifier = UUID() + // A healthy battery, so the settings row renders and no + // low-battery alert fires. + state.batteryVoltageA = 295 + state.batteryVoltageB = 282 + state.batteryResistance = 700 + state.batteryRuntimeDays = 12 + state.batteryTemperature = 31 + state.lastBatteryReadDate = now } self.scheduleSimulatedReadings() @@ -64,6 +74,9 @@ extension G6CGMManager { } private func emitSimulatedReading() { + // No active session (ended, or not yet started): stay quiet. + guard state.sensorStartDate != nil else { return } + guard !state.isInWarmup else { // Still warming up: the state change alone refreshes the UI. notifySimulationObservers() diff --git a/G6SensorKit/Localizable.xcstrings b/G6SensorKit/Localizable.xcstrings index ff7977d..8d6f1b6 100644 --- a/G6SensorKit/Localizable.xcstrings +++ b/G6SensorKit/Localizable.xcstrings @@ -120,6 +120,10 @@ "mg/dL": { "extractionState": "manual", "comment": "The short unit display string for milligrams of glucose per deciliter" + }, + "Stopped by user": { + "extractionState": "manual", + "comment": "Reason recorded when a session ends because the user stopped it" } }, "version": "1.0" diff --git a/G6SensorKitUI/Assets.xcassets/anubis.imageset/Contents.json b/G6SensorKitUI/Assets.xcassets/anubis.imageset/Contents.json new file mode 100644 index 0000000..7f5dd38 --- /dev/null +++ b/G6SensorKitUI/Assets.xcassets/anubis.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "anubis.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/G6SensorKitUI/Assets.xcassets/anubis.imageset/anubis.png b/G6SensorKitUI/Assets.xcassets/anubis.imageset/anubis.png new file mode 100644 index 0000000..b6f481d Binary files /dev/null and b/G6SensorKitUI/Assets.xcassets/anubis.imageset/anubis.png differ diff --git a/G6SensorKitUI/G6UICoordinator.swift b/G6SensorKitUI/G6UICoordinator.swift index 7c44fc5..98f74c2 100644 --- a/G6SensorKitUI/G6UICoordinator.swift +++ b/G6SensorKitUI/G6UICoordinator.swift @@ -21,6 +21,7 @@ enum G6UIScreen { // Onboarding case introduction case transmitterIDEntry + case connectionMode case sensorCodeEntry case placementGuide case pairing @@ -30,6 +31,7 @@ enum G6UIScreen { case settings case calibration case transmitterDetails + case batteryDetails case sensorLifeSettings case shareUpload case readingDetail @@ -38,6 +40,7 @@ enum G6UIScreen { var setupStep: G6SetupStep? { switch self { case .transmitterIDEntry: return .transmitterID + case .connectionMode: return .connectionMode case .placementGuide: return .placement case .sensorCodeEntry: return .sensorCode case .pairing: return .pairing @@ -49,6 +52,7 @@ enum G6UIScreen { init?(setupStep: G6SetupStep) { switch setupStep { case .transmitterID: self = .transmitterIDEntry + case .connectionMode: self = .connectionMode case .placement: self = .placementGuide case .sensorCode: self = .sensorCodeEntry case .pairing: self = .pairing @@ -59,7 +63,8 @@ enum G6UIScreen { var next: G6UIScreen? { switch self { case .introduction: return .transmitterIDEntry - case .transmitterIDEntry: return .placementGuide + case .transmitterIDEntry: return .connectionMode + case .connectionMode: return .placementGuide case .placementGuide: return .sensorCodeEntry case .sensorCodeEntry: return .pairing case .pairing: return .warmup @@ -97,6 +102,10 @@ public class G6UICoordinator: UINavigationController, CGMManagerOnboarding, Comp /// Transmitter ID collected during onboarding, before a manager exists. private var pendingTransmitterID: String? + /// Whether the user has answered the connection-mode screen in this flow. + /// Until then the manager's passive flag is a placeholder, not a choice. + private var hasChosenConnectionMode = false + private var screenStack: [G6UIScreen] = [] private var flowMode: G6FlowMode = .onboarding @@ -208,17 +217,24 @@ public class G6UICoordinator: UINavigationController, CGMManagerOnboarding, Comp } private func nextScreen(after screen: G6UIScreen) -> G6UIScreen? { + // Sensor codes are entered in the Dexcom app in passive mode, so the + // code screen has no meaning there. + let isPassive = cgmManager?.state.passiveModeEnabled ?? false + switch flowMode { case .onboarding: if screen == .pairing, !shouldShowWarmup { return nil } + if screen == .placementGuide, isPassive { + return .pairing + } return screen.next case .replacingTransmitter: // No introduction, and no placement guide: the sensor is already // worn, only the transmitter changed. switch screen { - case .transmitterIDEntry: return .sensorCodeEntry + case .transmitterIDEntry: return isPassive ? .pairing : .sensorCodeEntry case .sensorCodeEntry: return .pairing case .pairing: return shouldShowWarmup ? .warmup : nil default: return nil @@ -328,7 +344,11 @@ public class G6UICoordinator: UINavigationController, CGMManagerOnboarding, Comp return } - let state = G6CGMManagerState(transmitterID: transmitterID) + // Start passive until the user picks a mode on the next screen. A + // direct session would connect straight away and, if the Dexcom app + // already holds this transmitter, authenticate and try to pair with + // it before the user has said they want to listen instead. + let state = G6CGMManagerState(transmitterID: transmitterID, passiveModeEnabled: true) let manager = G6CGMManager(state: state) cgmManager = manager manager.recordSetupStep(.transmitterID) @@ -363,6 +383,25 @@ public class G6UICoordinator: UINavigationController, CGMManagerOnboarding, Comp title: LocalizedString("Transmitter ID", comment: "Navigation title for transmitter ID entry") ) + case .connectionMode: + return hostingController( + rootView: G6ConnectionModeView( + // The manager sits in passive mode until a choice is + // made, so only show its mode once it reflects one. + initialPassive: hasChosenConnectionMode ? (cgmManager?.state.passiveModeEnabled ?? false) : false, + didContinue: { [weak self] passive in + guard let self = self else { return } + self.hasChosenConnectionMode = true + if let manager = self.cgmManager { + manager.logSetupEvent(passive ? "Chose passive mode" : "Chose direct mode") + manager.setPassiveModeEnabled(passive) + } + self.stepFinished() + } + ), + title: LocalizedString("Connection Mode", comment: "Navigation title for connection mode screen") + ) + case .sensorCodeEntry: return hostingController( rootView: G6SensorCodeEntryView( @@ -408,6 +447,7 @@ public class G6UICoordinator: UINavigationController, CGMManagerOnboarding, Comp cgmManager: cgmManager, toCalibration: { [weak self] in self?.navigateTo(.calibration) }, toTransmitterDetails: { [weak self] in self?.navigateTo(.transmitterDetails) }, + toBatteryDetails: { [weak self] in self?.navigateTo(.batteryDetails) }, toSensorLifeSettings: { [weak self] in self?.navigateTo(.sensorLifeSettings) }, toShareUpload: { [weak self] in self?.navigateTo(.shareUpload) }, toReadingDetail: { [weak self] in self?.navigateTo(.readingDetail) }, @@ -458,6 +498,15 @@ public class G6UICoordinator: UINavigationController, CGMManagerOnboarding, Comp title: LocalizedString("Transmitter", comment: "Navigation title for transmitter details") ) + case .batteryDetails: + guard let cgmManager = cgmManager else { + return UIViewController() + } + return hostingController( + rootView: G6BatteryDetailsView(cgmManager: cgmManager), + title: LocalizedString("Battery", comment: "Navigation title for battery details") + ) + case .sensorLifeSettings: guard let cgmManager = cgmManager else { return UIViewController() diff --git a/G6SensorKitUI/Localizable.xcstrings b/G6SensorKitUI/Localizable.xcstrings index f218d5c..05b0738 100644 --- a/G6SensorKitUI/Localizable.xcstrings +++ b/G6SensorKitUI/Localizable.xcstrings @@ -1,838 +1,1028 @@ { "sourceLanguage": "en", "strings": { - "Sensor Failed": { - "extractionState": "manual", - "comment": "Status highlight for failed sensor" + "": { }, - "Sensor Warmup": { - "extractionState": "manual", - "comment": "Status highlight during warm-up" + "—": { + "comment": "Placeholder for missing value", + "extractionState": "manual" }, - "Transmitter Expired": { - "extractionState": "manual", - "comment": "Status highlight when the transmitter is past end of life" + "“Dexcom”, “G6”, and “ONE” are trademarks of Dexcom, Inc., used here only to describe which devices are compatible. This app is not made by or endorsed by Dexcom.": { + "comment": "Trademark attribution footer", + "extractionState": "manual" }, - "No Sensor": { - "extractionState": "manual", - "comment": "Status highlight when no session is running" + "%@ remaining": { + "comment": "Session remaining (1: duration)", + "extractionState": "manual" }, - "Sensor Expired": { - "extractionState": "manual", - "comment": "Status highlight for expired sensor" + "%@ until ready": { + "comment": "Warm-up remaining (1: duration)", + "extractionState": "manual" + }, + "%@. %@": { + "comment": "A label that describes the phase and detail of a lifecycle bar.", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "new", + "value": "%1$@. %2$@" + } + } + } }, - "Calibration Needed": { - "extractionState": "manual", - "comment": "Status highlight when a calibration is requested" + "%1$@ talks to your G6 or Dexcom ONE transmitter directly. It starts and stops sensor sessions, sends your calibrations, and fills in readings it missed.": { + "comment": "Introduction: what the native driver does (1: app name)", + "extractionState": "manual" }, - "Signal Loss": { - "extractionState": "manual", - "comment": "Status highlight for signal loss" + "%1$@%2$@ per minute": { + "comment": "Trend rate (1: sign, 2: formatted rate with unit)", + "extractionState": "manual" }, - "This app": { - "extractionState": "manual", - "comment": "Fallback when the host app name cannot be read" + "%1$@%2$@/min": { + "comment": "Trend rate (1: sign, 2: formatted rate with unit)", + "extractionState": "manual" }, - "Transmitter ID": { - "extractionState": "manual", - "comment": "Navigation title for transmitter ID entry" + "%d °C": { + "comment": "Temperature in Celsius (1: degrees)", + "extractionState": "manual" }, - "Sensor Code": { - "extractionState": "manual", - "comment": "Navigation title for sensor code entry" + "%d days": { + "comment": "Number of days (1: day count)", + "extractionState": "manual" + }, + "%d min ago": { + "comment": "Minutes since last reading (1: minutes)", + "extractionState": "manual" + }, + "%d mV": { + "comment": "Voltage in millivolts (1: millivolts)", + "extractionState": "manual" + }, + "%dd %dh": { + "comment": "Duration in days and hours", + "extractionState": "manual" + }, + "%dh %dm": { + "comment": "Duration in hours and minutes", + "extractionState": "manual" + }, + "%dm": { + "comment": "Duration in minutes", + "extractionState": "manual" + }, + "%lld": { + "comment": "A number in the center of a circle.", + "isCommentAutoGenerated": true + }, + "4-digit code": { + "comment": "Sensor code text field placeholder", + "extractionState": "manual" + }, + "6-character ID": { + "comment": "Transmitter ID text field placeholder", + "extractionState": "manual" + }, + "A display-only value has been shifted by a recent calibration. It is shown for continuity and is not used for dosing.": { + "comment": "Footer explaining display-only readings", + "extractionState": "manual" + }, + "A pairing request will appear": { + "comment": "Pairing callout title", + "extractionState": "manual" + }, + "Accept the Bluetooth pairing request within about a minute. If it disappears, the app tries again on the next connection.": { + "comment": "Pairing callout body while awaiting the prompt", + "extractionState": "manual" + }, + "Active": { + "comment": "Session phase: active", + "extractionState": "manual" + }, + "Adults wear the sensor on the belly. Children aged 2 to 17 can use the belly or upper buttocks.": { + "comment": "Apply-sensor site instruction", + "extractionState": "manual" + }, + "After warm-up": { + "comment": "Why calibration is unavailable during warm-up", + "extractionState": "manual" + }, + "Allow camera access in Settings to scan the Data Matrix code on your Dexcom transmitter packaging.": { + "comment": "Camera denied alert message", + "extractionState": "manual" + }, + "Apply a new sensor": { + "comment": "Apply-sensor screen title", + "extractionState": "manual" }, "Applying Your Sensor": { - "extractionState": "manual", - "comment": "Navigation title for placement guide" + "comment": "Navigation title for placement guide", + "extractionState": "manual" }, - "Pairing": { - "extractionState": "manual", - "comment": "Navigation title for pairing screen" + "ATTACH TRANSMITTER": { + "comment": "Section label for the attach-transmitter steps", + "extractionState": "manual" }, - "Warm-Up": { - "extractionState": "manual", - "comment": "Navigation title for warm-up screen" + "Battery": { + "comment": "Navigation title for battery details", + "extractionState": "manual" }, - "Dexcom G6 / ONE": { - "extractionState": "manual", - "comment": "Navigation title for settings" + "Battery B is the cell that runs down first and determines whether the transmitter can finish a session.": { + "comment": "Footer explaining what battery B means", + "extractionState": "manual" + }, + "CALIBRATE": { + "comment": "Section label for the calibration steps", + "extractionState": "manual" }, "Calibration": { - "extractionState": "manual", - "comment": "Navigation title for calibration" + "comment": "Navigation title for calibration", + "extractionState": "manual" }, - "Transmitter": { - "extractionState": "manual", - "comment": "Navigation title for transmitter details" + "Calibration becomes available once warm-up finishes. Do not make insulin decisions from early readings — use a fingerstick meter and follow your care team's guidance.": { + "comment": "Footer explaining warm-up restrictions", + "extractionState": "manual" }, - "Session Length": { - "extractionState": "manual", - "comment": "Navigation title for sensor life settings" + "Calibration Needed": { + "comment": "Status highlight when a calibration is requested", + "extractionState": "manual" }, - "Dexcom Share": { - "extractionState": "manual", - "comment": "Navigation title for Share upload settings" + "Calibration requested": { + "comment": "Algorithm state: needs calibration", + "extractionState": "manual" }, - "INSERT SENSOR": { - "extractionState": "manual", - "comment": "Section label for the insert-sensor steps" + "Calibrations must be between %1$@ and %2$@. If your meter reads outside that range, treat it and follow your care team's guidance instead of calibrating.": { + "comment": "Validation message for out-of-range calibration (1: minimum with unit, 2: maximum with unit)", + "extractionState": "manual" }, - "STEP %d of 10": { - "extractionState": "manual", - "comment": "Insert-sensor step counter (1: step number)" + "Camera access is off": { + "comment": "Camera denied alert title", + "extractionState": "manual" }, - "Thoroughly wash and dry your hands.": { - "extractionState": "manual", - "comment": "Insert step 1 body" + "Cancel": { + "comment": "Cancel button", + "extractionState": "manual" + }, + "Check that the Dexcom app is not connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.": { + "comment": "Pairing troubleshooting body", + "extractionState": "manual" }, "Clean the insertion site with alcohol. Let it dry.": { - "extractionState": "manual", - "comment": "Insert step 2 body" + "comment": "Insert step 2 body", + "extractionState": "manual" }, - "The site must be completely dry or the adhesive will not stick.": { - "extractionState": "manual", - "comment": "Insert step 2 note" + "Connected to your transmitter": { + "comment": "Pairing status: connected", + "extractionState": "manual" }, - "Optional: if you use a skin adhesive, draw an empty oval on the skin and let it dry.": { - "extractionState": "manual", - "comment": "Insert step 3 body" + "Connecting to your transmitter": { + "comment": "Pairing screen heading", + "extractionState": "manual" }, - "Insert the sensor on clean skin in the centre of the oval, not on the adhesive itself.": { - "extractionState": "manual", - "comment": "Insert step 3 note" + "Continue": { + "comment": "Confirm pairing a new transmitter", + "extractionState": "manual" }, - "Take out the applicator whose sensor code you entered, and peel off its cover.": { - "extractionState": "manual", - "comment": "Insert step 4 body" + "Could not save your credentials. Try again.": { + "comment": "Status message when saving Share credentials fails", + "extractionState": "manual" }, - "Do not use it if the packaging is damaged or was already opened. Keep the packaging until the session is finished.": { - "extractionState": "manual", - "comment": "Insert step 4 note" + "Credentials saved to the Keychain.": { + "comment": "Status message after saving Share credentials", + "extractionState": "manual" }, - "Pull off both adhesive labels.": { - "extractionState": "manual", - "comment": "Insert step 5 body" + "Device": { + "comment": "Reading detail label for device", + "extractionState": "manual" }, - "Keep the tab showing the sensor code. Do not touch the adhesive.": { - "extractionState": "manual", - "comment": "Insert step 5 note" + "Dexcom Account": { + "comment": "Section header for Share account", + "extractionState": "manual" }, - "Place the applicator flat against your skin — horizontally, not upright — and press down firmly.": { - "extractionState": "manual", - "comment": "Insert step 6 body" + "Dexcom G6": { + "comment": "Introduction screen product title", + "extractionState": "manual" }, - "Fold and break off the safety guard, then throw it away.": { - "extractionState": "manual", - "comment": "Insert step 7 body" + "Dexcom G6 / ONE": { + "comment": "Navigation title for settings", + "extractionState": "manual" }, - "Push and release the button to insert the sensor.": { - "extractionState": "manual", - "comment": "Insert step 8 body" + "Dexcom Share": { + "comment": "Navigation title for Share upload settings", + "extractionState": "manual" }, - "Lift the applicator away.": { - "extractionState": "manual", - "comment": "Insert step 9 body" + "Dexcom Share Upload": { + "comment": "Row to Share upload settings", + "extractionState": "manual" }, - "Dispose of the applicator following your local rules for items that have touched blood.": { - "extractionState": "manual", - "comment": "Insert step 9 note" + "Display only": { + "comment": "Reading detail label for display-only flag", + "extractionState": "manual" }, - "Left on your skin: the sensor wire and the transmitter holder. The sensor is in.": { - "extractionState": "manual", - "comment": "Insert step 10 body" + "Dispose of the applicator following your local rules for items that have touched blood.": { + "comment": "Insert step 9 note", + "extractionState": "manual" }, - "ATTACH TRANSMITTER": { - "extractionState": "manual", - "comment": "Section label for the attach-transmitter steps" + "Do not dose on early readings": { + "comment": "Warm-up callout title", + "extractionState": "manual" }, - "STEP %d of 4": { - "extractionState": "manual", - "comment": "Attach-transmitter step counter (1: step number)" + "Do not make insulin decisions from the first readings after warm-up, or from readings that look wrong for how you feel. Use a fingerstick meter instead, and follow your care team's guidance.": { + "comment": "Warm-up callout body about dosing safety", + "extractionState": "manual" }, - "Take the transmitter out of its box and prepare it for use. Wipe the bottom with an alcohol wipe and let it dry.": { - "extractionState": "manual", - "comment": "Attach step 1 body" + "Do not round the number or reuse an older reading. If more than 5 minutes have passed, measure again.": { + "comment": "Calibration step 3 note", + "extractionState": "manual" }, "Do not touch the metal contacts and do not scratch the bottom — that can break the waterproof seal.": { - "extractionState": "manual", - "comment": "Attach step 1 note" + "comment": "Attach step 1 note", + "extractionState": "manual" }, - "Slide the transmitter tab into the slot at the narrow end of the holder.": { - "extractionState": "manual", - "comment": "Attach step 3 body" - }, - "Press the wide end of the transmitter until it clicks into the holder.": { - "extractionState": "manual", - "comment": "Attach step 4 body" + "Do not use it if the packaging is damaged or was already opened. Keep the packaging until the session is finished.": { + "comment": "Insert step 4 note", + "extractionState": "manual" }, - "Rub your fingers around the patch three times to secure it.": { - "extractionState": "manual", - "comment": "Attach step 5 body" + "Done": { + "comment": "Done button", + "extractionState": "manual" }, - "You are almost done — the sensor session starts next.": { - "extractionState": "manual", - "comment": "Attach step 5 note" + "Ending Soon": { + "comment": "Session phase: expiring soon", + "extractionState": "manual" }, - "Apply a new sensor": { - "extractionState": "manual", - "comment": "Apply-sensor screen title" + "Enter a fingerstick value": { + "comment": "Calibration screen heading", + "extractionState": "manual" }, - "Adults wear the sensor on the belly. Children aged 2 to 17 can use the belly or upper buttocks.": { - "extractionState": "manual", - "comment": "Apply-sensor site instruction" + "Enter a number.": { + "comment": "Validation message for non-numeric calibration entry", + "extractionState": "manual" }, - "Keep at least 8 cm (3 inches) away from insulin pump sites and injection sites, and avoid scars, bony areas, and your waistband.": { - "extractionState": "manual", - "comment": "Apply-sensor spacing instruction" + "Enter Calibration": { + "comment": "Button to enter a calibration", + "extractionState": "manual" }, - "HOW TO APPLY A SENSOR": { - "extractionState": "manual", - "comment": "Apply-sensor help button" + "Enter the Code": { + "comment": "Return to entering the sensor code", + "extractionState": "manual" }, - "Next": { - "extractionState": "manual", - "comment": "Next button" + "Enter the exact value your meter showed, within 5 minutes of the fingerstick.": { + "comment": "Calibration step 3 body", + "extractionState": "manual" }, - "How to apply a sensor": { - "extractionState": "manual", - "comment": "Apply-sensor help screen title" + "Enter your sensor code": { + "comment": "Sensor code screen heading", + "extractionState": "manual" }, - "Done": { - "extractionState": "manual", - "comment": "Done button" + "Enter your transmitter ID": { + "comment": "Transmitter ID screen heading", + "extractionState": "manual" }, - "CALIBRATE": { - "extractionState": "manual", - "comment": "Section label for the calibration steps" + "Expected to expire": { + "comment": "Transmitter detail label for expiry", + "extractionState": "manual" }, - "STEP %d of 3": { - "extractionState": "manual", - "comment": "Calibration step counter (1: step number)" + "Expected to finish at %1$@, about %2$d minutes from now": { + "comment": "Warm-up completion time (1: time, 2: minutes remaining)", + "extractionState": "manual" }, - "Wash and dry your hands with soap and water — not a gel cleaner.": { - "extractionState": "manual", - "comment": "Calibration step 1 body" + "Firmware": { + "comment": "Transmitter detail label for firmware", + "extractionState": "manual" }, - "Poorly washed hands are behind most inaccurate meter readings, and an inaccurate reading teaches the sensor the wrong thing.": { - "extractionState": "manual", - "comment": "Calibration step 1 note" + "First used": { + "comment": "Transmitter detail label for activation date", + "extractionState": "manual" }, - "Take a blood sample from your fingertip and measure it with your meter.": { - "extractionState": "manual", - "comment": "Calibration step 2 body" + "Fold and break off the safety guard, then throw it away.": { + "comment": "Insert step 7 body", + "extractionState": "manual" }, - "Use a fingertip only — samples from other sites are less accurate. Use the same meter throughout a sensor session, and check that your strips are in date.": { - "extractionState": "manual", - "comment": "Calibration step 2 note" + "How to apply a sensor": { + "comment": "Apply-sensor help screen title", + "extractionState": "manual" }, - "Enter the exact value your meter showed, within 5 minutes of the fingerstick.": { - "extractionState": "manual", - "comment": "Calibration step 3 body" + "How to Apply a Sensor": { + "comment": "Row to the placement guide", + "extractionState": "manual" }, - "Do not round the number or reuse an older reading. If more than 5 minutes have passed, measure again.": { - "extractionState": "manual", - "comment": "Calibration step 3 note" + "HOW TO APPLY A SENSOR": { + "comment": "Apply-sensor help button", + "extractionState": "manual" }, "How to calibrate": { - "extractionState": "manual", - "comment": "Calibration help screen title" + "comment": "Calibration help screen title", + "extractionState": "manual" }, - "Enter a fingerstick value": { - "extractionState": "manual", - "comment": "Calibration screen heading" + "HOW TO CALIBRATE": { + "comment": "Calibration help button", + "extractionState": "manual" }, - "Use a fresh fingerstick reading from your meter, taken just now. Wash and dry your hands first — a value from unwashed fingers can be badly wrong and will teach the sensor the wrong thing.": { - "extractionState": "manual", - "comment": "Calibration instructions" + "Identifier": { + "comment": "Reading detail label for sync identifier", + "extractionState": "manual" }, - "HOW TO CALIBRATE": { - "extractionState": "manual", - "comment": "Calibration help button" + "IDs that begin with 4 belong to Dexcom G5 transmitters, which this app does not support.": { + "comment": "Validation message for G5 transmitter ID", + "extractionState": "manual" + }, + "INSERT SENSOR": { + "comment": "Section label for the insert-sensor steps", + "extractionState": "manual" + }, + "Insert the sensor on clean skin in the centre of the oval, not on the adhesive itself.": { + "comment": "Insert step 3 note", + "extractionState": "manual" }, "It may take a few minutes": { - "extractionState": "manual", - "comment": "Calibration callout title" + "comment": "Calibration callout title", + "extractionState": "manual" }, - "The calibration is sent the next time your transmitter connects, which can be up to 5 minutes away. The transmitter may accept it, ask for a second value, or reject it — this screen's status will tell you which.": { - "extractionState": "manual", - "comment": "Calibration callout body about delivery timing" + "Just now": { + "comment": "Reading received less than a minute ago", + "extractionState": "manual" }, - "Send Calibration": { - "extractionState": "manual", - "comment": "Calibration submit button" + "Keep at least 8 cm (3 inches) away from insulin pump sites and injection sites, and avoid scars, bony areas, and your waistband.": { + "comment": "Apply-sensor spacing instruction", + "extractionState": "manual" }, - "Enter a number.": { - "extractionState": "manual", - "comment": "Validation message for non-numeric calibration entry" + "Keep the tab showing the sensor code. Do not touch the adhesive.": { + "comment": "Insert step 5 note", + "extractionState": "manual" }, - "Pair New Transmitter": { - "extractionState": "manual", - "comment": "Row to pair a different transmitter" + "Last checked": { + "comment": "Transmitter detail label for last battery read", + "extractionState": "manual" }, - "Pair a new transmitter?": { - "extractionState": "manual", - "comment": "Confirmation title for pairing a new transmitter" + "Last Reading": { + "comment": "Settings section: last reading", + "extractionState": "manual" }, - "Continue": { - "extractionState": "manual", - "comment": "Confirm pairing a new transmitter" + "Latest Reading": { + "comment": "Reading detail screen title", + "extractionState": "manual" }, - "Your current sensor session will end. A session lives on the transmitter, so it cannot move to a new one — you will start a fresh session after pairing, and can reuse the sensor you are wearing. Stop the sensor first if your old transmitter is still attached and you want it stopped cleanly. Your other settings are kept.": { - "extractionState": "manual", - "comment": "Confirmation message for pairing a new transmitter while a session is running" + "Left on your skin: the sensor wire and the transmitter holder. The sensor is in.": { + "comment": "Insert step 10 body", + "extractionState": "manual" }, - "You will enter the new transmitter's ID and can then start a sensor session on it. Your other settings are kept.": { - "extractionState": "manual", - "comment": "Confirmation message for pairing a new transmitter with no session running" + "Lifetime": { + "comment": "Transmitter details section header for lifetime", + "extractionState": "manual" + }, + "Lift the applicator away.": { + "comment": "Insert step 9 body", + "extractionState": "manual" + }, + "Looking for your transmitter…": { + "comment": "Pairing status: searching", + "extractionState": "manual" }, "Model": { - "extractionState": "manual", - "comment": "Transmitter detail label for model" + "comment": "Transmitter detail label for model", + "extractionState": "manual" }, - "Firmware": { - "extractionState": "manual", - "comment": "Transmitter detail label for firmware" + "Modified transmitters can keep a sensor running past the standard 10 days. This setting only changes when this app treats the session as finished; it does not change the sensor's accuracy. Sensor readings can drift the longer a sensor is worn, so check against a fingerstick meter if a reading does not match how you feel.": { + "comment": "Footer explaining extended session length and its accuracy caveat", + "extractionState": "manual" }, - "Unknown": { - "extractionState": "manual", - "comment": "Placeholder for unknown value" + "Next": { + "comment": "Next button", + "extractionState": "manual" }, - "First used": { - "extractionState": "manual", - "comment": "Transmitter detail label for activation date" + "No": { + "comment": "Negative", + "extractionState": "manual" }, - "Expected to expire": { - "extractionState": "manual", - "comment": "Transmitter detail label for expiry" + "No code on your sensor?": { + "comment": "Sensor code callout title", + "extractionState": "manual" }, - "Reported lifetime": { - "extractionState": "manual", - "comment": "Transmitter detail label for reported lifetime" + "No readings — replace the sensor.": { + "comment": "Placeholder when the sensor has failed", + "extractionState": "manual" }, - "%d days": { - "extractionState": "manual", - "comment": "Number of days (1: day count)" + "No sensor": { + "comment": "Why calibration is unavailable without a session", + "extractionState": "manual" }, - "Lifetime": { - "extractionState": "manual", - "comment": "Transmitter details section header for lifetime" + "No Sensor": { + "comment": "Status highlight when no session is running", + "extractionState": "manual" }, - "This transmitter is past the end of its life. It still reports readings for a running session, but cannot start a new sensor.": { - "extractionState": "manual", - "comment": "Footer shown when the transmitter has expired" + "No Signal": { + "comment": "Session phase: signal loss", + "extractionState": "manual" }, - "This transmitter reports an extended 180-day lifetime, which means it has been modified. Warm-up is shorter and you can choose how long sessions run.": { - "extractionState": "manual", - "comment": "Footer explaining Anubis detection" + "Not available": { + "comment": "Value when the transmitter reports no trend", + "extractionState": "manual" }, - "Session length: %d days": { - "extractionState": "manual", - "comment": "Session length value (1: day count)" + "Not used": { + "comment": "Value when a session runs without a sensor code", + "extractionState": "manual" }, - "Modified transmitters can keep a sensor running past the standard 10 days. This setting only changes when this app treats the session as finished; it does not change the sensor's accuracy. Sensor readings can drift the longer a sensor is worn, so check against a fingerstick meter if a reading does not match how you feel.": { - "extractionState": "manual", - "comment": "Footer explaining extended session length and its accuracy caveat" + "OK": { + "comment": "Algorithm state: ok", + "extractionState": "manual" }, - "Upload to Dexcom Share": { - "extractionState": "manual", - "comment": "Toggle label for Share upload" + "On the transmitter box, next to the barcode.": { + "comment": "Transmitter ID: location on the box", + "extractionState": "manual" }, - "Turning this on sends your readings to Dexcom Share so people who follow you can see them in the Dexcom Follow app. It is optional, and your readings still work in this app without it.": { - "extractionState": "manual", - "comment": "Footer explaining Share upload" + "Open Settings": { + "comment": "Open Settings button", + "extractionState": "manual" }, - "Username": { - "extractionState": "manual", - "comment": "Share account username field" + "Optional: if you use a skin adhesive, draw an empty oval on the skin and let it dry.": { + "comment": "Insert step 3 body", + "extractionState": "manual" + }, + "Or on the back of the transmitter itself.": { + "comment": "Transmitter ID: location on the transmitter", + "extractionState": "manual" + }, + "Pair a new transmitter?": { + "comment": "Confirmation title for pairing a new transmitter", + "extractionState": "manual" + }, + "Pair New Transmitter": { + "comment": "Row to pair a different transmitter", + "extractionState": "manual" + }, + "Pairing": { + "comment": "Navigation title for pairing screen", + "extractionState": "manual" }, "Password": { - "extractionState": "manual", - "comment": "Share account password field" + "comment": "Share account password field", + "extractionState": "manual" }, - "Save Credentials": { - "extractionState": "manual", - "comment": "Button to save Share credentials" + "Place the applicator flat against your skin — horizontally, not upright — and press down firmly.": { + "comment": "Insert step 6 body", + "extractionState": "manual" }, - "Dexcom Account": { - "extractionState": "manual", - "comment": "Section header for Share account" + "Poorly washed hands are behind most inaccurate meter readings, and an inaccurate reading teaches the sensor the wrong thing.": { + "comment": "Calibration step 1 note", + "extractionState": "manual" }, - "Your username and password are stored in the iOS Keychain on this device only. They are never written into app settings, logs, or backups of this app's data.": { - "extractionState": "manual", - "comment": "Footer explaining Keychain credential storage" + "Press the wide end of the transmitter until it clicks into the holder.": { + "comment": "Attach step 4 body", + "extractionState": "manual" }, - "Upload turned off and saved credentials removed.": { - "extractionState": "manual", - "comment": "Status message after disabling Share upload" + "Pull off both adhesive labels.": { + "comment": "Insert step 5 body", + "extractionState": "manual" }, - "Credentials saved to the Keychain.": { - "extractionState": "manual", - "comment": "Status message after saving Share credentials" + "Push and release the button to insert the sensor.": { + "comment": "Insert step 8 body", + "extractionState": "manual" }, - "Could not save your credentials. Try again.": { - "extractionState": "manual", - "comment": "Status message when saving Share credentials fails" + "Reading": { + "comment": "Reading detail section header", + "extractionState": "manual" }, - "Warming Up": { - "extractionState": "manual", - "comment": "Session phase: warm-up" + "Readings will appear as they arrive from the transmitter, about every 5 minutes.": { + "comment": "Explanation shown once warm-up has completed", + "extractionState": "manual" }, - "Active": { - "extractionState": "manual", - "comment": "Session phase: active" + "Remove CGM": { + "comment": "Confirm CGM removal", + "extractionState": "manual" }, - "Ending Soon": { - "extractionState": "manual", - "comment": "Session phase: expiring soon" + "Remove CGM from App": { + "comment": "Button to delete the CGM manager", + "extractionState": "manual" }, - "Session Ended": { - "extractionState": "manual", - "comment": "Session phase: expired" + "Remove this CGM?": { + "comment": "Confirmation title for removing the CGM", + "extractionState": "manual" }, - "No Signal": { - "extractionState": "manual", - "comment": "Session phase: signal loss" + "Replace sensor": { + "comment": "Detail when the session is over", + "extractionState": "manual" }, - "Dexcom G6": { - "extractionState": "manual", - "comment": "Introduction screen product title" + "Reported lifetime": { + "comment": "Transmitter detail label for reported lifetime", + "extractionState": "manual" }, - "%1$@ talks to your G6 or Dexcom ONE transmitter directly. It starts and stops sensor sessions, sends your calibrations, and fills in readings it missed.": { - "extractionState": "manual", - "comment": "Introduction: what the native driver does (1: app name)" + "Resistance": { + "comment": "Transmitter detail label for battery resistance", + "extractionState": "manual" }, - "You do not need the Dexcom app — and you must not let it use the same transmitter at the same time.": { - "extractionState": "manual", - "comment": "Introduction: single-app requirement" + "Rub your fingers around the patch three times to secure it.": { + "comment": "Attach step 5 body", + "extractionState": "manual" + }, + "Save Credentials": { + "comment": "Button to save Share credentials", + "extractionState": "manual" + }, + "Scan package code": { + "comment": "Scan package barcode button", + "extractionState": "manual" + }, + "Scanned a Dexcom package, but couldn't find a transmitter ID in it. Type the ID manually.": { + "comment": "Scanned Dexcom barcode without a transmitter ID", + "extractionState": "manual" + }, + "Send Calibration": { + "comment": "Calibration submit button", + "extractionState": "manual" + }, + "Sensor": { + "comment": "Settings section: sensor", + "extractionState": "manual" + }, + "Sensor code": { + "comment": "Metadata label for sensor code", + "extractionState": "manual" + }, + "Sensor Code": { + "comment": "Navigation title for sensor code entry", + "extractionState": "manual" + }, + "Sensor Expired": { + "comment": "Status highlight for expired sensor", + "extractionState": "manual" + }, + "Sensor failed": { + "comment": "Algorithm state: failed", + "extractionState": "manual" + }, + "Sensor Failed": { + "comment": "Status highlight for failed sensor", + "extractionState": "manual" + }, + "Sensor session found": { + "comment": "Pairing success label for an existing session", + "extractionState": "manual" + }, + "Sensor start queued — it is sent the next time the transmitter connects, which can take up to 5 minutes.": { + "comment": "Shown while a session start is waiting for the next connection", + "extractionState": "manual" + }, + "Sensor started": { + "comment": "Metadata label for sensor start", + "extractionState": "manual" }, - "Enter your transmitter ID": { - "extractionState": "manual", - "comment": "Transmitter ID screen heading" + "Sensor status": { + "comment": "Reading detail label for algorithm state", + "extractionState": "manual" }, - "On the transmitter box, next to the barcode.": { - "extractionState": "manual", - "comment": "Transmitter ID: location on the box" + "Sensor Warmup": { + "comment": "Status highlight during warm-up", + "extractionState": "manual" }, - "Or on the back of the transmitter itself.": { - "extractionState": "manual", - "comment": "Transmitter ID: location on the transmitter" + "Session Ended": { + "comment": "Session phase: expired", + "extractionState": "manual" }, - "6-character ID": { - "extractionState": "manual", - "comment": "Transmitter ID text field placeholder" + "Session ends": { + "comment": "Metadata label for session end", + "extractionState": "manual" }, - "This app works with Dexcom G6 and Dexcom ONE transmitters. Older G5 transmitters, whose IDs begin with 4, are not supported.": { - "extractionState": "manual", - "comment": "Transmitter ID: supported hardware note" + "Session Length": { + "comment": "Navigation title for sensor life settings", + "extractionState": "manual" }, - "IDs that begin with 4 belong to Dexcom G5 transmitters, which this app does not support.": { - "extractionState": "manual", - "comment": "Validation message for G5 transmitter ID" + "Session stopped": { + "comment": "Algorithm state: stopped", + "extractionState": "manual" }, - "Enter your sensor code": { - "extractionState": "manual", - "comment": "Sensor code screen heading" + "Share Logs": { + "comment": "Row to export the log files", + "extractionState": "manual" }, - "The 4-digit code is on the applicator’s adhesive label, and is unique to that sensor. Use the code from the applicator you are about to insert — a code from a different one will make readings inaccurate.": { - "extractionState": "manual", - "comment": "Sensor code: where to find it" + "Signal Loss": { + "comment": "Status highlight for signal loss", + "extractionState": "manual" }, - "4-digit code": { - "extractionState": "manual", - "comment": "Sensor code text field placeholder" + "Slide the transmitter tab into the slot at the narrow end of the holder.": { + "comment": "Attach step 3 body", + "extractionState": "manual" }, - "No code on your sensor?": { - "extractionState": "manual", - "comment": "Sensor code callout title" + "Source": { + "comment": "Reading detail section header for source", + "extractionState": "manual" }, - "You can start without one. The transmitter will then ask you for two fingerstick glucose values after warm-up, and for one more each day. \"Calibration\" means telling the sensor what your meter reads, so it can correct itself.": { - "extractionState": "manual", - "comment": "Sensor code callout body explaining calibration" + "Start New Sensor": { + "comment": "Button to begin a new sensor session", + "extractionState": "manual" }, - "Use This Code": { - "extractionState": "manual", - "comment": "Sensor code submit button" + "Start time unknown": { + "comment": "Detail when a session is running but its start is unknown", + "extractionState": "manual" }, "Start Without a Code": { - "extractionState": "manual", - "comment": "Button to start a session with no sensor code" + "comment": "Button to start a session with no sensor code", + "extractionState": "manual" }, "Start without a sensor code?": { - "extractionState": "manual", - "comment": "Confirmation title for a code-less session" + "comment": "Confirmation title for a code-less session", + "extractionState": "manual" }, - "Enter the Code": { - "extractionState": "manual", - "comment": "Return to entering the sensor code" + "STEP %d of 3": { + "comment": "Calibration step counter (1: step number)", + "extractionState": "manual" }, - "Without the code the sensor cannot use its factory calibration, and the transmitter will ask you for two fingerstick values after warm-up and one every day after that. The code is on the applicator label.": { - "extractionState": "manual", - "comment": "Confirmation message explaining a code-less session" + "STEP %d of 4": { + "comment": "Attach-transmitter step counter (1: step number)", + "extractionState": "manual" }, - "That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.": { - "extractionState": "manual", - "comment": "Validation message for unrecognized sensor code" + "STEP %d of 10": { + "comment": "Insert-sensor step counter (1: step number)", + "extractionState": "manual" }, - "Connecting to your transmitter": { - "extractionState": "manual", - "comment": "Pairing screen heading" + "Still looking": { + "comment": "Pairing troubleshooting title", + "extractionState": "manual" }, - "Your transmitter only talks to your phone about once every 5 minutes, so this can take a few minutes. Keep your phone nearby.": { - "extractionState": "manual", - "comment": "Pairing: explanation of the 5-minute cycle" + "Stop Sensor Session": { + "comment": "Button to stop the sensor session", + "extractionState": "manual" + }, + "Stop Session": { + "comment": "Confirm stop session", + "extractionState": "manual" + }, + "Stop this sensor session?": { + "comment": "Confirmation title for stopping a session", + "extractionState": "manual" + }, + "Take a blood sample from your fingertip and measure it with your meter.": { + "comment": "Calibration step 2 body", + "extractionState": "manual" + }, + "Take out the applicator whose sensor code you entered, and peel off its cover.": { + "comment": "Insert step 4 body", + "extractionState": "manual" + }, + "Take the transmitter out of its box and prepare it for use. Wipe the bottom with an alcohol wipe and let it dry.": { + "comment": "Attach step 1 body", + "extractionState": "manual" }, "Tap Pair on the iOS prompt": { - "extractionState": "manual", - "comment": "Pairing callout title while awaiting the prompt" + "comment": "Pairing callout title while awaiting the prompt", + "extractionState": "manual" }, - "Accept the Bluetooth pairing request within about a minute. If it disappears, the app tries again on the next connection.": { - "extractionState": "manual", - "comment": "Pairing callout body while awaiting the prompt" + "Temperature": { + "comment": "Transmitter detail label for temperature", + "extractionState": "manual" }, - "A pairing request will appear": { - "extractionState": "manual", - "comment": "Pairing callout title" + "That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.": { + "comment": "Validation message for unrecognized sensor code", + "extractionState": "manual" }, - "The first time they connect, iOS shows a Bluetooth pairing request. Tap Pair within about a minute — if it disappears, the app will try again on the next connection.": { - "extractionState": "manual", - "comment": "Pairing callout body about the iOS prompt" + "That doesn't look like a Dexcom package. Try again or type the ID manually.": { + "comment": "Scanned non-Dexcom barcode", + "extractionState": "manual" }, - "Sensor session found": { - "extractionState": "manual", - "comment": "Pairing success label for an existing session" + "The 4-digit code is on the applicator’s adhesive label, and is unique to that sensor. Use the code from the applicator you are about to insert — a code from a different one will make readings inaccurate.": { + "comment": "Sensor code: where to find it", + "extractionState": "manual" }, - "Still looking": { - "extractionState": "manual", - "comment": "Pairing troubleshooting title" + "The calibration is sent the next time your transmitter connects, which can be up to 5 minutes away. The transmitter may accept it, ask for a second value, or reject it — this screen's status will tell you which.": { + "comment": "Calibration callout body about delivery timing", + "extractionState": "manual" }, - "Check that the Dexcom app is not connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.": { - "extractionState": "manual", - "comment": "Pairing troubleshooting body" + "The camera couldn't start. Try again or type the ID manually.": { + "comment": "Scanner failed to start", + "extractionState": "manual" }, - "Share Logs": { - "extractionState": "manual", - "comment": "Row to export the log files" + "The first time they connect, iOS shows a Bluetooth pairing request. Tap Pair within about a minute — if it disappears, the app will try again on the next connection.": { + "comment": "Pairing callout body about the iOS prompt", + "extractionState": "manual" }, - "Connected to your transmitter": { - "extractionState": "manual", - "comment": "Pairing status: connected" + "The identifier is derived from the transmitter's own clock, so the same reading keeps one identity whether it arrives live or is filled in later.": { + "comment": "Footer explaining the sync identifier", + "extractionState": "manual" }, - "Waiting for you to accept pairing…": { - "extractionState": "manual", - "comment": "Pairing status: awaiting prompt" + "The site must be completely dry or the adhesive will not stick.": { + "comment": "Insert step 2 note", + "extractionState": "manual" }, - "Looking for your transmitter…": { - "extractionState": "manual", - "comment": "Pairing status: searching" + "This app": { + "comment": "Fallback when the host app name cannot be read", + "extractionState": "manual" }, - "Your sensor is warming up": { - "extractionState": "manual", - "comment": "Warm-up screen heading" + "This app will stop reading from your transmitter. Your sensor session keeps running on the transmitter itself.": { + "comment": "Confirmation message for removing the CGM", + "extractionState": "manual" }, - "Warm-up takes about %d minutes. You will not get any glucose readings until it finishes.": { - "extractionState": "manual", - "comment": "Warm-up duration explanation (1: minutes)" + "This app works with Dexcom G6 and Dexcom ONE transmitters. Older G5 transmitters, whose IDs begin with 4, are not supported.": { + "comment": "Transmitter ID: supported hardware note", + "extractionState": "manual" }, - "Do not dose on early readings": { - "extractionState": "manual", - "comment": "Warm-up callout title" + "This transmitter is past the end of its life. It still reports readings for a running session, but cannot start a new sensor.": { + "comment": "Footer shown when the transmitter has expired", + "extractionState": "manual" }, - "Do not make insulin decisions from the first readings after warm-up, or from readings that look wrong for how you feel. Use a fingerstick meter instead, and follow your care team's guidance.": { - "extractionState": "manual", - "comment": "Warm-up callout body about dosing safety" + "This transmitter reports an extended 180-day lifetime, which means it has been modified. Warm-up is shorter and you can choose how long sessions run.": { + "comment": "Footer explaining Anubis detection", + "extractionState": "manual" }, - "Trend": { - "extractionState": "manual", - "comment": "Reading detail label for trend" + "Thoroughly wash and dry your hands.": { + "comment": "Insert step 1 body", + "extractionState": "manual" }, - "Sensor status": { - "extractionState": "manual", - "comment": "Reading detail label for algorithm state" + "Transmitter": { + "comment": "Navigation title for transmitter details", + "extractionState": "manual" }, - "Display only": { - "extractionState": "manual", - "comment": "Reading detail label for display-only flag" + "Transmitter Details": { + "comment": "Row to transmitter details", + "extractionState": "manual" }, - "Yes": { - "extractionState": "manual", - "comment": "Affirmative" + "Transmitter Expired": { + "comment": "Status highlight when the transmitter is past end of life", + "extractionState": "manual" }, - "No": { - "extractionState": "manual", - "comment": "Negative" + "Transmitter ID": { + "comment": "Navigation title for transmitter ID entry", + "extractionState": "manual" }, - "Reading": { - "extractionState": "manual", - "comment": "Reading detail section header" + "Trend": { + "comment": "Reading detail label for trend", + "extractionState": "manual" }, - "A display-only value has been shifted by a recent calibration. It is shown for continuity and is not used for dosing.": { - "extractionState": "manual", - "comment": "Footer explaining display-only readings" + "Turning this on sends your readings to Dexcom Share so people who follow you can see them in the Dexcom Follow app. It is optional, and your readings still work in this app without it.": { + "comment": "Footer explaining Share upload", + "extractionState": "manual" }, - "Device": { - "extractionState": "manual", - "comment": "Reading detail label for device" + "Unknown": { + "comment": "Placeholder for unknown value", + "extractionState": "manual" }, - "Identifier": { - "extractionState": "manual", - "comment": "Reading detail label for sync identifier" + "Unusable (code %d)": { + "comment": "Algorithm state: unusable with raw code", + "extractionState": "manual" }, - "Source": { - "extractionState": "manual", - "comment": "Reading detail section header for source" + "Upload to Dexcom Share": { + "comment": "Toggle label for Share upload", + "extractionState": "manual" }, - "The identifier is derived from the transmitter's own clock, so the same reading keeps one identity whether it arrives live or is filled in later.": { - "extractionState": "manual", - "comment": "Footer explaining the sync identifier" + "Upload turned off and saved credentials removed.": { + "comment": "Status message after disabling Share upload", + "extractionState": "manual" }, - "Latest Reading": { - "extractionState": "manual", - "comment": "Reading detail screen title" + "Use a fingertip only — samples from other sites are less accurate. Use the same meter throughout a sensor session, and check that your strips are in date.": { + "comment": "Calibration step 2 note", + "extractionState": "manual" }, - "Not available": { - "extractionState": "manual", - "comment": "Value when the transmitter reports no trend" + "Use a fresh fingerstick reading from your meter, taken just now. Wash and dry your hands first — a value from unwashed fingers can be badly wrong and will teach the sensor the wrong thing.": { + "comment": "Calibration instructions", + "extractionState": "manual" }, - "Sensor failed": { - "extractionState": "manual", - "comment": "Algorithm state: failed" + "Use This Code": { + "comment": "Sensor code submit button", + "extractionState": "manual" }, - "Warming up": { - "extractionState": "manual", - "comment": "Algorithm state: warm-up" + "Username": { + "comment": "Share account username field", + "extractionState": "manual" }, - "Session stopped": { - "extractionState": "manual", - "comment": "Algorithm state: stopped" + "Voltage A": { + "comment": "Transmitter detail label for battery A", + "extractionState": "manual" }, - "Calibration requested": { - "extractionState": "manual", - "comment": "Algorithm state: needs calibration" + "Voltage B": { + "comment": "Transmitter detail label for battery B", + "extractionState": "manual" }, - "OK": { - "extractionState": "manual", - "comment": "Algorithm state: ok" + "Voltage B is getting low. The transmitter should finish the sensor you are wearing, but order a replacement now.": { + "comment": "Footer shown when battery B is low", + "extractionState": "manual" }, - "Unusable (code %d)": { - "extractionState": "manual", - "comment": "Algorithm state: unusable with raw code" + "Voltage B is very low. This transmitter may stop sending readings at any time, even during a session. Replace it as soon as you can.": { + "comment": "Footer shown when battery B is very low", + "extractionState": "manual" }, - "Start time unknown": { - "extractionState": "manual", - "comment": "Detail when a session is running but its start is unknown" + "Waiting for first reading…": { + "comment": "Placeholder before the first reading", + "extractionState": "manual" }, - "%@ until ready": { - "extractionState": "manual", - "comment": "Warm-up remaining (1: duration)" + "Waiting for you to accept pairing…": { + "comment": "Pairing status: awaiting prompt", + "extractionState": "manual" }, - "Replace sensor": { - "extractionState": "manual", - "comment": "Detail when the session is over" + "Warm-Up": { + "comment": "Navigation title for warm-up screen", + "extractionState": "manual" }, - "%@ remaining": { - "extractionState": "manual", - "comment": "Session remaining (1: duration)" + "Warm-up is finished": { + "comment": "Warm-up screen heading once warm-up has completed", + "extractionState": "manual" }, - "—": { - "extractionState": "manual", - "comment": "Placeholder for missing value" + "Warm-up takes about %d minutes. You will not get any glucose readings until it finishes.": { + "comment": "Warm-up duration explanation (1: minutes)", + "extractionState": "manual" }, - "Just now": { - "extractionState": "manual", - "comment": "Reading received less than a minute ago" + "Warming up": { + "comment": "Algorithm state: warm-up", + "extractionState": "manual" }, - "%d min ago": { - "extractionState": "manual", - "comment": "Minutes since last reading (1: minutes)" + "Warming Up": { + "comment": "Session phase: warm-up", + "extractionState": "manual" }, - "%dd %dh": { - "extractionState": "manual", - "comment": "Duration in days and hours" + "Warming up — readings start soon.": { + "comment": "Placeholder during warm-up", + "extractionState": "manual" }, - "%dh %dm": { - "extractionState": "manual", - "comment": "Duration in hours and minutes" + "Wash and dry your hands with soap and water — not a gel cleaner.": { + "comment": "Calibration step 1 body", + "extractionState": "manual" }, - "%dm": { - "extractionState": "manual", - "comment": "Duration in minutes" + "Without the code the sensor cannot use its factory calibration, and the transmitter will ask you for two fingerstick values after warm-up and one every day after that. The code is on the applicator label.": { + "comment": "Confirmation message explaining a code-less session", + "extractionState": "manual" }, - "Sensor start queued — it is sent the next time the transmitter connects, which can take up to 5 minutes.": { - "extractionState": "manual", - "comment": "Shown while a session start is waiting for the next connection" + "Yes": { + "comment": "Affirmative", + "extractionState": "manual" }, - "Sensor": { - "extractionState": "manual", - "comment": "Settings section: sensor" + "You are almost done — the sensor session starts next.": { + "comment": "Attach step 5 note", + "extractionState": "manual" }, - "Last Reading": { - "extractionState": "manual", - "comment": "Settings section: last reading" + "You can start without one. The transmitter will then ask you for two fingerstick glucose values after warm-up, and for one more each day. \"Calibration\" means telling the sensor what your meter reads, so it can correct itself.": { + "comment": "Sensor code callout body explaining calibration", + "extractionState": "manual" }, - "No readings — replace the sensor.": { - "extractionState": "manual", - "comment": "Placeholder when the sensor has failed" + "You do not need the Dexcom app — and you must not let it use the same transmitter at the same time.": { + "comment": "Introduction: single-app requirement", + "extractionState": "manual" }, - "Warming up — readings start soon.": { - "extractionState": "manual", - "comment": "Placeholder during warm-up" + "You will enter the new transmitter's ID and can then start a sensor session on it. Your other settings are kept.": { + "comment": "Confirmation message for pairing a new transmitter with no session running", + "extractionState": "manual" }, - "Waiting for first reading…": { - "extractionState": "manual", - "comment": "Placeholder before the first reading" + "You will stop getting readings until you start a new sensor. A stopped session cannot be restarted.": { + "comment": "Confirmation message for stopping a session", + "extractionState": "manual" }, - "Sensor started": { - "extractionState": "manual", - "comment": "Metadata label for sensor start" + "Your current sensor session will end. A session lives on the transmitter, so it cannot move to a new one — you will start a fresh session after pairing, and can reuse the sensor you are wearing. Stop the sensor first if your old transmitter is still attached and you want it stopped cleanly. Your other settings are kept.": { + "comment": "Confirmation message for pairing a new transmitter while a session is running", + "extractionState": "manual" }, - "Session ends": { - "extractionState": "manual", - "comment": "Metadata label for session end" + "Your sensor is warming up": { + "comment": "Warm-up screen heading", + "extractionState": "manual" }, - "Sensor code": { + "Your transmitter only talks to your phone about once every 5 minutes, so this can take a few minutes. Keep your phone nearby.": { + "comment": "Pairing: explanation of the 5-minute cycle", + "extractionState": "manual" + }, + "Your username and password are stored in the iOS Keychain on this device only. They are never written into app settings, logs, or backups of this app's data.": { + "comment": "Footer explaining Keychain credential storage", + "extractionState": "manual" + }, + "Scan applicator code": { "extractionState": "manual", - "comment": "Metadata label for sensor code" + "comment": "Scan applicator barcode button" }, - "Not used": { + "Allow camera access in Settings to scan the Data Matrix code on your sensor applicator.": { "extractionState": "manual", - "comment": "Value when a session runs without a sensor code" + "comment": "Camera denied alert message (sensor code)" }, - "Start New Sensor": { + "Couldn't find a sensor code in that barcode. The code is in the Data Matrix on the applicator, not the sensor box.": { "extractionState": "manual", - "comment": "Button to begin a new sensor session" + "comment": "Scanned barcode without a sensor code" }, - "Enter Calibration": { + "The camera couldn't start. Try again or type the code manually.": { "extractionState": "manual", - "comment": "Button to enter a calibration" + "comment": "Scanner failed to start" }, - "After warm-up": { + "Current sensor reading": { "extractionState": "manual", - "comment": "Why calibration is unavailable during warm-up" + "comment": "Calibration: current reading label" }, - "No sensor": { + "Large calibration change": { "extractionState": "manual", - "comment": "Why calibration is unavailable without a session" + "comment": "Calibration deviation warning title" }, - "Stop Sensor Session": { + "Send Anyway": { "extractionState": "manual", - "comment": "Button to stop the sensor session" + "comment": "Confirm calibration despite large deviation" }, - "Stop this sensor session?": { + "This is %1$@ away from the current sensor reading. A change this large can be bad for sensor accuracy; try to limit each calibration to at most %2$@ at a time.": { "extractionState": "manual", - "comment": "Confirmation title for stopping a session" + "comment": "Calibration deviation warning (1: deviation with unit, 2: recommended maximum with unit)" }, - "Stop Session": { + "No recent sensor reading": { "extractionState": "manual", - "comment": "Confirm stop session" + "comment": "Calibration unavailable: title" }, - "You will stop getting readings until you start a new sensor. A stopped session cannot be restarted.": { + "Calibrating compares your fingerstick to what the sensor reads right now, so it needs a recent reading. Wait for the next reading, then calibrate.": { "extractionState": "manual", - "comment": "Confirmation message for stopping a session" + "comment": "Calibration unavailable: explanation" }, - "Calibration becomes available once warm-up finishes. Do not make insulin decisions from early readings — use a fingerstick meter and follow your care team's guidance.": { + "Glucose is changing": { "extractionState": "manual", - "comment": "Footer explaining warm-up restrictions" + "comment": "Calibration unavailable while trend is not flat: title" }, - "Transmitter Details": { + "Calibrating compares your fingerstick to the current reading, which only works when your glucose is steady. Wait for a flat trend arrow (→), then calibrate.": { "extractionState": "manual", - "comment": "Row to transmitter details" + "comment": "Calibration unavailable while trend is not flat: explanation" }, - "Dexcom Share Upload": { + "Session length": { "extractionState": "manual", - "comment": "Row to Share upload settings" + "comment": "Session length picker label" }, - "How to Apply a Sensor": { + "High": { "extractionState": "manual", - "comment": "Row to the placement guide" + "comment": "Battery level: high" }, - "Remove CGM from App": { + "Low": { "extractionState": "manual", - "comment": "Button to delete the CGM manager" + "comment": "Battery level: low" }, - "Remove this CGM?": { + "Replace Now": { "extractionState": "manual", - "comment": "Confirmation title for removing the CGM" + "comment": "Battery level: very low, replace transmitter" }, - "Remove CGM": { + "Save": { "extractionState": "manual", - "comment": "Confirm CGM removal" + "comment": "Save session length button" }, - "This app will stop reading from your transmitter. Your sensor session keeps running on the transmitter itself.": { + "Listening to the Dexcom app": { "extractionState": "manual", - "comment": "Confirmation message for removing the CGM" + "comment": "Pairing callout title in passive mode" }, - "“Dexcom”, “G6”, and “ONE” are trademarks of Dexcom, Inc., used here only to describe which devices are compatible. This app is not made by or endorsed by Dexcom.": { + "Passive mode never pairs with the transmitter itself. The Dexcom G6 or ONE app must be installed and connected to this transmitter — this app listens to its session.": { "extractionState": "manual", - "comment": "Trademark attribution footer" + "comment": "Pairing callout body in passive mode" }, - "Warm-up is finished": { + "Check that the Dexcom app is installed and connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.": { "extractionState": "manual", - "comment": "Warm-up screen heading once warm-up has completed" + "comment": "Pairing troubleshooting body in passive mode" }, - "Readings will appear as they arrive from the transmitter, about every 5 minutes.": { + "Passive Mode": { "extractionState": "manual", - "comment": "Explanation shown once warm-up has completed" + "comment": "Toggle label for passive (listen-only) mode" }, - "Expected to finish at %1$@, about %2$d minutes from now": { + "Connection": { "extractionState": "manual", - "comment": "Warm-up completion time (1: time, 2: minutes remaining)" + "comment": "Transmitter details section header for connection mode" }, - "Calibrations must be between %1$@ and %2$@. If your meter reads outside that range, treat it and follow your care team's guidance instead of calibrating.": { + "Passive mode listens to the Dexcom G6 or ONE app on this phone, which must be installed and running a sensor session with this transmitter. Start, stop and calibrate from the Dexcom app.": { "extractionState": "manual", - "comment": "Validation message for out-of-range calibration (1: minimum with unit, 2: maximum with unit)" + "comment": "Footer explaining passive mode" }, - "Voltage A": { + "Direct mode talks to the transmitter itself, which allows starting, stopping and calibrating sessions here. The Dexcom app must not be connected to this transmitter while direct mode is on.": { "extractionState": "manual", - "comment": "Transmitter detail label for battery A" + "comment": "Footer explaining direct mode" }, - "%d mV": { + "Switch to direct mode?": { "extractionState": "manual", - "comment": "Voltage in millivolts (1: millivolts)" + "comment": "Confirmation title for turning passive mode off" }, - "Voltage B": { + "Switch to Direct Mode": { "extractionState": "manual", - "comment": "Transmitter detail label for battery B" + "comment": "Confirm turning passive mode off" }, - "Resistance": { + "Close the Dexcom app and turn off its Bluetooth access, or delete it, before switching. Only one app on this phone can talk to the transmitter; if the Dexcom app is still connected, the two will interfere and readings can be lost.": { "extractionState": "manual", - "comment": "Transmitter detail label for battery resistance" + "comment": "Warning shown before turning passive mode off" }, - "Temperature": { + "Sessions and calibrations are managed in the Dexcom app while passive mode is on.": { "extractionState": "manual", - "comment": "Transmitter detail label for temperature" + "comment": "Explanation replacing the session action buttons in passive mode" }, - "%d °C": { + "Connection Mode": { "extractionState": "manual", - "comment": "Temperature in Celsius (1: degrees)" + "comment": "Navigation title for connection mode screen" }, - "Last checked": { + "%1$@ reads glucose from your G6 or Dexcom ONE transmitter over Bluetooth — either by talking to the transmitter itself, or by listening to the Dexcom app's session. You choose on the next screens.": { "extractionState": "manual", - "comment": "Transmitter detail label for last battery read" + "comment": "Introduction: what the driver does (1: app name)" }, - "Battery": { + "Choose how to connect": { "extractionState": "manual", - "comment": "Transmitter details section header for battery" + "comment": "Connection mode screen heading" }, - "Voltage B is very low. This transmitter may stop sending readings at any time, even during a session. Replace it as soon as you can.": { + "Direct": { "extractionState": "manual", - "comment": "Footer shown when battery B is very low" + "comment": "Connection mode option: direct" }, - "Voltage B is getting low. The transmitter should finish the sensor you are wearing, but order a replacement now.": { + "Recommended": { "extractionState": "manual", - "comment": "Footer shown when battery B is low" + "comment": "Badge on the recommended connection mode" }, - "Battery B is the cell that runs down first and determines whether the transmitter can finish a session.": { + "This app talks to the transmitter itself: start and stop sensor sessions, enter sensor codes and calibrations here. The Dexcom app must not be connected to this transmitter.": { "extractionState": "manual", - "comment": "Footer explaining what battery B means" + "comment": "Direct mode explanation" }, - "%1$@%2$@ per minute": { + "Listen to the Dexcom app": { "extractionState": "manual", - "comment": "Trend rate (1: sign, 2: formatted rate with unit)" + "comment": "Connection mode option: passive" }, - "%1$@%2$@/min": { + "This app listens to the session the Dexcom G6 or ONE app drives on this phone. The Dexcom app is required, and sessions, sensor codes and calibrations are managed there.": { "extractionState": "manual", - "comment": "Trend rate (1: sign, 2: formatted rate with unit)" + "comment": "Passive mode explanation" } }, - "version": "1.0" + "version": "1.1" } diff --git a/G6SensorKitUI/Views/G6DetailViews.swift b/G6SensorKitUI/Views/G6DetailViews.swift index d8edcc3..7bb19ce 100644 --- a/G6SensorKitUI/Views/G6DetailViews.swift +++ b/G6SensorKitUI/Views/G6DetailViews.swift @@ -4,8 +4,8 @@ // // Copyright © 2026 Nightscout Foundation. MIT License. // -// Calibration entry, transmitter details, Anubis session-length settings, -// and optional Dexcom Share upload configuration. +// Calibration entry, transmitter and battery details, Anubis session-length +// settings, and optional Dexcom Share upload configuration. // import SwiftUI @@ -17,20 +17,99 @@ import G6SensorCore // MARK: - Calibration +/// Keeps the calibration screen's "current reading" live. State updates +/// alone are not enough: they only arrive WITH a reading, so without the +/// tick a disconnected transmitter would leave an old reading looking +/// fresh forever. +final class G6CalibrationViewModel: ObservableObject, G6CGMManagerObserver { + @Published private(set) var latestReading: G6StoredReading? + @Published private(set) var now = Date() + + private var tick: Timer? + + init(cgmManager: G6CGMManager) { + latestReading = cgmManager.state.latestReading + cgmManager.addStateObserver(self) + + let timer = Timer(timeInterval: 30, repeats: true) { [weak self] _ in + self?.now = Date() + } + RunLoop.main.add(timer, forMode: .common) + tick = timer + } + + deinit { + tick?.invalidate() + } + + func g6CGMManagerDidUpdateState(_ manager: G6CGMManager) { + DispatchQueue.main.async { + self.latestReading = manager.state.latestReading + } + } + + /// Readings arrive every 5 minutes; one missed reading closes the gate. + /// The extra 30 seconds absorb delivery jitter so a slightly late reading + /// does not flicker the gate. + static let readingFreshnessInterval: TimeInterval = .minutes(5.5) + + /// Why calibration is or is not currently possible. + enum Readiness { + case ready + case noFreshReading + case notFlat + } + + var freshReading: G6StoredReading? { + guard let reading = latestReading, + now.timeIntervalSince(reading.date) <= Self.readingFreshnessInterval + else { + return nil + } + return reading + } + + /// Calibrating teaches the sensor from the difference between the + /// fingerstick and the current reading; a reading that is moving cannot + /// be compared fairly, so calibration requires a flat trend. + var readiness: Readiness { + guard let reading = freshReading else { + return .noFreshReading + } + guard G6TrendArrow(dexcomRateMgDLPerMinute: reading.trendRateMgDLPerMinute) == .flat else { + return .notFlat + } + return .ready + } +} + struct G6CalibrationView: View { let cgmManager: G6CGMManager let didSubmit: () -> Void + @StateObject private var viewModel: G6CalibrationViewModel + @State private var entry: String = "" @State private var validationMessage: String? @State private var showingGuide = false + @State private var showingDeviationWarning = false @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference private static let minimumMgDL: Double = 40 private static let maximumMgDL: Double = 400 + /// Deviation from the current reading at or beyond which the big-change + /// warning shows (mg/dL). + private static let largeDeviationWarningMgDL: Double = 40 + + init(cgmManager: G6CGMManager, didSubmit: @escaping () -> Void) { + self.cgmManager = cgmManager + self.didSubmit = didSubmit + _viewModel = StateObject(wrappedValue: G6CalibrationViewModel(cgmManager: cgmManager)) + } + private var unit: HKUnit { return displayGlucosePreference.unit } @@ -47,6 +126,38 @@ struct G6CalibrationView: View { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value) } + private func formatted(mgdl value: Double) -> String { + return displayGlucosePreference.format(quantity(fromMgDL: value)) + } + + /// Latest sensor reading in mg/dL, if one exists and is fresh enough to + /// calibrate against. + private var currentValueMgDL: Double? { + return viewModel.freshReading?.glucoseMgDL + } + + private var isCalibrationBlocked: Bool { + return viewModel.readiness != .ready + } + + private var enteredMgDL: Double? { + let normalized = entry.replacingOccurrences(of: decimalSeparator, with: ".") + return Double(normalized).map { + HKQuantity(unit: unit, doubleValue: $0).doubleValue(for: .milligramsPerDeciliter) + } + } + + private var deviationMgDL: Double? { + guard let entered = enteredMgDL, let current = currentValueMgDL else { + return nil + } + return abs(entered - current) + } + + private var showsLargeDeviationWarning: Bool { + return (deviationMgDL ?? 0) >= Self.largeDeviationWarningMgDL + } + private func sanitize(_ input: String) -> String { guard usesDecimals else { return String(input.filter(\.isNumber).prefix(3)) @@ -84,13 +195,26 @@ struct G6CalibrationView: View { } } + HStack { + Text(LocalizedString("Current sensor reading", comment: "Calibration: current reading label")) + Spacer() + if let current = currentValueMgDL { + Text(formatted(mgdl: current)) + .foregroundColor(.secondary) + } else { + Text(LocalizedString("—", comment: "Placeholder for missing value")) + .foregroundColor(.secondary) + } + } + .font(.subheadline) + TextField(unit.localizedShortUnitString, text: $entry) .keyboardType(usesDecimals ? .decimalPad : .numberPad) .font(.title3.monospaced()) .padding() .background(Color(.secondarySystemBackground)) .cornerRadius(10) - .onChange(of: entry) { newValue in + .onChange(of: entry) { _, newValue in let sanitized = sanitize(newValue) if sanitized != newValue { entry = sanitized @@ -112,34 +236,75 @@ struct G6CalibrationView: View { body: LocalizedString("The calibration is sent the next time your transmitter connects, which can be up to 5 minutes away. The transmitter may accept it, ask for a second value, or reject it — this screen's status will tell you which.", comment: "Calibration callout body about delivery timing") ) + switch viewModel.readiness { + case .ready: + EmptyView() + case .noFreshReading: + G6CalloutBox( + symbolName: "exclamationmark.triangle.fill", + tint: .orange, + title: LocalizedString("No recent sensor reading", comment: "Calibration unavailable: title"), + body: LocalizedString("Calibrating compares your fingerstick to what the sensor reads right now, so it needs a recent reading. Wait for the next reading, then calibrate.", comment: "Calibration unavailable: explanation") + ) + case .notFlat: + G6CalloutBox( + symbolName: "exclamationmark.triangle.fill", + tint: .orange, + title: LocalizedString("Glucose is changing", comment: "Calibration unavailable while trend is not flat: title"), + body: LocalizedString("Calibrating compares your fingerstick to the current reading, which only works when your glucose is steady. Wait for a flat trend arrow (→), then calibrate.", comment: "Calibration unavailable while trend is not flat: explanation") + ) + } + } .padding() } - Button(action: submit) { + Button(action: submitTapped) { Text(LocalizedString("Send Calibration", comment: "Calibration submit button")) .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(G6PrimaryButtonStyle()) - .disabled(entry.isEmpty) + .disabled(entry.isEmpty || isCalibrationBlocked) .padding() } .sheet(isPresented: $showingGuide) { G6CalibrationGuideView(onDone: { showingGuide = false }) } + .alert( + Text(LocalizedString("Large calibration change", comment: "Calibration deviation warning title")), + isPresented: $showingDeviationWarning + ) { + Button(role: .cancel, action: {}) { + Text(LocalizedString("Cancel", comment: "Cancel button")) + } + Button(action: submit) { + Text(LocalizedString("Send Anyway", comment: "Confirm calibration despite large deviation")) + } + } message: { + Text(String( + format: LocalizedString("This is %1$@ away from the current sensor reading. A change this large can be bad for sensor accuracy; try to limit each calibration to at most %2$@ at a time.", comment: "Calibration deviation warning (1: deviation with unit, 2: recommended maximum with unit)"), + formatted(mgdl: deviationMgDL ?? 0), + formatted(mgdl: Self.largeDeviationWarningMgDL) + )) + } } - private func submit() { - let normalized = entry.replacingOccurrences(of: decimalSeparator, with: ".") + private func submitTapped() { + // A large jump from the current reading gets a confirmation first; + // big corrections can teach the sensor the wrong thing. + guard showsLargeDeviationWarning else { + submit() + return + } + showingDeviationWarning = true + } - guard let entered = Double(normalized) else { + private func submit() { + guard let valueMgDL = enteredMgDL else { validationMessage = LocalizedString("Enter a number.", comment: "Validation message for non-numeric calibration entry") return } - let valueMgDL = HKQuantity(unit: unit, doubleValue: entered) - .doubleValue(for: .milligramsPerDeciliter) - guard (Self.minimumMgDL...Self.maximumMgDL).contains(valueMgDL) else { validationMessage = String( format: LocalizedString("Calibrations must be between %1$@ and %2$@. If your meter reads outside that range, treat it and follow your care team's guidance instead of calibrating.", comment: "Validation message for out-of-range calibration (1: minimum with unit, 2: maximum with unit)"), @@ -163,6 +328,12 @@ struct G6TransmitterDetailsView: View { @State private var showingPairConfirmation = false + /// Mirrors the persisted mode; writes go through the manager, which + /// rebuilds the session as the other kind. + @State private var passiveModeEnabled = false + + @State private var showingDirectModeConfirmation = false + var body: some View { List { // Top of the transmitter screen, matching where the @@ -188,7 +359,45 @@ struct G6TransmitterDetailsView: View { } Section { - row(LocalizedString("Model", comment: "Transmitter detail label for model"), cgmManager.state.deviceModel) + // Turning passive off is confirmed before the switch: a direct + // session connects straight away, and two clients talking to + // the transmitter from one phone interfere with each other. + Toggle(LocalizedString("Passive Mode", comment: "Toggle label for passive (listen-only) mode"), isOn: Binding( + get: { passiveModeEnabled }, + set: { newValue in + if newValue { + setPassiveModeEnabled(true) + } else { + showingDirectModeConfirmation = true + } + } + )) + .confirmationDialog( + LocalizedString("Switch to direct mode?", comment: "Confirmation title for turning passive mode off"), + isPresented: $showingDirectModeConfirmation, + titleVisibility: .visible + ) { + Button(LocalizedString("Switch to Direct Mode", comment: "Confirm turning passive mode off")) { + setPassiveModeEnabled(false) + } + } message: { + Text(LocalizedString("Close the Dexcom app and turn off its Bluetooth access, or delete it, before switching. Only one app on this phone can talk to the transmitter; if the Dexcom app is still connected, the two will interfere and readings can be lost.", comment: "Warning shown before turning passive mode off")) + } + } header: { + Text(LocalizedString("Connection", comment: "Transmitter details section header for connection mode")) + } footer: { + if passiveModeEnabled { + Text(LocalizedString("Passive mode listens to the Dexcom G6 or ONE app on this phone, which must be installed and running a sensor session with this transmitter. Start, stop and calibrate from the Dexcom app.", comment: "Footer explaining passive mode")) + } else { + Text(LocalizedString("Direct mode talks to the transmitter itself, which allows starting, stopping and calibrating sessions here. The Dexcom app must not be connected to this transmitter while direct mode is on.", comment: "Footer explaining direct mode")) + } + } + + Section { + row( + LocalizedString("Model", comment: "Transmitter detail label for model"), + cgmManager.state.isAnubis ? "Anubis" : cgmManager.state.deviceModel + ) row(LocalizedString("Transmitter ID", comment: "Transmitter detail label for ID"), cgmManager.state.transmitterID) row( LocalizedString("Firmware", comment: "Transmitter detail label for firmware"), @@ -225,56 +434,78 @@ struct G6TransmitterDetailsView: View { } } - batterySection } .listStyle(.insetGrouped) + .onAppear { passiveModeEnabled = cgmManager.state.passiveModeEnabled } } - @ViewBuilder - private var batterySection: some View { - if let voltageA = cgmManager.state.batteryVoltageAMillivolts, - let voltageB = cgmManager.state.batteryVoltageBMillivolts { - Section { - row( - LocalizedString("Voltage A", comment: "Transmitter detail label for battery A"), - String(format: LocalizedString("%d mV", comment: "Voltage in millivolts (1: millivolts)"), voltageA) - ) - HStack { - Text(LocalizedString("Voltage B", comment: "Transmitter detail label for battery B")) - Spacer() - Text(String(format: LocalizedString("%d mV", comment: "Voltage in millivolts (1: millivolts)"), voltageB)) - .foregroundColor(cgmManager.state.isBatteryLow ? .orange : .secondary) - } - if let resistance = cgmManager.state.batteryResistance { - row( - LocalizedString("Resistance", comment: "Transmitter detail label for battery resistance"), - String(Int(resistance)) - ) - } - if let temperature = cgmManager.state.batteryTemperature { - row( - LocalizedString("Temperature", comment: "Transmitter detail label for temperature"), - String(format: LocalizedString("%d °C", comment: "Temperature in Celsius (1: degrees)"), temperature) - ) - } - if let read = cgmManager.state.lastBatteryReadDate { + private func setPassiveModeEnabled(_ enabled: Bool) { + passiveModeEnabled = enabled + cgmManager.setPassiveModeEnabled(enabled) + } + + private func row(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value).foregroundColor(.secondary) + } + } +} + +// MARK: - Battery details + +struct G6BatteryDetailsView: View { + + let cgmManager: G6CGMManager + + var body: some View { + List { + if let voltageA = cgmManager.state.batteryVoltageAMillivolts, + let voltageB = cgmManager.state.batteryVoltageBMillivolts { + Section { row( - LocalizedString("Last checked", comment: "Transmitter detail label for last battery read"), - read.formatted(date: .abbreviated, time: .shortened) + LocalizedString("Voltage A", comment: "Transmitter detail label for battery A"), + String(format: LocalizedString("%d mV", comment: "Voltage in millivolts (1: millivolts)"), voltageA) ) - } - } header: { - Text(LocalizedString("Battery", comment: "Transmitter details section header for battery")) - } footer: { - if cgmManager.state.isBatteryVeryLow { - Text(LocalizedString("Voltage B is very low. This transmitter may stop sending readings at any time, even during a session. Replace it as soon as you can.", comment: "Footer shown when battery B is very low")) - } else if cgmManager.state.isBatteryLow { - Text(LocalizedString("Voltage B is getting low. The transmitter should finish the sensor you are wearing, but order a replacement now.", comment: "Footer shown when battery B is low")) - } else { - Text(LocalizedString("Battery B is the cell that runs down first and determines whether the transmitter can finish a session.", comment: "Footer explaining what battery B means")) + HStack { + Text(LocalizedString("Voltage B", comment: "Transmitter detail label for battery B")) + Spacer() + Text(String(format: LocalizedString("%d mV", comment: "Voltage in millivolts (1: millivolts)"), voltageB)) + .foregroundColor(cgmManager.state.isBatteryLow ? .orange : .secondary) + } + if let resistance = cgmManager.state.batteryResistance { + row( + LocalizedString("Resistance", comment: "Transmitter detail label for battery resistance"), + String(Int(resistance)) + ) + } + if let temperature = cgmManager.state.batteryTemperature { + row( + LocalizedString("Temperature", comment: "Transmitter detail label for temperature"), + String(format: LocalizedString("%d °C", comment: "Temperature in Celsius (1: degrees)"), temperature) + ) + } + if let read = cgmManager.state.lastBatteryReadDate { + row( + LocalizedString("Last checked", comment: "Transmitter detail label for last battery read"), + read.formatted(date: .abbreviated, time: .shortened) + ) + } + } header: { + Text(LocalizedString("Battery", comment: "Transmitter details section header for battery")) + } footer: { + if cgmManager.state.isBatteryVeryLow { + Text(LocalizedString("Voltage B is very low. This transmitter may stop sending readings at any time, even during a session. Replace it as soon as you can.", comment: "Footer shown when battery B is very low")) + } else if cgmManager.state.isBatteryLow { + Text(LocalizedString("Voltage B is getting low. The transmitter should finish the sensor you are wearing, but order a replacement now.", comment: "Footer shown when battery B is low")) + } else { + Text(LocalizedString("Battery B is the cell that runs down first and determines whether the transmitter can finish a session.", comment: "Footer explaining what battery B means")) + } } } } + .listStyle(.insetGrouped) } private func row(_ label: String, _ value: String) -> some View { @@ -292,42 +523,66 @@ struct G6SensorLifeSettingsView: View { let cgmManager: G6CGMManager - @State private var days: Double + @State private var days: Int + @State private var savedDays: Int init(cgmManager: G6CGMManager) { self.cgmManager = cgmManager - _days = State(initialValue: Double(cgmManager.state.sensorLifeDays)) + let initial = cgmManager.state.sensorLifeDays + _days = State(initialValue: initial) + _savedDays = State(initialValue: initial) } - private var range: ClosedRange { - let bounds = TransmitterManagerState.sensorLifeDaysRange - return Double(bounds.lowerBound)...Double(bounds.upperBound) + private var isDirty: Bool { + return days != savedDays } var body: some View { List { Section { - VStack(alignment: .leading, spacing: 12) { + HStack { + Text(LocalizedString("Session length", comment: "Session length picker label")) + Spacer() Text(String( - format: LocalizedString("Session length: %d days", comment: "Session length value (1: day count)"), - Int(days) + format: LocalizedString("%d days", comment: "Session length value (1: day count)"), + savedDays )) - .font(.headline) + .foregroundColor(.secondary) + } - Slider(value: $days, in: range, step: 1) { editing in - if !editing { - cgmManager.setSensorLifeDays(Int(days)) - } + Picker(selection: $days) { + ForEach(TransmitterManagerState.sensorLifeDaysRange, id: \.self) { value in + Text(String( + format: LocalizedString("%d days", comment: "Session length option (1: day count)"), + value + )) + .tag(value) } + } label: { + Text(LocalizedString("Session length", comment: "Session length picker label")) } - } header: { - Text(LocalizedString("Session Length", comment: "Section header for session length")) + .pickerStyle(.wheel) + .labelsHidden() } footer: { Text(LocalizedString("Modified transmitters can keep a sensor running past the standard 10 days. This setting only changes when this app treats the session as finished; it does not change the sensor's accuracy. Sensor readings can drift the longer a sensor is worn, so check against a fingerstick meter if a reading does not match how you feel.", comment: "Footer explaining extended session length and its accuracy caveat")) } + + // Applied explicitly: a wheel picker fires intermediate values + // while scrolling, and each write hits persistent state. + Button(action: save) { + Text(LocalizedString("Save", comment: "Save session length button")) + } + .disabled(!isDirty) + .buttonStyle(G6PrimaryButtonStyle()) + .listRowInsets(EdgeInsets()) } .listStyle(.insetGrouped) } + + private func save() { + cgmManager.setSensorLifeDays(days) + savedDays = days + } } // MARK: - Dexcom Share upload diff --git a/G6SensorKitUI/Views/G6Graphics.swift b/G6SensorKitUI/Views/G6Graphics.swift index d91efa4..4f1041c 100644 --- a/G6SensorKitUI/Views/G6Graphics.swift +++ b/G6SensorKitUI/Views/G6Graphics.swift @@ -328,11 +328,12 @@ struct G6AssetImage: View { struct G6FindCodeCard: View { let assetName: String let caption: String + var imageHeight: CGFloat = 170 @ViewBuilder var fallback: () -> Fallback var body: some View { VStack(alignment: .leading, spacing: 10) { - G6AssetImage(name: assetName, height: 170, fallback: fallback) + G6AssetImage(name: assetName, height: imageHeight, fallback: fallback) .frame(maxWidth: .infinity) .padding(10) .background( @@ -411,9 +412,10 @@ struct G6SensorCodeLabelGlyph: View { struct G6TransmitterImage: View { var size: CGFloat = 88 var isActive: Bool = true + var assetName: String = "G6Transmitter" var body: some View { - if let image = UIImage(named: "G6Transmitter", in: Bundle(for: G6UICoordinator.self), compatibleWith: nil) { + if let image = UIImage(named: assetName, in: Bundle(for: G6UICoordinator.self), compatibleWith: nil) { Image(uiImage: image) .resizable() .scaledToFit() diff --git a/G6SensorKitUI/Views/G6OnboardingViews.swift b/G6SensorKitUI/Views/G6OnboardingViews.swift index 7655934..c59eae6 100644 --- a/G6SensorKitUI/Views/G6OnboardingViews.swift +++ b/G6SensorKitUI/Views/G6OnboardingViews.swift @@ -19,6 +19,21 @@ import LoopKitUI import G6SensorKit import G6SensorCore +#if targetEnvironment(simulator) +/// Simulator stand-in for the camera scanner, which needs hardware the +/// simulator lacks: the scan buttons stay visible there and deliver a +/// fixed payload, so the whole scan flow can be exercised without a +/// device. Compiled out on hardware. Both payloads are real, public +/// Dexcom packaging data (the same ones the parser tests use). +enum G6DemoScan { + /// The full Data Matrix from a public G6 transmitter box photo. + static let transmitterBox = G6PackageScan(payload: + "241STT-OM-001\u{1D}1018038762\u{1D}2188H03B\u{1D}17250226") + /// A real, public G6 applicator label. + static let applicator = G6PackageScan(payload: "10731863521434687D2405937") +} +#endif + // MARK: - Shared building blocks /// A numbered instruction row used by the placement guide. @@ -74,12 +89,82 @@ struct G6ContinueButton: View { } } +/// Large per-character boxes over a hidden text field that does the real +/// input. Shared by the transmitter-ID screen (6 boxes, alphanumeric) +/// and the sensor-code screen (4 boxes, numeric). +struct G6CodeBoxField: View { + enum AllowedCharacters { + case alphanumericUppercased + case numeric + } + + @Binding var text: String + let length: Int + let allowedCharacters: AllowedCharacters + var focused: FocusState.Binding + + private var filteredText: Binding { + Binding( + get: { text }, + set: { newValue in + let filtered: String + switch allowedCharacters { + case .alphanumericUppercased: + filtered = newValue.filter { $0.isLetter || $0.isNumber }.uppercased() + case .numeric: + filtered = newValue.filter(\.isNumber) + } + text = String(filtered.prefix(length)) + } + ) + } + + var body: some View { + ZStack { + TextField("", text: filteredText) + .focused(focused) + .keyboardType(allowedCharacters == .alphanumericUppercased ? .asciiCapable : .numberPad) + .autocorrectionDisabled() + .textInputAutocapitalization(allowedCharacters == .alphanumericUppercased ? .characters : .never) + .foregroundColor(.clear) + .accentColor(.clear) + .opacity(0.02) + + HStack(spacing: 10) { + ForEach(0 ..< length, id: \.self) { index in + let characters = Array(text) + let character = index < characters.count ? String(characters[index]) : "" + let isActive = index == characters.count + + Text(character) + .font(.title.weight(.semibold).monospaced()) + .frame(maxWidth: .infinity) + .frame(height: 64) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isActive ? Color.accentColor : Color(.separator), lineWidth: isActive ? 2 : 1) + ) + } + } + .allowsHitTesting(false) + } + .frame(height: 64) + .contentShape(Rectangle()) + .onTapGesture { focused.wrappedValue = true } + } +} + // MARK: - 1. Introduction /// Product hero, in the shape G7SensorKit uses: name, device, a short -/// statement of what this driver does, then Continue. The wording is the -/// inverse of G7's — that one piggybacks the manufacturer's app, this one -/// replaces it, and getting that distinction wrong would be dangerous. +/// statement of what this driver does, then Continue. The mode distinction +/// (direct vs. listening to the Dexcom app) is introduced here and chosen on +/// the connection-mode screen — getting that distinction wrong in the copy +/// would be dangerous. struct G6IntroductionView: View { let didContinue: () -> Void @@ -97,12 +182,9 @@ struct G6IntroductionView: View { VStack(spacing: 14) { Text(String( - format: LocalizedString("%1$@ talks to your G6 or Dexcom ONE transmitter directly. It starts and stops sensor sessions, sends your calibrations, and fills in readings it missed.", comment: "Introduction: what the native driver does (1: app name)"), + format: LocalizedString("%1$@ reads glucose from your G6 or Dexcom ONE transmitter over Bluetooth — either by talking to the transmitter itself, or by listening to the Dexcom app's session. You choose on the next screens.", comment: "Introduction: what the driver does (1: app name)"), appName )) - - Text(LocalizedString("You do not need the Dexcom app — and you must not let it use the same transmitter at the same time.", comment: "Introduction: single-app requirement")) - .foregroundStyle(.secondary) } .font(.body) .multilineTextAlignment(.leading) @@ -164,6 +246,94 @@ struct G6CalloutBox: View { } } +// MARK: - 3. Connection mode + +/// Lets the user choose between owning the transmitter (direct) and +/// listening to the Dexcom app's session (passive). Direct is presented as +/// the recommendation; passive exists for people who want to keep the +/// manufacturer's app running. +struct G6ConnectionModeView: View { + @State private var usePassive: Bool + let didContinue: (Bool) -> Void + + init(initialPassive: Bool, didContinue: @escaping (Bool) -> Void) { + _usePassive = State(initialValue: initialPassive) + self.didContinue = didContinue + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(LocalizedString("Choose how to connect", comment: "Connection mode screen heading")) + .font(.title2.bold()) + + modeCard( + title: LocalizedString("Direct", comment: "Connection mode option: direct"), + badge: LocalizedString("Recommended", comment: "Badge on the recommended connection mode"), + description: LocalizedString("This app talks to the transmitter itself: start and stop sensor sessions, enter sensor codes and calibrations here. The Dexcom app must not be connected to this transmitter.", comment: "Direct mode explanation"), + symbolName: "antenna.radiowaves.left.and.right", + isSelected: !usePassive + ) { + usePassive = false + } + + modeCard( + title: LocalizedString("Listen to the Dexcom app", comment: "Connection mode option: passive"), + badge: nil, + description: LocalizedString("This app listens to the session the Dexcom G6 or ONE app drives on this phone. The Dexcom app is required, and sessions, sensor codes and calibrations are managed there.", comment: "Passive mode explanation"), + symbolName: "ear.badge.waveform", + isSelected: usePassive + ) { + usePassive = true + } + } + .padding() + } + + G6ContinueButton(action: { didContinue(usePassive) }) + } + } + + private func modeCard(title: String, badge: String?, description: String, symbolName: String, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: symbolName) + .foregroundColor(isSelected ? Color.accentColor : .secondary) + Text(title) + .font(.headline) + .foregroundColor(.primary) + if let badge = badge { + Text(badge) + .font(.caption.weight(.semibold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.accentColor) + .cornerRadius(6) + } + Spacer() + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? Color.accentColor : Color(.separator)) + } + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} + // MARK: - 2. Transmitter ID struct G6TransmitterIDEntryView: View { @@ -172,6 +342,10 @@ struct G6TransmitterIDEntryView: View { @State private var transmitterID: String = "" @State private var validationMessage: String? + @State private var showScanner = false + @State private var showCameraDeniedAlert = false + @State private var scanFailedMessage: String? + @FocusState private var fieldFocused: Bool init(initialValue: String, didSubmit: @escaping (String) -> Void) { self.initialValue = initialValue @@ -202,24 +376,46 @@ struct G6TransmitterIDEntryView: View { G6TransmitterBackGlyph(size: 150) } - TextField(LocalizedString("6-character ID", comment: "Transmitter ID text field placeholder"), text: $transmitterID) - .textCase(.uppercase) - .autocorrectionDisabled() - .textInputAutocapitalization(.characters) - .font(.title3.monospaced()) - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(10) - .onChange(of: transmitterID) { newValue in - transmitterID = String(newValue.uppercased().prefix(6)) - validationMessage = nil - } + G6CodeBoxField( + text: $transmitterID, + length: 6, + allowedCharacters: .alphanumericUppercased, + focused: $fieldFocused + ) + .frame(maxWidth: .infinity) + .onChange(of: transmitterID) { + validationMessage = nil + autoSubmitIfComplete() + } - if let validationMessage = validationMessage { - Text(validationMessage) - .font(.subheadline) - .foregroundColor(.red) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 6) { + if let validationMessage = validationMessage { + Text(validationMessage) + .foregroundColor(.red) + } + if let scanFailedMessage = scanFailedMessage { + Text(verbatim: scanFailedMessage) + .foregroundColor(.red) + } + } + .font(.subheadline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + if isScanAvailable { + Button(action: scanTapped) { + HStack(spacing: 8) { + Image(systemName: "barcode.viewfinder") + Text(LocalizedString("Scan package code", comment: "Scan package barcode button")) + } + .font(.subheadline.weight(.semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Capsule().fill(Color.accentColor)) + } + .frame(maxWidth: .infinity) } Text(LocalizedString("This app works with Dexcom G6 and Dexcom ONE transmitters. Older G5 transmitters, whose IDs begin with 4, are not supported.", comment: "Transmitter ID: supported hardware note")) @@ -229,11 +425,120 @@ struct G6TransmitterIDEntryView: View { } .padding() } + .onTapGesture { fieldFocused = false } G6ContinueButton(isEnabled: transmitterID.count == 6) { submit() } } + .sheet(isPresented: $showScanner) { scannerSheet } + .alert( + Text(LocalizedString("Camera access is off", comment: "Camera denied alert title")), + isPresented: $showCameraDeniedAlert + ) { + Button(action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) { Text(LocalizedString("Open Settings", comment: "Open Settings button")) } + Button(role: .cancel, action: {}) { Text(LocalizedString("Cancel", comment: "Cancel button")) } + } message: { + Text(LocalizedString("Allow camera access in Settings to scan the Data Matrix code on your Dexcom transmitter packaging.", comment: "Camera denied alert message")) + } + } + + /// A complete ID submits itself, typed or scanned: with the boxes full + /// there is nothing left to enter. A short delay lets the last box + /// visibly fill first. + private func autoSubmitIfComplete() { + guard transmitterID.count == 6, !isG5 else { + return + } + fieldFocused = false + let submitted = transmitterID + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + guard transmitterID == submitted else { return } + didSubmit(submitted) + } + } + + // MARK: - Package scanning + + /// The camera scanner needs hardware support and a camera usage + /// description; the simulator gets the demo-payload stand-in instead + /// (see `G6DemoScan`). + private var isScanAvailable: Bool { + #if targetEnvironment(simulator) + return true + #else + return G6PackageScannerView.isSupported + #endif + } + + private func scanTapped() { + #if targetEnvironment(simulator) + handleScan(G6DemoScan.transmitterBox) + #else + switch G6PackageScannerView.cameraAuthorization { + case .authorized: + showScanner = true + case .notDetermined: + G6PackageScannerView.requestCameraAccess { granted in + if granted { + showScanner = true + } else { + showCameraDeniedAlert = true + } + } + default: + showCameraDeniedAlert = true + } + #endif + } + + private func handleScan(_ scan: G6PackageScan) { + showScanner = false + scanFailedMessage = nil + + // A usable ID outweighs the GTIN check: the transmitter box's own + // Data Matrices carry no (01) — the GTIN sits on a separate linear + // barcode — so gating on isDexcomPackage would reject the box. + guard let candidate = scan.transmitterIDCandidate else { + scanFailedMessage = scan.isDexcomPackage + ? LocalizedString("Scanned a Dexcom package, but couldn't find a transmitter ID in it. Type the ID manually.", comment: "Scanned Dexcom barcode without a transmitter ID") + : LocalizedString("That doesn't look like a Dexcom package. Try again or type the ID manually.", comment: "Scanned non-Dexcom barcode") + return + } + + // Assigning the field fires the auto-submit path, same as typing. + transmitterID = candidate + } + + private func handleScanStartFailure(_: Error) { + showScanner = false + scanFailedMessage = LocalizedString("The camera couldn't start. Try again or type the ID manually.", comment: "Scanner failed to start") + } + + private var scannerSheet: some View { + NavigationView { + G6PackageScannerView(onScan: handleScan, onStartFailure: handleScanStartFailure) + .edgesIgnoringSafeArea(.all) + .navigationTitle(Text(LocalizedString("Scan package code", comment: "Scanner screen title"))) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { showScanner = false }) { + Text(LocalizedString("Cancel", comment: "Cancel button")) + } + } + } + } + } + + // MARK: - Submit + + private var isG5: Bool { + return transmitterID.hasPrefix("4") } private func submit() { @@ -241,7 +546,7 @@ struct G6TransmitterIDEntryView: View { return } - if transmitterID.hasPrefix("4") { + if isG5 { validationMessage = LocalizedString("IDs that begin with 4 belong to Dexcom G5 transmitters, which this app does not support.", comment: "Validation message for G5 transmitter ID") return } @@ -250,7 +555,7 @@ struct G6TransmitterIDEntryView: View { } } -// MARK: - 3. Sensor code +// MARK: - 4. Sensor code struct G6SensorCodeEntryView: View { /// Passes nil when the user chooses to start without a code. @@ -259,6 +564,9 @@ struct G6SensorCodeEntryView: View { @State private var sensorCode: String = "" @State private var validationMessage: String? @State private var confirmingNoCode = false + @State private var showScanner = false + @State private var showCameraDeniedAlert = false + @State private var scanFailedMessage: String? @FocusState private var codeFieldFocused: Bool var body: some View { @@ -270,41 +578,66 @@ struct G6SensorCodeEntryView: View { G6FindCodeCard( assetName: "G6SensorCodeLocation", - caption: LocalizedString("The 4-digit code is on the applicator’s adhesive label, and is unique to that sensor. Use the code from the applicator you are about to insert — a code from a different one will make readings inaccurate.", comment: "Sensor code: where to find it") + caption: LocalizedString("The 4-digit code is on the applicator’s adhesive label, and is unique to that sensor. Use the code from the applicator you are about to insert — a code from a different one will make readings inaccurate.", comment: "Sensor code: where to find it"), + imageHeight: 110 ) { - G6SensorCodeLabelGlyph(size: 150) + G6SensorCodeLabelGlyph(size: 110) } - TextField(LocalizedString("4-digit code", comment: "Sensor code text field placeholder"), text: $sensorCode) - .keyboardType(.numberPad) - .focused($codeFieldFocused) - .font(.title3.monospaced()) - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(10) - .onChange(of: sensorCode) { newValue in - sensorCode = String(newValue.filter(\.isNumber).prefix(4)) - validationMessage = nil - } + G6CodeBoxField( + text: $sensorCode, + length: 4, + allowedCharacters: .numeric, + focused: $codeFieldFocused + ) + .frame(maxWidth: .infinity) + .padding(.horizontal, 32) + .onChange(of: sensorCode) { + validationMessage = nil + autoSubmitIfComplete() + } - if let validationMessage = validationMessage { - Text(validationMessage) - .font(.subheadline) - .foregroundColor(.red) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 6) { + if let validationMessage = validationMessage { + Text(validationMessage) + .foregroundColor(.red) + } + if let scanFailedMessage = scanFailedMessage { + Text(verbatim: scanFailedMessage) + .foregroundColor(.red) + } + } + .font(.subheadline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + if isScanAvailable { + Button(action: scanTapped) { + HStack(spacing: 8) { + Image(systemName: "barcode.viewfinder") + Text(LocalizedString("Scan applicator code", comment: "Scan applicator barcode button")) + } + .font(.subheadline.weight(.semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Capsule().fill(Color.accentColor)) + } + .frame(maxWidth: .infinity) } G6CalloutBox( symbolName: "drop.fill", tint: .blue, title: LocalizedString("No code on your sensor?", comment: "Sensor code callout title"), - body: LocalizedString("You can start without one. The transmitter will then ask you for two fingerstick glucose values after warm-up, and for one more each day. \"Calibration\" means telling the sensor what your meter reads, so it can correct itself.", comment: "Sensor code callout body explaining calibration") + body: LocalizedString("You can start without one. The transmitter will then ask you for two fingerstick glucose values after warm-up, and for one more each day.", comment: "Sensor code callout body explaining calibration") ) } .padding() } - .onAppear { codeFieldFocused = true } + .onTapGesture { codeFieldFocused = false } VStack(spacing: 10) { G6ContinueButton( @@ -334,8 +667,114 @@ struct G6SensorCodeEntryView: View { } } } + .sheet(isPresented: $showScanner) { scannerSheet } + .alert( + Text(LocalizedString("Camera access is off", comment: "Camera denied alert title")), + isPresented: $showCameraDeniedAlert + ) { + Button(action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) { Text(LocalizedString("Open Settings", comment: "Open Settings button")) } + Button(role: .cancel, action: {}) { Text(LocalizedString("Cancel", comment: "Cancel button")) } + } message: { + Text(LocalizedString("Allow camera access in Settings to scan the Data Matrix code on your sensor applicator.", comment: "Camera denied alert message (sensor code)")) + } + } + + /// A complete code submits itself when it is a known factory code, + /// typed or scanned. Unknown codes show the validation message instead + /// of advancing. + private func autoSubmitIfComplete() { + guard sensorCode.count == 4 else { + return + } + guard SensorCode(sensorCode) != nil else { + validationMessage = LocalizedString("That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.", comment: "Validation message for unrecognized sensor code") + return + } + codeFieldFocused = false + let submitted = sensorCode + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + guard sensorCode == submitted else { return } + didSubmit(submitted) + } + } + + // MARK: - Package scanning + + /// The camera scanner needs hardware support and a camera usage + /// description; the simulator gets the demo-payload stand-in instead + /// (see `G6DemoScan`). + private var isScanAvailable: Bool { + #if targetEnvironment(simulator) + return true + #else + return G6PackageScannerView.isSupported + #endif + } + + private func scanTapped() { + #if targetEnvironment(simulator) + handleScan(G6DemoScan.applicator) + #else + switch G6PackageScannerView.cameraAuthorization { + case .authorized: + showScanner = true + case .notDetermined: + G6PackageScannerView.requestCameraAccess { granted in + if granted { + showScanner = true + } else { + showCameraDeniedAlert = true + } + } + default: + showCameraDeniedAlert = true + } + #endif + } + + private func handleScan(_ scan: G6PackageScan) { + showScanner = false + scanFailedMessage = nil + + // Applicator labels carry no GTIN, so unlike the transmitter-ID + // screen there is no Dexcom-package gate: either a sensor code + // falls out of the payload or it does not. + guard let candidate = scan.sensorCodeCandidate else { + scanFailedMessage = LocalizedString("Couldn't find a sensor code in that barcode. The code is in the Data Matrix on the applicator, not the sensor box.", comment: "Scanned barcode without a sensor code") + return + } + + // Assigning the field fires the auto-submit path, same as typing. + sensorCode = candidate } + private func handleScanStartFailure(_: Error) { + showScanner = false + scanFailedMessage = LocalizedString("The camera couldn't start. Try again or type the code manually.", comment: "Scanner failed to start") + } + + private var scannerSheet: some View { + NavigationView { + G6PackageScannerView(onScan: handleScan, onStartFailure: handleScanStartFailure) + .edgesIgnoringSafeArea(.all) + .navigationTitle(Text(LocalizedString("Scan applicator code", comment: "Scanner screen title (sensor code)"))) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { showScanner = false }) { + Text(LocalizedString("Cancel", comment: "Cancel button")) + } + } + } + } + } + + // MARK: - Submit + private func submit() { guard SensorCode(sensorCode) != nil else { validationMessage = LocalizedString("That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.", comment: "Validation message for unrecognized sensor code") @@ -346,7 +785,7 @@ struct G6SensorCodeEntryView: View { } } -// MARK: - 5. Pairing +// MARK: - 6. Pairing /// Observes the manager so the screen reflects the radio live. Without this /// the view read `manager.state` once and never updated, so a successful @@ -357,6 +796,7 @@ final class G6PairingViewModel: ObservableObject, G6CGMManagerObserver { @Published private(set) var phase: G6ConnectionPhase = .searching @Published private(set) var isPaired = false @Published private(set) var hasSession = false + @Published private(set) var isPassive = false @Published private(set) var searchingLongerThanExpected = false private let startedAt = Date() @@ -394,11 +834,12 @@ final class G6PairingViewModel: ObservableObject, G6CGMManagerObserver { let phase = cgmManager.connectionPhase let paired = cgmManager.isPaired let session = cgmManager.state.sensorStartDate != nil + let passive = cgmManager.state.passiveModeEnabled if Thread.isMainThread { - self.phase = phase; isPaired = paired; hasSession = session + self.phase = phase; isPaired = paired; hasSession = session; isPassive = passive } else { DispatchQueue.main.async { - self.phase = phase; self.isPaired = paired; self.hasSession = session + self.phase = phase; self.isPaired = paired; self.hasSession = session; self.isPassive = passive } } } @@ -456,7 +897,14 @@ struct G6PairingView: View { Text(LocalizedString("Your transmitter only talks to your phone about once every 5 minutes, so this can take a few minutes. Keep your phone nearby.", comment: "Pairing: explanation of the 5-minute cycle")) } - if viewModel.phase == .awaitingPairing { + if viewModel.isPassive, !viewModel.isPaired { + G6CalloutBox( + symbolName: "ear.badge.waveform", + tint: .blue, + title: LocalizedString("Listening to the Dexcom app", comment: "Pairing callout title in passive mode"), + body: LocalizedString("Passive mode never pairs with the transmitter itself. The Dexcom G6 or ONE app must be installed and connected to this transmitter — this app listens to its session.", comment: "Pairing callout body in passive mode") + ) + } else if viewModel.phase == .awaitingPairing { G6CalloutBox( symbolName: "lock.shield.fill", tint: .orange, @@ -485,7 +933,9 @@ struct G6PairingView: View { symbolName: "exclamationmark.triangle.fill", tint: .orange, title: LocalizedString("Still looking", comment: "Pairing troubleshooting title"), - body: LocalizedString("Check that the Dexcom app is not connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.", comment: "Pairing troubleshooting body") + body: viewModel.isPassive + ? LocalizedString("Check that the Dexcom app is installed and connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.", comment: "Pairing troubleshooting body in passive mode") + : LocalizedString("Check that the Dexcom app is not connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.", comment: "Pairing troubleshooting body") ) Button { @@ -534,7 +984,7 @@ struct G6PairingView: View { } } -// MARK: - 6. Warm-up +// MARK: - 7. Warm-up struct G6WarmupView: View { let manager: G6CGMManager? diff --git a/G6SensorKitUI/Views/G6PackageScannerView.swift b/G6SensorKitUI/Views/G6PackageScannerView.swift new file mode 100644 index 0000000..eb0f057 --- /dev/null +++ b/G6SensorKitUI/Views/G6PackageScannerView.swift @@ -0,0 +1,85 @@ +// +// G6PackageScannerView.swift +// G6SensorKitUI +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import AVFoundation +import G6SensorCore +import SwiftUI +import VisionKit + +/// Camera scanner for the GS1 Data Matrix barcodes on Dexcom packaging: +/// the applicator label and the transmitter box. The box carries two Data +/// Matrices — serial-only (21) and the full (241)(10)(21)(17) — and +/// either yields the transmitter ID. +struct G6PackageScannerView: UIViewControllerRepresentable { + let onScan: (G6PackageScan) -> Void + let onStartFailure: (Error) -> Void + + static var isSupported: Bool { + return DataScannerViewController.isSupported + && Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription") != nil + } + + static var cameraAuthorization: AVAuthorizationStatus { + return AVCaptureDevice.authorizationStatus(for: .video) + } + + static func requestCameraAccess(completion: @escaping (Bool) -> Void) { + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { completion(granted) } + } + } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.dataMatrix])], + qualityLevel: .balanced, + recognizesMultipleItems: false, + isHighFrameRateTrackingEnabled: false, + isHighlightingEnabled: true + ) + scanner.delegate = context.coordinator + do { + try scanner.startScanning() + } catch { + DispatchQueue.main.async { onStartFailure(error) } + } + return scanner + } + + func updateUIViewController(_: DataScannerViewController, context _: Context) {} + + func makeCoordinator() -> Coordinator { + return Coordinator(onScan: onScan) + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + private let onScan: (G6PackageScan) -> Void + private var didDeliver = false + + init(onScan: @escaping (G6PackageScan) -> Void) { + self.onScan = onScan + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + didAdd addedItems: [RecognizedItem], + allItems _: [RecognizedItem] + ) { + guard !didDeliver else { return } + for item in addedItems { + guard case let .barcode(barcode) = item, + let payload = barcode.payloadStringValue + else { continue } + didDeliver = true + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + onScan(G6PackageScan(payload: payload)) + dataScanner.stopScanning() + return + } + } + } +} diff --git a/G6SensorKitUI/Views/G6SettingsView.swift b/G6SensorKitUI/Views/G6SettingsView.swift index 1cc7742..e9b9afa 100644 --- a/G6SensorKitUI/Views/G6SettingsView.swift +++ b/G6SensorKitUI/Views/G6SettingsView.swift @@ -22,6 +22,7 @@ final class G6SettingsViewModel: ObservableObject, G6CGMManagerObserver { let toCalibration: () -> Void let toTransmitterDetails: () -> Void + let toBatteryDetails: () -> Void let toSensorLifeSettings: () -> Void let toShareUpload: () -> Void let toReadingDetail: () -> Void @@ -35,6 +36,7 @@ final class G6SettingsViewModel: ObservableObject, G6CGMManagerObserver { cgmManager: G6CGMManager, toCalibration: @escaping () -> Void, toTransmitterDetails: @escaping () -> Void, + toBatteryDetails: @escaping () -> Void, toSensorLifeSettings: @escaping () -> Void, toShareUpload: @escaping () -> Void, toReadingDetail: @escaping () -> Void, @@ -46,6 +48,7 @@ final class G6SettingsViewModel: ObservableObject, G6CGMManagerObserver { state = cgmManager.state self.toCalibration = toCalibration self.toTransmitterDetails = toTransmitterDetails + self.toBatteryDetails = toBatteryDetails self.toSensorLifeSettings = toSensorLifeSettings self.toShareUpload = toShareUpload self.toReadingDetail = toReadingDetail @@ -151,15 +154,17 @@ final class G6SettingsViewModel: ObservableObject, G6CGMManagerObserver { } var trendSymbol: String? { - guard let rate = state.latestReading?.trendRateMgDLPerMinute, !isSignalLost else { + guard !isSignalLost, + let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: state.latestReading?.trendRateMgDLPerMinute) + else { return nil } - switch rate { - case ..<(-3): return "arrow.down" - case ..<(-1): return "arrow.down.right" - case ..<1: return "arrow.right" - case ..<3: return "arrow.up.right" - default: return "arrow.up" + switch arrow { + case .downDownDown, .downDown: return "arrow.down" + case .down: return "arrow.down.right" + case .flat: return "arrow.right" + case .up, .upUp: return "arrow.up.right" + case .upUpUp: return "arrow.up" } } @@ -178,6 +183,22 @@ final class G6SettingsViewModel: ObservableObject, G6CGMManagerObserver { state.sensorStartDate != nil && !state.isInWarmup } + /// Headline battery level for the settings row; nil until the first + /// battery read, so the row stays hidden for transmitters we have + /// never heard battery from. + var batteryLevelText: String? { + switch state.batteryLevel { + case .unknown: + return nil + case .high: + return LocalizedString("High", comment: "Battery level: high") + case .low: + return LocalizedString("Low", comment: "Battery level: low") + case .veryLow: + return LocalizedString("Replace Now", comment: "Battery level: very low, replace transmitter") + } + } + var canStartSensor: Bool { state.sensorStartDate == nil } @@ -250,7 +271,11 @@ struct G6SettingsView: View { Section { HStack { Spacer() - G6TransmitterImage(size: 200, isActive: viewModel.phase == .active) + G6TransmitterImage( + size: 200, + isActive: viewModel.phase == .active, + assetName: viewModel.state.isAnubis ? "anubis" : "G6Transmitter" + ) Spacer() } .listRowBackground(Color.clear) @@ -398,7 +423,11 @@ struct G6SettingsView: View { private var actionsSection: some View { Section { - if viewModel.canStartSensor { + if viewModel.state.passiveModeEnabled { + Label(LocalizedString("Sessions and calibrations are managed in the Dexcom app while passive mode is on.", comment: "Explanation replacing the session action buttons in passive mode"), systemImage: "info.circle") + .font(.footnote) + .foregroundStyle(.secondary) + } else if viewModel.canStartSensor { Button { viewModel.toPlacementGuide() } label: { @@ -440,7 +469,7 @@ struct G6SettingsView: View { } } } footer: { - if viewModel.state.isInWarmup { + if viewModel.state.isInWarmup, !viewModel.state.passiveModeEnabled { Text(LocalizedString("Calibration becomes available once warm-up finishes. Do not make insulin decisions from early readings — use a fingerstick meter and follow your care team's guidance.", comment: "Footer explaining warm-up restrictions")) } } @@ -456,7 +485,22 @@ struct G6SettingsView: View { navRow(LocalizedString("Transmitter Details", comment: "Row to transmitter details"), "cpu", viewModel.toTransmitterDetails) - if viewModel.state.isAnubis { + if let batteryLevel = viewModel.batteryLevelText { + Button(action: viewModel.toBatteryDetails) { + HStack { + Label(LocalizedString("Battery", comment: "Row to transmitter battery details"), systemImage: "battery.75") + Spacer() + Text(batteryLevel) + .foregroundColor(.secondary) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(G6RowButtonStyle()) + } + + if viewModel.state.isAnubis, !viewModel.state.passiveModeEnabled { navRow(LocalizedString("Session Length", comment: "Row to session length settings"), "calendar", viewModel.toSensorLifeSettings) } diff --git a/Package.swift b/Package.swift index 79005f4..815577b 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ import PackageDescription let package = Package( name: "G6SensorCore", platforms: [ - .iOS(.v15), + .iOS(.v17), .macOS(.v13), ], products: [ diff --git a/README.md b/README.md index c5e96d2..8e46dbb 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,32 @@ # G6SensorKit -A [LoopKit](https://github.com/LoopKit/LoopKit) CGMManager plugin that connects -**directly and natively** to Dexcom G6 and Dexcom ONE transmitters over -Bluetooth — an owning, active connection (authentication, session start/stop, -sensor codes, calibration, backfill), usable by both +A [LoopKit](https://github.com/LoopKit/LoopKit) CGMManager plugin for Dexcom G6 +and Dexcom ONE transmitters over Bluetooth, usable by both [Loop](https://github.com/LoopKit/Loop) and [Trio](https://github.com/nightscout/Trio) -from this single repository. +from this single repository. Two connection modes: + +- **Direct (default)** — an owning, active connection: authentication, + session start/stop, sensor codes, calibration, backfill. +- **Passive** — listens to the session the official Dexcom app drives on the + same phone, like CGMBLEKit does in Loop. Users migrated from CGMBLEKit land + here automatically (see [Docs/HostIntegration.md](Docs/HostIntegration.md)). > **Status: pre-alpha, under active development. Do not use for therapy.** ## What "native" means -Unlike existing drivers that passively read alongside the official Dexcom app, -G6SensorKit *owns* the transmitter relationship: it performs the authentication -handshake keyed on the transmitter ID, starts and stops sensor sessions -(including 4-digit sensor codes), sends calibrations, and backfills missed -readings after connectivity gaps. The official Dexcom app must **not** be used -at the same time. +In the default **direct** mode, G6SensorKit *owns* the transmitter +relationship: it performs the authentication handshake keyed on the +transmitter ID, starts and stops sensor sessions (including 4-digit sensor +codes), sends calibrations, and backfills missed readings after connectivity +gaps. The official Dexcom app must **not** be used at the same time. + +In **passive** mode the plugin never writes to the transmitter: it connects +alongside the official Dexcom app and decodes the notification traffic of the +session that app drives. Sessions, calibrations and sensor codes are then +managed in the Dexcom app, and the Dexcom app is **required** — without it +there is no traffic to observe. Backfill arrives only when the Dexcom app +requests it. Supported: Dexcom G6 (`8…` transmitter IDs), Dexcom ONE (`5…`/`C…`), and Anubis-modified G6 transmitters (auto-detected, extended session length). @@ -31,7 +41,7 @@ Not supported: Dexcom G5 (`4…` IDs — no longer in circulation), G7/ONE+ | `G6SensorKit.framework` | LoopKit `CGMManager` conformance, state persistence, sample conversion | | `G6SensorKitUI.framework` | `CGMManagerUI`, onboarding and settings UI | | `G6SensorKitPlugin.loopplugin` | Plugin bundle discovered by Loop at runtime | -| `Apps/G6SensorHarness` | Standalone proof-of-concept app exercising the core on a live transmitter | +| `Apps/G6SensorHarness` | Standalone proof-of-concept app exercising both session modes on a live transmitter | ## License diff --git a/Scripts/generate_project.rb b/Scripts/generate_project.rb index e0b9b6f..5ce1b7b 100644 --- a/Scripts/generate_project.rb +++ b/Scripts/generate_project.rb @@ -20,7 +20,7 @@ ROOT = File.expand_path('..', __dir__) PROJECT_PATH = File.join(ROOT, 'G6SensorKit.xcodeproj') -DEPLOYMENT_TARGET = '15.1' +DEPLOYMENT_TARGET = '17.6' SWIFT_VERSION = '5.9' BUNDLE_PREFIX = 'org.nightscout' diff --git a/Sources/G6SensorCore/Auth/G6TransmitterModel.swift b/Sources/G6SensorCore/Auth/G6TransmitterModel.swift new file mode 100644 index 0000000..0558c08 --- /dev/null +++ b/Sources/G6SensorCore/Auth/G6TransmitterModel.swift @@ -0,0 +1,27 @@ +// +// G6TransmitterModel.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// The G6-family model, classified from the transmitter ID the user entered. +public enum G6TransmitterModel: CaseIterable { + case g6 + case one + + /// Dexcom ONE transmitters use `5…` or `C…`; G6 uses `8…`. An unrecognized + /// prefix falls back to G6, the stock case. + public init(transmitterID: String) { + switch transmitterID.first { + case "5", + "c", + "C": + self = .one + default: + self = .g6 + } + } +} diff --git a/Sources/G6SensorCore/Auth/TransmitterID.swift b/Sources/G6SensorCore/Auth/TransmitterID.swift index f81a1eb..cebeffe 100644 --- a/Sources/G6SensorCore/Auth/TransmitterID.swift +++ b/Sources/G6SensorCore/Auth/TransmitterID.swift @@ -21,6 +21,11 @@ public struct TransmitterID { self.id = id } + /// The G6-family model, classified from the transmitter ID prefix. + public var model: G6TransmitterModel { + return G6TransmitterModel(transmitterID: id) + } + private var cryptKey: Data? { return "00\(id)00\(id)".data(using: .utf8) } diff --git a/Sources/G6SensorCore/Model/G6TrendArrow.swift b/Sources/G6SensorCore/Model/G6TrendArrow.swift new file mode 100644 index 0000000..95ad0e7 --- /dev/null +++ b/Sources/G6SensorCore/Model/G6TrendArrow.swift @@ -0,0 +1,50 @@ +// +// G6TrendArrow.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// The Dexcom trend arrow for a reported rate of change. +/// +/// The raw values match LoopKit's `GlucoseTrend` so the integration layer +/// can bridge with `GlucoseTrend(rawValue:)`. +public enum G6TrendArrow: Int, CaseIterable { + case upUpUp = 1 + case upUp = 2 + case up = 3 + case flat = 4 + case down = 5 + case downDown = 6 + case downDownDown = 7 + + /// Beyond this the official apps show no arrow at all. Reachable: the + /// rate arrives as Int8 tenths (0x7F is the "no trend" sentinel), so + /// roughly -12.8 to +12.6 comes in over the wire. + private static let dexcomArrowLimit = 8.0 + + /// The Dexcom arrow for the rate the transmitter reported, in + /// (mg/dL)/min. + /// + /// Thresholds match the apps' shared trend-arrow table: every threshold + /// belongs to the steeper arrow, `flat` excludes both of its ends, and + /// |rate| above 8 is no arrow. The transmitter sends the rate; the + /// receiver only buckets it. + public init?(dexcomRateMgDLPerMinute rate: Double?) { + guard let rate = rate, + (-Self.dexcomArrowLimit ... Self.dexcomArrowLimit).contains(rate) + else { return nil } + + switch rate { + case ...(-3): self = .downDownDown + case ...(-2): self = .downDown + case ...(-1): self = .down + case ..<1: self = .flat + case ..<2: self = .up + case ..<3: self = .upUp + default: self = .upUpUp + } + } +} diff --git a/Sources/G6SensorCore/Model/TransmitterManagerState.swift b/Sources/G6SensorCore/Model/TransmitterManagerState.swift index 6511a7e..6497c97 100644 --- a/Sources/G6SensorCore/Model/TransmitterManagerState.swift +++ b/Sources/G6SensorCore/Model/TransmitterManagerState.swift @@ -5,8 +5,10 @@ // Adapted for G6SensorKit from CGMBLEKit, created by Pete Schwamb on 9/11/23. // Copyright © 2023 LoopKit Authors. All rights reserved. (MIT License) // -// Adaptations: LoopKit-free (RawValue is a plain [String: Any]); -// `passiveModeEnabled` removed — G6SensorKit is active/native mode only. +// Adaptations: LoopKit-free (RawValue is a plain [String: Any]). +// `passiveModeEnabled` lives in G6CGMManagerState (the integration layer's +// persisted state); this struct keeps CGMBLEKit's key names so its rawValue +// doubles as the legacy-state reference shape for migration. // import Foundation @@ -69,7 +71,7 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { let sensorStartOffset = rawValue["sensorStartOffset"] as? UInt32 let transmitterExpiryInDays = (rawValue["transmitterExpiryInDays"] as? UInt16) - ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16($0) } + ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16(clamping: $0) } let sensorLifeDays = rawValue["sensorLifeDays"] as? Int ?? Self.defaultSensorLifeDays diff --git a/Sources/G6SensorCore/Pairing/GS1DataMatrixParser.swift b/Sources/G6SensorCore/Pairing/GS1DataMatrixParser.swift new file mode 100644 index 0000000..e161428 --- /dev/null +++ b/Sources/G6SensorCore/Pairing/GS1DataMatrixParser.swift @@ -0,0 +1,207 @@ +// +// GS1DataMatrixParser.swift +// G6SensorKit +// +// Ported from DexKit (Pairing/GS1DataMatrixParser.swift), trimmed to G6 +// and extended with applicator sensor-code extraction (AI 240) and +// FNC1-stripped payload fallbacks. +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// One GS1 element string: application identifier + value. +public struct GS1Element: Equatable { + public let ai: String + public let value: String +} + +/// Pure GS1 Data Matrix parser for Dexcom packaging barcodes. +/// +/// The Data Matrix on Dexcom packaging encodes (GS1 element structure, +/// verified against our own captures of G6/ONE packaging): +/// - AI (01) GTIN: 14 digits; Dexcom company prefix `0038627` +/// - AI (21) serial: G6: 6-char transmitter ID; G7: 12-char sensor serial +/// - AI (240) additional product ID: G6: the 4-digit sensor code on the +/// applicator label; G7: the 4-digit pairing code +/// - AI (10) lot, AI (11) production date, AI (17) expiry +/// Variable-length AIs are terminated by FNC1 (ASCII GS, 0x1D) when more +/// elements follow. +public enum GS1DataMatrixParser { + /// GS1 FNC1 separator as delivered in scanned payload strings. + public static let fnc1: Character = "\u{1D}" + + private struct AIInfo { + let fixedLength: Int? + let maxLength: Int + } + + /// AI registry: fixed-length AIs never need a trailing FNC1; variable + /// ones are read up to FNC1 or end of payload. + private static let registry: [String: AIInfo] = [ + "01": AIInfo(fixedLength: 14, maxLength: 14), // GTIN + "11": AIInfo(fixedLength: 6, maxLength: 6), // production date YYMMDD + "17": AIInfo(fixedLength: 6, maxLength: 6), // expiry YYMMDD + "10": AIInfo(fixedLength: nil, maxLength: 20), // lot/batch + "21": AIInfo(fixedLength: nil, maxLength: 20), // serial + "240": AIInfo(fixedLength: nil, maxLength: 30), // additional product ID (G6 sensor code / G7 pairing code) + "241": AIInfo(fixedLength: nil, maxLength: 30), // customer part number + "250": AIInfo(fixedLength: nil, maxLength: 30) // secondary serial + ] + + /// Parses a scanned payload into GS1 elements. Tolerates the AIM + /// symbology identifier ("]d1"/"]d2") and/or a leading FNC1 that some + /// scanners prepend. Stops at the first unrecognized AI and keeps + /// everything parsed up to that point. + public static func parse(_ payload: String) -> [GS1Element] { + var remaining = Substring(normalize(payload)) + var elements: [GS1Element] = [] + + while !remaining.isEmpty { + // Skip stray FNC1 separators (robustness). + if remaining.first == fnc1 { + remaining = remaining.dropFirst() + continue + } + + guard let (ai, info) = readAI(remaining) else { + break + } + remaining = remaining.dropFirst(ai.count) + + let value: Substring + if let fixed = info.fixedLength { + guard remaining.count >= fixed else { break } + value = remaining.prefix(fixed) + remaining = remaining.dropFirst(fixed) + } else { + let end = remaining.firstIndex(of: fnc1) ?? remaining.endIndex + value = remaining[.. info.maxLength { + break + } + } + elements.append(GS1Element(ai: ai, value: String(value))) + } + + return elements + } + + /// Strips the AIM symbology identifier and any leading FNC1. + static func normalize(_ payload: String) -> String { + var s = payload.trimmingCharacters(in: .whitespacesAndNewlines) + if s.hasPrefix("]d") { + // AIM identifier for Data Matrix: ]d1 = plain, ]d2 = GS1. + s = String(s.dropFirst(3)) + } + while s.first == fnc1 { + s = String(s.dropFirst()) + } + return s + } + + /// Longest-match AI read: 4 digits, then 3, then 2. + private static func readAI(_ s: Substring) -> (String, AIInfo)? { + for length in [4, 3, 2] { + guard s.count >= length else { continue } + let candidate = String(s.prefix(length)) + guard candidate.allSatisfy(\.isNumber) else { continue } + if let info = registry[candidate] { + return (candidate, info) + } + } + return nil + } +} + +/// A scanned Dexcom package, mapped to what G6 onboarding needs. +public struct G6PackageScan: Equatable { + public let elements: [GS1Element] + + /// The normalized payload, kept for the fallback candidate extraction: + /// some scanners strip the FNC1 separators, leaving one long string the + /// strict element parse cannot segment. + private let normalizedPayload: String + + public init(payload: String) { + normalizedPayload = GS1DataMatrixParser.normalize(payload) + elements = GS1DataMatrixParser.parse(payload) + } + + public func value(forAI ai: String) -> String? { + elements.first(where: { $0.ai == ai })?.value + } + + /// AI (01); identifies the product model. + public var gtin: String? { value(forAI: "01") } + /// AI (10) lot/batch. + public var lot: String? { value(forAI: "10") } + /// AI (21); G6: 6-char transmitter ID; G7: 12-char sensor serial. + public var serial: String? { value(forAI: "21") } + + /// Dexcom's GS1 company prefix (0038627…) inside the GTIN. + public var isDexcomPackage: Bool { + gtin?.hasPrefix("0038627") == true + } + + /// The AI (21) serial as a transmitter ID. Three shapes occur: the + /// serial is the ID exactly; the ID is followed by a non-alphanumeric + /// delimiter and trailing packaging data (seen on G6 transmitter boxes: + /// "-"); or the payload lost its FNC1 separators, in which + /// case the raw payload is scanned for the pattern. A 12-char + /// alphanumeric G7 serial never matches: the character after the + /// would-be ID is alphanumeric too. G5 rejection ("4…" prefix) is left + /// to the entry view, matching how a typed ID is handled. + public var transmitterIDCandidate: String? { + if let serial = serial, let id = Self.transmitterIDPrefix(of: serial) { + return id + } + guard isDexcomPackage else { return nil } + var rest = normalizedPayload[...] + while let range = rest.range(of: "21") { + if let id = Self.transmitterIDPrefix(of: String(rest[range.upperBound...])) { + return id + } + rest = rest[range.upperBound...] + } + return nil + } + + /// The AI (240) field as a sensor code. On G6 the applicator's Data + /// Matrix carries the 4-digit session code there (the field G7 uses + /// for its pairing code). It is the last element on applicator labels, + /// so an FNC1-stripped payload is recovered by reading the tail. + /// Applicator labels carry no GTIN, so unlike the transmitter ID this + /// is not gated on `isDexcomPackage`. Validity against the known-code + /// table is left to the entry view, matching a typed code. + public var sensorCodeCandidate: String? { + if let pin = value(forAI: "240"), pin.count == 4, pin.allSatisfy(\.isNumber) { + return pin + } + let tail = normalizedPayload.suffix(7) + guard tail.count == 7, + tail.hasPrefix("240"), + tail.dropFirst(3).allSatisfy(\.isNumber) + else { return nil } + return String(tail.dropFirst(3)) + } + + /// The leading 6 characters when they are alphanumeric and the string + /// either ends there or continues with a non-alphanumeric delimiter. + /// FNC1 does not count as a delimiter: a 6-char run followed by FNC1 is + /// a complete (21) field the strict parse handles, and in the fallback + /// scan it is the middle of a longer serial (a G7's 12-char serial + /// contains "21" six digits in), never a truncated transmitter ID. + private static func transmitterIDPrefix(of serial: String) -> String? { + guard serial.count >= 6 else { return nil } + let prefix = serial.prefix(6) + guard prefix.allSatisfy({ $0.isLetter || $0.isNumber }) else { return nil } + if serial.count == 6 { + return prefix.uppercased() + } + let next = serial.dropFirst(6).first! + guard !next.isLetter, !next.isNumber, next != GS1DataMatrixParser.fnc1 else { return nil } + return prefix.uppercased() + } +} diff --git a/Sources/G6SensorCore/Session/CommandQueue.swift b/Sources/G6SensorCore/Session/CommandQueue.swift new file mode 100644 index 0000000..dbf6c18 --- /dev/null +++ b/Sources/G6SensorCore/Session/CommandQueue.swift @@ -0,0 +1,74 @@ +// +// CommandQueue.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// A persisted queue of transmitter commands awaiting the next connection. +/// +/// Pure value logic, extracted from the G6 manager so the supersede and +/// stale-drop rules can be tested without LoopKit. The manager mirrors +/// `rawValues` into its persisted state so queued commands survive relaunch. +public struct CommandQueue: Equatable { + + public static let staleCalibrationInterval: TimeInterval = .minutes(5) + + public private(set) var commands: [Command] + + public init(_ commands: [Command] = []) { + self.commands = commands + } + + public init(rawValues: [Command.RawValue]) { + self.init(rawValues.compactMap(Command.init(rawValue:))) + } + + public var rawValues: [Command.RawValue] { + commands.map(\.rawValue) + } + + public var isEmpty: Bool { + commands.isEmpty + } + + /// Queues a command for the next connection cycle. Newest wins: any + /// queued command the new one supersedes is dropped first, so repeated + /// trips through setup cannot stack session starts. + /// + /// - Returns: the number of queued commands the new one replaced. + @discardableResult public mutating func enqueue(_ command: Command) -> Int { + let before = commands.count + commands.removeAll { $0.supersededBy(command) } + let superseded = before - commands.count + commands.append(command) + return superseded + } + + /// Pops the next command to send, silently dropping stale calibrations. + /// + /// - Returns: the command to send, plus any commands dropped as stale + /// (for logging). + public mutating func dequeue(now: Date = Date()) -> (next: Command?, dropped: [Command]) { + var dropped: [Command] = [] + var next: Command? + while let candidate = commands.first { + commands.removeFirst() + if case let .calibrateSensor(_, date) = candidate, + now.timeIntervalSince(date) > Self.staleCalibrationInterval + { + dropped.append(candidate) + continue + } + next = candidate + break + } + return (next, dropped) + } + + public mutating func removeAll() { + commands.removeAll() + } +} diff --git a/Sources/G6SensorCore/Session/PassiveMessageHandler.swift b/Sources/G6SensorCore/Session/PassiveMessageHandler.swift new file mode 100644 index 0000000..0f814c3 --- /dev/null +++ b/Sources/G6SensorCore/Session/PassiveMessageHandler.swift @@ -0,0 +1,218 @@ +// +// PassiveMessageHandler.swift +// G6SensorKit +// +// Adapted for G6SensorKit from CGMBLEKit (Transmitter.swift's passive +// control-response dispatcher), originally xDripG5, created by Nathan +// Racklyeft on 11/22/15. +// Copyright © 2015 Nathan Racklyeft. (MIT License) +// Copyright © 2026 Nightscout Foundation. MIT License. +// +// Adaptation: the opcode switch, time-message caching and backfill frame +// validation follow CGMBLEKit's passive path, restructured into a pure type +// with no CoreBluetooth so the whole decode path is unit-testable. +// + +import Foundation +import os.log + + +/// What an observed frame turned into. Forwarded to the session delegate. +enum PassiveEvent { + /// Another client completed the auth exchange. Informational: control + /// subscription is not gated on this (CGMBLEKit's `dec4ff0` gating was a + /// workaround for toggling subscriptions mid-exchange, which this design + /// avoids by subscribing everything once at connect). + case observedAuthentication(isAuthenticated: Bool, isBonded: Bool) + + /// A time frame was observed; carries the derived activation date. + case transmitterTime(activationDate: Date) + + case glucose(Glucose) + + case backfill([Glucose]) + + case transmitterVersion(TransmitterVersionRxMessage) + + case unknown(Data) + + case error(TransmitterError) +} + + +/// Stateful decoder for observed traffic. A glucose frame cannot be dated +/// until a time frame has been observed (the glucose timestamp is seconds +/// since transmitter activation), so time frames are cached; frames arriving +/// before the first one are dropped with an observation error. +final class PassiveMessageHandler { + + private let transmitterID: String + + /// Seeded from persisted state and corrected by every observed time + /// frame. The seed alone does not let a reading through: the sensor + /// session start only comes from a time frame, and a reading without one + /// would look like the session ended. Frames before the first time frame + /// are therefore still dropped. + private(set) var activationDate: Date? + + private var lastTimeMessage: TransmitterTimeRxMessage? + + private var lastCalibration: CalibrationDataRxMessage? + + /// Frames accumulate whenever they arrive; the acknowledgement on the + /// control characteristic is what validates and triggers decoding. + private var backfillBuffer: GlucoseBackfillFrameBuffer? + + private let log = OSLog(category: "PassiveMessageHandler") + + init(transmitterID: String, activationDate: Date? = nil) { + self.transmitterID = transmitterID + self.activationDate = activationDate + } + + // MARK: - Authentication characteristic + + func handleAuthentication(_ data: Data) -> [PassiveEvent] { + // Only the challenge response carries anything worth reporting; the + // rest of the auth exchange is ignored. + guard let message = AuthChallengeRxMessage(data: data) else { + return [] + } + + return [.observedAuthentication(isAuthenticated: message.isAuthenticated, isBonded: message.isBonded)] + } + + // MARK: - Control characteristic + + func handleControl(_ data: Data, at date: Date = Date()) -> [PassiveEvent] { + guard let first = data.first, let opcode = Opcode(rawValue: first) else { + return [.unknown(data)] + } + + switch opcode { + case .transmitterTimeRx: + guard let message = TransmitterTimeRxMessage(data: data) else { + return [.unknown(data)] + } + + lastTimeMessage = message + let activation = date.addingTimeInterval(-TimeInterval(message.currentTime)) + activationDate = activation + return [.transmitterTime(activationDate: activation)] + + case .glucoseRx, .glucoseG6Rx: + guard let message = GlucoseRxMessage(data: data) else { + return [.unknown(data)] + } + + guard let timeMessage = lastTimeMessage, let activationDate = activationDate else { + return [.error(.observationError( + "Glucose frame observed before any time frame; cannot date it, dropping"))] + } + + let glucose = Glucose( + transmitterID: transmitterID, + glucoseMessage: message, + timeMessage: timeMessage, + calibrationMessage: lastCalibration, + activationDate: activationDate + ) + return [.glucose(glucose)] + + case .glucoseBackfillRx: + guard let ack = GlucoseBackfillRxMessage(data: data) else { + return [.unknown(data)] + } + return handleBackfillAcknowledgement(ack) + + case .calibrationDataRx: + guard let message = CalibrationDataRxMessage(data: data) else { + return [.unknown(data)] + } + lastCalibration = message + return [] + + case .transmitterVersionRx: + guard let message = TransmitterVersionRxMessage(data: data) else { + return [.unknown(data)] + } + return [.transmitterVersion(message)] + + default: + return [.unknown(data)] + } + } + + // MARK: - Backfill characteristic + + /// Appends an observed frame to the buffer. Decoding happens when the + /// acknowledgement arrives on the control characteristic. + func handleBackfill(_ data: Data) { + guard data.count > 2 else { + return + } + + // Byte 0 is the frame index, byte 1 the buffer identifier. + if data[0] == 1 { + log.default("Starting new observed backfill buffer with ID %d", data[1]) + backfillBuffer = GlucoseBackfillFrameBuffer(identifier: data[1]) + } + + backfillBuffer?.append(data) + } + + // MARK: - Private + + private func handleBackfillAcknowledgement(_ ack: GlucoseBackfillRxMessage) -> [PassiveEvent] { + defer { backfillBuffer = nil } + + guard let buffer = backfillBuffer, buffer.count > 0 else { + // Nothing observed for the window: an empty result, not a failure. + if ack.bufferLength == 0 { + return [] + } + return [.error(.observationError( + "Backfill acknowledged \(ack.bufferLength) bytes but no frames were observed"))] + } + + let complete = buffer.count == Int(ack.bufferLength) + + if complete { + guard ack.bufferCRC == buffer.crc16 else { + return [.error(.observationError( + "Backfill expected CRC \(String(format: "%04x", ack.bufferCRC)), but was \(String(format: "%04x", buffer.crc16))"))] + } + } else { + // The CRC covers the whole buffer, so it cannot check a prefix. + log.default("Observed backfill incomplete: %{public}@ of %{public}@ bytes; delivering what arrived", + String(buffer.count), String(ack.bufferLength)) + } + + guard let timeMessage = lastTimeMessage, let activationDate = activationDate else { + return [.error(.observationError( + "Backfill observed before any time frame; cannot date it, dropping"))] + } + + let glucose = buffer.glucose.map { + Glucose(transmitterID: transmitterID, status: ack.status, glucoseMessage: $0, timeMessage: timeMessage, activationDate: activationDate) + } + + guard !glucose.isEmpty else { + return [] + } + + guard glucose.first!.glucoseMessage.timestamp <= glucose.last!.glucoseMessage.timestamp else { + return [.error(.observationError( + "Backfill timestamps out of order: \(glucose.first!.glucoseMessage.timestamp) - \(glucose.last!.glucoseMessage.timestamp)"))] + } + + var events: [PassiveEvent] = [.backfill(glucose)] + + if !complete { + events.append(.error(.observationError( + "Backfill delivered \(glucose.count) readings but was incomplete"))) + } + + return events + } +} diff --git a/Sources/G6SensorCore/Session/PassiveTransmitterSession.swift b/Sources/G6SensorCore/Session/PassiveTransmitterSession.swift new file mode 100644 index 0000000..b276d19 --- /dev/null +++ b/Sources/G6SensorCore/Session/PassiveTransmitterSession.swift @@ -0,0 +1,232 @@ +// +// PassiveTransmitterSession.swift +// G6SensorKit +// +// Adapted for G6SensorKit from CGMBLEKit (Transmitter.swift, passive path), +// originally xDripG5, created by Nathan Racklyeft on 11/22/15. +// Copyright © 2015 Nathan Racklyeft. (MIT License) +// Copyright © 2026 Nightscout Foundation. MIT License. +// +// Adaptations: +// - All three subscriptions happen once, at connect, and are never toggled. +// CGMBLEKit subscribes to control only after observing an authenticated +// session (commit dec4ff0) because toggling subscriptions mid-exchange +// confused CoreBluetooth; subscribing everything up front sidesteps that +// entirely. If field testing shows missed traffic, gate the control +// subscription on `PassiveEvent.observedAuthentication` — the handler +// already parses it. +// - There is no write API in scope here at all: no auth, no bonding, no +// commands, no active reads, no disconnect request. The link drops when +// the driving client is done, and TransmitterConnection's reconnect loop +// picks the transmitter up again on its next cycle. +// +// A passive session never writes a request to the transmitter. It connects, +// subscribes to the authentication, control and backfill characteristics +// once, and decodes the notification traffic another client on the same +// phone (the Dexcom app, or the transmitter's bonded session) generates. +// iOS shares the system-level connection between apps, so the Dexcom app's +// five-minute polling drives the exchange and this session observes it. +// +// Because nothing here writes, passive mode cannot start or stop sessions, +// calibrate, read battery status, or request backfill. +// + +import Foundation +import CoreBluetooth +import os.log + + +public final class PassiveTransmitterSession: TransmitterSessioning { + + /// The ID of the transmitter to connect to + public var ID: String { + return id.id + } + + private var id: TransmitterID + + public weak var delegate: TransmitterSessionDelegate? + + /// Frames are decoded on the Bluetooth queue while `retarget` swaps the + /// handler from the caller's thread, so every access goes through here. + private let handler: Locked + + private let log = OSLog(category: "PassiveTransmitterSession") + + private let connection: TransmitterConnection + + private let delegateQueue = DispatchQueue(label: "org.nightscout.G6SensorKit.passiveSessionDelegateQueue", qos: .unspecified) + + /// - Parameter activationDate: the persisted transmitter start date. It + /// seeds the decoder, but readings still wait for an observed time + /// frame (see `PassiveMessageHandler.activationDate`). + public init(id: String, peripheralIdentifier: UUID? = nil, activationDate: Date? = nil) { + self.id = TransmitterID(id: id) + self.handler = Locked(PassiveMessageHandler(transmitterID: id, activationDate: activationDate)) + self.connection = TransmitterConnection(peripheralIdentifier: peripheralIdentifier) + + connection.delegate = self + } + + /// Starts (or resumes) looking for the transmitter. Idempotent. + public func start() { + connection.stayConnected = true + connection.scanForPeripheral() + } + + /// Stops the reconnect loop and drops any live connection. + public func stop() { + connection.stayConnected = false + connection.disconnect() + } + + /// Points this session at a different transmitter, keeping the existing + /// connection and its central manager. + public func retarget(id newID: String) { + log.default("Retargeting session to transmitter %{public}@", newID) + + connection.stayConnected = false + connection.disconnect() + + self.id = TransmitterID(id: newID) + handler.value = PassiveMessageHandler(transmitterID: newID) + connection.peripheralIdentifier = nil + + start() + } + + public var peripheralIdentifier: UUID? { + get { + return connection.peripheralIdentifier + } + set { + connection.peripheralIdentifier = newValue + } + } + + // MARK: - Event forwarding + + private func forward(_ events: [PassiveEvent]) { + guard !events.isEmpty else { + return + } + + delegateQueue.async { + for event in events { + switch event { + case .observedAuthentication(let isAuthenticated, let isBonded): + self.log.default("Observed authentication exchange (authenticated: %{public}@, bonded: %{public}@)", + String(describing: isAuthenticated), String(describing: isBonded)) + case .transmitterTime(let activationDate): + self.delegate?.transmitterSession(self, didReadTransmitterTime: activationDate) + case .glucose(let glucose): + self.delegate?.transmitterSession(self, didRead: glucose) + case .backfill(let glucose): + self.delegate?.transmitterSession(self, didReadBackfill: glucose) + case .transmitterVersion(let message): + self.delegate?.transmitterSession(self, didReadTransmitterVersion: message) + case .unknown(let data): + self.delegate?.transmitterSession(self, didReadUnknownData: data) + case .error(let error): + // A frame this session could not use — seen before a time + // frame, or a partial backfill — says nothing about the + // link, which the other client is still driving. Reporting + // it as a session error would flip the UI to searching + // and raise CGM errors in the host while all is well. + self.log.error("Observation: %{public}@", String(describing: error)) + } + } + } + } +} + + +extension PassiveTransmitterSession: TransmitterConnectionDelegate { + + func transmitterConnection(_ connection: TransmitterConnection, peripheralManager: PeripheralManager, isReadyWithError error: Error?) { + if let error = error { + delegateQueue.async { + self.delegate?.transmitterSession(self, didError: error) + } + return + } + + delegateQueue.async { + self.delegate?.transmitterSessionDidConnect(self) + } + + peripheralManager.perform { (peripheral) in + do { + // The whole setup: subscribe and get out of the way. No + // writes, and crucially no disconnect — the link belongs to + // the client driving the exchange. + try peripheral.listenToCharacteristic(.authentication) + try peripheral.listenToCharacteristic(.control) + try peripheral.listenToCharacteristic(.backfill) + self.log.default("Listening for another client's session traffic") + } catch let error { + self.delegateQueue.async { + self.delegate?.transmitterSession(self, didError: error) + } + } + } + } + + func transmitterConnection(_ connection: TransmitterConnection, shouldConnectPeripheral peripheral: CBPeripheral, advertisementData: [String: Any]) -> Bool { + // G6/ONE transmitters advertise a name of "DexcomXX", where "XX" is + // the last two characters of the transmitter ID. Unlike the active + // session there is no auth handshake to reject a wrong transmitter, + // so this name match is the only identity check. + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String + let expected = id.id.suffix(2).uppercased() + + if let name = advertisedName ?? peripheral.name { + let matches = name.suffix(2).uppercased() == expected + if matches { + log.default("Matched transmitter %{public}@", name) + } else { + log.info("Ignoring peripheral %{public}@ (expected suffix %{public}@)", name, String(expected)) + } + return matches + } + + // No name from either source. The scan is already filtered to the + // Dexcom advertisement service, and a system-connected peripheral on + // the user's own phone is virtually always their transmitter, so + // attempt the connection rather than stranding setup. + log.default("Peripheral advertised no name; attempting connection anyway (passive mode has no auth check)") + return true + } + + func transmitterConnection(_ connection: TransmitterConnection, didUpdatePeripheralIdentifier identifier: UUID?) { + delegateQueue.async { + self.delegate?.transmitterSession(self, didUpdatePeripheralIdentifier: identifier) + } + } + + func transmitterConnection(_ connection: TransmitterConnection, didReceiveAuthenticationResponse response: Data) { + var events: [PassiveEvent] = [] + handler.mutate { events = $0.handleAuthentication(response) } + forward(events) + } + + func transmitterConnection(_ connection: TransmitterConnection, didReceiveControlResponse response: Data) { + var events: [PassiveEvent] = [] + handler.mutate { events = $0.handleControl(response) } + forward(events) + } + + func transmitterConnection(_ connection: TransmitterConnection, didReceiveBackfillResponse response: Data) { + handler.mutate { $0.handleBackfill(response) } + } +} + + +extension PassiveTransmitterSession: CustomDebugStringConvertible { + public var debugDescription: String { + return [ + "## PassiveTransmitterSession", + String(reflecting: connection), + ].joined(separator: "\n") + } +} diff --git a/Sources/G6SensorCore/Session/TransmitterSession.swift b/Sources/G6SensorCore/Session/TransmitterSession.swift index 3176be4..20d3e09 100644 --- a/Sources/G6SensorCore/Session/TransmitterSession.swift +++ b/Sources/G6SensorCore/Session/TransmitterSession.swift @@ -7,8 +7,9 @@ // Copyright © 2015 Nathan Racklyeft. All rights reserved. (MIT License) // // Adaptations (behavioural reference: xDrip4iOS — no code copied): -// - Active/native mode ONLY: the passive "observe another app's session" -// path is removed by design. G6SensorKit owns the transmitter. +// - Active/native mode: this session owns the transmitter. The passive +// "observe another app's session" mode lives separately in +// PassiveTransmitterSession. // - Per-connection flow follows the Firefly ordering: authenticate → // bond if needed → subscribe control + backfill → time → pending // commands (session stop / session start / calibrate / reset) → @@ -25,42 +26,70 @@ import CoreBluetooth import os.log +/// The surface shared by the active (`TransmitterSession`) and passive +/// (`PassiveTransmitterSession`) sessions, so the integration layer can hold +/// either without knowing which one is driving the radio. +public protocol TransmitterSessioning: AnyObject { + /// The ID of the transmitter the session connects to. + var ID: String { get } + + /// Called on a private background queue. It is the responsibility of the + /// client to ensure thread-safety. + var delegate: TransmitterSessionDelegate? { get set } + + /// The known peripheral identifier; persist it to skip rediscovery. + var peripheralIdentifier: UUID? { get set } + + /// Starts (or resumes) looking for the transmitter. Idempotent. + func start() + + /// Stops the reconnect loop and drops any live connection. + func stop() + + /// Points the session at a different transmitter, keeping the existing + /// connection and its central manager. + func retarget(id: String) +} + +/// These methods are called on a private background queue. It is the responsibility of the client to ensure thread-safety. public protocol TransmitterSessionDelegate: AnyObject { - func transmitterSessionDidConnect(_ session: TransmitterSession) + func transmitterSessionDidConnect(_ session: any TransmitterSessioning) - func transmitterSession(_ session: TransmitterSession, didError error: Error) + func transmitterSession(_ session: any TransmitterSessioning, didError error: Error) - func transmitterSession(_ session: TransmitterSession, didRead glucose: Glucose) + func transmitterSession(_ session: any TransmitterSessioning, didRead glucose: Glucose) - func transmitterSession(_ session: TransmitterSession, didReadBackfill glucose: [Glucose]) + func transmitterSession(_ session: any TransmitterSessioning, didReadBackfill glucose: [Glucose]) - func transmitterSession(_ session: TransmitterSession, didReadTransmitterVersion message: TransmitterVersionRxMessage) + func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterVersion message: TransmitterVersionRxMessage) - func transmitterSession(_ session: TransmitterSession, didReadBattery message: BatteryStatusRxMessage) + func transmitterSession(_ session: any TransmitterSessioning, didReadBattery message: BatteryStatusRxMessage) /// The transmitter's clock was read, which means authentication - /// succeeded. Reported separately from glucose because a stopped or + /// succeeded — or, in a passive session, that another client's time read + /// was observed. Reported separately from glucose because a stopped or /// failed sensor produces no usable reading, and the app still needs to /// know it is talking to the transmitter. - func transmitterSession(_ session: TransmitterSession, didReadTransmitterTime activationDate: Date) + func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterTime activationDate: Date) - func transmitterSession(_ session: TransmitterSession, didReadUnknownData data: Data) + func transmitterSession(_ session: any TransmitterSessioning, didReadUnknownData data: Data) /// The known peripheral identifier changed; persist it. - func transmitterSession(_ session: TransmitterSession, didUpdatePeripheralIdentifier identifier: UUID?) + func transmitterSession(_ session: any TransmitterSessioning, didUpdatePeripheralIdentifier identifier: UUID?) /// A bond request was sent; the user must accept the iOS pairing prompt - /// within the keep-alive window (~60 s). - func transmitterSessionDidRequestBond(_ session: TransmitterSession) + /// within the keep-alive window (~60 s). Active sessions only — a + /// passive session never writes, so it never bonds. + func transmitterSessionDidRequestBond(_ session: any TransmitterSessioning) } /// These methods are called on a private background queue. It is the responsibility of the client to ensure thread-safety. public protocol TransmitterCommandSource: AnyObject { - func dequeuePendingCommand(for session: TransmitterSession) -> Command? + func dequeuePendingCommand(for session: any TransmitterSessioning) -> Command? - func transmitterSession(_ session: TransmitterSession, didFail command: Command, with error: Error) + func transmitterSession(_ session: any TransmitterSessioning, didFail command: Command, with error: Error) - func transmitterSession(_ session: TransmitterSession, didComplete command: Command, response: TransmitterRxMessage?) + func transmitterSession(_ session: any TransmitterSessioning, didComplete command: Command, response: TransmitterRxMessage?) } public enum TransmitterError: Error { @@ -83,7 +112,7 @@ extension TransmitterError: CustomStringConvertible { } -public final class TransmitterSession: TransmitterConnectionDelegate { +public final class TransmitterSession: TransmitterSessioning, TransmitterConnectionDelegate { /// Seconds the transmitter is asked to keep the link alive while the user /// responds to the iOS pairing prompt. @@ -401,11 +430,20 @@ public final class TransmitterSession: TransmitterConnectionDelegate { } + func transmitterConnection(_ connection: TransmitterConnection, didReceiveControlResponse response: Data) { + // Control responses to this session's own requests are claimed by + // the peripheral manager's conditions. Anything arriving here is + // another client's traffic, which an active session has no use for. + } + + func transmitterConnection(_ connection: TransmitterConnection, didReceiveAuthenticationResponse response: Data) { + // See didReceiveControlResponse. + } + func transmitterConnection(_ connection: TransmitterConnection, didReceiveBackfillResponse response: Data) { guard response.count > 2 else { return } - backfillLock.lock() defer { backfillLock.broadcast() @@ -466,10 +504,10 @@ public final class TransmitterSession: TransmitterConnectionDelegate { let ack = try peripheral.requestBackfill(startTime: startTime, endTime: endTime) - log.default("Backfill acknowledged: status %{public}@, backfillStatus %{public}@, id %{public}@, range %{public}@-%{public}@, length %{public}@, crc %{public}@", - String(ack.status), String(ack.backfillStatus), String(ack.identifier), - String(ack.startTime), String(ack.endTime), - String(ack.bufferLength), String(format: "%04x", ack.bufferCRC)) + log.default("Backfill acknowledged: %{public}@", + "status \(ack.status), backfillStatus \(ack.backfillStatus), id \(ack.identifier), " + + "range \(ack.startTime)-\(ack.endTime), length \(ack.bufferLength), " + + "crc \(String(format: "%04x", ack.bufferCRC))") // Nothing stored for the window: an empty result, not a failure. guard ack.bufferLength > 0 else { @@ -703,12 +741,4 @@ fileprivate extension PeripheralManager { } catch { } } - - func listenToCharacteristic(_ characteristic: CGMServiceCharacteristicUUID) throws { - do { - try setNotifyValue(true, for: characteristic) - } catch let error { - throw TransmitterError.controlError("Error enabling notification for \(characteristic): \(error)") - } - } } diff --git a/Sources/G6SensorCore/Support/Locked.swift b/Sources/G6SensorCore/Support/Locked.swift index fd6e35b..4a82830 100644 --- a/Sources/G6SensorCore/Support/Locked.swift +++ b/Sources/G6SensorCore/Support/Locked.swift @@ -31,6 +31,7 @@ internal class Locked { } } + @discardableResult func mutate(_ changes: (_ value: inout T) -> Void) -> T { os_unfair_lock_lock(&lock) defer { os_unfair_lock_unlock(&lock) } diff --git a/Sources/G6SensorCore/Transport/OSLog.swift b/Sources/G6SensorCore/Transport/OSLog.swift index 847d5be..999052d 100644 --- a/Sources/G6SensorCore/Transport/OSLog.swift +++ b/Sources/G6SensorCore/Transport/OSLog.swift @@ -34,7 +34,8 @@ extension OSLog { } private func log(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) { - mirrorToFile(message, type: type, args) + let rendered = render(message, args) + mirrorToFile(rendered, type: type) switch args.count { case 0: @@ -50,7 +51,10 @@ extension OSLog { case 5: os_log(message, log: self, type: type, args[0], args[1], args[2], args[3], args[4]) default: - os_log(message, log: self, type: type, args) + // os_log can't take the array as varargs: handing it over reads + // garbage pointers for each %@ and crashes. Log the rendered line. + assertionFailure("OSLog supports at most 5 arguments, got \(args.count): \(message)") + os_log("%{public}@", log: self, type: type, rendered) } } @@ -65,15 +69,17 @@ extension OSLog { return Self.categories[ObjectIdentifier(self)] ?? "G6SensorKit" } - /// Mirrors every OSLog line into the exportable file log. Format - /// specifiers are OSLog-flavoured (%{public}@), so the privacy qualifier - /// is stripped before String(format:) sees them. - private func mirrorToFile(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) { + /// Format specifiers are OSLog-flavoured (%{public}@), so the privacy + /// qualifier is stripped before String(format:) sees them. + private func render(_ message: StaticString, _ args: [CVarArg]) -> String { let template = "\(message)" .replacingOccurrences(of: "%{public}", with: "%") .replacingOccurrences(of: "%{private}", with: "%") - let rendered = args.isEmpty ? template : String(format: template, arguments: args) + return args.isEmpty ? template : String(format: template, arguments: args) + } + /// Mirrors every OSLog line into the exportable file log. + private func mirrorToFile(_ rendered: String, type: OSLogType) { let level: String switch type { case .debug: level = "DEBUG" diff --git a/Sources/G6SensorCore/Transport/PeripheralManager+G5.swift b/Sources/G6SensorCore/Transport/PeripheralManager+G5.swift index c1caac4..e23c98c 100644 --- a/Sources/G6SensorCore/Transport/PeripheralManager+G5.swift +++ b/Sources/G6SensorCore/Transport/PeripheralManager+G5.swift @@ -187,6 +187,17 @@ extension PeripheralManager { try writeValue(message.data, for: characteristic, type: type, timeout: timeout) } + + /// Subscribes to a characteristic's notifications without expecting a + /// response to anything — the passive session's only use of the radio. + /// - Throws: TransmitterError.controlError + func listenToCharacteristic(_ characteristic: CGMServiceCharacteristicUUID) throws { + do { + try setNotifyValue(true, for: characteristic) + } catch let error { + throw TransmitterError.controlError("Error enabling notification for \(characteristic): \(error)") + } + } } diff --git a/Sources/G6SensorCore/Transport/PeripheralManager.swift b/Sources/G6SensorCore/Transport/PeripheralManager.swift index 8239e10..834298b 100644 --- a/Sources/G6SensorCore/Transport/PeripheralManager.swift +++ b/Sources/G6SensorCore/Transport/PeripheralManager.swift @@ -147,7 +147,7 @@ extension PeripheralManager { } } - private func applyConfiguration(discoveryTimeout: TimeInterval = 2) throws { + private func applyConfiguration(discoveryTimeout: TimeInterval = 10) throws { try discoverServices(configuration.serviceCharacteristics.keys.map { $0 }, timeout: discoveryTimeout) for service in peripheral.services ?? [] { @@ -400,11 +400,13 @@ extension PeripheralManager: CBPeripheralDelegate { var notifyDelegate = false if let index = commandConditions.firstIndex(where: { (condition) -> Bool in - if case .valueUpdate(characteristic: characteristic, matching: let matching) = condition { - return matching?(characteristic.value) ?? true - } else { + guard case .valueUpdate(characteristic: let conditionCharacteristic, matching: let matching) = condition, + conditionCharacteristic == characteristic + else { return false } + + return matching?(characteristic.value) ?? true }) { commandConditions.remove(at: index) commandError = error @@ -414,7 +416,9 @@ extension PeripheralManager: CBPeripheralDelegate { } } else if let macro = configuration.valueUpdateMacros[characteristic.uuid] { macro(self) - } else if commandConditions.isEmpty { + } else { + // Unclaimed by any condition or macro: pass it along to the + // delegate even while an unrelated command is still pending. notifyDelegate = true // execute after the unlock } diff --git a/Sources/G6SensorCore/Transport/TransmitterConnection.swift b/Sources/G6SensorCore/Transport/TransmitterConnection.swift index 94bd855..ef56657 100644 --- a/Sources/G6SensorCore/Transport/TransmitterConnection.swift +++ b/Sources/G6SensorCore/Transport/TransmitterConnection.swift @@ -22,8 +22,11 @@ // // The central is owned by this instance, as CGMBLEKit does it. // -// There is no passive mode. This connection owns the transmitter; it never -// observes another app's session. +// Both session types share this connection: an active session drives the +// exchange with writes, a passive session only subscribes and observes +// another app's traffic. Unsolicited values on all three notify +// characteristics are forwarded to the delegate; which of them matter +// depends on the session. // import CoreBluetooth @@ -49,6 +52,16 @@ protocol TransmitterConnectionDelegate: AnyObject { /// notifications rather than as a command response, so it comes through /// here rather than the peripheral manager's condition machinery. func transmitterConnection(_ connection: TransmitterConnection, didReceiveBackfillResponse response: Data) + + /// Unsolicited data on the control characteristic. In an active session + /// control responses are consumed by the peripheral manager's condition + /// machinery, so anything arriving here is traffic generated by someone + /// else's session — the raw material of passive mode. + func transmitterConnection(_ connection: TransmitterConnection, didReceiveControlResponse response: Data) + + /// Unsolicited data on the authentication characteristic. Only passive + /// sessions listen to this, to observe another client authenticating. + func transmitterConnection(_ connection: TransmitterConnection, didReceiveAuthenticationResponse response: Data) } @@ -339,15 +352,26 @@ extension TransmitterConnection: PeripheralManagerDelegate { } func peripheralManager(_ manager: PeripheralManager, didUpdateValueFor characteristic: CBCharacteristic) { - // Control and authentication responses are consumed by the peripheral - // manager's condition machinery as part of the active command flow. - // Only backfill arrives unsolicited. + // Control and authentication responses to our own requests are + // consumed by the peripheral manager's condition machinery as part + // of the active command flow. Everything arriving here was not + // claimed by a condition: backfill frames (unsolicited by design) + // and, for a passive session, another client's session traffic. guard let value = characteristic.value, - case .backfill? = CGMServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString.uppercased()) + let characteristicID = CGMServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString.uppercased()) else { return } - delegate?.transmitterConnection(self, didReceiveBackfillResponse: value) + switch characteristicID { + case .backfill: + delegate?.transmitterConnection(self, didReceiveBackfillResponse: value) + case .control: + delegate?.transmitterConnection(self, didReceiveControlResponse: value) + case .authentication: + delegate?.transmitterConnection(self, didReceiveAuthenticationResponse: value) + default: + break + } } } diff --git a/Tests/G6SensorCoreTests/CommandQueueTests.swift b/Tests/G6SensorCoreTests/CommandQueueTests.swift new file mode 100644 index 0000000..bdcb7cd --- /dev/null +++ b/Tests/G6SensorCoreTests/CommandQueueTests.swift @@ -0,0 +1,95 @@ +// +// CommandQueueTests.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import XCTest +@testable import G6SensorCore + +class CommandQueueTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + func testEnqueueAppendsDistinctCommands() { + var queue = CommandQueue() + queue.enqueue(.startSensor(at: now, sensorCode: .none)) + queue.enqueue(.calibrateSensor(toMgDL: 120, at: now)) + + XCTAssertEqual(queue.commands.count, 2) + } + + func testEnqueueSupersedesSameKind() { + var queue = CommandQueue() + queue.enqueue(.startSensor(at: now, sensorCode: .none)) + queue.enqueue(.startSensor(at: now, sensorCode: .none)) + queue.enqueue(.calibrateSensor(toMgDL: 100, at: now)) + queue.enqueue(.calibrateSensor(toMgDL: 140, at: now)) + + XCTAssertEqual(queue.commands.count, 2) + guard case let .calibrateSensor(value, _) = queue.commands[1] else { + return XCTFail("expected calibration") + } + XCTAssertEqual(value, 140) + } + + func testEnqueueReturnsSupersededCount() { + var queue = CommandQueue() + queue.enqueue(.calibrateSensor(toMgDL: 100, at: now)) + queue.enqueue(.calibrateSensor(toMgDL: 110, at: now)) + // The 110 enqueue already superseded the 100 one, so a single queued + // calibration remains for the 120 to replace. + let superseded = queue.enqueue(.calibrateSensor(toMgDL: 120, at: now)) + XCTAssertEqual(superseded, 1) + XCTAssertEqual(queue.commands.count, 1) + } + + func testDequeueDropsStaleCalibrations() { + var queue = CommandQueue() + // 6 minutes old at dequeue time: beyond the 5-minute window. + queue.enqueue(.calibrateSensor(toMgDL: 120, at: now.addingTimeInterval(-360))) + queue.enqueue(.stopSensor(at: now)) + + let (next, dropped) = queue.dequeue(now: now) + + XCTAssertEqual(dropped.count, 1) + guard case .stopSensor? = next else { + return XCTFail("expected stopSensor, got \(String(describing: next))") + } + XCTAssertTrue(queue.isEmpty) + } + + func testDequeueKeepsFreshCalibration() { + var queue = CommandQueue() + queue.enqueue(.calibrateSensor(toMgDL: 120, at: now.addingTimeInterval(-60))) + + let (next, dropped) = queue.dequeue(now: now) + + XCTAssertTrue(dropped.isEmpty) + guard case .calibrateSensor? = next else { + return XCTFail("expected calibration, got \(String(describing: next))") + } + } + + func testDequeueEmptyReturnsNil() { + var queue = CommandQueue() + let (next, dropped) = queue.dequeue(now: now) + XCTAssertNil(next) + XCTAssertTrue(dropped.isEmpty) + } + + func testRawValuesRoundTrip() { + var queue = CommandQueue() + queue.enqueue(.startSensor(at: now, sensorCode: SensorCode("5931") ?? .none)) + queue.enqueue(.calibrateSensor(toMgDL: 123.4, at: now)) + queue.enqueue(.resetTransmitter) + + let restored = CommandQueue(rawValues: queue.rawValues) + XCTAssertEqual(restored, queue) + } + + func testRawValuesDropMalformedEntries() { + let queue = CommandQueue(rawValues: [["action": 99], [:]]) + XCTAssertTrue(queue.isEmpty) + } +} diff --git a/Tests/G6SensorCoreTests/G6TrendArrowTests.swift b/Tests/G6SensorCoreTests/G6TrendArrowTests.swift new file mode 100644 index 0000000..9062794 --- /dev/null +++ b/Tests/G6SensorCoreTests/G6TrendArrowTests.swift @@ -0,0 +1,53 @@ +// +// G6TrendArrowTests.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import XCTest +@testable import G6SensorCore + +/// Rates are integer tenths on the wire, so every case here is reachable. +class G6TrendArrowTests: XCTestCase { + func testNoRateHasNoArrow() { + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: nil)) + } + + func testArrowsByRate() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -4), .downDownDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -2.5), .downDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -1.5), .down) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 0), .flat) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 1.5), .up) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 2.5), .upUp) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 4), .upUpUp) + } + + /// Every threshold belongs to the steeper arrow, on both sides. + /// The old `..<(-3)` bucketing gave a single-down arrow at exactly -3.0. + func testThresholdsTakeTheSteeperArrow() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -3), .downDownDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 3), .upUpUp) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -2), .downDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 2), .upUp) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -1), .down) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 1), .up) + } + + /// Flat excludes both of its ends, so it is the narrowest bucket. + func testFlatIsOnlyBetweenTheUnitThresholds() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -0.9), .flat) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 0.9), .flat) + } + + /// Past +/-8 the official apps show no arrow rather than a triple one. + /// The wire carries Int8 tenths, so this reaches roughly +/-12.6. + func testImplausibleRatesHaveNoArrow() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -8), .downDownDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 8), .upUpUp) + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: -8.1)) + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: 8.1)) + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: 12.6)) + } +} diff --git a/Tests/G6SensorCoreTests/GS1DataMatrixParserTests.swift b/Tests/G6SensorCoreTests/GS1DataMatrixParserTests.swift new file mode 100644 index 0000000..c21b7d5 --- /dev/null +++ b/Tests/G6SensorCoreTests/GS1DataMatrixParserTests.swift @@ -0,0 +1,239 @@ +// +// GS1DataMatrixParserTests.swift +// G6SensorKit +// +// Ported from DexKit (DexKitTests/GS1DataMatrixParserTests.swift), trimmed +// to G6 alongside the parser. +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import XCTest +@testable import G6SensorCore + +/// Payloads follow the GS1 spec: a package Data Matrix = (01) GTIN, +/// (17) expiry, (10) lot, (21) serial, (240) additional product ID, with +/// FNC1 (\u{1D}) separating variable-length elements that aren't last. +final class GS1DataMatrixParserTests: XCTestCase { + private let fnc1 = "\u{1D}" + + // MARK: - Basic element extraction + + /// Real payload from a public G7 box image: (01) GTIN, (11) production + /// date, (17) expiry, (10) lot, (21) 12-char serial, (241) customer + /// part number. Note the box carries no (240): the pairing/sensor code + /// lives on the applicator label. + private var g7BoxPayload: String { + "01" + "00386270002839" + "11" + "230201" + "17" + "240731" + + "10" + "1523041787" + fnc1 + "21" + "448421319350" + fnc1 + "241" + "STP-AT-012301" + } + + func testParsesFullPayloadWithAIMPrefixAndFNC1() { + // ]d2 = AIM symbology identifier for GS1 Data Matrix + let elements = GS1DataMatrixParser.parse("]d2" + g7BoxPayload) + XCTAssertEqual(elements, [ + GS1Element(ai: "01", value: "00386270002839"), + GS1Element(ai: "11", value: "230201"), + GS1Element(ai: "17", value: "240731"), + GS1Element(ai: "10", value: "1523041787"), + GS1Element(ai: "21", value: "448421319350"), + GS1Element(ai: "241", value: "STP-AT-012301") + ]) + } + + func testParsesPayloadWithoutAIMPrefix() { + // VisionKit/Vision typically deliver the payload without "]d2". + let scan = G6PackageScan(payload: g7BoxPayload) + XCTAssertEqual(scan.gtin, "00386270002839") + XCTAssertEqual(scan.serial, "448421319350") + XCTAssertEqual(scan.value(forAI: "241"), "STP-AT-012301") + } + + func testStripsLeadingFNC1() { + // Some hardware scanners report the leading FNC1 of the first element. + let scan = G6PackageScan(payload: fnc1 + g7BoxPayload) + XCTAssertEqual(scan.gtin, "00386270002839") + XCTAssertEqual(scan.serial, "448421319350") + } + + // MARK: - Dexcom mapping + + func testG6PackageYieldsTransmitterIDCandidate() { + // G6 transmitter box: (21) serial is the 6-char transmitter ID. + let payload = "0100386270004070" + fnc1 + "218KAB12" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.transmitterIDCandidate, "8KAB12") + } + + func testSensorPackageYieldsNoTransmitterIDCandidate() { + // G7 box (the public payload above): (21) is a 12-char sensor + // serial, not a transmitter ID. + let scan = G6PackageScan(payload: g7BoxPayload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertNil(scan.transmitterIDCandidate) + XCTAssertNil(scan.sensorCodeCandidate) // the box carries no (240) + } + + func testFNC1StrippedG7BoxYieldsNoCandidates() { + // The same public payload as scanners and pastes deliver it without + // separators: the dates still parse, nothing G6-usable falls out. + let payload = "0100386270002839112302011724073110152304178721448421319350241STP-AT-012301" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.value(forAI: "11"), "230201") + XCTAssertEqual(scan.value(forAI: "17"), "240731") + XCTAssertNil(scan.transmitterIDCandidate) + XCTAssertNil(scan.sensorCodeCandidate) + } + + func testLowercaseSerialIsUppercased() { + let scan = G6PackageScan(payload: "0100386270004070" + fnc1 + "218kab12") + XCTAssertEqual(scan.transmitterIDCandidate, "8KAB12") + } + + func testNonDexcomGTINIsFlagged() { + // Another manufacturer's package: same GS1 shape, different company prefix. + let payload = "01" + "06991234567890" + fnc1 + "10LOT123" + fnc1 + "21991234ABCD5678" + let scan = G6PackageScan(payload: payload) + XCTAssertFalse(scan.isDexcomPackage) + XCTAssertEqual(scan.gtin, "06991234567890") + XCTAssertEqual(scan.lot, "LOT123") + XCTAssertEqual(scan.serial, "991234ABCD5678") + XCTAssertNil(scan.transmitterIDCandidate) + } + + // MARK: - G6 applicator sensor codes + + // The applicator payloads below are real, public G6 labels; the + // transmitter/sensor box structures mirror our own captures with + // serials, lots and dates anonymized. + + func testApplicatorYieldsSensorCodeCandidate() { + // Real public applicator labels: (10) 16-char lot glued to + // (240) + the 4-digit sensor code, FNC1 separators stripped. + let cases: [(payload: String, code: String)] = [ + ("10731863521434687D2405937", "5937"), + ("10527390421846477D2405931", "5931"), + ("10729148021365863F2409117", "9117") + ] + for (payload, code) in cases { + let scan = G6PackageScan(payload: payload) + XCTAssertEqual(scan.sensorCodeCandidate, code, payload) + XCTAssertNotNil(SensorCode(scan.sensorCodeCandidate), payload) + // No GTIN and no (21): an applicator label says nothing about the transmitter. + XCTAssertNil(scan.transmitterIDCandidate, payload) + XCTAssertFalse(scan.isDexcomPackage, payload) + } + } + + func testApplicatorWithFNC1YieldsSensorCodeCandidate() { + let payload = "10" + "731863521434687D" + fnc1 + "2405937" + let scan = G6PackageScan(payload: payload) + XCTAssertEqual(scan.lot, "731863521434687D") + XCTAssertEqual(scan.sensorCodeCandidate, "5937") + } + + func testApplicatorTailMustBeExactlyFourDigits() { + // "240" followed by 5 digits is not a sensor code. + let scan = G6PackageScan(payload: "10731863521434687D24059377") + XCTAssertNil(scan.sensorCodeCandidate) + } + + func testTransmitterBoxSerialWithTrailingPackagingData() { + // Transmitter box: (21) is the 6-char ID glued to "-". + let payload = "0100386270003072" + "241STT-GS-003" + fnc1 + + "10AB12CD34" + fnc1 + "218ZXY12-199270101" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.transmitterIDCandidate, "8ZXY12") + XCTAssertNil(scan.sensorCodeCandidate) + } + + func testFNC1StrippedTransmitterBoxYieldsTransmitterID() { + // Same box with every FNC1 stripped: the strict parse cannot + // segment past (241), so the raw-payload fallback recovers the ID. + let payload = "0100386270003072241STT-GS-00310AB12CD34218ZXY12-199270101" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.transmitterIDCandidate, "8ZXY12") + } + + func testTransmitterBoxSerialOnlyBarcode() { + // Real public G6 transmitter box: the small Data Matrix carries + // only (21) + the 6-char transmitter ID. + let scan = G6PackageScan(payload: "2188H03B") + + XCTAssertEqual(scan.transmitterIDCandidate, "88H03B") + XCTAssertNil(scan.sensorCodeCandidate) + XCTAssertFalse(scan.isDexcomPackage) + } + + func testTransmitterBoxFullDataMatrix() { + // Same box's large Data Matrix: (241) customer part number, (10) + // lot, (21) serial, (17) expiry. The GTIN is a separate linear + // barcode on the box, so no (01) appears here. + let payload = "241STT-OM-001" + fnc1 + "1018038762" + fnc1 + "2188H03B" + fnc1 + "17250226" + let scan = G6PackageScan(payload: payload) + + XCTAssertEqual(scan.elements, [ + GS1Element(ai: "241", value: "STT-OM-001"), + GS1Element(ai: "10", value: "18038762"), + GS1Element(ai: "21", value: "88H03B"), + GS1Element(ai: "17", value: "250226") + ]) + XCTAssertEqual(scan.transmitterIDCandidate, "88H03B") + XCTAssertNil(scan.sensorCodeCandidate) + XCTAssertFalse(scan.isDexcomPackage) + } + + func testSensorBoxYieldsNoCandidates() { + // Sensor box: GTIN, customer part number, dates; no code at all. + // The sensor code is only on the applicator label. + let payload = "0100386270000927" + "2419500-46107380885" + "11251207" + "17270630" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertNil(scan.transmitterIDCandidate) + XCTAssertNil(scan.sensorCodeCandidate) + } + + // MARK: - Robustness + + func testVariableLengthSerialAtEndOfPayloadWithoutFNC1() { + let payload = "0100386270004758" + fnc1 + "21ABCD12345678" + XCTAssertEqual(GS1DataMatrixParser.parse(payload).last, + GS1Element(ai: "21", value: "ABCD12345678")) + } + + func testStopsAtUnknownAIKeepingParsedPrefix() { + // (99) is not in the registry; parsing stops but keeps (01) and (21). + let payload = "0100386270004758" + fnc1 + "218KAB12" + fnc1 + "99JUNK" + let elements = GS1DataMatrixParser.parse(payload) + XCTAssertEqual(elements, [ + GS1Element(ai: "01", value: "00386270004758"), + GS1Element(ai: "21", value: "8KAB12") + ]) + } + + func testTruncatedFixedLengthFieldStopsParsing() { + let payload = "0100386" // truncated GTIN + XCTAssertTrue(GS1DataMatrixParser.parse(payload).isEmpty) + } + + func testGarbageYieldsNoElements() { + XCTAssertTrue(GS1DataMatrixParser.parse("hello world").isEmpty) + XCTAssertTrue(GS1DataMatrixParser.parse("").isEmpty) + } + + func testFourDigitAIIsNotMisreadAsTwoDigitAI() { + // "240..." must read as AI 240, not AI 24 + garbage. + let payload = "0100386270004758" + fnc1 + "2401249" + XCTAssertEqual(GS1DataMatrixParser.parse(payload).last, + GS1Element(ai: "240", value: "1249")) + } +} diff --git a/Tests/G6SensorCoreTests/PassiveMessageHandlerTests.swift b/Tests/G6SensorCoreTests/PassiveMessageHandlerTests.swift new file mode 100644 index 0000000..64091e5 --- /dev/null +++ b/Tests/G6SensorCoreTests/PassiveMessageHandlerTests.swift @@ -0,0 +1,236 @@ +// +// PassiveMessageHandlerTests.swift +// G6SensorKitTests +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// +// The captured-traffic fixtures come from CGMBLEKit's test suite +// (GlucoseTests, GlucoseBackfillMessageTests), originally xDripG5. +// Copyright © 2016-2018 LoopKit Authors. (MIT License) +// + +import XCTest +@testable import G6SensorCore + +class PassiveMessageHandlerTests: XCTestCase { + + // Captured G6 traffic, same fixtures as the codec tests. + let timeFrame = Data(hexadecimalString: "2500470272007cff710001000000fa1d")! + let glucoseFrame = Data(hexadecimalString: "3100680a00008a715700cc0006ffc42a")! + let calibrationFrame = Data(hexadecimalString: "33002b290090012900ae00800050e929001225")! + let versionFrame = Data(hexadecimalString: "4b0001000011df2900005100037000f00009b6")! + let backfillAck = Data(hexadecimalString: "51000100b7ff52006604530032000000e6cb9805")! + let backfillFrames = [ + Data(hexadecimalString: "0100bc460000b7ff52008b0006eee30053008500")!, + Data(hexadecimalString: "020006eb0f025300800006ee3a0353007e0006f5")!, + Data(hexadecimalString: "030066045300790006f8")!, + ] + + let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func handler(seedActivation: Bool = false) -> PassiveMessageHandler { + PassiveMessageHandler( + transmitterID: "123456", + activationDate: seedActivation ? now.addingTimeInterval(-1_000_000) : nil + ) + } + + // MARK: - Authentication + + func testAuthChallengeIsReported() { + let events = handler().handleAuthentication(Data(hexadecimalString: "050101")!) + + guard case .observedAuthentication(let isAuthenticated, let isBonded) = events.first else { + return XCTFail("Expected observedAuthentication, got \(events)") + } + XCTAssertTrue(isAuthenticated) + XCTAssertTrue(isBonded) + } + + func testOtherAuthTrafficIsIgnored() { + // AuthRequestRx (0x03) carries nothing worth reporting. + XCTAssertTrue(handler().handleAuthentication(Data(hexadecimalString: "0300a1b2c3d4e5f6a7b8")!).isEmpty) + } + + // MARK: - Time and glucose + + func testGlucoseDroppedBeforeAnyTimeFrame() { + let events = handler().handleControl(glucoseFrame, at: now) + + guard case .error(let error) = events.first, events.count == 1 else { + return XCTFail("Expected a single error, got \(events)") + } + guard case .observationError = error else { + return XCTFail("Expected observationError, got \(error)") + } + } + + func testSeededActivationDateAloneCannotDateGlucose() { + // The session start time comes from the time message, not the + // activation date, so a seeded date alone is not enough. + let events = handler(seedActivation: true).handleControl(glucoseFrame, at: now) + + guard case .error = events.first, events.count == 1 else { + return XCTFail("Expected a single error, got \(events)") + } + } + + func testTimeFrameDerivesActivationDate() { + let handler = handler() + let events = handler.handleControl(timeFrame, at: now) + + let currentTime = TransmitterTimeRxMessage(data: timeFrame)!.currentTime + let expected = now.addingTimeInterval(-TimeInterval(currentTime)) + + guard case .transmitterTime(let activationDate) = events.first, events.count == 1 else { + return XCTFail("Expected transmitterTime, got \(events)") + } + XCTAssertEqual(expected, activationDate) + XCTAssertEqual(expected, handler.activationDate) + } + + func testGlucoseAfterTimeFrame() { + let handler = handler() + handler.handleControl(timeFrame, at: now) + let events = handler.handleControl(glucoseFrame, at: now) + + guard case .glucose(let glucose) = events.first, events.count == 1 else { + return XCTFail("Expected glucose, got \(events)") + } + XCTAssertEqual(204, glucose.glucoseMgDL) + XCTAssertEqual(.known(.ok), glucose.state) + XCTAssertTrue(glucose.hasValidSensorSession) + XCTAssertNil(glucose.lastCalibration) + } + + // MARK: - Calibration + + func testCalibrationIsCachedAndAttachedToLaterReadings() { + let handler = handler() + XCTAssertTrue(handler.handleControl(calibrationFrame, at: now).isEmpty) + + handler.handleControl(timeFrame, at: now) + let events = handler.handleControl(glucoseFrame, at: now) + + guard case .glucose(let glucose) = events.first else { + return XCTFail("Expected glucose, got \(events)") + } + XCTAssertNotNil(glucose.lastCalibration) + } + + // MARK: - Transmitter version + + func testTransmitterVersionIsForwarded() { + let events = handler().handleControl(versionFrame, at: now) + + guard case .transmitterVersion(let message) = events.first, events.count == 1 else { + return XCTFail("Expected transmitterVersion, got \(events)") + } + XCTAssertEqual(112, message.transmitterExpiryInDays) + } + + // MARK: - Backfill + + private func primedHandler() -> PassiveMessageHandler { + let handler = handler() + handler.handleControl(timeFrame, at: now) + return handler + } + + func testCompleteBackfill() { + let handler = primedHandler() + for frame in backfillFrames { + handler.handleBackfill(frame) + } + let events = handler.handleControl(backfillAck, at: now) + + guard case .backfill(let readings) = events.first, events.count == 1 else { + return XCTFail("Expected backfill, got \(events)") + } + XCTAssertEqual(5, readings.count) + XCTAssertEqual(139, readings.first?.glucoseMgDL) + XCTAssertEqual(121, readings.last?.glucoseMgDL) + } + + func testBackfillAckWithoutFramesIsAnError() { + let events = primedHandler().handleControl(backfillAck, at: now) + + guard case .error = events.first, events.count == 1 else { + return XCTFail("Expected a single error, got \(events)") + } + } + + func testBackfillEmptyWindowIsNotAnError() { + // An ack reporting zero bytes stored is an empty result. + var ack = Data([0x51, 0x00, 0x00, 0x00]) + ack.append(UInt32(1000)) + ack.append(UInt32(2000)) + ack.append(UInt32(0)) + ack.append(UInt16(0)) + ack = ack.appendingCRC() + + XCTAssertTrue(primedHandler().handleControl(ack, at: now).isEmpty) + } + + func testBackfillCRCMismatch() { + let handler = primedHandler() + for frame in backfillFrames { + handler.handleBackfill(frame) + } + + // Same ack but a corrupted buffer CRC (message CRC recomputed so the + // ack itself still parses). + var bytes = [UInt8](backfillAck) + bytes[16] ^= 0xff + let corrupted = Data(bytes[0..<18]).appendingCRC() + + let events = handler.handleControl(corrupted, at: now) + + guard case .error = events.first, events.count == 1 else { + return XCTFail("Expected a single error, got \(events)") + } + } + + func testPartialBackfillDeliversPrefixWithError() { + let handler = primedHandler() + // Two of the three frames; the buffer is a valid prefix. + handler.handleBackfill(backfillFrames[0]) + handler.handleBackfill(backfillFrames[1]) + let events = handler.handleControl(backfillAck, at: now) + + guard case .backfill(let readings) = events.first else { + return XCTFail("Expected backfill, got \(events)") + } + // Each full frame carries two 8-byte sub-messages past the header. + XCTAssertEqual(4, readings.count) + guard case .error = events.last, events.count == 2 else { + return XCTFail("Expected a trailing incompleteness error, got \(events)") + } + } + + func testBackfillDroppedBeforeAnyTimeFrame() { + let handler = handler() + for frame in backfillFrames { + handler.handleBackfill(frame) + } + let events = handler.handleControl(backfillAck, at: now) + + guard case .error = events.first, events.count == 1 else { + return XCTFail("Expected a single error, got \(events)") + } + } + + // MARK: - Unknown traffic + + func testUnknownOpcode() { + // 0x09 (disconnectTx) is a known opcode but meaningless as observed + // traffic; 0x7f is not an opcode at all. + for hex in ["0900aa55", "7f00aa55"] { + let events = handler().handleControl(Data(hexadecimalString: hex)!, at: now) + guard case .unknown(let data) = events.first, events.count == 1 else { + return XCTFail("Expected unknown for \(hex), got \(events)") + } + XCTAssertEqual(hex, data.hexadecimalString) + } + } +} diff --git a/Tests/G6SensorCoreTests/TransmitterIDTests.swift b/Tests/G6SensorCoreTests/TransmitterIDTests.swift index 59aedba..c807053 100644 --- a/Tests/G6SensorCoreTests/TransmitterIDTests.swift +++ b/Tests/G6SensorCoreTests/TransmitterIDTests.swift @@ -16,5 +16,18 @@ class TransmitterIDTests: XCTestCase { XCTAssertEqual("e60d4a7999b0fbb2", id.computeHash(of: Data(hexadecimalString: "0123456789abcdef")!)!.hexadecimalString) } - + + /// The prefix decides the model: 8… is G6, 5… and C… are Dexcom ONE. + func testModelByPrefix() { + XCTAssertEqual(TransmitterID(id: "8ZXY12").model, .g6) + XCTAssertEqual(TransmitterID(id: "5ZXY12").model, .one) + XCTAssertEqual(TransmitterID(id: "CZXY12").model, .one) + XCTAssertEqual(TransmitterID(id: "cZXY12").model, .one) + } + + /// An unrecognized prefix is the stock case, not a failure. + func testUnknownPrefixFallsBackToG6() { + XCTAssertEqual(TransmitterID(id: "9ZXY12").model, .g6) + } + }