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 @@ -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 */,
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
112 changes: 112 additions & 0 deletions G7SensorKit/G7CGMManager/G7SensorType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// 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) -> G7SensorType {
let name = sensorName.uppercased()
if name.hasPrefix("DXCM") {
// 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
}
}

public static func forFifteenDayOption(baseType: G7SensorType, isFifteenDaySensor: Bool) -> G7SensorType {
switch baseType {
case .g7:
return isFifteenDaySensor ? .g7extended : .g7
default:
return baseType
}
}
}
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
29 changes: 16 additions & 13 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.effectiveSensorType.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.effectiveSensorType.lifetime + viewModel.effectiveSensorType.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 @@ -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 () {
Expand All @@ -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 {
Expand Down
Loading