Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Features.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions LibreTransmitter/LibreGlucose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions LibreTransmitter/LibreTransmitterManager+Libre2EU.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions LibreTransmitter/LibreTransmitterManager+Transmitters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
63 changes: 61 additions & 2 deletions LibreTransmitter/LibreTransmitterManagerV3.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 2 additions & 0 deletions LibreTransmitterUI/LibreTransmitterManager+UI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ extension LibreTransmitterManagerV3: CGMManagerUI {
let wantToRestablishConnectionNotifier = GenericObservableObject()

let settingsView = SettingsView(
cgmManager: self,
transmitterInfo: self.transmitterInfoObservable,
sensorInfo: self.sensorInfoObservable,
glucoseMeasurement: self.glucoseInfoObservable,
Expand All @@ -58,6 +59,7 @@ extension LibreTransmitterManagerV3: CGMManagerUI {
content: settingsView
.navigationTitle(self.localizedTitle)
.environmentObject(displayGlucosePreference)
.environment(\.appName, Bundle.main.bundleDisplayName)
)

let nav = CGMManagerSettingsNavigationViewController(rootViewController: hostedView)
Expand Down
42 changes: 42 additions & 0 deletions LibreTransmitterUI/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down
106 changes: 106 additions & 0 deletions LibreTransmitterUI/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down