Procházet zdrojové kódy

Upload Insulin, some fixes for wrong Predicates

polscm32 aka Marvout před 1 rokem
rodič
revize
895d1bc369

+ 1 - 1
FreeAPS/Sources/APS/Storage/CarbsStorage.swift

@@ -367,7 +367,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: coredataContext,
-            predicate: NSPredicate.carbsNotYetUploadedToNightscout,
+            predicate: NSPredicate.carbsNotYetUploadedToHealth,
             key: "date",
             ascending: false
         )

+ 60 - 0
FreeAPS/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -4,15 +4,24 @@ import LoopKit
 import SwiftDate
 import Swinject
 
+protocol PumpHistoryDelegate: AnyObject {
+    /*
+     Informs the delegate that the Carbs Storage has updated Carbs
+     */
+    func pumpHistoryHasUpdated(_ pumpHistoryStorage: BasePumpHistoryStorage)
+}
+
 protocol PumpHistoryObserver {
     func pumpHistoryDidUpdate(_ events: [PumpHistoryEvent])
 }
 
 protocol PumpHistoryStorage {
+    var delegate: PumpHistoryDelegate? { get set }
     func storePumpEvents(_ events: [NewPumpEvent])
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async
     func recent() -> [PumpHistoryEvent]
     func getPumpHistoryNotYetUploadedToNightscout() async -> [NightscoutTreatment]
+    func getPumpHistoryNotYetUploadedToHealth() async -> [PumpHistoryEvent]
     func deleteInsulin(at date: Date)
 }
 
@@ -22,6 +31,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settings: SettingsManager!
 
+    public weak var delegate: PumpHistoryDelegate?
+
     init(resolver: Resolver) {
         injectServices(resolver)
     }
@@ -72,6 +83,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                                         existingEvent.bolus?.amount = amount as NSDecimalNumber
                                         existingEvent.bolus?.isSMB = dose.automatic ?? true
                                         existingEvent.isUploadedToNS = false
+                                        existingEvent.isUploadedToHealth = false
 
                                         print("Updated existing event with smaller value: \(amount)")
                                     }
@@ -85,6 +97,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.bolus.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
 
                         let newBolusEntry = BolusStored(context: self.context)
                         newBolusEntry.pumpEvent = newPumpEvent
@@ -114,6 +127,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = date
                         newPumpEvent.type = PumpEvent.tempBasal.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
 
                         let newTempBasal = TempBasalStored(context: self.context)
                         newTempBasal.pumpEvent = newPumpEvent
@@ -132,6 +146,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.pumpSuspend.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
 
                     case .resume:
                         guard existingEvents.isEmpty else {
@@ -144,6 +159,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.pumpResume.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
 
                     case .rewind:
                         guard existingEvents.isEmpty else {
@@ -156,6 +172,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.rewind.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
 
                     case .prime:
                         guard existingEvents.isEmpty else {
@@ -168,6 +185,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.prime.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
 
                     case .alarm:
                         guard existingEvents.isEmpty else {
@@ -180,6 +198,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                         newPumpEvent.timestamp = event.date
                         newPumpEvent.type = PumpEvent.pumpAlarm.rawValue
                         newPumpEvent.isUploadedToNS = false
+                        newPumpEvent.isUploadedToHealth = false
                         newPumpEvent.note = event.title
 
                     default:
@@ -190,6 +209,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
                 do {
                     guard self.context.hasChanges else { return }
                     try self.context.save()
+
+                    self.delegate?.pumpHistoryHasUpdated(self)
                     debugPrint("\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
                 } catch let error as NSError {
                     debugPrint("\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
@@ -207,6 +228,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             newPumpEvent.timestamp = timestamp
             newPumpEvent.type = PumpEvent.bolus.rawValue
             newPumpEvent.isUploadedToNS = false
+            newPumpEvent.isUploadedToHealth = false
 
             // create bolus entry and specify relationship to pump event
             let newBolusEntry = BolusStored(context: self.context)
@@ -218,6 +240,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             do {
                 guard self.context.hasChanges else { return }
                 try self.context.save()
+
+                self.delegate?.pumpHistoryHasUpdated(self)
             } catch {
                 print(error.localizedDescription)
             }
@@ -411,4 +435,40 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             }.compactMap { $0 }
         }
     }
+
+    func getPumpHistoryNotYetUploadedToHealth() async -> [PumpHistoryEvent] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+
+        return await context.perform {
+            fetchedPumpEvents.map { event in
+                switch event.type {
+                case PumpEvent.bolus.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .bolus,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.bolus?.amount as Decimal?
+                    )
+                case PumpEvent.tempBasal.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .tempBasal,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.tempBasal?.rate as Decimal?
+                    )
+                default:
+                    return nil
+                }
+            }.compactMap { $0 }
+        }
+    }
 }

