diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 9582049..7155ff6 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 3B0FD2A52D803BF100E5E921 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B0FD2A42D803BF000E5E921 /* LoopKitUI.framework */; }; + 6515DB522E695F77005C42DC /* G7SensorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6515DB512E695F77005C42DC /* G7SensorType.swift */; }; B60BB2E42BC649DA00D2BB39 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60BB2E32BC649DA00D2BB39 /* Bundle.swift */; }; B66D1F6D2E6A803800471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F6C2E6A803800471149 /* Localizable.xcstrings */; }; B66D1F6F2E6A803800471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F6E2E6A803800471149 /* Localizable.xcstrings */; }; @@ -109,6 +110,7 @@ /* Begin PBXFileReference section */ 3B0FD2A42D803BF000E5E921 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6515DB512E695F77005C42DC /* G7SensorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7SensorType.swift; sourceTree = ""; }; B60BB2E32BC649DA00D2BB39 /* Bundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Bundle.swift; path = G7SensorKitUI/Extensions/Bundle.swift; sourceTree = SOURCE_ROOT; }; B66D1F6C2E6A803800471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B66D1F6E2E6A803800471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -249,6 +251,7 @@ C17F50DE291EAC6500555EB5 /* G7CGMManager */ = { isa = PBXGroup; children = ( + 6515DB512E695F77005C42DC /* G7SensorType.swift */, C17F50DF291EAC6500555EB5 /* G7BackfillMessage.swift */, C17F50E7291EAC6500555EB5 /* G7BluetoothManager.swift */, C17F50E5291EAC6500555EB5 /* G7CGMManager.swift */, @@ -571,6 +574,7 @@ C17F5140291EB27D00555EB5 /* TimeInterval.swift in Sources */, C17F50F0291EAC6500555EB5 /* G7CGMManagerState.swift in Sources */, C17F5145291EB45900555EB5 /* CBPeripheral.swift in Sources */, + 6515DB522E695F77005C42DC /* G7SensorType.swift in Sources */, C17F513A291EB0D900555EB5 /* GlucoseLimits.swift in Sources */, C17F5143291EB36700555EB5 /* AuthChallengeRxMessage.swift in Sources */, C17F50EA291EAC6500555EB5 /* G7DeviceStatus.swift in Sources */, diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index ccddaeb..b3182a6 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -116,14 +116,14 @@ public class G7CGMManager: CGMManager { guard let activatedAt = sensorActivatedAt else { return nil } - return activatedAt.addingTimeInterval(G7Sensor.lifetime) + return activatedAt.addingTimeInterval(state.sensorType.lifetime) } public var sensorEndsAt: Date? { guard let activatedAt = sensorActivatedAt else { return nil } - return activatedAt.addingTimeInterval(G7Sensor.lifetime + G7Sensor.gracePeriod) + return activatedAt.addingTimeInterval(state.sensorType.lifetime + state.sensorType.gracePeriod) } @@ -131,7 +131,7 @@ public class G7CGMManager: CGMManager { guard let activatedAt = sensorActivatedAt else { return nil } - return activatedAt.addingTimeInterval(G7Sensor.warmupDuration) + return activatedAt.addingTimeInterval(state.sensorType.warmupDuration) } public var latestReading: G7GlucoseMessage? { @@ -147,14 +147,13 @@ public class G7CGMManager: CGMManager { } public var uploadReadings: Bool { - get { - return state.uploadReadings - } - set { - mutateState { state in - state.uploadReadings = newValue - } - } + get { state.uploadReadings } + set { mutateState { $0.uploadReadings = newValue } } + } + + public var isFifteenDaySensor: Bool { + get { state.isFifteenDaySensor } + set { mutateState { $0.isFifteenDaySensor = newValue } } } public let sensor: G7Sensor @@ -229,7 +228,9 @@ public class G7CGMManager: CGMManager { public static let pluginIdentifier: String = "G7CGMManager" - public let localizedTitle = LocalizedString("Dexcom G7", comment: "CGM display title") + public var localizedTitle: String { + return state.sensorType.displayName + } public let isOnboarded = true // No distinction between created and onboarded @@ -242,6 +243,7 @@ public class G7CGMManager: CGMManager { mutateState { state in state.sensorID = nil + state.sensorType = .unknown state.activatedAt = nil } sensor.scanForNewSensor() @@ -251,7 +253,7 @@ public class G7CGMManager: CGMManager { return HKDevice( name: state.sensorID ?? "Unknown", manufacturer: "Dexcom", - model: "G7", + model: state.sensorType.rawValue, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: "CGMBLEKit" + String(G7SensorKitVersionNumber), @@ -292,14 +294,15 @@ extension G7CGMManager: G7SensorDelegate { if shouldSwitchToNewSensor { mutateState { state in state.sensorID = name + state.sensorType = sensor.sensorType state.activatedAt = activatedAt } let event = PersistedCgmEvent( date: activatedAt, type: .sensorStart, deviceIdentifier: name, - expectedLifetime: .hours(24 * 10 + 12), - warmupPeriod: .hours(2) + expectedLifetime: .hours(sensor.sensorType.lifetime.hours + sensor.sensorType.gracePeriod.hours), + warmupPeriod: .hours(sensor.sensorType.warmupDuration.hours) ) delegate.notify { delegate in delegate?.cgmManager(self, hasNew: [event]) diff --git a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift index 948b02f..a382dca 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -14,17 +14,27 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { public typealias RawValue = CGMManager.RawStateValue public var sensorID: String? + public var sensorType: G7SensorType = .unknown public var activatedAt: Date? public var latestReading: G7GlucoseMessage? public var latestReadingTimestamp: Date? public var latestConnect: Date? public var uploadReadings: Bool = false + public var isFifteenDaySensor: Bool = false init() { } public init(rawValue: RawValue) { self.sensorID = rawValue["sensorID"] as? String + if let sensorTypeString = rawValue["sensorType"] as? String, + let sensorType = G7SensorType(rawValue: sensorTypeString) { + self.sensorType = sensorType + } else { + if let sensorID = rawValue["sensorID"] as? String { + self.sensorType = G7SensorType.detect(from: sensorID, isFifteenDaySensor: rawValue["isFifteenDaySensor"] as? Bool ?? false) + } + } self.activatedAt = rawValue["activatedAt"] as? Date if let readingData = rawValue["latestReading"] as? Data { latestReading = G7GlucoseMessage(data: readingData) @@ -32,16 +42,19 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { self.latestReadingTimestamp = rawValue["latestReadingTimestamp"] as? Date self.latestConnect = rawValue["latestConnect"] as? Date self.uploadReadings = rawValue["uploadReadings"] as? Bool ?? false + self.isFifteenDaySensor = rawValue["isFifteenDaySensor"] as? Bool ?? false } public var rawValue: RawValue { var rawValue: RawValue = [:] rawValue["sensorID"] = sensorID + rawValue["sensorType"] = sensorType.rawValue rawValue["activatedAt"] = activatedAt rawValue["latestReading"] = latestReading?.data rawValue["latestReadingTimestamp"] = latestReadingTimestamp rawValue["latestConnect"] = latestConnect rawValue["uploadReadings"] = uploadReadings + rawValue["isFifteenDaySensor"] = isFifteenDaySensor return rawValue } } diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index aa88883..1977095 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -62,9 +62,13 @@ public enum G7SensorLifecycleState { public final class G7Sensor: G7BluetoothManagerDelegate { + // Legacy static properties for backward compatibility public static let lifetime = TimeInterval(hours: 10 * 24) public static let warmupDuration = TimeInterval(minutes: 25) public static let gracePeriod = TimeInterval(hours: 12) + + // Current sensor type for dynamic timing + public var sensorType: G7SensorType = .unknown public weak var delegate: G7SensorDelegate? @@ -222,8 +226,12 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } /// The Dexcom G7 advertises a peripheral name of "DXCMxx", and later reports a full name of "Dexcomxx" - /// Dexcom One+ peripheral name start with "DX02" - if name.hasPrefix("DXCM") || name.hasPrefix("DX02"){ + /// The Dexcom Stelo prefix is "DX01" + /// The Dexcom One+ prefix is "DX02" + if name.hasPrefix("DXCM") || name.hasPrefix("DX01") || name.hasPrefix("DX02"){ + // Auto-detect sensor type when connecting + sensorType = G7SensorType.detect(from: name, isFifteenDaySensor: false) + // If we're following this name or if we're scanning, connect if let sensorName = sensorID, name.suffix(2) == sensorName.suffix(2) { return .makeActive diff --git a/G7SensorKit/G7CGMManager/G7SensorType.swift b/G7SensorKit/G7CGMManager/G7SensorType.swift new file mode 100644 index 0000000..c1785f8 --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7SensorType.swift @@ -0,0 +1,113 @@ +// +// G7SensorType.swift +// G7SensorKit +// +// Created by Daniel Johansson on 12/19/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum G7SensorType: String, CaseIterable, CustomStringConvertible { + case g7 = "G7" + case g7extended = "G7 Extended" + case onePlus = "ONE+" + case stelo = "Stelo" + case unknown = "Unknown" + + public var description: String { + switch self { + case .g7: + return "Dexcom G7" + case .g7extended: + return "Dexcom G7 Extended" + case .onePlus: + return "Dexcom ONE+" + case .stelo: + return "Dexcom Stelo" + case .unknown: + return "Unknown Sensor" + } + } + + public var displayName: String { + return description + } + + public var lifetime: TimeInterval { + switch self { + case .g7: + return TimeInterval(hours: 10 * 24) // 10 days + case .g7extended: + return TimeInterval(hours: 15 * 24) // 15 days + case .onePlus: + return TimeInterval(hours: 10 * 24) // 10 days + case .stelo: + return TimeInterval(hours: 15 * 24) // 15 days + case .unknown: + return TimeInterval(hours: 10 * 24) // Default to 10 days + } + } + + public var gracePeriod: TimeInterval { + switch self { + case .g7, .g7extended, .onePlus, .stelo, .unknown: + return TimeInterval(hours: 12) // 12 hours for all + } + } + + public var warmupDuration: TimeInterval { + switch self { + case .g7, .onePlus, .stelo, .unknown: + return TimeInterval(minutes: 25) // 25 minutes for all other + case .g7extended: + return TimeInterval(minutes: 60) // 60 minutes for extended + } + } + public var totalLifetimeHours: Double { + return (lifetime + gracePeriod).hours + } + + public var warmupHours: Double { + return warmupDuration.hours + } + + public var dexcomAppURL: String { + switch self { + case .g7, .g7extended: + return "dexcomg7://" + case .onePlus: + return "dexcomg7://" // ONE+ Uses same URL as G7 app. If G7 and One+ is installed, the G7 app will open + case .stelo: + return "stelo://" + case .unknown: + return "dexcomg7://" // Default to G7 app + } + } + + /// Detects sensor type based on the sensor name/ID + public static func detect(from sensorName: String, isFifteenDaySensor: Bool) -> G7SensorType { + let name = sensorName.uppercased() + if name.hasPrefix("DXCM") { + if isFifteenDaySensor { + return .g7extended + } + return .g7 + } else if name.hasPrefix("DX01") { + return .stelo + } else if name.hasPrefix("DX02") { + return .onePlus + } else { + return .unknown + } + } + + public static func forFifteenDayOption(baseType: G7SensorType, isFifteenDaySensor: Bool) -> G7SensorType { + switch baseType { + case .g7: + return isFifteenDaySensor ? .g7extended : .g7 + default: + return baseType + } + } +} diff --git a/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift b/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift index fb91acb..e290ca4 100644 --- a/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift +++ b/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift @@ -109,7 +109,7 @@ extension G7CGMManager: CGMManagerUI { let remaining = max(0, expiration.timeIntervalSinceNow) if remaining < .hours(24) { - return G7LifecycleProgress(percentComplete: 1-(remaining/G7Sensor.lifetime), progressState: .warning) + return G7LifecycleProgress(percentComplete: 1-(remaining/state.sensorType.lifetime), progressState: .warning) } return nil case .gracePeriod: @@ -117,7 +117,7 @@ extension G7CGMManager: CGMManagerUI { return nil } let remaining = max(0, endTime.timeIntervalSinceNow) - return G7LifecycleProgress(percentComplete: 1-(remaining/G7Sensor.gracePeriod), progressState: .critical) + return G7LifecycleProgress(percentComplete: 1-(remaining/state.sensorType.gracePeriod), progressState: .critical) case .expired: return G7LifecycleProgress(percentComplete: 1, progressState: .critical) default: diff --git a/G7SensorKitUI/Views/G7SettingsView.swift b/G7SensorKitUI/Views/G7SettingsView.swift index b5b50e4..e52407b 100644 --- a/G7SensorKitUI/Views/G7SettingsView.swift +++ b/G7SensorKitUI/Views/G7SettingsView.swift @@ -65,13 +65,13 @@ struct G7SettingsView: View { HStack { Text(LocalizedString("Sensor Expiration", comment: "title for g7 settings row showing sensor expiration time")) Spacer() - Text(timeFormatter.string(from: activatedAt.addingTimeInterval(G7Sensor.lifetime))) + Text(timeFormatter.string(from: activatedAt.addingTimeInterval(viewModel.sensorType.lifetime))) .foregroundColor(.secondary) } HStack { Text(LocalizedString("Grace Period End", comment: "title for g7 settings row showing sensor grace period end time")) Spacer() - Text(timeFormatter.string(from: activatedAt.addingTimeInterval(G7Sensor.lifetime + G7Sensor.gracePeriod))) + Text(timeFormatter.string(from: activatedAt.addingTimeInterval(viewModel.sensorType.lifetime + viewModel.sensorType.gracePeriod))) .foregroundColor(.secondary) } } @@ -85,6 +85,14 @@ struct G7SettingsView: View { LabeledValueView(label: LocalizedString("Trend", comment: "Field label"), value: viewModel.lastGlucoseTrendString) } + + Section () { + Button(LocalizedString("Open Dexcom App", comment:"Opens the dexcom app to allow users to manage active sensors"), action: { + if let appURL = URL(string: viewModel.sensorType.dexcomAppURL) { + UIApplication.shared.open(appURL) + } + }) + } Section("Bluetooth") { if let name = viewModel.sensorName { @@ -119,17 +127,12 @@ struct G7SettingsView: View { } Section("Configuration") { - HStack { - Toggle(LocalizedString("Upload Readings", comment: "title for g7 config settings to upload readings"), isOn: $viewModel.uploadReadings) - } - } - - Section () { - Button(LocalizedString("Open Dexcom App", comment:"Opens the dexcom G7 app to allow users to manage active sensors"), action: { - if let appURL = URL(string: "dexcomg7://") { - UIApplication.shared.open(appURL) + HStack { + Toggle(LocalizedString("Upload Readings", comment: "title for g7 config settings to upload readings"), isOn: $viewModel.uploadReadings) + } + HStack { + Toggle(LocalizedString("15 Day Sensor", comment: "title for g7 config settings to enable 15 day sensor option"), isOn: $viewModel.isFifteenDaySensor) } - }) } Section () { @@ -144,7 +147,7 @@ struct G7SettingsView: View { } .insetGroupedListStyle() .navigationBarItems(trailing: doneButton) - .navigationBarTitle(LocalizedString("Dexcom G7", comment: "Navigation bar title for G7SettingsView")) + .navigationBarTitle(viewModel.sensorTypeDisplayName) } private var deleteCGMButton: some View { diff --git a/G7SensorKitUI/Views/G7SettingsViewModel.swift b/G7SensorKitUI/Views/G7SettingsViewModel.swift index 93bff10..1b4382b 100644 --- a/G7SensorKitUI/Views/G7SettingsViewModel.swift +++ b/G7SensorKitUI/Views/G7SettingsViewModel.swift @@ -20,6 +20,7 @@ class G7SettingsViewModel: ObservableObject { @Published private(set) var scanning: Bool = false @Published private(set) var connected: Bool = false @Published private(set) var sensorName: String? + @Published private(set) var sensorType: G7SensorType = .unknown @Published private(set) var activatedAt: Date? @Published private(set) var lastConnect: Date? @Published private(set) var latestReadingTimestamp: Date? @@ -28,6 +29,12 @@ class G7SettingsViewModel: ObservableObject { cgmManager.uploadReadings = uploadReadings } } + + @Published var isFifteenDaySensor: Bool = false { + didSet { + cgmManager.isFifteenDaySensor = isFifteenDaySensor + } + } let displayGlucosePreference: DisplayGlucosePreference @@ -64,18 +71,25 @@ class G7SettingsViewModel: ObservableObject { self.displayGlucosePreference = displayGlucosePreference updateValues() + self.isFifteenDaySensor = cgmManager.isFifteenDaySensor self.cgmManager.addStateObserver(self, queue: DispatchQueue.main) } + var sensorTypeDisplayName: String { + return sensorType.displayName + } + func updateValues() { scanning = cgmManager.isScanning sensorName = cgmManager.sensorName + sensorType = cgmManager.state.sensorType activatedAt = cgmManager.sensorActivatedAt connected = cgmManager.isConnected lastConnect = cgmManager.lastConnect lastReading = cgmManager.latestReading latestReadingTimestamp = cgmManager.latestReadingTimestamp uploadReadings = cgmManager.state.uploadReadings + isFifteenDaySensor = cgmManager.isFifteenDaySensor } var progressBarColorStyle: ColorStyle { @@ -108,17 +122,17 @@ class G7SettingsViewModel: ObservableObject { guard let value = progressValue, value > 0 else { return 0 } - return 1 - value / G7Sensor.warmupDuration + return 1 - value / sensorType.warmupDuration case .lifetimeRemaining: guard let value = progressValue, value > 0 else { return 0 } - return 1 - value / G7Sensor.lifetime + return 1 - value / sensorType.lifetime case .gracePeriodRemaining: guard let value = progressValue, value > 0 else { return 0 } - return 1 - value / G7Sensor.gracePeriod + return 1 - value / sensorType.gracePeriod case .sensorExpired, .sensorFailed: return 1 } diff --git a/G7SensorKitUI/Views/G7StartupView.swift b/G7SensorKitUI/Views/G7StartupView.swift index b7a4d07..58ab48b 100644 --- a/G7SensorKitUI/Views/G7StartupView.swift +++ b/G7SensorKitUI/Views/G7StartupView.swift @@ -28,7 +28,7 @@ struct G7StartupView: View { .frame(height: 120) .padding(.horizontal) }.frame(maxWidth: .infinity) - Text(String(format: LocalizedString("%1$@ can read G7 CGM data, but you must still use the Dexcom G7 App for pairing, calibration, and other sensor management.", comment: "Descriptive text on G7StartupView (1: appName)"), self.appName)) + Text(String(format: LocalizedString("%1$@ can read CGM data from the G7 platform, but you must still use the Dexcom App for pairing, calibration, alarms and other sensor management available to the sensor series (G7, ONE+, Stelo).\n\nWARNING: Dexcom Stelo app provides no alerts and alarms. Glucose alerts and alarms are not provided by %2$@.", comment: "Descriptive text on G7StartupView (1: appName, 2: appName)"), self.appName, self.appName)) .fixedSize(horizontal: false, vertical: true) .foregroundColor(.secondary) Spacer() diff --git a/G7SensorPlugin/Info.plist b/G7SensorPlugin/Info.plist index 996e0e1..b97e45d 100644 --- a/G7SensorPlugin/Info.plist +++ b/G7SensorPlugin/Info.plist @@ -23,7 +23,7 @@ NSPrincipalClass G7SensorPlugin com.loopkit.Loop.CGMManagerDisplayName - Dexcom G7 / ONE+ + Dexcom G7 / ONE+ / Stelo com.loopkit.Loop.CGMManagerIdentifier G7CGMManager