diff --git a/Features.swift b/Features.swift index 0253e1d..e60c8d6 100644 --- a/Features.swift +++ b/Features.swift @@ -18,7 +18,6 @@ public final class Features { static public var alarmSettingsViewRequiresAuthentication = false static public var allowsEditingFactoryCalibrationData = false - static public var allowOneMinuteReadings = false // Uses Vibration through apples audio api for glucose alarms. This could be considered an api abuse from apple's standpoint; // since apis invoked for this feature are meant for audio streaming apps. diff --git a/LibreTransmitter/LibreGlucose.swift b/LibreTransmitter/LibreGlucose.swift index 43ec73c..d52e507 100644 --- a/LibreTransmitter/LibreGlucose.swift +++ b/LibreTransmitter/LibreGlucose.swift @@ -125,10 +125,11 @@ extension LibreGlucose { return arr } - static func fromTrendMeasurements(_ measurements: [Measurement], nativeCalibrationData: SensorData.CalibrationInfo) -> [LibreGlucose] { + /// - parameter smoothGlucose: false when every reading is forwarded (minute-by-minute mode) + static func fromTrendMeasurements(_ measurements: [Measurement], nativeCalibrationData: SensorData.CalibrationInfo, smoothGlucose: Bool = true) -> [LibreGlucose] { var arr = [LibreGlucose]() - var shouldSmoothGlucose = true + var shouldSmoothGlucose = smoothGlucose for trend in measurements { // trend arrows on each libreglucose value is not needed // instead we calculate it once when latestbackfill is set, which in turn sets diff --git a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift index 257f315..721a97b 100644 --- a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift +++ b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift @@ -27,9 +27,11 @@ extension LibreTransmitterManagerV3 { self.logger.debug("got sensordata: \(String(describing: bleData))") let typeDesc = Device.sensorType().debugDescription + recordConnectionKind(isLibre2Direct: true) + let now = Date() // only one reading per 1 minute / 5 minutes - let mins = Features.allowOneMinuteReadings ? 0.8 : 4.5 + let mins = allowOneMinuteReadings ? 0.8 : 4.5 if let earlierplus = lastDirectUpdate?.addingTimeInterval(mins * 60), earlierplus >= now { logger.debug("last ble update was less than \(mins) minutes ago, aborting loop update") //self.logDeviceCommunication("Sensor didUpdate (not used) \(bleData)", type: .receive) @@ -78,7 +80,7 @@ extension LibreTransmitterManagerV3 { let sortedTrends = bleData.trend.sorted { $0.date > $1.date} - let glucose = LibreGlucose.fromTrendMeasurements(sortedTrends, nativeCalibrationData: calibrationData) + let glucose = LibreGlucose.fromTrendMeasurements(sortedTrends, nativeCalibrationData: calibrationData, smoothGlucose: !allowOneMinuteReadings) var newGlucose : [NewGlucoseSample] = glucosesToSamplesFilter(glucose, startDate: getStartDateForFilter()) // For libre2 bluetooth we do need all trend elements to calculate trendarrow, diff --git a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift index 6c5d7f6..a19e42a 100644 --- a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift +++ b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift @@ -21,6 +21,8 @@ extension LibreTransmitterManagerV3 { self.logger.debug("got sensordata: \(String(describing: sensorData)), bytescount: \( sensorData.bytes.count), bytes: \(sensorData.bytes)") var sensorData = sensorData + recordConnectionKind(isLibre2Direct: false) + NotificationHelper.sendLowBatteryNotificationIfNeeded(device: Device) self.setObservables(sensorData: nil, bleData: nil, metaData: Device) diff --git a/LibreTransmitter/LibreTransmitterManagerV3.swift b/LibreTransmitter/LibreTransmitterManagerV3.swift index 93973a3..3e4075f 100644 --- a/LibreTransmitter/LibreTransmitterManagerV3.swift +++ b/LibreTransmitter/LibreTransmitterManagerV3.swift @@ -234,11 +234,70 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { self.init() logger.debug("LibreTransmitterManager has run init from rawstate") - + self.experimentalMinuteByMinuteForwarding = rawState[Self.minuteByMinuteForwardingKey] as? Bool ?? false + self.lastKnownLibre2DirectConnection = rawState[Self.libre2DirectConnectionKey] as? Bool + } public var rawState: CGMManager.RawStateValue { - [:] + var raw = CGMManager.RawStateValue() + if experimentalMinuteByMinuteForwarding { + raw[Self.minuteByMinuteForwardingKey] = true + } + if let lastKnownLibre2DirectConnection { + raw[Self.libre2DirectConnectionKey] = lastKnownLibre2DirectConnection + } + return raw + } + + private static let minuteByMinuteForwardingKey = "experimentalMinuteByMinuteForwarding" + private static let libre2DirectConnectionKey = "isLibre2DirectConnection" + + /// Experimental minute-by-minute forwarding: send every reading the sensor + /// produces (~1/min) instead of throttling to the ~5 minute cadence the + /// loop algorithms were designed against. Persisted in `rawState`. + /// + /// Only the libre2 direct bluetooth connection produces a reading every + /// minute, so the effective value (`allowOneMinuteReadings`) is false for + /// third party transmitters even when this was enabled earlier while + /// running a libre2 sensor directly. + public private(set) var experimentalMinuteByMinuteForwarding: Bool = false + + private var lastKnownLibre2DirectConnection: Bool? + + public var isLibre2DirectConnection: Bool { + if let lastKnownLibre2DirectConnection { + return lastKnownLibre2DirectConnection + } + return UserDefaults.standard.preSelectedUid != nil || UserDefaults.standard.preSelectedSensor != nil + } + + /// The effective setting: what the glucose pipeline should act on. + public var allowOneMinuteReadings: Bool { + isLibre2DirectConnection && experimentalMinuteByMinuteForwarding + } + + public func setExperimentalMinuteByMinuteForwarding(_ enabled: Bool) { + guard experimentalMinuteByMinuteForwarding != enabled else { return } + + experimentalMinuteByMinuteForwarding = enabled + logger.debug("experimentalMinuteByMinuteForwarding set to \(enabled)") + notifyDelegateOfStateChange() + } + + func recordConnectionKind(isLibre2Direct: Bool) { + guard lastKnownLibre2DirectConnection != isLibre2Direct else { return } + + lastKnownLibre2DirectConnection = isLibre2Direct + logger.debug("connection kind recorded as \(isLibre2Direct ? "libre2 direct" : "transmitter")") + notifyDelegateOfStateChange() + } + + private func notifyDelegateOfStateChange() { + delegateQueue?.async { [weak self] in + guard let self else { return } + self.cgmManagerDelegate?.cgmManagerDidUpdateState(self) + } } open var localizedTitle: String { "FreeStyle Libre" } diff --git a/LibreTransmitterUI/LibreTransmitterManager+UI.swift b/LibreTransmitterUI/LibreTransmitterManager+UI.swift index aa50375..033639f 100644 --- a/LibreTransmitterUI/LibreTransmitterManager+UI.swift +++ b/LibreTransmitterUI/LibreTransmitterManager+UI.swift @@ -42,6 +42,7 @@ extension LibreTransmitterManagerV3: CGMManagerUI { let wantToRestablishConnectionNotifier = GenericObservableObject() let settingsView = SettingsView( + cgmManager: self, transmitterInfo: self.transmitterInfoObservable, sensorInfo: self.sensorInfoObservable, glucoseMeasurement: self.glucoseInfoObservable, @@ -58,6 +59,7 @@ extension LibreTransmitterManagerV3: CGMManagerUI { content: settingsView .navigationTitle(self.localizedTitle) .environmentObject(displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) ) let nav = CGMManagerSettingsNavigationViewController(rootViewController: hostedView) diff --git a/LibreTransmitterUI/Localizable.xcstrings b/LibreTransmitterUI/Localizable.xcstrings index 840774a..29c2a85 100644 --- a/LibreTransmitterUI/Localizable.xcstrings +++ b/LibreTransmitterUI/Localizable.xcstrings @@ -410,6 +410,9 @@ } } }, + "%1$@'s algorithm was designed and tuned against CGMs that emit a new reading every 5 minutes. With this setting on, %1$@ receives a new reading from the sensor every minute instead." : { + "comment" : "Minute-by-minute warning paragraph 1 (1: appName)" + }, "%lld%%" : { "localizations" : { "ar" : { @@ -552,6 +555,18 @@ } } }, + "• Dosing decisions may shift sooner or further than what %1$@'s review and tuning guidance assumes." : { + "comment" : "Minute-by-minute warning bullet 1 (1: appName)" + }, + "• Readings are sent unsmoothed, so single-reading noise is passed on as-is." : { + "comment" : "Minute-by-minute warning bullet 3" + }, + "• Trend math, retrospective correction, and momentum effects were validated at the 5-minute cadence." : { + "comment" : "Minute-by-minute warning bullet 2" + }, + "• You're accepting responsibility for monitoring outcomes more closely while this is on." : { + "comment" : "Minute-by-minute warning bullet 4" + }, "Activate and finish warming up a new sensor with another app or physical reader." : { "comment" : "Label text for step 1 of AuthView", "localizations" : { @@ -5455,6 +5470,15 @@ } } }, + "Enable" : { + "comment" : "Enable button" + }, + "Every reading from the sensor (~1/min) is sent to %1$@." : { + "comment" : "Forwarding footer: minute-by-minute on (1: appName)" + }, + "Experimental setting" : { + "comment" : "Minute-by-minute warning header" + }, "Fair warning: The sensor will be not be using the manufacturer's algorithm, and some safety mitigations present in the manufacturers algorithm might be missing when you use this." : { "comment" : "Label text for step 4 of connection setup", "localizations" : { @@ -5598,6 +5622,9 @@ } } }, + "Forwarding to %1$@" : { + "comment" : "Text describing header for forwarding section (1: appName)" + }, "Found devices:" : { "comment" : "Text for discovered number of devices", "localizations" : { @@ -7635,6 +7662,9 @@ } } }, + "Leave this off unless you understand the implications. You can turn it off again at any time." : { + "comment" : "Minute-by-minute warning footer" + }, "Libre 2 Direct" : { "comment" : "Libre 2 connection option", "localizations" : { @@ -9633,6 +9663,9 @@ } } }, + "Only one reading every ~5 minutes is sent to %1$@, matching the cadence other CGMs use." : { + "comment" : "Forwarding footer: minute-by-minute off (1: appName)" + }, "Outside US" : { "comment" : "Outside US share server option title", "extractionState" : "manual", @@ -11653,6 +11686,12 @@ } } }, + "Send every reading" : { + "comment" : "Minute-by-minute warning screen title" + }, + "Send every reading (experimental)" : { + "comment" : "Experimental minute-by-minute forwarding toggle" + }, "Sensor change" : { "localizations" : { "ar" : { @@ -13377,6 +13416,9 @@ } } }, + "This can change how %1$@ reacts to glucose movement compared to default behavior:" : { + "comment" : "Minute-by-minute warning paragraph 2 (1: appName)" + }, "To " : { "comment" : "Very short text describing separation between start and end datetimes", "localizations" : { diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index ac1b6e1..184bb32 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -49,6 +49,7 @@ public struct SettingsItem: View { struct SettingsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.appName) private var appName var longDateFormatter: DateFormatter = ({ let df = DateFormatter() @@ -72,13 +73,18 @@ struct SettingsView: View { @ObservedObject var alarmStatus: LibreTransmitter.AlarmStatus @State private var showingDestructQuestion = false + @State private var showingMinuteByMinuteWarning = false + @State private var minuteByMinuteForwardingEnabled: Bool // @State private var showingExporter = false // @Environment(\.presentationMode) var presentationMode var pairingService: SensorPairingProtocol var bluetoothSearcher: BluetoothSearcher + private let cgmManager: LibreTransmitterManagerV3 + init( + cgmManager: LibreTransmitterManagerV3, transmitterInfo: LibreTransmitter.TransmitterInfo, sensorInfo: LibreTransmitter.SensorInfo, glucoseMeasurement: LibreTransmitter.GlucoseInfo, @@ -90,6 +96,8 @@ struct SettingsView: View { pairingService: SensorPairingProtocol, bluetoothSearcher: BluetoothSearcher) { + self.cgmManager = cgmManager + self._minuteByMinuteForwardingEnabled = State(initialValue: cgmManager.experimentalMinuteByMinuteForwarding) self.transmitterInfo = transmitterInfo self.sensorInfo = sensorInfo self.glucoseMeasurement = glucoseMeasurement @@ -132,11 +140,26 @@ struct SettingsView: View { print("edit calibration clicked") } } + if cgmManager.isLibre2DirectConnection { + forwardingSection + } advancedSection sensorChangeSection destructSection }.listStyle(InsetGroupedListStyle()) + // The sheet must be attached at the List level. Attached inside a + // Section, the List rebuilds its rows on the state change and + // dismisses the sheet immediately. + .sheet(isPresented: $showingMinuteByMinuteWarning) { + MinuteByMinuteWarningSheet( + onEnable: { + setMinuteByMinuteForwarding(true) + showingMinuteByMinuteWarning = false + }, + onCancel: { showingMinuteByMinuteWarning = false } + ) + } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { doneButton @@ -257,6 +280,36 @@ struct SettingsView: View { } } + /// Toggle for the per-minute experimental forwarding mode. Default is + /// off (readings throttled to ~5 min) because the loop algorithms were + /// designed against 5-minute CGM input. Turning it on requires the user + /// to read the warning sheet. Only shown for the libre2 direct bluetooth + /// connection, the only mode that produces a reading every minute. + var forwardingSection: some View { + Section(header: Text(String(format: LocalizedString("Forwarding to %1$@", comment: "Text describing header for forwarding section (1: appName)"), appName))) { + Toggle(LocalizedString("Send every reading (experimental)", comment: "Experimental minute-by-minute forwarding toggle"), isOn: Binding( + get: { minuteByMinuteForwardingEnabled }, + set: { newValue in + if newValue { + showingMinuteByMinuteWarning = true + } else { + setMinuteByMinuteForwarding(false) + } + } + )) + Text(minuteByMinuteForwardingEnabled + ? String(format: LocalizedString("Every reading from the sensor (~1/min) is sent to %1$@.", comment: "Forwarding footer: minute-by-minute on (1: appName)"), appName) + : String(format: LocalizedString("Only one reading every ~5 minutes is sent to %1$@, matching the cadence other CGMs use.", comment: "Forwarding footer: minute-by-minute off (1: appName)"), appName)) + .font(.caption) + .foregroundColor(.secondary) + } + } + + private func setMinuteByMinuteForwarding(_ enabled: Bool) { + cgmManager.setExperimentalMinuteByMinuteForwarding(enabled) + minuteByMinuteForwardingEnabled = enabled + } + var advancedSection: some View { Section(header: Text(LocalizedString("Configuration", comment: "Text describing header for advanced settings section"))) { // these subviews don't really need to be notified once glucose unit changes @@ -461,6 +514,59 @@ struct SettingsView: View { } +struct MinuteByMinuteWarningSheet: View { + let onEnable: () -> Void + let onCancel: () -> Void + @Environment(\.appName) private var appName + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Label(LocalizedString("Experimental setting", comment: "Minute-by-minute warning header"), systemImage: "exclamationmark.triangle.fill") + .font(.title3.weight(.semibold)) + .foregroundColor(.orange) + + Text(String(format: LocalizedString("%1$@'s algorithm was designed and tuned against CGMs that emit a new reading every 5 minutes. With this setting on, %1$@ receives a new reading from the sensor every minute instead.", comment: "Minute-by-minute warning paragraph 1 (1: appName)"), appName)) + Text(String(format: LocalizedString("This can change how %1$@ reacts to glucose movement compared to default behavior:", comment: "Minute-by-minute warning paragraph 2 (1: appName)"), appName)) + VStack(alignment: .leading, spacing: 8) { + Text(String(format: LocalizedString("• Dosing decisions may shift sooner or further than what %1$@'s review and tuning guidance assumes.", comment: "Minute-by-minute warning bullet 1 (1: appName)"), appName)) + Text(LocalizedString("• Trend math, retrospective correction, and momentum effects were validated at the 5-minute cadence.", comment: "Minute-by-minute warning bullet 2")) + Text(LocalizedString("• Readings are sent unsmoothed, so single-reading noise is passed on as-is.", comment: "Minute-by-minute warning bullet 3")) + Text(LocalizedString("• You're accepting responsibility for monitoring outcomes more closely while this is on.", comment: "Minute-by-minute warning bullet 4")) + } + .font(.callout) + Text(LocalizedString("Leave this off unless you understand the implications. You can turn it off again at any time.", comment: "Minute-by-minute warning footer")) + .font(.footnote) + .foregroundColor(.secondary) + } + .padding() + } + .navigationBarTitle(Text(LocalizedString("Send every reading", comment: "Minute-by-minute warning screen title")), displayMode: .inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(LocalizedString("Cancel", comment: "Cancel button"), action: onCancel) + } + ToolbarItem(placement: .confirmationAction) { + Button(LocalizedString("Enable", comment: "Enable button"), action: onEnable) + .foregroundColor(.red) + } + } + } + } +} + +extension Bundle { + /// The host app's display name (Loop, iAPS, Trio, a rebrand, …). + /// `Bundle.main` is the running app, not this plugin, so this resolves to + /// whatever app embedded LibreTransmitter. + var bundleDisplayName: String { + object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "Loop" + } +} + struct SettingsOverview_Previews: PreviewProvider { static var previews: some View { NotificationSettingsView()