Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions G7SensorKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
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 = "<group>"; };
B66D1F6E2E6A803800471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -249,6 +251,7 @@
C17F50DE291EAC6500555EB5 /* G7CGMManager */ = {
isa = PBXGroup;
children = (
6515DB512E695F77005C42DC /* G7SensorType.swift */,
C17F50DF291EAC6500555EB5 /* G7BackfillMessage.swift */,
C17F50E7291EAC6500555EB5 /* G7BluetoothManager.swift */,
C17F50E5291EAC6500555EB5 /* G7CGMManager.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
18 changes: 11 additions & 7 deletions G7SensorKit/G7CGMManager/G7CGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,22 @@ 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)
}


public var sensorFinishesWarmupAt: Date? {
guard let activatedAt = sensorActivatedAt else {
return nil
}
return activatedAt.addingTimeInterval(G7Sensor.warmupDuration)
return activatedAt.addingTimeInterval(state.sensorType.warmupDuration)
}

public var latestReading: G7GlucoseMessage? {
Expand Down Expand Up @@ -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

Expand All @@ -242,6 +244,7 @@ public class G7CGMManager: CGMManager {

mutateState { state in
state.sensorID = nil
state.sensorType = .unknown
state.activatedAt = nil
}
sensor.scanForNewSensor()
Expand All @@ -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),
Expand Down Expand Up @@ -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])
Expand Down
10 changes: 10 additions & 0 deletions G7SensorKit/G7CGMManager/G7CGMManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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)
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions G7SensorKit/G7CGMManager/G7Sensor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions G7SensorKit/G7CGMManager/G7SensorType.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
4 changes: 2 additions & 2 deletions G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,15 @@ 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:
guard let endTime = sensorEndsAt else {
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:
Expand Down
22 changes: 11 additions & 11 deletions G7SensorKitUI/Views/G7SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions G7SensorKitUI/Views/G7SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion G7SensorKitUI/Views/G7StartupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this long string should be broken into two localized strings that are concatenated together. I think this would make it easier for localization tasks moving forward.

It is not true that Stelo provides no alerts or alarms. After wearing one for 15 days, I got plenty of alerts. Also, we want this submodule to be suitable for use with Trio as well as Loop. (And Trio does have alerts.) Perhaps we could say:
"Dexcom Stelo app does not provide the same critical alarms as G7 or ONE+."

.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.secondary)
Spacer()
Expand Down
2 changes: 1 addition & 1 deletion G7SensorPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<key>NSPrincipalClass</key>
<string>G7SensorPlugin</string>
<key>com.loopkit.Loop.CGMManagerDisplayName</key>
<string>Dexcom G7 / ONE+</string>
<string>Dexcom G7 / ONE+ / Stelo</string>
<key>com.loopkit.Loop.CGMManagerIdentifier</key>
<string>G7CGMManager</string>
</dict>
Expand Down