Prechádzať zdrojové kódy

Merge branch 'dev' into Lamercho/treatmentview-decimal-separator

/dev/null 9 mesiacov pred
rodič
commit
e9de55c99f

+ 4 - 3
.github/workflows/build_trio.yml

@@ -7,8 +7,9 @@ on:
   #push:
 
   schedule:
-    - cron: "0 8 * * 3" # Checks for updates at 08:00 UTC every Wednesday
-    - cron: "0 6 1 * *" # Builds the app on the 1st of every month at 06:00 UTC
+    # avoid starting an action at xx:00 when GitHub resources are more likely to be impacted
+    - cron: "43 8 * * 3" # Checks for updates at 08:43 UTC every Wednesday
+    - cron: "43 6 1 * *" # Builds the app on the 1st of every month at 06:43 UTC
 
 env:
   UPSTREAM_REPO: nightscout/Trio
@@ -212,7 +213,7 @@ jobs:
       | # runs if started manually, or if sync schedule is set and enabled and scheduled on the first Saturday each month, or if sync schedule is set and enabled and new commits were found
       github.event_name == 'workflow_dispatch' ||
       (needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-        (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '0 6 1 * *') ||
+        (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '43 6 1 * *') ||
         (vars.SCHEDULED_SYNC != 'false' && needs.check_latest_from_upstream.outputs.NEW_COMMITS == 'true' )
       )
     steps:

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.5.1
-APP_DEV_VERSION = 0.5.1.5
+APP_DEV_VERSION = 0.5.1.8
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit ca240f9df3cb5dbda9ad574161c9bbf9612908b2
+Subproject commit 1ea5e384c88f4ff51c7679fea4e17fe13c279d40

+ 17 - 3
Trio/Sources/APS/APSManager.swift

@@ -423,20 +423,26 @@ final class BaseAPSManager: APSManager, Injectable {
         // Fetch glucose asynchronously
         let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
 
+        var invalidGlucoseError: String?
+
         // Perform the context-related checks and actions
         let isValidGlucoseData = await privateContext.perform { [weak self] in
             guard let self else { return false }
 
             guard glucose.count > 2 else {
                 debug(.apsManager, "Not enough glucose data")
-                self.processError(APSError.glucoseError(message: String(localized: "Not enough glucose data")))
+                invalidGlucoseError =
+                    String(
+                        localized: "Not enough glucose data. You need at least three glucose readings in the last six hours to run the algorithm."
+                    )
                 return false
             }
 
             let dateOfLastGlucose = glucose.first?.date
             guard dateOfLastGlucose ?? Date() >= Date().addingTimeInterval(-12.minutes.timeInterval) else {
                 debug(.apsManager, "Glucose data is stale")
-                self.processError(APSError.glucoseError(message: String(localized: "Glucose data is stale")))
+                invalidGlucoseError =
+                    String(localized: "Glucose data is stale. The most recent glucose reading is from more than 12 minutes ago.")
                 return false
             }
 
@@ -468,7 +474,15 @@ final class BaseAPSManager: APSManager, Injectable {
                 }
             }
         } catch {
-            throw APSError.apsError(message: "Error determining basal: \(error.localizedDescription)")
+            // if we have a glucose validation error we might still run
+            // determineBasal to try to get IoB and CoB updates but we
+            // know that it will fail, so the invalidGlucoseError always
+            // takes priority
+            if let invalidGlucoseError = invalidGlucoseError {
+                throw APSError.apsError(message: invalidGlucoseError)
+            } else {
+                throw APSError.apsError(message: "Error determining basal: \(error.localizedDescription)")
+            }
         }
     }
 

+ 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 {

+ 8 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -104886,6 +104886,7 @@
       }
     },
     "Glucose data is stale" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -104991,6 +104992,9 @@
         }
       }
     },
+    "Glucose data is stale. The most recent glucose reading is from more than 12 minutes ago." : {
+
+    },
     "Glucose data is too flat" : {
       "extractionState" : "stale",
       "localizations" : {
@@ -149675,6 +149679,7 @@
       }
     },
     "Not enough glucose data" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -149780,6 +149785,9 @@
         }
       }
     },
+    "Not enough glucose data. You need at least three glucose readings in the last six hours to run the algorithm." : {
+
+    },
     "Not looping." : {
       "localizations" : {
         "bg" : {