Explorar o código

Merge pull request #375 from loopandlearn/estimated-delay

Estimated bg delay
Jonas Björkert hai 1 ano
pai
achega
9e43be498d

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -70,6 +70,7 @@
 		DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; };
 		DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; };
 		DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; };
+		DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; };
 		DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; };
 		DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19852ACDA59700DBD158 /* BGCheck.swift */; };
 		DD7E19882ACDA5DA00DBD158 /* Notes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19872ACDA5DA00DBD158 /* Notes.swift */; };
@@ -347,6 +348,7 @@
 		DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = "<group>"; };
 		DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = "<group>"; };
 		DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = "<group>"; };
+		DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = "<group>"; };
 		DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = "<group>"; };
 		DD7E19852ACDA59700DBD158 /* BGCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGCheck.swift; sourceTree = "<group>"; };
 		DD7E19872ACDA5DA00DBD158 /* Notes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notes.swift; sourceTree = "<group>"; };
@@ -1107,6 +1109,7 @@
 		FCC688542489367300A0279D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
+				DD7B0D432D730A320063DCB6 /* CycleHelper.swift */,
 				DDF6999C2C5AAA4C0058A8D9 /* Views */,
 				FCC6886E2489A53800A0279D /* AppConstants.swift */,
 				FCC6886A24898FD800A0279D /* ObservationToken.swift */,
@@ -1459,6 +1462,7 @@
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
+				DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */,
 				DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */,
 				DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
 				DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */,

+ 9 - 2
LoopFollow/BackgroundRefresh/BT/BLEDeviceSelectionView.swift

