From 26ad72115d3b98b05668d57b817b1bf961f8a721 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:49:30 +0200 Subject: [PATCH 1/4] Surface G5/G6 sensor lifecycle; detect Anubis-modded G6 - Implement cgmStatusHighlight and cgmLifecycleProgress on G5/G6 so consumers can render sensor warmup, calibration state, sensor and session failure, and remaining-time UI. Status is driven by Glucose.state (CalibrationState); lifecycle is driven by sessionStartDate / sessionExpDate from the transmitter. - Parse transmitterExpiryInDays from the version-rx frame (bytes 13-14) and expose isAnubis on G6CGMManager. Stock G6 reports a 90-day lifetime; Anubis-modded G6 reports 180 (heuristic borrowed from xDrip4iOS). - Active-mode connect reads the version frame after glucose; passive mode catches it via the control-response listener. The field is persisted on TransmitterManagerState so isAnubis survives an app restart. - The lifecycle ring fills against the actual warmup window: 2 h for stock G5/G6, 50 min for Anubis-modded G6. --- .../TransmitterVersionRxMessage.swift | 16 +- .../TransmitterVersionTxMessage.swift | 6 +- CGMBLEKit/Transmitter.swift | 33 ++++ CGMBLEKit/TransmitterManager.swift | 13 ++ CGMBLEKit/TransmitterManagerState.swift | 20 ++- CGMBLEKitUI/Localizable.xcstrings | 2 +- CGMBLEKitUI/TransmitterManager+UI.swift | 170 +++++++++++++++++- 7 files changed, 244 insertions(+), 16 deletions(-) diff --git a/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift b/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift index 4854385b..16763d2c 100644 --- a/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift +++ b/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift @@ -9,11 +9,13 @@ import Foundation -struct TransmitterVersionRxMessage: TransmitterRxMessage { - let status: UInt8 - let firmwareVersion: [UInt8] +public struct TransmitterVersionRxMessage: TransmitterRxMessage { + public let status: UInt8 + public let firmwareVersion: [UInt8] + /// Lifetime the transmitter reports for itself (stock G6 = 90, Anubis-modded G6 = 180). + public let transmitterExpiryInDays: UInt16 - init?(data: Data) { + public init?(data: Data) { guard data.count == 19 && data.isCRCValid else { return nil } @@ -24,6 +26,12 @@ struct TransmitterVersionRxMessage: TransmitterRxMessage { status = data[1] firmwareVersion = data[2..<6].map { $0 } + transmitterExpiryInDays = (UInt16(data[14]) << 8) + UInt16(data[13]) } + /// Heuristic borrowed from xDrip4iOS: Anubis-modded G6 transmitters + /// report a 180-day expiry in the version-rx frame; stock G6 reports 90. + public var isAnubis: Bool { + return transmitterExpiryInDays == 180 + } } diff --git a/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift b/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift index 60df2c7f..8c163ea9 100644 --- a/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift +++ b/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift @@ -9,8 +9,10 @@ import Foundation -struct TransmitterVersionTxMessage { +struct TransmitterVersionTxMessage: RespondableMessage { typealias Response = TransmitterVersionRxMessage - let opcode: Opcode = .transmitterVersionTx + var data: Data { + return Data(for: .transmitterVersionTx).appendingCRC() + } } diff --git a/CGMBLEKit/Transmitter.swift b/CGMBLEKit/Transmitter.swift index d2d15342..da5db39e 100644 --- a/CGMBLEKit/Transmitter.swift +++ b/CGMBLEKit/Transmitter.swift @@ -22,6 +22,13 @@ public protocol TransmitterDelegate: AnyObject { func transmitter(_ transmitter: Transmitter, didReadBackfill glucose: [Glucose]) func transmitter(_ transmitter: Transmitter, didReadUnknownData data: Data) + + func transmitter(_ transmitter: Transmitter, didReadTransmitterVersion message: TransmitterVersionRxMessage) +} + +public extension TransmitterDelegate { + // Default no-op so existing implementors don't need to opt in. + func transmitter(_ transmitter: Transmitter, didReadTransmitterVersion message: TransmitterVersionRxMessage) {} } /// These methods are called on a private background queue. It is the responsibility of the client to ensure thread-safety. @@ -206,6 +213,16 @@ public final class Transmitter: BluetoothManagerDelegate { self.log.debug("Reading calibration data") let calibrationMessage = try? peripheral.readCalibrationData() + // Best-effort version read — surfaces transmitter-reported + // expiry (Anubis detection). Optional: don't fail the + // connect cycle if the transmitter doesn't answer. + self.log.debug("Reading transmitter version") + if let versionMessage = try? peripheral.readTransmitterVersion() { + self.delegateQueue.async { + self.delegate?.transmitter(self, didReadTransmitterVersion: versionMessage) + } + } + let glucose = Glucose( transmitterID: self.id.id, glucoseMessage: glucoseMessage, @@ -338,6 +355,14 @@ public final class Transmitter: BluetoothManagerDelegate { } lastCalibrationMessage = calibrationDataMessage + case .transmitterVersionRx?: + guard let versionMessage = TransmitterVersionRxMessage(data: response) else { + break + } + + delegateQueue.async { + self.delegate?.transmitter(self, didReadTransmitterVersion: versionMessage) + } case .none: delegateQueue.async { self.delegate?.transmitter(self, didReadUnknownData: response) @@ -551,6 +576,14 @@ fileprivate extension PeripheralManager { } } + func readTransmitterVersion() throws -> TransmitterVersionRxMessage { + do { + return try writeMessage(TransmitterVersionTxMessage(), for: .control) + } catch let error { + throw TransmitterError.controlError("Error getting transmitter version: \(error)") + } + } + func disconnect() { do { try setNotifyValue(false, for: .control) diff --git a/CGMBLEKit/TransmitterManager.swift b/CGMBLEKit/TransmitterManager.swift index 682d9dde..ca6e10fe 100644 --- a/CGMBLEKit/TransmitterManager.swift +++ b/CGMBLEKit/TransmitterManager.swift @@ -431,6 +431,19 @@ public class TransmitterManager: TransmitterDelegate { logDeviceCommunication("Unknown sensor data: \(data.hexadecimalString)", type: .error) } + + public func transmitter(_ transmitter: Transmitter, didReadTransmitterVersion message: TransmitterVersionRxMessage) { + log.default("Transmitter reports expiry of %d days (isAnubis=%@)", + message.transmitterExpiryInDays, String(describing: message.isAnubis)) + mutateState { state in + state.transmitterExpiryInDays = message.transmitterExpiryInDays + } + } + + /// `true` once the transmitter has reported the Anubis 180-day lifetime. + public var isAnubis: Bool { + return state.isAnubis + } } diff --git a/CGMBLEKit/TransmitterManagerState.swift b/CGMBLEKit/TransmitterManagerState.swift index 80f7cdb2..a79107dd 100644 --- a/CGMBLEKit/TransmitterManagerState.swift +++ b/CGMBLEKit/TransmitterManagerState.swift @@ -24,16 +24,22 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { public var shouldSyncToRemoteService: Bool + /// Transmitter-reported lifetime in days (90 for stock G6, 180 for + /// Anubis-modded). `nil` until the first version-rx frame comes in. + public var transmitterExpiryInDays: UInt16? + public init( transmitterID: String, shouldSyncToRemoteService: Bool = true, transmitterStartDate: Date? = nil, - sensorStartOffset: UInt32? = nil + sensorStartOffset: UInt32? = nil, + transmitterExpiryInDays: UInt16? = nil ) { self.transmitterID = transmitterID self.shouldSyncToRemoteService = shouldSyncToRemoteService self.transmitterStartDate = transmitterStartDate self.sensorStartOffset = sensorStartOffset + self.transmitterExpiryInDays = transmitterExpiryInDays } public init?(rawValue: RawValue) { @@ -48,11 +54,15 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { let sensorStartOffset = rawValue["sensorStartOffset"] as? UInt32 + let transmitterExpiryInDays = (rawValue["transmitterExpiryInDays"] as? UInt16) + ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16($0) } + self.init( transmitterID: transmitterID, shouldSyncToRemoteService: shouldSyncToRemoteService, transmitterStartDate: transmitterStartDate, - sensorStartOffset: sensorStartOffset + sensorStartOffset: sensorStartOffset, + transmitterExpiryInDays: transmitterExpiryInDays ) } @@ -64,7 +74,13 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { rval["transmitterStartDate"] = transmitterStartDate rval["sensorStartOffset"] = sensorStartOffset + rval["transmitterExpiryInDays"] = transmitterExpiryInDays.map { Int($0) } return rval } + + /// `true` once the transmitter has reported the Anubis 180-day lifetime. + public var isAnubis: Bool { + return transmitterExpiryInDays == 180 + } } diff --git a/CGMBLEKitUI/Localizable.xcstrings b/CGMBLEKitUI/Localizable.xcstrings index 6a2d2420..61314a20 100644 --- a/CGMBLEKitUI/Localizable.xcstrings +++ b/CGMBLEKitUI/Localizable.xcstrings @@ -1821,7 +1821,7 @@ } }, "Sensor Expired" : { - "comment" : "Title describing past sensor sensor expiration", + "comment" : "Sensor expired status\nTitle describing past sensor sensor expiration", "localizations" : { "ar" : { "stringUnit" : { diff --git a/CGMBLEKitUI/TransmitterManager+UI.swift b/CGMBLEKitUI/TransmitterManager+UI.swift index 0cabda72..b332af6b 100644 --- a/CGMBLEKitUI/TransmitterManager+UI.swift +++ b/CGMBLEKitUI/TransmitterManager+UI.swift @@ -33,9 +33,8 @@ extension G5CGMManager: CGMManagerUI { return nil } - // TODO Placeholder. public var cgmStatusHighlight: DeviceStatusHighlight? { - return nil + return TransmitterSessionStatus.highlight(for: latestReading) } // TODO Placeholder. @@ -43,9 +42,9 @@ extension G5CGMManager: CGMManagerUI { return nil } - // TODO Placeholder. public var cgmLifecycleProgress: DeviceLifecycleProgress? { - return nil + // G5 has no Anubis variant — always 2 h warmup. + return TransmitterSessionStatus.lifecycle(for: latestReading, isAnubis: false) } } @@ -71,9 +70,8 @@ extension G6CGMManager: CGMManagerUI { UIImage(named: "g6", in: Bundle(for: TransmitterSetupViewController.self), compatibleWith: nil)! } - // TODO Placeholder. public var cgmStatusHighlight: DeviceStatusHighlight? { - return nil + return TransmitterSessionStatus.highlight(for: latestReading) } // TODO Placeholder. @@ -81,8 +79,166 @@ extension G6CGMManager: CGMManagerUI { return nil } - // TODO Placeholder. public var cgmLifecycleProgress: DeviceLifecycleProgress? { + return TransmitterSessionStatus.lifecycle(for: latestReading, isAnubis: isAnubis) + } +} + + +/// Shared lifecycle + status-highlight derivation for both G5 and G6. +/// Both transmitters compute `sessionStartDate` / `sessionExpDate` on every +/// reading (`Glucose.swift`), so we don't need to hardcode a sensor lifetime +/// — the transmitter already knows. Sensor state (warmup, sensor failure, +/// calibration needed, session failure) comes from `Glucose.state`. +private enum TransmitterSessionStatus { + /// Stock G5/G6 warmup window (2 h from `sessionStartDate`). + static let standardWarmupDuration: TimeInterval = 2 * 60 * 60 + + /// Anubis-modded G6 warmup window (50 min from `sessionStartDate`). + static let anubisWarmupDuration: TimeInterval = 50 * 60 + + static func lifecycle(for glucose: Glucose?, isAnubis: Bool) -> DeviceLifecycleProgress? { + guard let glucose, let start = glucose.sessionStartDate else { return nil } + + // During warmup the session expiry is ~10 days out — using it as the + // ring's denominator would render ~0% and feel broken. Switch the + // ring's denominator to the actual warmup window (50 min for Anubis, + // 2 h for stock) so the arc visibly fills as warmup completes. + if case .known(.warmup) = glucose.state { + let elapsed = Date().timeIntervalSince(start) + let warmupDuration = isAnubis ? anubisWarmupDuration : standardWarmupDuration + let fraction = max(0, min(1, elapsed / warmupDuration)) + return G5G6LifecycleProgress(percentComplete: fraction, progressState: .normalCGM) + } + + // Sensor / session failure or stopped: timing is meaningless. + if case let .known(state) = glucose.state, !state.exposesLifecycle { + return nil + } + + guard let end = glucose.sessionExpDate else { return nil } + let total = end.timeIntervalSince(start) + guard total > 0 else { return nil } + let elapsed = Date().timeIntervalSince(start) + let fraction = max(0, min(1, elapsed / total)) + let progressState: DeviceLifecycleProgressState + if fraction >= 1.0 { + progressState = .critical + } else if elapsed >= total - 24 * 60 * 60 { + progressState = .warning + } else { + progressState = .normalCGM + } + return G5G6LifecycleProgress(percentComplete: fraction, progressState: progressState) + } + + static func highlight(for glucose: Glucose?) -> DeviceStatusHighlight? { + guard let glucose else { return nil } + + // Calibration-state surface wins over time-based expiry — a warming- + // up sensor hasn't reached expiry yet, and an explicit sensor / + // session failure is the more useful signal even if expiry is also + // in the past. + if case let .known(state) = glucose.state, let highlight = state.statusHighlight { + return highlight + } + + // Time-based expiry fallback. + if let end = glucose.sessionExpDate, Date() >= end { + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Sensor Expired", comment: "Sensor expired status"), + imageName: "exclamationmark.circle.fill", + state: .critical + ) + } return nil } } + +private extension CalibrationState.State { + /// `false` for states where session timing isn't yet meaningful — + /// warmup, sensor / session failure, fully stopped sensor. + var exposesLifecycle: Bool { + switch self { + case .warmup, + .stopped, + .sensorFailure11, + .sensorFailure12, + .sessionFailure15, + .sessionFailure16, + .sessionFailure17: + return false + default: + return true + } + } + + var statusHighlight: DeviceStatusHighlight? { + switch self { + case .warmup: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Warming Up", comment: "Sensor warmup status"), + imageName: "hourglass", + state: .warning + ) + case .needFirstInitialCalibration, + .needSecondInitialCalibration, + .needCalibration7, + .needCalibration14: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Calibration Needed", comment: "Sensor needs calibration"), + imageName: "drop.fill", + state: .warning + ) + case .calibrationError8, + .calibrationError9, + .calibrationError10, + .calibrationError13: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Calibration Error", comment: "Sensor calibration error"), + imageName: "exclamationmark.circle", + state: .warning + ) + case .sensorFailure11, + .sensorFailure12: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Sensor Failed", comment: "Sensor hardware failure"), + imageName: "exclamationmark.triangle.fill", + state: .critical + ) + case .sessionFailure15, + .sessionFailure16, + .sessionFailure17: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Session Failed", comment: "Sensor session failed"), + imageName: "exclamationmark.triangle.fill", + state: .critical + ) + case .stopped: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Sensor Stopped", comment: "Sensor session stopped"), + imageName: "stop.circle", + state: .critical + ) + case .questionMarks: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Signal Problem", comment: "Sensor signal problem"), + imageName: "questionmark.circle", + state: .warning + ) + case .ok: + return nil + } + } +} + +private struct G5G6LifecycleProgress: DeviceLifecycleProgress { + let percentComplete: Double + let progressState: DeviceLifecycleProgressState +} + +private struct G5G6StatusHighlight: DeviceStatusHighlight { + let localizedMessage: String + let imageName: String + let state: DeviceStatusHighlightState +} From ce6ea56b6b334addc44047e6d23dc362df9f0de7 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:41:33 +0200 Subject: [PATCH 2/4] Sync CGMBLEKitUI xcstrings for Anubis-mod sensor lifecycle strings --- CGMBLEKitUI/Localizable.xcstrings | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CGMBLEKitUI/Localizable.xcstrings b/CGMBLEKitUI/Localizable.xcstrings index b34e64bc..44636fec 100644 --- a/CGMBLEKitUI/Localizable.xcstrings +++ b/CGMBLEKitUI/Localizable.xcstrings @@ -312,6 +312,12 @@ } } }, + "Calibration Error" : { + "comment" : "Sensor calibration error" + }, + "Calibration Needed" : { + "comment" : "Sensor needs calibration" + }, "Cancel" : { "comment" : "The title of the cancel action in an action sheet", "localizations" : { @@ -2088,6 +2094,12 @@ } } }, + "Sensor Failed" : { + "comment" : "Sensor hardware failure" + }, + "Sensor Stopped" : { + "comment" : "Sensor session stopped" + }, "Session Age" : { "comment" : "Title describing sensor session age", "localizations" : { @@ -2237,6 +2249,12 @@ } } }, + "Session Failed" : { + "comment" : "Sensor session failed" + }, + "Signal Problem" : { + "comment" : "Sensor signal problem" + }, "Status" : { "comment" : "Title describing CGM calibration and battery state", "localizations" : { @@ -2981,6 +2999,9 @@ } } } + }, + "Warming Up" : { + "comment" : "Sensor warmup status" } }, "version" : "1.0" From 2af513384a0195d5b4bebb949530fa23a2f60710 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:41:33 +0200 Subject: [PATCH 3/4] Add Anubis detection tests covering version-rx parsing + manager state round-trip --- CGMBLEKit.xcodeproj/project.pbxproj | 4 + CGMBLEKitTests/AnubisDetectionTests.swift | 118 ++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 CGMBLEKitTests/AnubisDetectionTests.swift diff --git a/CGMBLEKit.xcodeproj/project.pbxproj b/CGMBLEKit.xcodeproj/project.pbxproj index 51a9b6c8..4582d78a 100644 --- a/CGMBLEKit.xcodeproj/project.pbxproj +++ b/CGMBLEKit.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 438621CE2292074A00741DFE /* ShareClientUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4325E9EE210EAF3F00969CE5 /* ShareClientUI.framework */; }; 43880F981D9E19FC009061A8 /* TransmitterVersionRxMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43880F971D9E19FC009061A8 /* TransmitterVersionRxMessage.swift */; }; 43880F9A1D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */; }; + CA04000000000000000010C2 /* AnubisDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA04000000000000000010C1 /* AnubisDetectionTests.swift */; }; 43A8EC4A210D09BE00A81379 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43A8EC49210D09BE00A81379 /* LoopKitUI.framework */; }; 43A8EC4C210D09DA00A81379 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43A8EC49210D09BE00A81379 /* LoopKitUI.framework */; }; 43A8EC56210D0A7400A81379 /* CGMBLEKitUI.h in Headers */ = {isa = PBXBuildFile; fileRef = 43A8EC54210D0A7400A81379 /* CGMBLEKitUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -391,6 +392,7 @@ 43846AC71D8F89BE00799272 /* CalibrationDataRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationDataRxMessageTests.swift; sourceTree = ""; }; 43880F971D9E19FC009061A8 /* TransmitterVersionRxMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterVersionRxMessage.swift; sourceTree = ""; }; 43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterVersionRxMessageTests.swift; sourceTree = ""; }; + CA04000000000000000010C1 /* AnubisDetectionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnubisDetectionTests.swift; sourceTree = ""; }; 43A8EC49210D09BE00A81379 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43A8EC52210D0A7400A81379 /* CGMBLEKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CGMBLEKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43A8EC54210D0A7400A81379 /* CGMBLEKitUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CGMBLEKitUI.h; sourceTree = ""; }; @@ -730,6 +732,7 @@ 43460F87200B30D10030C0E3 /* TransmitterIDTests.swift */, 43F82BCB1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift */, 43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */, + CA04000000000000000010C1 /* AnubisDetectionTests.swift */, ); path = CGMBLEKitTests; sourceTree = ""; @@ -1281,6 +1284,7 @@ 43F82BD21D037040006F5DD7 /* SessionStopRxMessageTests.swift in Sources */, 43E397911D5692080028E321 /* GlucoseTests.swift in Sources */, 43880F9A1D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift in Sources */, + CA04000000000000000010C2 /* AnubisDetectionTests.swift in Sources */, 43DC87C21C8B520F005BC30D /* GlucoseRxMessageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CGMBLEKitTests/AnubisDetectionTests.swift b/CGMBLEKitTests/AnubisDetectionTests.swift new file mode 100644 index 00000000..f18eb54d --- /dev/null +++ b/CGMBLEKitTests/AnubisDetectionTests.swift @@ -0,0 +1,118 @@ +// +// AnubisDetectionTests.swift +// CGMBLEKit +// +// Covers transmitter-expiry parsing + Anubis-mod detection across the two +// layers it spans: the version-rx frame decoder and the persisted manager +// state. +// + +import XCTest +@testable import CGMBLEKit + +class AnubisDetectionTests: XCTestCase { + + // MARK: - TransmitterVersionRxMessage parsing + + /// Stock G6 reports a 90-day lifetime in the version-rx frame. + func testStockG6ExpiryParsedAndNotAnubis() { + // Same layout as TransmitterVersionRxMessageTests' fixture; expiry + // bytes (13..14, little-endian) overwritten to 0x5A 0x00 = 90. + let data = makeVersionRxPayload(expiryDays: 90) + let message = TransmitterVersionRxMessage(data: data)! + + XCTAssertEqual(90, message.transmitterExpiryInDays) + XCTAssertFalse(message.isAnubis) + } + + /// Anubis-modded G6 reports 180 days. Mirrors xDrip4iOS's heuristic. + func testAnubisG6ExpiryParsedAndIsAnubis() { + let data = makeVersionRxPayload(expiryDays: 180) + let message = TransmitterVersionRxMessage(data: data)! + + XCTAssertEqual(180, message.transmitterExpiryInDays) + XCTAssertTrue(message.isAnubis) + } + + /// Some early transmitters report neither 90 nor 180. They're stock G6 + /// firmware that just doesn't expose the lifetime field reliably; the + /// only known classification we care about is "is this a 180-day Anubis." + func testOddExpiryNotClassifiedAsAnubis() { + let data = makeVersionRxPayload(expiryDays: 112) + let message = TransmitterVersionRxMessage(data: data)! + + XCTAssertEqual(112, message.transmitterExpiryInDays) + XCTAssertFalse(message.isAnubis) + } + + func testWrongOpcodeReturnsNil() { + var data = makeVersionRxPayload(expiryDays: 90) + data[0] = 0x4c // not .transmitterVersionRx + // Re-CRC so the opcode rejection is exercised, not a CRC failure. + let resealed = data.dropLast(2).appendingCRC() + XCTAssertNil(TransmitterVersionRxMessage(data: resealed)) + } + + func testInvalidCRCReturnsNil() { + var data = makeVersionRxPayload(expiryDays: 90) + // Flip the last byte of the CRC trailer. + data[data.count - 1] ^= 0xFF + XCTAssertNil(TransmitterVersionRxMessage(data: data)) + } + + func testWrongLengthReturnsNil() { + let data = makeVersionRxPayload(expiryDays: 90).dropLast() + XCTAssertNil(TransmitterVersionRxMessage(data: Data(data))) + } + + // MARK: - TransmitterManagerState round-trip + + func testManagerStateStoresAndRestoresExpiry() { + let state = TransmitterManagerState(transmitterID: "ABCDEF", transmitterExpiryInDays: 180) + let restored = TransmitterManagerState(rawValue: state.rawValue)! + + XCTAssertEqual(180, restored.transmitterExpiryInDays) + XCTAssertTrue(restored.isAnubis) + } + + /// Older installs persisted the expiry as a plain `Int` (UserDefaults + /// rounds UInt16 → Int on archive). The decoder accepts both shapes so an + /// upgrade doesn't drop the Anubis flag. + func testManagerStateRestoresLegacyIntExpiry() { + let raw: [String: Any] = [ + "transmitterID": "ABCDEF", + "transmitterExpiryInDays": Int(180) + ] + let restored = TransmitterManagerState(rawValue: raw)! + + XCTAssertEqual(180, restored.transmitterExpiryInDays) + XCTAssertTrue(restored.isAnubis) + } + + func testManagerStateMissingExpiryIsNotAnubis() { + let state = TransmitterManagerState(transmitterID: "ABCDEF") + + XCTAssertNil(state.transmitterExpiryInDays) + XCTAssertFalse(state.isAnubis) + } + + // MARK: - Fixture helper + + /// Builds a 19-byte version-rx payload with the given expiry, opcode + + /// CRC trailer correctly applied. Patterned on the canonical fixture in + /// `TransmitterVersionRxMessageTests`. + private func makeVersionRxPayload(expiryDays: UInt16) -> Data { + // Bytes 0..12: opcode + status + firmware + filler from the canonical + // existing fixture. Bytes 13..14: expiry (little-endian). Bytes + // 15..16: trailing filler. CRC re-computed at the end. + let body = Data([ + 0x4b, 0x00, // opcode + status + 0x01, 0x00, 0x00, 0x11, // firmwareVersion + 0xdf, 0x29, 0x00, 0x00, 0x51, 0x00, 0x03, // filler (bytes 6..12) + UInt8(expiryDays & 0xff), // byte 13: low byte + UInt8((expiryDays >> 8) & 0xff), // byte 14: high byte + 0xf0, 0x00 // filler (bytes 15..16) + ]) + return body.appendingCRC() + } +} From 976a4d848b184f07cb8b291f34c5f7fe321ad4db Mon Sep 17 00:00:00 2001 From: Mike Plante <82073483+MikePlante1@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:12:42 -0400 Subject: [PATCH 4/4] Add sensor life configuration for Anubis transmitters (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add sensor life configuration for Anubis transmitters Implements user-configurable sensor session length (10–60 days) for Anubis-modded transmitters, while maintaining stock 10-day sessions for standard G5/G6 transmitters. Key changes: - Transmitter version frame detection identifies Anubis modification (180-day expiry) - sessionExpDate now updated dynamically based on configured sensor life - Warmup period adjusted for Anubis (50 min) vs stock (2 h) - Settings UI provides wheel picker for sensor life selection (10–60 days) - State persistence for selected sensor life across app sessions * Hide session ring until final 48 hours Add a guard statement to keep the session progress ring hidden until the transmitter session enters its final 48 hours. This improves the user experience by reducing visual clutter during the early stages of a session. * Correct ring to lifecycle --- CGMBLEKit/Glucose.swift | 4 +- CGMBLEKit/Transmitter.swift | 9 ++ CGMBLEKit/TransmitterManager.swift | 41 ++++++++- CGMBLEKit/TransmitterManagerState.swift | 27 +++++- CGMBLEKitUI/Localizable.xcstrings | 12 +++ CGMBLEKitUI/TransmitterManager+UI.swift | 21 +++-- .../TransmitterSettingsViewController.swift | 85 +++++++++++++++++-- 7 files changed, 178 insertions(+), 21 deletions(-) diff --git a/CGMBLEKit/Glucose.swift b/CGMBLEKit/Glucose.swift index 042a1251..56fa95c3 100644 --- a/CGMBLEKit/Glucose.swift +++ b/CGMBLEKit/Glucose.swift @@ -52,7 +52,7 @@ public struct Glucose { if timeMessage.hasValidSensorSession { let sessionStartDate = activationDate.addingTimeInterval(TimeInterval(timeMessage.sessionStartTime)) self.sessionStartDate = sessionStartDate - self.sessionExpDate = sessionStartDate.addingTimeInterval(10*24*60*60) + self.sessionExpDate = sessionStartDate.addingTimeInterval(.hours(24 * Double(TransmitterManagerState.defaultSensorLifeDays))) } else { sessionStartDate = nil sessionExpDate = nil @@ -66,7 +66,7 @@ public struct Glucose { public let status: TransmitterStatus public let activationDate: Date public let sessionStartDate: Date? - public let sessionExpDate: Date? + public internal(set) var sessionExpDate: Date? public var hasValidSensorSession: Bool { return sessionStartDate != nil diff --git a/CGMBLEKit/Transmitter.swift b/CGMBLEKit/Transmitter.swift index da5db39e..8387c96b 100644 --- a/CGMBLEKit/Transmitter.swift +++ b/CGMBLEKit/Transmitter.swift @@ -71,6 +71,8 @@ public final class Transmitter: BluetoothManagerDelegate { public var passiveModeEnabled: Bool + public var needsExpiryRead: Bool = false + public weak var delegate: TransmitterDelegate? public weak var commandSource: TransmitterCommandSource? @@ -284,6 +286,13 @@ public final class Transmitter: BluetoothManagerDelegate { self.delegate?.transmitter(self, didError: error) } } + + if self.needsExpiryRead, let versionMessage = try? peripheral.readTransmitterVersion() { + self.needsExpiryRead = false + self.delegateQueue.async { + self.delegate?.transmitter(self, didReadTransmitterVersion: versionMessage) + } + } } case .transmitterTimeRx?: if let timeMessage = TransmitterTimeRxMessage(data: response) { diff --git a/CGMBLEKit/TransmitterManager.swift b/CGMBLEKit/TransmitterManager.swift index ca6e10fe..c55b4999 100644 --- a/CGMBLEKit/TransmitterManager.swift +++ b/CGMBLEKit/TransmitterManager.swift @@ -81,6 +81,8 @@ public class TransmitterManager: TransmitterDelegate { self.transmitter.delegate = self + self.transmitter.needsExpiryRead = state.transmitterExpiryInDays == nil + #if targetEnvironment(simulator) setupSimulatedSampleGenerator() #endif @@ -270,6 +272,9 @@ public class TransmitterManager: TransmitterDelegate { "latestConnection: \(String(describing: latestConnection))", "dataIsFresh: \(dataIsFresh)", "providesBLEHeartbeat: \(providesBLEHeartbeat)", + "transmitterExpiryInDays: \(String(describing: state.transmitterExpiryInDays))", + "isAnubis: \(isAnubis)", + "sensorLifeDays: \(state.sensorLifeDays)", shareManager.debugDescription, "observers.count: \(observers.cleanupDeallocatedElements().count)", String(reflecting: transmitter), @@ -310,6 +315,9 @@ public class TransmitterManager: TransmitterDelegate { } public func transmitter(_ transmitter: Transmitter, didRead glucose: Glucose) { + var glucose = glucose + glucose.sessionExpDate = glucose.sessionStartDate?.addingTimeInterval(state.sensorLife) + guard glucose != latestReading else { updateDelegate(with: .noData) return @@ -341,8 +349,8 @@ public class TransmitterManager: TransmitterDelegate { date: sessionStartDate, type: .sensorStart, deviceIdentifier: transmitter.ID, - expectedLifetime: .hours(24 * 10), - warmupPeriod: .hours(2) + expectedLifetime: state.sensorLife, + warmupPeriod: state.isAnubis ? .minutes(50) : .hours(2) )) } else { log.error("Ignoring sensor start event with invalid session start time: %{public}@", String(describing: glucose)) @@ -435,15 +443,44 @@ public class TransmitterManager: TransmitterDelegate { public func transmitter(_ transmitter: Transmitter, didReadTransmitterVersion message: TransmitterVersionRxMessage) { log.default("Transmitter reports expiry of %d days (isAnubis=%@)", message.transmitterExpiryInDays, String(describing: message.isAnubis)) + logDeviceCommunication("Transmitter version: expiry \(message.transmitterExpiryInDays) days", type: .receive) + transmitter.needsExpiryRead = false mutateState { state in state.transmitterExpiryInDays = message.transmitterExpiryInDays } + updateLatestReadingSessionExpDate() } /// `true` once the transmitter has reported the Anubis 180-day lifetime. public var isAnubis: Bool { return state.isAnubis } + + /// User-configured session length; only honored for Anubis (see `sensorLife`). + public var sensorLifeDays: Int { + get { + return state.sensorLifeDays + } + set { + mutateState { state in + state.sensorLifeDays = TransmitterManagerState.clampedSensorLifeDays(newValue) + } + updateLatestReadingSessionExpDate() + } + } + + public var sensorLife: TimeInterval { + return state.sensorLife + } + + /// Re-stamps the cached reading so a sensor-life change applies immediately. + private func updateLatestReadingSessionExpDate() { + guard var reading = latestReading else { + return + } + reading.sessionExpDate = reading.sessionStartDate?.addingTimeInterval(state.sensorLife) + latestReading = reading + } } diff --git a/CGMBLEKit/TransmitterManagerState.swift b/CGMBLEKit/TransmitterManagerState.swift index a79107dd..5df3b293 100644 --- a/CGMBLEKit/TransmitterManagerState.swift +++ b/CGMBLEKit/TransmitterManagerState.swift @@ -14,6 +14,15 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { public static let version = 1 + public static let defaultSensorLifeDays = 10 + + /// Selectable session lengths for Anubis-modded transmitters. + public static let sensorLifeDaysRange = 10...60 + + static func clampedSensorLifeDays(_ days: Int) -> Int { + return min(max(days, sensorLifeDaysRange.lowerBound), sensorLifeDaysRange.upperBound) + } + public var transmitterID: String public var passiveModeEnabled: Bool = true @@ -28,18 +37,23 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { /// Anubis-modded). `nil` until the first version-rx frame comes in. public var transmitterExpiryInDays: UInt16? + /// User-configured session length; only honored for Anubis (see `sensorLife`). + public var sensorLifeDays: Int + public init( transmitterID: String, shouldSyncToRemoteService: Bool = true, transmitterStartDate: Date? = nil, sensorStartOffset: UInt32? = nil, - transmitterExpiryInDays: UInt16? = nil + transmitterExpiryInDays: UInt16? = nil, + sensorLifeDays: Int = Self.defaultSensorLifeDays ) { self.transmitterID = transmitterID self.shouldSyncToRemoteService = shouldSyncToRemoteService self.transmitterStartDate = transmitterStartDate self.sensorStartOffset = sensorStartOffset self.transmitterExpiryInDays = transmitterExpiryInDays + self.sensorLifeDays = Self.clampedSensorLifeDays(sensorLifeDays) } public init?(rawValue: RawValue) { @@ -57,12 +71,15 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { let transmitterExpiryInDays = (rawValue["transmitterExpiryInDays"] as? UInt16) ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16($0) } + let sensorLifeDays = rawValue["sensorLifeDays"] as? Int ?? Self.defaultSensorLifeDays + self.init( transmitterID: transmitterID, shouldSyncToRemoteService: shouldSyncToRemoteService, transmitterStartDate: transmitterStartDate, sensorStartOffset: sensorStartOffset, - transmitterExpiryInDays: transmitterExpiryInDays + transmitterExpiryInDays: transmitterExpiryInDays, + sensorLifeDays: sensorLifeDays ) } @@ -75,6 +92,7 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { rval["transmitterStartDate"] = transmitterStartDate rval["sensorStartOffset"] = sensorStartOffset rval["transmitterExpiryInDays"] = transmitterExpiryInDays.map { Int($0) } + rval["sensorLifeDays"] = sensorLifeDays return rval } @@ -83,4 +101,9 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { public var isAnubis: Bool { return transmitterExpiryInDays == 180 } + + /// Active session length: `sensorLifeDays` for Anubis, else the stock 10 days. + public var sensorLife: TimeInterval { + return .hours(24 * Double(isAnubis ? sensorLifeDays : Self.defaultSensorLifeDays)) + } } diff --git a/CGMBLEKitUI/Localizable.xcstrings b/CGMBLEKitUI/Localizable.xcstrings index 44636fec..eaa93551 100644 --- a/CGMBLEKitUI/Localizable.xcstrings +++ b/CGMBLEKitUI/Localizable.xcstrings @@ -163,6 +163,9 @@ } } }, + "%d days" : { + "comment" : "The format string for a sensor life option in days (1: number of days)" + }, "Are you sure you want to delete this CGM?" : { "comment" : "Confirmation message for deleting a CGM", "localizations" : { @@ -1808,6 +1811,9 @@ } } }, + "Sensor expiration, including the countdown on the home screen, is calculated from the session start using this value." : { + "comment" : "The footer text for the sensor life picker" + }, "Sensor Expired" : { "comment" : "Sensor expired status\nTitle describing past sensor sensor expiration", "localizations" : { @@ -2097,6 +2103,9 @@ "Sensor Failed" : { "comment" : "Sensor hardware failure" }, + "Sensor Life" : { + "comment" : "The title text for the sensor life setting" + }, "Sensor Stopped" : { "comment" : "Sensor session stopped" }, @@ -2410,6 +2419,9 @@ } } }, + "This transmitter is Anubis-modified and supports extended sensor sessions. Set how long a sensor lasts before it is considered expired." : { + "comment" : "The footer text for the sensor life setting" + }, "Transmitter Age" : { "comment" : "Title describing transmitter session age", "localizations" : { diff --git a/CGMBLEKitUI/TransmitterManager+UI.swift b/CGMBLEKitUI/TransmitterManager+UI.swift index b332af6b..2456925f 100644 --- a/CGMBLEKitUI/TransmitterManager+UI.swift +++ b/CGMBLEKitUI/TransmitterManager+UI.swift @@ -86,10 +86,11 @@ extension G6CGMManager: CGMManagerUI { /// Shared lifecycle + status-highlight derivation for both G5 and G6. -/// Both transmitters compute `sessionStartDate` / `sessionExpDate` on every -/// reading (`Glucose.swift`), so we don't need to hardcode a sensor lifetime -/// — the transmitter already knows. Sensor state (warmup, sensor failure, -/// calibration needed, session failure) comes from `Glucose.state`. +/// Readings carry `sessionStartDate` from the transmitter and a +/// `sessionExpDate` stamped by `TransmitterManager` with the configured +/// sensor life (10 days stock, user-set 10–60 days for Anubis). Sensor state +/// (warmup, sensor failure, calibration needed, session failure) comes from +/// `Glucose.state`. private enum TransmitterSessionStatus { /// Stock G5/G6 warmup window (2 h from `sessionStartDate`). static let standardWarmupDuration: TimeInterval = 2 * 60 * 60 @@ -100,10 +101,10 @@ private enum TransmitterSessionStatus { static func lifecycle(for glucose: Glucose?, isAnubis: Bool) -> DeviceLifecycleProgress? { guard let glucose, let start = glucose.sessionStartDate else { return nil } - // During warmup the session expiry is ~10 days out — using it as the - // ring's denominator would render ~0% and feel broken. Switch the - // ring's denominator to the actual warmup window (50 min for Anubis, - // 2 h for stock) so the arc visibly fills as warmup completes. + // During warmup the session expiry is over a week out — using it as the + // lifecycle's denominator would render ~0% and feel broken. Switch the + // lifecycle's denominator to the actual warmup window (50 min for Anubis, + // 2 h for stock) so the lifecycle visibly fills as warmup completes. if case .known(.warmup) = glucose.state { let elapsed = Date().timeIntervalSince(start) let warmupDuration = isAnubis ? anubisWarmupDuration : standardWarmupDuration @@ -119,6 +120,10 @@ private enum TransmitterSessionStatus { guard let end = glucose.sessionExpDate else { return nil } let total = end.timeIntervalSince(start) guard total > 0 else { return nil } + + // Hide lifecycle countdown until the final 48 h + guard end.timeIntervalSinceNow < 48 * 60 * 60 else { return nil } + let elapsed = Date().timeIntervalSince(start) let fraction = max(0, min(1, elapsed / total)) let progressState: DeviceLifecycleProgressState diff --git a/CGMBLEKitUI/TransmitterSettingsViewController.swift b/CGMBLEKitUI/TransmitterSettingsViewController.swift index da548e6f..ba04e868 100644 --- a/CGMBLEKitUI/TransmitterSettingsViewController.swift +++ b/CGMBLEKitUI/TransmitterSettingsViewController.swift @@ -7,6 +7,7 @@ import UIKit import Combine +import SwiftUI import HealthKit import LoopKit import LoopKitUI @@ -27,6 +28,8 @@ class TransmitterSettingsViewController: UITableViewController { super.init(style: .grouped) + updateSections() + cgmManager.addObserver(self, queue: .main) displayGlucosePreference.$unit @@ -82,6 +85,7 @@ class TransmitterSettingsViewController: UITableViewController { private enum Section: Int, CaseIterable { case transmitterID case remoteDataSync + case sensorLife case latestReading case latestCalibration case latestConnection @@ -90,8 +94,15 @@ class TransmitterSettingsViewController: UITableViewController { case delete } + /// Visible sections; `sensorLife` is only offered once Anubis is detected. + private var sections: [Section] = [] + + private func updateSections() { + sections = Section.allCases.filter { $0 != .sensorLife || cgmManager.isAnubis } + } + override func numberOfSections(in tableView: UITableView) -> Int { - return Section.allCases.count + return sections.count } private enum LatestReadingRow: Int, CaseIterable { @@ -123,11 +134,13 @@ class TransmitterSettingsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch Section(rawValue: section)! { + switch sections[section] { case .transmitterID: return 1 case .remoteDataSync: return 1 + case .sensorLife: + return 1 case .latestReading: return LatestReadingRow.allCases.count case .latestCalibration: @@ -200,7 +213,7 @@ class TransmitterSettingsViewController: UITableViewController { }() override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch Section(rawValue: indexPath.section)! { + switch sections[indexPath.section] { case .transmitterID: let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell @@ -219,6 +232,14 @@ class TransmitterSettingsViewController: UITableViewController { switchCell.switch?.addTarget(self, action: #selector(uploadEnabledChanged(_:)), for: .valueChanged) return switchCell + case .sensorLife: + let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell + + cell.textLabel?.text = LocalizedString("Sensor Life", comment: "The title text for the sensor life setting") + cell.detailTextLabel?.text = transmitterLengthFormatter.string(from: cgmManager.sensorLife) + cell.accessoryType = .disclosureIndicator + + return cell case .latestReading: let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell let glucose = cgmManager.latestReading @@ -364,11 +385,13 @@ class TransmitterSettingsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch Section(rawValue: section)! { + switch sections[section] { case .transmitterID: return nil case .remoteDataSync: return LocalizedString("Remote Data Synchronization", comment: "Section title for remote data synchronization") + case .sensorLife: + return nil case .latestReading: return LocalizedString("Latest Reading", comment: "Section title for latest glucose reading") case .latestCalibration: @@ -385,11 +408,13 @@ class TransmitterSettingsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - switch Section(rawValue: indexPath.section)! { + switch sections[indexPath.section] { case .transmitterID: return false case .remoteDataSync: return false + case .sensorLife: + return true case .latestReading: return false case .latestCalibration: @@ -414,11 +439,19 @@ class TransmitterSettingsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch Section(rawValue: indexPath.section)! { + switch sections[indexPath.section] { case .transmitterID: break case .remoteDataSync: break + case .sensorLife: + let picker = SensorLifeSettingsView(sensorLifeDays: cgmManager.sensorLifeDays) { [weak self] days in + self?.cgmManager.sensorLifeDays = days + } + let vc = UIHostingController(rootView: picker) + vc.title = LocalizedString("Sensor Life", comment: "The title text for the sensor life setting") + show(vc, sender: nil) + return // Don't deselect case .latestReading: break case .latestCalibration: @@ -460,11 +493,13 @@ class TransmitterSettingsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? { - switch Section(rawValue: indexPath.section)! { + switch sections[indexPath.section] { case .transmitterID: break case .remoteDataSync: break + case .sensorLife: + tableView.reloadRows(at: [indexPath], with: .fade) case .latestReading: break case .latestCalibration: @@ -495,6 +530,7 @@ class TransmitterSettingsViewController: UITableViewController { extension TransmitterSettingsViewController: TransmitterManagerObserver { func transmitterManagerDidUpdateLatestReading(_ manager: TransmitterManager) { + updateSections() tableView.reloadData() } } @@ -547,3 +583,38 @@ private extension SettingsTableViewCell { } } } + + +private struct SensorLifeSettingsView: View { + @State private var sensorLifeDays: Int + private let onChange: (Int) -> Void + + init(sensorLifeDays: Int, onChange: @escaping (Int) -> Void) { + _sensorLifeDays = State(initialValue: sensorLifeDays) + self.onChange = onChange + } + + var body: some View { + List { + Section(footer: footer) { + Picker(LocalizedString("Sensor Life", comment: "The title text for the sensor life setting"), selection: $sensorLifeDays) { + ForEach(TransmitterManagerState.sensorLifeDaysRange, id: \.self) { days in + Text(String(format: LocalizedString("%d days", comment: "The format string for a sensor life option in days (1: number of days)"), days)).tag(days) + } + } + .pickerStyle(.wheel) + } + } + .listStyle(.insetGrouped) + .onChange(of: sensorLifeDays) { newValue in + onChange(newValue) + } + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 8) { + Text(LocalizedString("This transmitter is Anubis-modified and supports extended sensor sessions. Set how long a sensor lasts before it is considered expired.", comment: "The footer text for the sensor life setting")) + Text(LocalizedString("Sensor expiration, including the countdown on the home screen, is calculated from the session start using this value.", comment: "The footer text for the sensor life picker")) + } + } +}