+ 78 - 169
FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift

@@ -19,7 +19,7 @@ protocol HealthKitManager: GlucoseSource {
     /// Save carbs to Health store
     func uploadCarbs() async
     /// Save Insulin to Health store
-    func saveIfNeeded(pumpEvents events: [PumpHistoryEvent])
+    func uploadInsulin() async
     /// Create observer for data passing beetwen Health Store and Trio
     func createBGObserver()
     /// Enable background delivering objects from Apple Health to Trio
@@ -32,7 +32,7 @@ protocol HealthKitManager: GlucoseSource {
     func deleteInsulin(syncID: String)
 }
 
-final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver, PumpHistoryObserver, CarbsStoredDelegate {
+final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsStoredDelegate, PumpHistoryDelegate {
     private enum Config {
         // unwraped HKObjects
         static var readPermissions: Set<HKSampleType> {
@@ -57,6 +57,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver, P
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var broadcaster: Broadcaster!
     @Injected() var carbsStorage: CarbsStorage!
+    @Injected() var pumpHistoryStorage: PumpHistoryStorage!
 
     private var backgroundContext = CoreDataStack.shared.newTaskContext()
 
@@ -67,6 +68,13 @@ final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver, P
         }
     }
 
+    func pumpHistoryHasUpdated(_: BasePumpHistoryStorage) {
+        Task.detached { [weak self] in
+            guard let self = self else { return }
+            await self.uploadInsulin()
+        }
+    }
+
     private let processQueue = DispatchQueue(label: "BaseHealthKitManager.processQueue")
     private var lifetime = Lifetime()
 
@@ -124,10 +132,8 @@ final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver, P
         guard isAvailableOnCurrentDevice,
               Config.healthBGObject != nil else { return }
 
-        broadcaster.register(CarbsObserver.self, observer: self)
-        broadcaster.register(PumpHistoryObserver.self, observer: self)
-
         carbsStorage.delegate = self
+        pumpHistoryStorage.delegate = self
 
         debug(.service, "HealthKitManager did create")
     }
@@ -313,13 +319,13 @@ final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver, P
             }
 
             try await healthKitStore.save(samples)
-            debug(.service, "Successfully stored \(samples.count) samples in HealthKit.")
+            debug(.service, "Successfully stored \(samples.count) carb samples in HealthKit.")
 
             // After successful upload, update the isUploadedToHealth flag in Core Data
             await updateCarbsAsUploaded(carbs)
 
         } catch {
-            debug(.service, "Failed to upload samples to HealthKit: \(error.localizedDescription)")
+            debug(.service, "Failed to upload carb samples to HealthKit: \(error.localizedDescription)")
         }
     }
 
@@ -345,181 +351,88 @@ final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver, P
         }
     }
 