@@ -13,13 +13,14 @@ struct BLEDeviceSelectionView: View {
     var body: some View {
         VStack {
             List {
-                if bleManager.devices.filter({ selectedFilter.matches($0) && !isSelected($0) }).isEmpty {
+                let filteredDevices = bleManager.devices.filter { selectedFilter.matches($0) && !isSelected($0) }
+                if filteredDevices.isEmpty {
                     Text("No devices found yet. They'll appear here when discovered.")
                         .foregroundColor(.secondary)
                         .multilineTextAlignment(.center)
                         .padding()
                 } else {
-                    ForEach(bleManager.devices.filter { selectedFilter.matches($0) && !isSelected($0) }, id: \.id) { device in
+                    ForEach(filteredDevices, id: \.id) { device in
                         HStack {
                             VStack(alignment: .leading) {
                                 Text(device.name ?? "Unknown")
@@ -27,6 +28,12 @@ struct BLEDeviceSelectionView: View {
                                 Text("RSSI: \(device.rssi) dBm")
                                     .foregroundColor(.secondary)
                                     .font(.footnote)
+
+                                if let offset = BLEManager.shared.expectedSensorFetchOffsetString(for: device) {
+                                    Text("Expected bg delay: \(offset)")
+                                        .foregroundColor(.secondary)
+                                        .font(.footnote)
+                                }
                             }
                             Spacer()
                         }

+ 39 - 0
LoopFollow/BackgroundRefresh/BT/BLEManager.swift

@@ -213,3 +213,42 @@ extension BLEManager: BluetoothDeviceDelegate {
         TaskScheduler.shared.checkTasksNow()
     }
 }
+
+extension BLEManager {
+    /// Returns the expected sensor fetch offset as a formatted string ("mm:ss (fetch delay: XX sec)")
+    /// for Dexcom and RileyLink devices. The expected offset is computed as the sensor's schedule offset plus the polling delay.
+    /// The device’s lastSeen time is used (mod cycleDuration) to calculate the effective delay between when the sensor value
+    /// becomes available and when the fetch is actually triggered.
+    func expectedSensorFetchOffsetString(for device: BLEDevice) -> String? {
+        guard
+            let matchedType = BackgroundRefreshType.allCases.first(where: { $0.matches(device) }),
+            let heartBeatInterval = matchedType.heartBeatInterval,
+            let sensorOffset = Storage.shared.sensorScheduleOffset.value
+        else {
+            return nil
+        }
+
+        let heartbeatLast: Date? = {
+            if matchedType.estimatedDelayBasedOnHeartbeat {
+                guard device.isConnected, let lastHeartbeat = activeDevice?.lastHeartbeatTime else {
+                    return nil
+                }
+                return lastHeartbeat
+            } else {
+                return device.lastSeen
+            }
+        }()
+
+        guard let heartbeatLast = heartbeatLast else {
+            return nil
+        }
+
+        let pollingDelay: TimeInterval = Double(UserDefaultsRepository.bgUpdateDelay.value)
+
+        let expectedOffset = sensorOffset + pollingDelay
+
+        let effectiveDelay = CycleHelper.computeDelay(sensorOffset: expectedOffset, heartbeatLast: heartbeatLast, heartbeatInterval: heartBeatInterval)
+
+        return "\(Int(effectiveDelay)) sec"
+    }
+}

+ 0 - 2
LoopFollow/BackgroundRefresh/BT/Devices/RileyLinkHeartbeatBluetoothDevice.swift

@@ -27,8 +27,6 @@ class RileyLinkHeartbeatBluetoothDevice: BluetoothDevice {
 
     override func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
         super.centralManager(central, didConnect: peripheral)
-
-        self.bluetoothDeviceDelegate?.heartBeat()
     }
 
     override func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {

+ 6 - 1
LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift

@@ -73,7 +73,7 @@ struct BackgroundRefreshSettingsView: View {
                         .foregroundColor(.secondary)
 
                 case .dexcom:
-                    Text("Requires a Dexcom G6/ONE/G7/ONE+ transmitter within Bluetooth range. Provides updates every 5 minutes and uses less battery than the silent tune method.")
+                    Text("Requires a Dexcom G6/ONE/G7/ONE+ transmitter within Bluetooth range. Provides updates every 5 minutes and uses less battery than the silent tune method. If you have more than one device to choose from, select the one with the smallest expected bg delay.")
                         .font(.footnote)
                         .foregroundColor(.secondary)
                 }
@@ -97,6 +97,11 @@ struct BackgroundRefreshSettingsView: View {
                             .foregroundColor(.secondary)
                             .font(.footnote)
                     }
+                    if let offset = BLEManager.shared.expectedSensorFetchOffsetString(for: storedDevice) {
+                        Text("Expected bg delay: \(offset)")
+                            .foregroundColor(.secondary)
+                            .font(.footnote)
+                    }
 
                     HStack {
                         Spacer()

+ 20 - 0
LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift

@@ -23,6 +23,26 @@ enum BackgroundRefreshType: String, Codable, CaseIterable {
         }
     }
 
+    var heartBeatInterval: TimeInterval? {
+        switch self {
+        case .rileyLink:
+            return 60
+        case .dexcom:
+            return 5 * 60
+        case .silentTune, .none:
+            return nil
+        }
+    }
+
+    var estimatedDelayBasedOnHeartbeat: Bool {
+        switch self {
+        case .rileyLink:
+            return true
+        case .dexcom, .silentTune, .none:
+            return false
+        }
+    }
+
     /// Determines if a BLEDevice matches the specific device type
     func matches(_ device: BLEDevice) -> Bool {
         switch self {

+ 85 - 62
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -8,6 +8,7 @@
 
 import Foundation
 import UIKit
+
 extension MainViewController {
     // Dex Share Web Call
     func webLoadDexShare() {
@@ -16,19 +17,19 @@ extension MainViewController {
         let graphHours = 24 * UserDefaultsRepository.downloadDays.value
         let count = graphHours * 12
         dexShare?.fetchData(count) { (err, result) -> () in
-            
+
             if let error = err {
                 LogManager.shared.log(category: .dexcom, message: "Error fetching Dexcom data: \(error.localizedDescription)")
                 self.webLoadNSBGData()
                 return
             }
-            
+
             guard let data = result else {
                 LogManager.shared.log(category: .dexcom, message: "Received nil data from Dexcom")
                 self.webLoadNSBGData()
                 return
             }
-            
+
             // If Dex data is old, load from NS instead
             let latestDate = data[0].date
             let now = dateTimeUtils.getNowTimeIntervalUTC()
@@ -37,7 +38,7 @@ extension MainViewController {
                 self.webLoadNSBGData()
                 return
             }
-            
+
             // Dexcom only returns 24 hrs of data. If we need more, call NS.
             if graphHours > 24 && IsNightscoutEnabled() {
                 self.webLoadNSBGData(dexData: data)
@@ -62,7 +63,7 @@ extension MainViewController {
 
         // Exclude 'cal' entries
         parameters["find[type][$ne]"] = "cal"
-        
+
         NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in
             switch result {
             case .success(let entriesResponse):
@@ -119,76 +120,98 @@ extension MainViewController {
             }
         }
     }
-    
-    // Dexcom BG Data Response processor
-    func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String){
+
+    /// Processes incoming BG data.
+    func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String) {
         let graphHours = 24 * UserDefaultsRepository.downloadDays.value
-        
-        if data.count == 0 {
+
+        guard !data.isEmpty else {
+            LogManager.shared.log(category: .nightscout, message: "No bg data received. Skipping processing.")
             return
         }
-        let latestDate = data[0].date
+
+        let latestReading = data[0]
+        let sensorTimestamp = latestReading.date
         let now = dateTimeUtils.getNowTimeIntervalUTC()
-        
-        // Start the BG timer based on the reading
-        let secondsAgo = now - latestDate
-        
+        // secondsAgo is how old the newest reading is
+        let secondsAgo = now - sensorTimestamp
+
+        // Compute the current sensor schedule offset
+        let currentOffset = CycleHelper.cycleOffset(for: sensorTimestamp, interval: 5 * 60)
+
+        if Storage.shared.sensorScheduleOffset.value != currentOffset {
+            Storage.shared.sensorScheduleOffset.value = currentOffset
+            LogManager.shared.log(category: .nightscout,
+                                  message: "Sensor schedule offset: \(currentOffset) seconds.",
+                                  isDebug: true)
+        }
+
+        // Determine the next polling delay.
+        var delayToSchedule: Double = 0
+
         DispatchQueue.main.async {
+            // Fallback scheduling for older readings.
             if secondsAgo >= (20 * 60) {
-                TaskScheduler.shared.rescheduleTask(
-                    id: .fetchBG,
-                    to: Date().addingTimeInterval(5 * 60)
-                )
+                delayToSchedule = 5 * 60
+                LogManager.shared.log(category: .nightscout,
+                                      message: "Reading is very old (\(secondsAgo) sec). Scheduling next fetch in 5 minutes.",
+                                      isDebug: true)
             } else if secondsAgo >= (10 * 60) {
-                TaskScheduler.shared.rescheduleTask(
-                    id: .fetchBG,
-                    to: Date().addingTimeInterval(60)
-                )
+                delayToSchedule = 60
+                LogManager.shared.log(category: .nightscout,
+                                      message: "Reading is moderately old (\(secondsAgo) sec). Scheduling next fetch in 60 seconds.",
+                                      isDebug: true)
             } else if secondsAgo >= (7 * 60) {
-                TaskScheduler.shared.rescheduleTask(
-                    id: .fetchBG,
-                    to: Date().addingTimeInterval(30)
-                )
+                delayToSchedule = 30
+                LogManager.shared.log(category: .nightscout,
+                                      message: "Reading is a bit old (\(secondsAgo) sec). Scheduling next fetch in 30 seconds.",
+                                      isDebug: true)
             } else if secondsAgo >= (5 * 60) {
-                TaskScheduler.shared.rescheduleTask(
-                    id: .fetchBG,
-                    to: Date().addingTimeInterval(10)
-                )
+                delayToSchedule = 5
+                LogManager.shared.log(category: .nightscout,
+                                      message: "Reading is close to 5 minutes old (\(secondsAgo) sec). Scheduling next fetch in 5 seconds.",
+                                      isDebug: true)
             } else {
-                let delay = (300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value))
-                TaskScheduler.shared.rescheduleTask(
-                    id: .fetchBG,
-                    to: Date().addingTimeInterval(delay)
-                )
-
-                if data.count > 1 {
-                    self.evaluateSpeakConditions(currentValue: data[0].sgv, previousValue: data[1].sgv)
-                }
+                delayToSchedule = 300 - secondsAgo + Double(UserDefaultsRepository.bgUpdateDelay.value)
+                LogManager.shared.log(category: .nightscout,
+                                      message: "Fresh reading. Scheduling next fetch in \(delayToSchedule) seconds.",
+                                      isDebug: true)
+            }
+
+            TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date().addingTimeInterval(delayToSchedule))
+
+            // Evaluate speak conditions if there is a previous value.
+            if data.count > 1 {
+                self.evaluateSpeakConditions(currentValue: data[0].sgv, previousValue: data[1].sgv)
             }
         }
-        
+
+        // Process data for graph display.
         bgData.removeAll()
-        
-        // loop through the data so we can reverse the order to oldest first for the graph
         for i in 0..<data.count {
-            let dateString = data[data.count - 1 - i].date
-            if dateString >= dateTimeUtils.getTimeIntervalNHoursAgo(N: graphHours) {
+            let readingTimestamp = data[data.count - 1 - i].date
+            if readingTimestamp >= dateTimeUtils.getTimeIntervalNHoursAgo(N: graphHours) {
                 let sgvValue = data[data.count - 1 - i].sgv
-                
-                // Skip the current iteration if the sgv value is over 600
-                // First time a user starts a G7, they get a value of 4000
+
+                // Skip outlier values (e.g. first reading of a new sensor might be abnormally high).
                 if sgvValue > 600 {
+                    LogManager.shared.log(category: .nightscout,
+                                          message: "Skipping reading with sgv \(sgvValue) as it exceeds threshold.",
+                                          isDebug: true)
                     continue
                 }
-                
-                let reading = ShareGlucoseData(sgv: sgvValue, date: dateString, direction: data[data.count - 1 - i].direction)
+
+                let reading = ShareGlucoseData(sgv: sgvValue, date: readingTimestamp, direction: data[data.count - 1 - i].direction)
                 bgData.append(reading)
             }
         }
-        
+
+        LogManager.shared.log(category: .nightscout,
+                              message: "Graph data updated with \(bgData.count) entries.",
+                              isDebug: true)
         viewUpdateNSBG(sourceName: sourceName)
     }
-    
+
     func updateServerText(with serverText: String? = nil) {
         if UserDefaultsRepository.showDisplayName.value, let displayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
             self.serverText.text = displayName
@@ -196,7 +219,7 @@ extension MainViewController {
             self.serverText.text = serverText
         }
     }
-    
+
     // NS BG Data Front end updater
     func viewUpdateNSBG(sourceName: String) {
         DispatchQueue.main.async {
@@ -204,28 +227,28 @@ extension MainViewController {
 
             let entries = self.bgData
             if entries.count < 2 { return } // Protect index out of bounds
-            
+
             self.updateBGGraph()
             self.updateStats()
-            
+
             let latestEntryIndex = entries.count - 1
             let latestBG = entries[latestEntryIndex].sgv
             let priorBG = entries[latestEntryIndex - 1].sgv
             let deltaBG = latestBG - priorBG
             let lastBGTime = entries[latestEntryIndex].date
-            
-            let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - lastBGTime) / 60            
+
+            let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - lastBGTime) / 60
             self.updateServerText(with: sourceName)
-            
+
             var snoozerBG = ""
             var snoozerDirection = ""
             var snoozerDelta = ""
-            
+
             // Set BGText with the latest BG value
             self.BGText.text = Localizer.toDisplayUnits(String(latestBG))
             snoozerBG = Localizer.toDisplayUnits(String(latestBG))
             self.setBGTextColor()
-            
+
             // Direction handling
             if let directionBG = entries[latestEntryIndex].direction {
                 self.DirectionText.text = self.bgDirectionGraphic(directionBG)
@@ -236,7 +259,7 @@ extension MainViewController {
                 snoozerDirection = ""
                 self.latestDirectionString = ""
             }
-            
+
             // Delta handling
             if deltaBG < 0 {
                 self.latestDeltaString = Localizer.toDisplayUnits(String(deltaBG))
@@ -259,7 +282,7 @@ extension MainViewController {
                 self.updateBadge(val: latestBG)
             }
             self.BGText.attributedText = attributeString
-            
+
             // Snoozer Display
             guard let snoozer = self.tabBarController!.viewControllers?[2] as? SnoozeViewController else { return }
             snoozer.BGLabel.text = snoozerBG

+ 52 - 0
LoopFollow/Helpers/CycleHelper.swift

@@ -0,0 +1,52 @@
+//
+//  CycleHelper.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-03-01.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+struct CycleHelper {
+    /// Returns a positive modulus value (always between 0 and modulus).
+    static func positiveModulo(_ value: TimeInterval, modulus: TimeInterval) -> TimeInterval {
+        let remainder = value.truncatingRemainder(dividingBy: modulus)
+        return remainder < 0 ? remainder + modulus : remainder
+    }
+
+    /// Calculates the cycle offset for a given date relative to midnight.
+    /// The offset is the number of seconds into the cycle (i.e., date mod interval).
+    static func cycleOffset(for date: Date, interval: TimeInterval) -> TimeInterval {
+        let calendar = Calendar.current
+        let startOfDay = calendar.startOfDay(for: date)
+        let secondsSinceMidnight = date.timeIntervalSince(startOfDay)
+        return secondsSinceMidnight.truncatingRemainder(dividingBy: interval)
+    }
+
+    /// Same as above, but takes a timestamp (seconds since 1970) instead of a Date.
+    static func cycleOffset(for timestamp: TimeInterval, interval: TimeInterval) -> TimeInterval {
+        let date = Date(timeIntervalSince1970: timestamp)
+        return cycleOffset(for: date, interval: interval)
+    }
+
+    /// Computes the delay experienced when using a heartbeat device to read a sensor value.
+    /// The calculation is based on a sensor reference (Date) and sensor interval.
+    /// All calculations assume midnight as the base reference.
+    static func computeDelay(sensorReference: Date,
+                             sensorInterval: TimeInterval,
+                             heartbeatLast: Date,
+                             heartbeatInterval: TimeInterval) -> TimeInterval {
+        let sensorOffset = cycleOffset(for: sensorReference, interval: sensorInterval)
+        let hbOffset = cycleOffset(for: heartbeatLast, interval: heartbeatInterval)
+        return positiveModulo(hbOffset - sensorOffset, modulus: heartbeatInterval)
+    }
+
+    /// Overloaded version of computeDelay where the sensor cycle offset is already known.
+    static func computeDelay(sensorOffset: TimeInterval,
+                             heartbeatLast: Date,
+                             heartbeatInterval: TimeInterval) -> TimeInterval {
+        let hbOffset = cycleOffset(for: heartbeatLast, interval: heartbeatInterval)
+        return positiveModulo(hbOffset - sensorOffset, modulus: heartbeatInterval)
+    }
+}

+ 3 - 0
LoopFollow/Storage/Storage.swift

@@ -42,6 +42,9 @@ class Storage {
     var contactEnabled = StorageValue<Bool>(key: "contactEnabled", defaultValue: false)
     var contactBackgroundColor = StorageValue<String>(key: "contactBackgroundColor", defaultValue: ContactColorOption.black.rawValue)
     var contactTextColor = StorageValue<String>(key: "contactTextColor", defaultValue: ContactColorOption.white.rawValue)
+    
+    var sensorScheduleOffset = StorageValue<Double?>(key: "sensorScheduleOffset", defaultValue: nil)
+
     static let shared = Storage()
 
     private init() { }