diff --git a/CGMBLEKit/Glucose.swift b/CGMBLEKit/Glucose.swift index 042a125..56fa95c 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 da5db39..8387c96 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 ca6e10f..c55b499 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 a79107d..5df3b29 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 44636fe..eaa9355 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 b332af6..2456925 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 da548e6..ba04e86 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")) + } + } +}