-    func saveIfNeeded(carbs: [CarbsEntry]) {
-        guard settingsManager.settings.useAppleHealth,
-              let sampleType = Config.healthCarbObject,
-              checkAvailabilitySave(objectTypeToHealthStore: sampleType),
-              carbs.isNotEmpty
-        else { return }
-
-        let carbsWithId = carbs.filter { c in
-            guard c.id != nil else { return false }
-            return true
-        }
-
-        func save(samples: [HKSample]) {
-            let sampleIDs = samples.compactMap(\.syncIdentifier)
-            let sampleDates = samples.map(\.startDate)
-            let samplesToSave = carbsWithId
-                .filter { !sampleIDs.contains($0.id ?? "") } // id existing in AH
-//                .filter { !sampleDates.contains($0.actualDate ?? $0.createdAt) } // not id but exactly the same datetime
-                .filter { !sampleDates.contains($0.createdAt) } // not id but exactly the same datetime
-
-                .map {
-                    HKQuantitySample(
-                        type: sampleType,
-                        quantity: HKQuantity(unit: .gram(), doubleValue: Double($0.carbs)),
-                        start: $0.actualDate ?? $0.createdAt,
-                        end: $0.actualDate ?? $0.createdAt,
-                        metadata: [
-                            HKMetadataKeySyncIdentifier: $0.id ?? "_id",
-                            HKMetadataKeySyncVersion: 1,
-                            Config.freeAPSMetaKey: true
-                        ]
-                    )
-                }
-
-            healthKitStore.save(samplesToSave) { (success: Bool, error: Error?) -> Void in
-                if !success {
-                    debug(.service, "Failed to store carb entry in HealthKit Store!")
-                    debug(.service, error?.localizedDescription ?? "Unknown error")
-                }
-            }
-        }
+    // Insulin Upload
 
-        loadSamplesFromHealth(sampleType: sampleType, completion: { samples in
-            save(samples: samples)
-        })
+    func uploadInsulin() async {
+        await uploadInsulin(pumpHistoryStorage.getPumpHistoryNotYetUploadedToHealth())
     }
 
-    func saveIfNeeded(pumpEvents events: [PumpHistoryEvent]) {
+    func uploadInsulin(_ insulin: [PumpHistoryEvent]) async {
         guard settingsManager.settings.useAppleHealth,
               let sampleType = Config.healthInsulinObject,
               checkAvailabilitySave(objectTypeToHealthStore: sampleType),
-              events.isNotEmpty
+              insulin.isNotEmpty
         else { return }
 
-        func save(bolusToModify: [InsulinBolus], bolus: [InsulinBolus], basal: [InsulinBasal]) {
-            // first step : delete the HK value
-            // second step : recreate with the new value !
-            bolusToModify.forEach { syncID in
-                let predicate = HKQuery.predicateForObjects(
-                    withMetadataKey: HKMetadataKeySyncIdentifier,
-                    operatorType: .equalTo,
-                    value: syncID.id
-                )
-                self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in
-                    guard let error = error else { return }
-                    warning(.service, "Cannot delete sample with syncID: \(syncID.id)", error: error)
-                }
-            }
-            let bolusTotal = bolus + bolusToModify
-            let bolusSamples = bolusTotal
-                .map {
-                    HKQuantitySample(
-                        type: sampleType,
-                        quantity: HKQuantity(unit: .internationalUnit(), doubleValue: Double($0.amount)),
-                        start: $0.date,
-                        end: $0.date,
-                        metadata: [
-                            HKMetadataKeyInsulinDeliveryReason: NSNumber(2),
-                            HKMetadataKeyExternalUUID: $0.id,
-                            HKMetadataKeySyncIdentifier: $0.id,
-                            HKMetadataKeySyncVersion: 1,
-                            Config.freeAPSMetaKey: true
-                        ]
-                    )
+        do {
+            let insulinSamples = insulin.compactMap { insulinSample -> HKQuantitySample? in
+                guard let insulinValue = insulinSample.amount else { return nil }
+
+                // Determine the insulin delivery reason (bolus or basal)
+                let deliveryReason: HKInsulinDeliveryReason
+                switch insulinSample.type {
+                case .bolus:
+                    deliveryReason = .bolus
+                case .tempBasal:
+                    deliveryReason = .basal
+                default:
+                    // Skip other types
+                    /// If deliveryReason is nil, the compactMap will filter this sample out preventing a crash
+                    return nil
                 }
 
-            let basalSamples = basal
-                .map {
-                    HKQuantitySample(
-                        type: sampleType,
-                        quantity: HKQuantity(unit: .internationalUnit(), doubleValue: Double($0.amount)),
-                        start: $0.startDelivery,
-                        end: $0.endDelivery,
-                        metadata: [
-                            HKMetadataKeyInsulinDeliveryReason: NSNumber(1),
-                            HKMetadataKeyExternalUUID: $0.id,
-                            HKMetadataKeySyncIdentifier: $0.id,
-                            HKMetadataKeySyncVersion: 1,
-                            Config.freeAPSMetaKey: true
-                        ]
-                    )
-                }
+                return HKQuantitySample(
+                    type: sampleType,
+                    quantity: HKQuantity(unit: .internationalUnit(), doubleValue: Double(insulinValue)),
+                    start: insulinSample.timestamp,
+                    end: insulinSample.timestamp,
+                    metadata: [
+                        HKMetadataKeyExternalUUID: insulinSample.id,
+                        HKMetadataKeySyncIdentifier: insulinSample.id,
+                        HKMetadataKeySyncVersion: 1,
+                        HKMetadataKeyInsulinDeliveryReason: deliveryReason.rawValue,
+                        Config.freeAPSMetaKey: true
+                    ]
+                )
+            }
 
-            healthKitStore.save(bolusSamples + basalSamples) { (success: Bool, error: Error?) -> Void in
-                if !success {
-                    debug(.service, "Failed to store insulin entry in HealthKit Store!")
-                    debug(.service, error?.localizedDescription ?? "Unknown error")
-                }
+            guard insulinSamples.isNotEmpty else {
+                debug(.service, "No insulin samples available for upload.")
+                return
             }
-        }
 
-        loadSamplesFromHealth(sampleType: sampleType, withIDs: events.map(\.id), completion: { samples in
-            let sampleIDs = samples.compactMap(\.syncIdentifier)
-            let bolusToModify = events
-                .filter { $0.type == .bolus && sampleIDs.contains($0.id) }
-                .compactMap { event -> InsulinBolus? in
-                    guard let amount = event.amount else { return nil }
-                    guard let sampleAmount = samples.first(where: { $0.syncIdentifier == event.id }) as? HKQuantitySample
-                    else { return nil }
-                    if Double(amount) != sampleAmount.quantity.doubleValue(for: .internationalUnit()) {
-                        return InsulinBolus(id: sampleAmount.syncIdentifier!, amount: amount, date: event.timestamp)
-                    } else { return nil }
-                }
+            // Attempt to save the insulin samples to Apple Health
+            try await healthKitStore.save(insulinSamples)
+            debug(.service, "Successfully stored \(insulinSamples.count) insulin samples in HealthKit.")
 
-            let bolus = events
-                .filter { $0.type == .bolus && !sampleIDs.contains($0.id) }
-                .compactMap { event -> InsulinBolus? in
-                    guard let amount = event.amount else { return nil }
-                    return InsulinBolus(id: event.id, amount: amount, date: event.timestamp)
-                }
-            let basalEvents = events
-                .filter { $0.type == .tempBasal && !sampleIDs.contains($0.id) }
-                .sorted(by: { $0.timestamp < $1.timestamp })
-            let basal = basalEvents.enumerated()
-                .compactMap { item -> InsulinBasal? in
-                    let nextElementEventIndex = item.offset + 1
-                    guard basalEvents.count > nextElementEventIndex else { return nil }
-
-                    var minimalDose = self.settingsManager.preferences.bolusIncrement
-                    if (minimalDose != 0.05) || (minimalDose != 0.025) {
-                        minimalDose = Decimal(0.05)
-                    }
-
-                    let nextBasalEvent = basalEvents[nextElementEventIndex]
-                    let secondsOfCurrentBasal = nextBasalEvent.timestamp.timeIntervalSince(item.element.timestamp)
-                    let amount = Decimal(secondsOfCurrentBasal / 3600) * (item.element.rate ?? 0)
-                    let incrementsRaw = amount / minimalDose
-
-                    var amountRounded: Decimal
-                    if incrementsRaw >= 1 {
-                        let incrementsRounded = floor(Double(incrementsRaw))
-                        amountRounded = Decimal(round(incrementsRounded * Double(minimalDose) * 100_000.0) / 100_000.0)
-                    } else {
-                        amountRounded = 0
-                    }
-
-                    let id = String(item.element.id.dropFirst())
-                    guard amountRounded > 0,
-                          id != ""
-                    else { return nil }
-
-                    return InsulinBasal(
-                        id: id,
-                        amount: amountRounded,
-                        startDelivery: item.element.timestamp,
-                        endDelivery: nextBasalEvent.timestamp
-                    )
-                }
+            // After successful upload, update the isUploadedToHealth flag in Core Data
+            await updateInsulinAsUploaded(insulin)
 
-            save(bolusToModify: bolusToModify, bolus: bolus, basal: basal)
-        })
+        } catch {
+            debug(.service, "Failed to upload insulin samples to HealthKit: \(error.localizedDescription)")
+        }
     }
 
-    func pumpHistoryDidUpdate(_ events: [PumpHistoryEvent]) {
-        saveIfNeeded(pumpEvents: events)
+    private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
+        await backgroundContext.perform {
+            let ids = insulin.map(\.id) as NSArray
+            let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
+            fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
+
+            do {
+                let results = try self.backgroundContext.fetch(fetchRequest)
+                for result in results {
+                    result.isUploadedToHealth = true
+                }
+
+                guard self.backgroundContext.hasChanges else { return }
+                try self.backgroundContext.save()
+            } catch let error as NSError {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
+                )
+            }
+        }
     }
 
     func createBGObserver() {
@@ -769,10 +682,6 @@ final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver, P
         }
     }
 
-    func carbsDidUpdate(_ carbs: [CarbsEntry]) {
-        saveIfNeeded(carbs: carbs)
-    }
-
     // - MARK Insulin function
 
     func deleteInsulin(syncID: String) {

+ 1 - 0
Model/Classes+Properties/PumpEventStored+CoreDataProperties.swift

@@ -8,6 +8,7 @@ public extension PumpEventStored {
 
     @NSManaged var id: String?
     @NSManaged var isUploadedToNS: Bool
+    @NSManaged var isUploadedToHealth: Bool
     @NSManaged var note: String?
     @NSManaged var timestamp: Date?
     @NSManaged var type: String?

+ 9 - 0
Model/Helper/CarbEntryStored+helper.swift

@@ -22,6 +22,15 @@ extension NSPredicate {
         )
     }
 
+    static var carbsNotYetUploadedToHealth: NSPredicate {
+        let date = Date.oneDayAgo
+        return NSPredicate(
+            format: "date >= %@ AND isUploadedToHealth == %@",
+            date as NSDate,
+            false as NSNumber
+        )
+    }
+
     static var fpusNotYetUploadedToNightscout: NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(

+ 5 - 0
Model/Helper/PumpEvent+helper.swift

@@ -82,6 +82,11 @@ extension NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(format: "timestamp >= %@ AND isUploadedToNS == %@", date as NSDate, false as NSNumber)
     }
+
+    static var pumpEventsNotYetUploadedToHealth: NSPredicate {
+        let date = Date.oneDayAgo
+        return NSPredicate(format: "timestamp >= %@ AND isUploadedToHealth == %@", date as NSDate, false as NSNumber)
+    }
 }
 
 // Declare helper structs ("data transfer objects" = DTO) to utilize parsing a flattened pump history

+ 1 - 0
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -166,6 +166,7 @@
     </entity>
     <entity name="PumpEventStored" representedClassName="PumpEventStored" syncable="YES">
         <attribute name="id" optional="YES" attributeType="String"/>
+        <attribute name="isUploadedToHealth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToNS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="note" optional="YES" attributeType="String"/>
         <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>