diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 9582049..9868d2d 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 = ""; }; B66D1F6E2E6A803800471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessage.swift; sourceTree = ""; }; + C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessageTests.swift; sourceTree = ""; }; C109F149291ECCE2008EA5B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7GlucoseMessageTests.swift; sourceTree = ""; }; C1409A08291EC22F006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; @@ -240,6 +244,7 @@ C17F50D2291EAC3800555EB5 /* G7SensorKitTests */ = { isa = PBXGroup; children = ( + C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */, C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */, C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */, ); @@ -319,6 +324,7 @@ C17F5141291EB34800555EB5 /* Messages */ = { isa = PBXGroup; children = ( + C107607E2F05912B008B2B39 /* ExtendedVersionMessage.swift */, C17F50E3291EAC6500555EB5 /* G7Opcode.swift */, C17F5146291EB57700555EB5 /* SensorMessage.swift */, C17F50E8291EAC6500555EB5 /* G7GlucoseMessage.swift */, @@ -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 */, @@ -595,6 +602,7 @@ buildActionMask = 2147483647; files = ( C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */, + C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */, C17F50D4291EAC3800555EB5 /* G7SensorKitTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index ccddaeb..096f018 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -112,18 +112,34 @@ 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) } @@ -131,7 +147,7 @@ public class G7CGMManager: CGMManager { guard let activatedAt = sensorActivatedAt else { return nil } - return activatedAt.addingTimeInterval(G7Sensor.warmupDuration) + return activatedAt.addingTimeInterval(warmupDuration) } public var latestReading: G7GlucoseMessage? { @@ -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 { @@ -243,6 +260,7 @@ public class G7CGMManager: CGMManager { mutateState { state in state.sensorID = nil state.activatedAt = nil + state.extendedVersion = nil } sensor.scanForNewSensor() } @@ -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]) @@ -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() diff --git a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift index 948b02f..e1d020c 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -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? @@ -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 @@ -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 diff --git a/G7SensorKit/G7CGMManager/G7PeripheralManager.swift b/G7SensorKit/G7CGMManager/G7PeripheralManager.swift index 2bea009..cffae13 100644 --- a/G7SensorKit/G7CGMManager/G7PeripheralManager.swift +++ b/G7SensorKit/G7CGMManager/G7PeripheralManager.swift @@ -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) + } } diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index aa88883..c3bf75a 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -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) } @@ -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? @@ -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? @@ -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 @@ -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 { @@ -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 { @@ -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: diff --git a/G7SensorKit/Messages/ExtendedVersionMessage.swift b/G7SensorKit/Messages/ExtendedVersionMessage.swift new file mode 100644 index 0000000..2ed9c4d --- /dev/null +++ b/G7SensorKit/Messages/ExtendedVersionMessage.swift @@ -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))" + } +} diff --git a/G7SensorKit/Messages/G7Opcode.swift b/G7SensorKit/Messages/G7Opcode.swift index 5198462..4db9fe5 100644 --- a/G7SensorKit/Messages/G7Opcode.swift +++ b/G7SensorKit/Messages/G7Opcode.swift @@ -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 } diff --git a/G7SensorKitTests/ExtendedVersionMessageTests.swift b/G7SensorKitTests/ExtendedVersionMessageTests.swift new file mode 100644 index 0000000..97bf0d3 --- /dev/null +++ b/G7SensorKitTests/ExtendedVersionMessageTests.swift @@ -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) + } +} diff --git a/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift b/G7SensorKitUI/G7CGMManager/G7CGMManager+UI.swift index fb91acb..877d68e 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/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/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..66ff2f4 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.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) } } diff --git a/G7SensorKitUI/Views/G7SettingsViewModel.swift b/G7SensorKitUI/Views/G7SettingsViewModel.swift index 93bff10..424a0ae 100644 --- a/G7SensorKitUI/Views/G7SettingsViewModel.swift +++ b/G7SensorKitUI/Views/G7SettingsViewModel.swift @@ -22,6 +22,9 @@ class G7SettingsViewModel: ObservableObject { @Published private(set) var sensorName: String? @Published private(set) var activatedAt: Date? @Published private(set) var lastConnect: Date? + @Published private(set) var lifetime: TimeInterval + @Published private(set) var warmupDuration: TimeInterval + @Published private(set) var gracePeriod: TimeInterval @Published private(set) var latestReadingTimestamp: Date? @Published var uploadReadings: Bool = false { didSet { @@ -62,6 +65,9 @@ class G7SettingsViewModel: ObservableObject { init(cgmManager: G7CGMManager, displayGlucosePreference: DisplayGlucosePreference) { self.cgmManager = cgmManager self.displayGlucosePreference = displayGlucosePreference + self.lifetime = cgmManager.lifetime + self.warmupDuration = cgmManager.warmupDuration + self.gracePeriod = cgmManager.gracePeriod updateValues() self.cgmManager.addStateObserver(self, queue: DispatchQueue.main) @@ -76,6 +82,8 @@ class G7SettingsViewModel: ObservableObject { lastReading = cgmManager.latestReading latestReadingTimestamp = cgmManager.latestReadingTimestamp uploadReadings = cgmManager.state.uploadReadings + lifetime = cgmManager.lifetime + warmupDuration = cgmManager.warmupDuration } var progressBarColorStyle: ColorStyle { @@ -108,17 +116,17 @@ class G7SettingsViewModel: ObservableObject { guard let value = progressValue, value > 0 else { return 0 } - return 1 - value / G7Sensor.warmupDuration + return 1 - value / warmupDuration case .lifetimeRemaining: guard let value = progressValue, value > 0 else { return 0 } - return 1 - value / G7Sensor.lifetime + return 1 - value / lifetime case .gracePeriodRemaining: guard let value = progressValue, value > 0 else { return 0 } - return 1 - value / G7Sensor.gracePeriod + return 1 - value / gracePeriod case .sensorExpired, .sensorFailed: return 1 }