Skip to content
Open
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
8 changes: 8 additions & 0 deletions G7SensorKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
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 */; };
C107607F2F059130008B2B39 /* ExtendedVersionMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */; };
C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */; };
C109F14A291ECCE2008EA5B6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C109F149291ECCE2008EA5B6 /* Assets.xcassets */; };
C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */; };
C139829829295D7D0047DB5F /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F514A291EB6F000555EB5 /* HKUnit.swift */; };
Expand Down Expand Up @@ -112,6 +114,8 @@
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>"; };
C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessage.swift; sourceTree = "<group>"; };
C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessageTests.swift; sourceTree = "<group>"; };
C109F149291ECCE2008EA5B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7GlucoseMessageTests.swift; sourceTree = "<group>"; };
C1409A08291EC22F006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -240,6 +244,7 @@
C17F50D2291EAC3800555EB5 /* G7SensorKitTests */ = {
isa = PBXGroup;
children = (
C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */,
C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */,
C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */,
);
Expand Down Expand Up @@ -319,6 +324,7 @@
C17F5141291EB34800555EB5 /* Messages */ = {
isa = PBXGroup;
children = (
C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */,
C17F50E3291EAC6500555EB5 /* G7Opcode.swift */,
C17F5146291EB57700555EB5 /* SensorMessage.swift */,
C17F50E8291EAC6500555EB5 /* G7GlucoseMessage.swift */,
Expand Down Expand Up @@ -571,6 +577,7 @@
C17F5140291EB27D00555EB5 /* TimeInterval.swift in Sources */,
C17F50F0291EAC6500555EB5 /* G7CGMManagerState.swift in Sources */,
C17F5145291EB45900555EB5 /* CBPeripheral.swift in Sources */,
C107607F2F059130008B2B39 /* ExtendedVersionMessage.swift in Sources */,
C17F513A291EB0D900555EB5 /* GlucoseLimits.swift in Sources */,
C17F5143291EB36700555EB5 /* AuthChallengeRxMessage.swift in Sources */,
C17F50EA291EAC6500555EB5 /* G7DeviceStatus.swift in Sources */,
Expand All @@ -595,6 +602,7 @@
buildActionMask = 2147483647;
files = (
C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */,
C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */,
C17F50D4291EAC3800555EB5 /* G7SensorKitTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
34 changes: 29 additions & 5 deletions G7SensorKit/G7CGMManager/G7CGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,42 @@ public class G7CGMManager: CGMManager {
return state.activatedAt
}

public var lifetime: TimeInterval {
if let sessionLength = state.extendedVersion?.sessionDuration {
return sessionLength - gracePeriod
} else {
return G7Sensor.defaultLifetime
}
}

public var warmupDuration: TimeInterval {
state.extendedVersion?.warmupDuration ?? G7Sensor.defaultWarmupDuration
}

public var gracePeriod: TimeInterval {
state.extendedVersion?.gracePeriodDuration ?? G7Sensor.defaultGracePeriod
}

public var sensorExpiresAt: Date? {
guard let activatedAt = sensorActivatedAt else {
return nil
}
return activatedAt.addingTimeInterval(G7Sensor.lifetime)
return activatedAt.addingTimeInterval(lifetime)
}

public var sensorEndsAt: Date? {
guard let activatedAt = sensorActivatedAt else {
return nil
}
return activatedAt.addingTimeInterval(G7Sensor.lifetime + G7Sensor.gracePeriod)
return activatedAt.addingTimeInterval(lifetime + gracePeriod)
}


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

public var latestReading: G7GlucoseMessage? {
Expand Down Expand Up @@ -201,6 +217,7 @@ public class G7CGMManager: CGMManager {
lockedState = Locked(state)
sensor = G7Sensor(sensorID: state.sensorID)
sensor.delegate = self
sensor.needsVersionInfo = state.extendedVersion == nil
}

public var rawState: RawStateValue {
Expand Down Expand Up @@ -243,6 +260,7 @@ public class G7CGMManager: CGMManager {
mutateState { state in
state.sensorID = nil
state.activatedAt = nil
state.extendedVersion = nil
}
sensor.scanForNewSensor()
}
Expand Down Expand Up @@ -298,8 +316,8 @@ extension G7CGMManager: G7SensorDelegate {
date: activatedAt,
type: .sensorStart,
deviceIdentifier: name,
expectedLifetime: .hours(24 * 10 + 12),
warmupPeriod: .hours(2)
expectedLifetime: lifetime + gracePeriod,
warmupPeriod: warmupDuration
)
delegate.notify { delegate in
delegate?.cgmManager(self, hasNew: [event])
Expand All @@ -309,6 +327,12 @@ extension G7CGMManager: G7SensorDelegate {
return shouldSwitchToNewSensor
}

public func sensor(_ sensor: G7Sensor, didReceive extendedVersion: ExtendedVersionMessage) {
mutateState { state in
state.extendedVersion = extendedVersion
}
}

public func sensorDidConnect(_ sensor: G7Sensor, name: String) {
mutateState { state in
state.latestConnect = Date()
Expand Down
5 changes: 5 additions & 0 deletions G7SensorKit/G7CGMManager/G7CGMManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable {

public var sensorID: String?
public var activatedAt: Date?
public var extendedVersion: ExtendedVersionMessage?
public var latestReading: G7GlucoseMessage?
public var latestReadingTimestamp: Date?
public var latestConnect: Date?
Expand All @@ -29,6 +30,9 @@ public struct G7CGMManagerState: RawRepresentable, Equatable {
if let readingData = rawValue["latestReading"] as? Data {
latestReading = G7GlucoseMessage(data: readingData)
}
if let extendedVersionData = rawValue["extendedVersion"] as? Data {
extendedVersion = ExtendedVersionMessage(data: extendedVersionData)
}
self.latestReadingTimestamp = rawValue["latestReadingTimestamp"] as? Date
self.latestConnect = rawValue["latestConnect"] as? Date
self.uploadReadings = rawValue["uploadReadings"] as? Bool ?? false
Expand All @@ -39,6 +43,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable {
rawValue["sensorID"] = sensorID
rawValue["activatedAt"] = activatedAt
rawValue["latestReading"] = latestReading?.data
rawValue["extendedVersion"] = extendedVersion?.data
rawValue["latestReadingTimestamp"] = latestReadingTimestamp
rawValue["latestConnect"] = latestConnect
rawValue["uploadReadings"] = uploadReadings
Expand Down
14 changes: 14 additions & 0 deletions G7SensorKit/G7CGMManager/G7PeripheralManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ class G7PeripheralManager: NSObject {

assertConfiguration()
}

func requestExtendedVersion() throws {
self.log.default("Requesting sensor extended version");
guard let service = peripheral.services?.itemWithUUID(SensorServiceUUID.cgmService.cbUUID) else {
self.log.error("Peripheral missing cgm service. Services = %{public}@", String(describing: peripheral.services));
throw PeripheralManagerError.invalidConfiguration
}

guard let characteristic = service.characteristics?.itemWithUUID(CGMServiceCharacteristicUUID.control.cbUUID) else {
throw PeripheralManagerError.unknownCharacteristic
}

try writeValue(Data([G7Opcode.extendedVersionTx.rawValue]), for: characteristic, type: .withResponse, timeout: 1)
}
}


Expand Down
44 changes: 37 additions & 7 deletions G7SensorKit/G7CGMManager/G7Sensor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public protocol G7SensorDelegate: AnyObject {
// If this returns true, then start following this sensor
func sensor(_ sensor: G7Sensor, didDiscoverNewSensor name: String, activatedAt: Date) -> Bool

func sensor(_ sensor: G7Sensor, didReceive extendedVersion: ExtendedVersionMessage)

// This is triggered for connection/disconnection events, and enabling/disabling scan
func sensorConnectionStatusDidUpdate(_ sensor: G7Sensor)
}
Expand Down Expand Up @@ -62,9 +64,9 @@ public enum G7SensorLifecycleState {


public final class G7Sensor: G7BluetoothManagerDelegate {
public static let lifetime = TimeInterval(hours: 10 * 24)
public static let warmupDuration = TimeInterval(minutes: 25)
public static let gracePeriod = TimeInterval(hours: 12)
public static let defaultLifetime = TimeInterval(hours: 10 * 24)
public static let defaultWarmupDuration = TimeInterval(minutes: 27)
public static let defaultGracePeriod = TimeInterval(hours: 12)

public weak var delegate: G7SensorDelegate?

Expand All @@ -73,6 +75,9 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
/// The initial activation date of the sensor
var activationDate: Date?

/// The initial activation date of the sensor
var needsVersionInfo: Bool = false

/// The date of last connection
private var lastConnection: Date?

Expand All @@ -92,10 +97,6 @@ public final class G7Sensor: G7BluetoothManagerDelegate {

private var sensorID: String?

public func setSensorId(_ newId: String) {
self.sensorID = newId
}

public init(sensorID: String?) {
self.sensorID = sensorID
bluetoothManager.delegate = self
Expand Down Expand Up @@ -138,6 +139,17 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
}
}
}

if needsVersionInfo, let name = peripheralManager.peripheral.name, name == sensorID {
peripheralManager.perform { (peripheral) in
do {
try peripheral.requestExtendedVersion()
} catch let error {
self.log.error("Error trying to request extended version: %{public}@", String(describing: error))
}
}
}

if sensorID == nil, let name = peripheralManager.peripheral.name, let activationDate = activationDate {
delegateQueue.async {
guard let delegate = self.delegate else {
Expand All @@ -147,8 +159,18 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
if delegate.sensor(self, didDiscoverNewSensor: name, activatedAt: activationDate) {
self.sensorID = name
self.activationDate = activationDate
self.needsVersionInfo = true
self.delegate?.sensor(self, didRead: message)
self.bluetoothManager.stopScanning()
if self.needsVersionInfo, let name = peripheralManager.peripheral.name, name == self.sensorID {
peripheralManager.perform { (peripheral) in
do {
try peripheral.requestExtendedVersion()
} catch let error {
self.log.error("Error trying to request extended version on initial detection: %{public}@", String(describing: error))
}
}
}
}
}
} else if sensorID != nil {
Expand Down Expand Up @@ -251,6 +273,14 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
self.delegate?.sensor(self, didError: G7SensorError.observationError("Unable to handle glucose control response"))
}
}
case .extendedVersionTx:
if let extendedVersionMessage = ExtendedVersionMessage(data: response) {
log.default("Received %{public}@", String(describing: extendedVersionMessage))
delegateQueue.async {
self.delegate?.sensor(self, didReceive: extendedVersionMessage)
self.needsVersionInfo = false
}
}
case .backfillFinished:
flushBackfillBuffer()
default:
Expand Down
46 changes: 46 additions & 0 deletions G7SensorKit/Messages/ExtendedVersionMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// ExtendedVersion.swift
// G7SensorKit
//
// Created by Pete Schwamb on 12/31/25.
// Copyright © 2025 LoopKit Authors. All rights reserved.
//

import Foundation
import LoopKit

public struct ExtendedVersionMessage: SensorMessage, Equatable {
public let sessionDuration: TimeInterval
public let warmupDuration: TimeInterval
public let algorithmVersion: UInt32
public let hardwareVersion: UInt8
public let gracePeriodDuration: TimeInterval

public let data: Data

init?(data: Data) {
self.data = data

// 52 00 c0d70d00 5406 00020404 ff 0c00

guard data.starts(with: .extendedVersionTx) else {
return nil
}

guard data.count >= 15 else {
return nil
}

sessionDuration = TimeInterval(data[2..<6].to(UInt32.self))
warmupDuration = TimeInterval(data[6..<8].to(UInt16.self))
algorithmVersion = data[8..<12].to(UInt32.self)
hardwareVersion = data[12]
gracePeriodDuration = TimeInterval(hours: Double(data[13..<15].to(UInt16.self)))
}
}

extension ExtendedVersionMessage: CustomDebugStringConvertible {
public var debugDescription: String {
return "ExtendedVersionMessage(sessionDuration:\(sessionDuration), warmupDuration:\(warmupDuration) algorithmVersion:\(algorithmVersion) hardwareVersion:\(hardwareVersion) gracePeriodDuration:\(gracePeriodDuration))"
}
}
2 changes: 2 additions & 0 deletions G7SensorKit/Messages/G7Opcode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ enum G7Opcode: UInt8 {
case authChallengeRx = 0x05
case sessionStopTx = 0x28
case glucoseTx = 0x4e
case extendedVersionTx = 0x52
case extendedVersionRx = 0x53
case backfillFinished = 0x59
}
24 changes: 24 additions & 0 deletions G7SensorKitTests/ExtendedVersionMessageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// ExtendedVersionMessageTests.swift
// G7SensorKit
//
// Created by Pete Schwamb on 12/31/25.
// Copyright © 2025 LoopKit Authors. All rights reserved.
//

import XCTest
@testable import G7SensorKit

final class ExtendedVersionMessageTests: XCTestCase {

func testBasicMessage() {
let data = Data(hexadecimalString: "5200c0d70d00540600020404ff0c00")!
let message = ExtendedVersionMessage(data: data)!

XCTAssertEqual(10.5, message.sessionDuration.hours / 24)
XCTAssertEqual(27, message.warmupDuration.minutes)
XCTAssertEqual(67371520, message.algorithmVersion)
XCTAssertEqual(255, message.hardwareVersion)
XCTAssertEqual(12, message.gracePeriodDuration.hours)
}
}
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/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/gracePeriod), progressState: .critical)
case .expired:
return G7LifecycleProgress(percentComplete: 1, progressState: .critical)
default:
Expand Down
4 changes: 2 additions & 2 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.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.lifetime + viewModel.gracePeriod)))
.foregroundColor(.secondary)
}
}
Expand Down
Loading