Sfoglia il codice sorgente

Merge pull request #714 from kingst/backfill-glucose-from-cgm-manager

Adds handling for plugin CGM managers to backfill glucose
Sam King 10 mesi fa
parent
commit
01d54bb2d1

+ 13 - 22
Trio/Sources/APS/CGM/PluginSource.swift

@@ -15,8 +15,6 @@ final class PluginSource: GlucoseSource {
 
     var cgmHasValidSensorSession: Bool = false
 
-    private var promise: Future<[BloodGlucose], Error>.Promise?
-
     init(glucoseStorage: GlucoseStorage, glucoseManager: FetchGlucoseManager) {
         self.glucoseStorage = glucoseStorage
         self.glucoseManager = glucoseManager
@@ -34,25 +32,12 @@ final class PluginSource: GlucoseSource {
     /// - Parameter timer: An optional `DispatchTimer` (not used in the function but can be used to trigger fetch logic).
     /// - Returns: An `AnyPublisher` that emits an array of `BloodGlucose` values or an empty array if an error occurs or the timeout is reached.
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
-        Publishers.Merge(
-            callBLEFetch(),
-            fetchIfNeeded()
-        )
-        .filter { !$0.isEmpty }
-        .first()
-        .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
-        .replaceError(with: [])
-        .eraseToAnyPublisher()
-    }
-
-    func callBLEFetch() -> AnyPublisher<[BloodGlucose], Never> {
-        Future<[BloodGlucose], Error> { [weak self] promise in
-            self?.promise = promise
-        }
-        .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
-        .replaceError(with: [])
-        .replaceEmpty(with: [])
-        .eraseToAnyPublisher()
+        fetchIfNeeded()
+            .filter { !$0.isEmpty }
+            .first()
+            .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
+            .replaceError(with: [])
+            .eraseToAnyPublisher()
     }
 
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
@@ -129,7 +114,13 @@ extension PluginSource: CGMManagerDelegate {
 
             dispatchPrecondition(condition: .onQueue(self.processQueue))
 
-            self.promise?(self.readCGMResult(readingResult: readingResult))
+            switch self.readCGMResult(readingResult: readingResult) {
+            case let .success(glucose):
+                self.glucoseManager?.newGlucoseFromCgmManager(newGlucose: glucose)
+            case .failure:
+                debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
+            }
+
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
         }
     }

+ 38 - 0
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -11,6 +11,7 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?)
     func deleteGlucoseSource() async
     func removeCalibrations()
+    func newGlucoseFromCgmManager(newGlucose: [BloodGlucose])
     var glucoseSource: GlucoseSource? { get }
     var cgmManager: CGMManagerUI? { get }
     var cgmGlucoseSourceType: CGMType { get set }
@@ -55,6 +56,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
 
     private let context = CoreDataStack.shared.newTaskContext()
 
+    /// Enforce mutual exclusion on calls to glucoseStoreAndHeartDecision
+    private let glucoseStoreAndHeartLock = DispatchSemaphore(value: 1)
+
     var shouldSyncToRemoteService: Bool {
         guard let cgmManager = cgmManager else {
             return true
@@ -95,6 +99,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                 )
                 .eraseToAnyPublisher()
                 .sink { newGlucose, syncDate in
+                    self.glucoseStoreAndHeartLock.wait()
                     Task {
                         do {
                             try await self.glucoseStoreAndHeartDecision(
@@ -104,6 +109,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                         } catch {
                             debug(.deviceManager, "Failed to store glucose: \(error)")
                         }
+                        self.glucoseStoreAndHeartLock.signal()
                     }
                 }
                 .store(in: &self.lifetime)
@@ -113,6 +119,28 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         timer.resume()
     }
 
+    /// Store new glucose readings from the CGM manager
+    ///
+    /// This function enables plugin CGM managers to send new glucose readings directly
+    /// to the FetchGlucoseManager, bypassing the Combine pipeline. By bypassing the
+    /// Combine pipeline CGM managers can send backfill glucose readings, which come
+    /// right after a new glucose reading, typically.
+    func newGlucoseFromCgmManager(newGlucose: [BloodGlucose]) {
+        glucoseStoreAndHeartLock.wait()
+        let syncDate = glucoseStorage.syncDate()
+        Task {
+            do {
+                try await glucoseStoreAndHeartDecision(
+                    syncDate: syncDate,
+                    glucose: newGlucose
+                )
+            } catch {
+                debug(.deviceManager, "Failed to store glucose from CGM manager: \(error)")
+            }
+            glucoseStoreAndHeartLock.signal()
+        }
+    }
+
     var glucoseSource: GlucoseSource?
 
     func removeCalibrations() {
@@ -256,6 +284,16 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             return
         }
 
+        let backfillGlucose = newGlucose.filter { $0.dateString <= syncDate }
+        if backfillGlucose.isNotEmpty {
+            debug(.deviceManager, "Backfilling glucose...")
+            do {
+                try await glucoseStorage.storeGlucose(backfillGlucose)
+            } catch {
+                debug(.deviceManager, "Unable to backfill glucose: \(error)")
+            }
+        }
+
         filteredByDate = newGlucose.filter { $0.dateString > syncDate }
         filtered = glucoseStorage.filterTooFrequentGlucose(filteredByDate, at: syncDate)
 

+ 27 - 6
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -82,25 +82,46 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
+    /// filter out duplicate CGM readings
+    ///
+    /// This function will look through existing stored CGM values and filter out any new CGM values that
+    /// already exist. It does matching using dates and adds a small amount of time buffer for matching (1 second)
+    /// to account for precision loss that can happen with backfill CGM readings.
     private func filterNewGlucoseValues(_ glucose: [BloodGlucose]) -> [BloodGlucose] {
-        let datesToCheck: Set<Date?> = Set(glucose.compactMap { $0.dateString as Date? })
+        let datesToCheck = glucose.map(\.dateString).sorted()
+        guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-1) }),
+              let lastDate = datesToCheck.last.map({ $0.addingTimeInterval(1) })
+        else {
+            return glucose
+        }
         let fetchRequest: NSFetchRequest<NSFetchRequestResult> = GlucoseStored.fetchRequest()
         fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
-            NSPredicate(format: "date IN %@", datesToCheck),
-            NSPredicate.predicateForOneDayAgo
+            NSPredicate(format: "date >= %@", firstDate as NSDate),
+            NSPredicate(format: "date <= %@", lastDate as NSDate)
         ])
         fetchRequest.propertiesToFetch = ["date"]
         fetchRequest.resultType = .dictionaryResultType
 
-        var existingDates = Set<Date>()
+        var existingDates = [Date]()
         do {
             let results = try context.fetch(fetchRequest) as? [NSDictionary]
-            existingDates = Set(results?.compactMap({ $0["date"] as? Date }) ?? [])
+            existingDates = results?.compactMap({ $0["date"] as? Date }) ?? []
         } catch {
             debugPrint("Failed to fetch existing glucose dates: \(error)")
         }
 
-        return glucose.filter { !existingDates.contains($0.dateString) }
+        // This is an inefficient filtering algorithm, but I'm assuming that the
+        // time spans are short and that duplicates are rare, so in the common
+        // case there won't be any existing dates.
+        return glucose.filter { glucose in
+            for existingDate in existingDates {
+                let difference = abs(existingDate.timeIntervalSince(glucose.dateString))
+                if difference <= 1 {
+                    return false
+                }
+            }
+            return true
+        }
     }
 
     private func storeGlucoseInCoreData(_ glucose: [BloodGlucose]) throws {