diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 90f7a36..2b2d2ab 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 */, @@ -568,6 +571,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..8a0af52 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? { @@ -229,7 +229,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 +244,7 @@ public class G7CGMManager: CGMManager { mutateState { state in state.sensorID = nil + state.sensorType = .unknown state.activatedAt = nil } sensor.scanForNewSensor() @@ -251,7 +254,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 +295,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..cae351e 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -14,6 +14,7 @@ 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? @@ -25,6 +26,14 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { 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) + } + } self.activatedAt = rawValue["activatedAt"] as? Date if let readingData = rawValue["latestReading"] as? Data { latestReading = G7GlucoseMessage(data: readingData) @@ -37,6 +46,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { public var rawValue: RawValue { var rawValue: RawValue = [:] rawValue["sensorID"] = sensorID + rawValue["sensorType"] = sensorType.rawValue rawValue["activatedAt"] = activatedAt rawValue["latestReading"] = latestReading?.data rawValue["latestReadingTimestamp"] = latestReadingTimestamp diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index aa88883..223aaaf 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) + // 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..f2d1a0d --- /dev/null +++ b/G7SensorKit/G7CGMManager/G7SensorType.swift @@ -0,0 +1,98 @@ +// +// 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 onePlus = "ONE+" + case stelo = "Stelo" + case unknown = "Unknown" + + public var description: String { + switch self { + case .g7: + return "Dexcom G7" + 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 .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, .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 + } + } + public var totalLifetimeHours: Double { + return (lifetime + gracePeriod).hours + } + + public var warmupHours: Double { + return warmupDuration.hours + } + + public var dexcomAppURL: String { + switch self { + case .g7: + 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) -> G7SensorType { + let name = sensorName.uppercased() + + if name.hasPrefix("DXCM") { + // Check for 15-day G7 sensors (these might have a different prefix pattern) + // For now, assume all DXCM are 10-day G7, but this could be enhanced + // based on additional sensor data or naming patterns + return .g7 + } else if name.hasPrefix("DX01") { + return .stelo + } else if name.hasPrefix("DX02") { + return .onePlus + } else { + return .unknown + } + } +} 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..2d4ab96 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 { @@ -123,14 +131,6 @@ struct G7SettingsView: View { 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) - } - }) - } Section () { if !self.viewModel.scanning { @@ -144,7 +144,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..856e70a 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? @@ -67,9 +68,14 @@ class G7SettingsViewModel: ObservableObject { 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 @@ -108,17 +114,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