Explorar o código

Add the writing to Apple Health of carbs and insulin

Add the writing to Apple Health of carbs and insulin. Based on the PR of @DobbyWanKenoby without the reading capabilities.
Pierre L %!s(int64=3) %!d(string=hai) anos
pai
achega
9372199431

+ 5 - 5
FreeAPS.xcodeproj/project.pbxproj

@@ -2991,7 +2991,7 @@
 				388E596625AD948E0019842D /* Release */,
 			);
 			defaultConfigurationIsVisible = 0;
-			defaultConfigurationName = Release;
+			defaultConfigurationName = Debug;
 		};
 		388E596725AD948E0019842D /* Build configuration list for PBXNativeTarget "FreeAPS" */ = {
 			isa = XCConfigurationList;
@@ -3000,7 +3000,7 @@
 				388E596925AD948E0019842D /* Release */,
 			);
 			defaultConfigurationIsVisible = 0;
-			defaultConfigurationName = Release;
+			defaultConfigurationName = Debug;
 		};
 		38E8754327554D5900975559 /* Build configuration list for PBXNativeTarget "FreeAPSWatch WatchKit Extension" */ = {
 			isa = XCConfigurationList;
@@ -3009,7 +3009,7 @@
 				38E8754227554D5900975559 /* Release */,
 			);
 			defaultConfigurationIsVisible = 0;
-			defaultConfigurationName = Release;
+			defaultConfigurationName = Debug;
 		};
 		38E8754427554D5900975559 /* Build configuration list for PBXNativeTarget "FreeAPSWatch" */ = {
 			isa = XCConfigurationList;
@@ -3018,7 +3018,7 @@
 				38E8753F27554D5900975559 /* Release */,
 			);
 			defaultConfigurationIsVisible = 0;
-			defaultConfigurationName = Release;
+			defaultConfigurationName = Debug;
 		};
 		38FCF3F425E9028E0078B0D1 /* Build configuration list for PBXNativeTarget "FreeAPSTests" */ = {
 			isa = XCConfigurationList;
@@ -3027,7 +3027,7 @@
 				38FCF3F625E9028E0078B0D1 /* Release */,
 			);
 			defaultConfigurationIsVisible = 0;
-			defaultConfigurationName = Release;
+			defaultConfigurationName = Debug;
 		};
 /* End XCConfigurationList section */
 

+ 5 - 0
FreeAPS/Sources/APS/APSManager.swift

@@ -72,6 +72,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var nightscout: NightscoutManager!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var healthKitManager: HealthKitManager!
     @Persisted(key: "lastAutotuneDate") private var lastAutotuneDate = Date()
     @Persisted(key: "lastStartLoopDate") private var lastStartLoopDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
@@ -250,6 +251,10 @@ final class BaseAPSManager: APSManager, Injectable {
     private func loopCompleted(error: Error? = nil, loopStatRecord: LoopStats) {
         isLooping.send(false)
 
+        // save AH events
+        let events = pumpHistoryStorage.recent()
+        healthKitManager.saveIfNeeded(pumpEvents: events)
+
         if let error = error {
             warning(.apsManager, "Loop failed with error: \(error.localizedDescription)")
             if let backgroundTask = backGroundTaskID {

+ 3 - 0
FreeAPS/Sources/Models/CarbsEntry.swift

@@ -1,11 +1,13 @@
 import Foundation
 
 struct CarbsEntry: JSON, Equatable, Hashable {
+    let id: UUID?
     let createdAt: Date
     let carbs: Decimal
     let enteredBy: String?
 
     static let manual = "freeaps-x"
+    static let appleHealth = "applehealth"
 
     static func == (lhs: CarbsEntry, rhs: CarbsEntry) -> Bool {
         lhs.createdAt == rhs.createdAt
@@ -18,6 +20,7 @@ struct CarbsEntry: JSON, Equatable, Hashable {
 
 extension CarbsEntry {
     private enum CodingKeys: String, CodingKey {
+        case id = "_id"
         case createdAt = "created_at"
         case carbs
         case enteredBy

+ 1 - 1
FreeAPS/Sources/Modules/AddCarbs/AddCarbsStateModel.swift

@@ -19,7 +19,7 @@ extension AddCarbs {
             }
 
             carbsStorage.storeCarbs([
-                CarbsEntry(createdAt: date, carbs: carbs, enteredBy: CarbsEntry.manual)
+                CarbsEntry(id: UUID(), createdAt: date, carbs: carbs, enteredBy: CarbsEntry.manual)
             ])
 
             if settingsManager.settings.skipBolusScreenAfterCarbs {

+ 6 - 4
FreeAPS/Sources/Modules/DataTable/DataTableDataFlow.swift

@@ -52,7 +52,7 @@ enum DataTable {
     }
 
     class Treatment: Identifiable, Hashable, Equatable {
-        let id = UUID()
+        var id: UUID
         let units: GlucoseUnits
         let type: DataType
         let date: Date
@@ -73,7 +73,8 @@ enum DataTable {
             date: Date,
             amount: Decimal? = nil,
             secondAmount: Decimal? = nil,
-            duration: Decimal? = nil
+            duration: Decimal? = nil,
+            id: UUID? = nil
         ) {
             self.units = units
             self.type = type
@@ -81,6 +82,7 @@ enum DataTable {
             self.amount = amount
             self.secondAmount = secondAmount
             self.duration = duration
+            self.id = id ?? UUID()
         }
 
         static func == (lhs: Treatment, rhs: Treatment) -> Bool {
@@ -172,7 +174,7 @@ protocol DataTableProvider: Provider {
     func tempTargets() -> [TempTarget]
     func carbs() -> [CarbsEntry]
     func glucose() -> [BloodGlucose]
-    func deleteCarbs(at date: Date)
-    func deleteInsulin(at date: Date)
+    func deleteCarbs(_ treatement: DataTable.Treatment)
+    func deleteInsulin(_ treatement: DataTable.Treatment)
     func deleteGlucose(id: String)
 }

+ 7 - 5
FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift

@@ -21,12 +21,14 @@ extension DataTable {
             carbsStorage.recent()
         }
 
-        func deleteCarbs(at date: Date) {
-            nightscoutManager.deleteCarbs(at: date)
+        func deleteCarbs(_ treatement: Treatment) {
+            nightscoutManager.deleteCarbs(at: treatement.date)
+            healthkitManager.deleteCarbs(syncID: treatement.id.uuidString)
         }
 
-        func deleteInsulin(at date: Date) {
-            nightscoutManager.deleteInsulin(at: date)
+        func deleteInsulin(_ treatement: Treatment) {
+            nightscoutManager.deleteInsulin(at: treatement.date)
+            healthkitManager.deleteInsulin(syncID: treatement.id.uuidString)
         }
 
         func glucose() -> [BloodGlucose] {
@@ -35,7 +37,7 @@ extension DataTable {
 
         func deleteGlucose(id: String) {
             glucoseStorage.removeGlucose(ids: [id])
-            healthkitManager.deleteGlucise(syncID: id)
+            healthkitManager.deleteGlucose(syncID: id)
         }
     }
 }

+ 15 - 5
FreeAPS/Sources/Modules/DataTable/DataTableStateModel.swift

@@ -26,7 +26,17 @@ extension DataTable {
                 let units = self.settingsManager.settings.units
 
                 let carbs = self.provider.carbs().map {
-                    Treatment(units: units, type: .carbs, date: $0.createdAt, amount: $0.carbs)
+                    if let id = $0.id {
+                        return Treatment(
+                            units: units,
+                            type: .carbs,
+                            date: $0.createdAt,
+                            amount: $0.carbs,
+                            id: id
+                        )
+                    } else {
+                        return Treatment(units: units, type: .carbs, date: $0.createdAt, amount: $0.carbs)
+                    }
                 }
 
                 let boluses = self.provider.pumpHistory()
@@ -90,15 +100,15 @@ extension DataTable {
             }
         }
 
-        func deleteCarbs(at date: Date) {
-            provider.deleteCarbs(at: date)
+        func deleteCarbs(_ treatment: Treatment) {
+            provider.deleteCarbs(treatment)
         }
 
-        func deleteInsulin(at date: Date) {
+        func deleteInsulin(_ treatment: Treatment) {
             unlockmanager.unlock()
                 .sink { _ in } receiveValue: { [weak self] _ in
                     guard let self = self else { return }
-                    self.provider.deleteInsulin(at: date)
+                    self.provider.deleteInsulin(treatment)
                 }
                 .store(in: &lifetime)
         }

+ 2 - 2
FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -94,7 +94,7 @@ extension DataTable {
                                 message: Text(item.amountText),
                                 primaryButton: .destructive(
                                     Text("Delete"),
-                                    action: { state.deleteCarbs(at: item.date) }
+                                    action: { state.deleteCarbs(item) }
                                 ),
                                 secondaryButton: .cancel()
                             )
@@ -116,7 +116,7 @@ extension DataTable {
                                 message: Text(item.amountText),
                                 primaryButton: .destructive(
                                     Text("Delete"),
-                                    action: { state.deleteInsulin(at: item.date) }
+                                    action: { state.deleteInsulin(item) }
                                 ),
                                 secondaryButton: .cancel()
                             )

+ 1 - 1
FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift

@@ -35,7 +35,7 @@ extension AppleHealthKit {
 
                     debug(.service, "Permission  granted HealthKitManager")
 
-                    self.healthKitManager.createObserver()
+                    self.healthKitManager.createBGObserver()
                     self.healthKitManager.enableBackgroundDelivery()
                 }
             }

+ 214 - 15
FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift

@@ -1,6 +1,7 @@
 import Combine
 import Foundation
 import HealthKit
+import LoopKit
 import LoopKitUI
 import Swinject
 
@@ -14,21 +15,35 @@ protocol HealthKitManager: GlucoseSource {
     func requestPermission(completion: ((Bool, Error?) -> Void)?)
     /// Save blood glucose to Health store (dublicate of bg will ignore)
     func saveIfNeeded(bloodGlucose: [BloodGlucose])
+    /// Save carbs to Health store (dublicate of bg will ignore)
+    func saveIfNeeded(carbs: [CarbsEntry])
+    /// Save Insulin to Health store
+    func saveIfNeeded(pumpEvents events: [PumpHistoryEvent])
     /// Create observer for data passing beetwen Health Store and FreeAPS
-    func createObserver()
+    func createBGObserver()
     /// Enable background delivering objects from Apple Health to FreeAPS
     func enableBackgroundDelivery()
     /// Delete glucose with syncID
-    func deleteGlucise(syncID: String)
+    func deleteGlucose(syncID: String)
+    /// delete carbs with syncID
+    func deleteCarbs(syncID: String)
+    /// delete insulin with syncID
+    func deleteInsulin(syncID: String)
 }
 
-final class BaseHealthKitManager: HealthKitManager, Injectable {
+final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver {
     private enum Config {
         // unwraped HKObjects
-        static var permissions: Set<HKSampleType> { Set([healthBGObject].compactMap { $0 }) }
+        static var readPermissions: Set<HKSampleType> {
+            Set([healthBGObject].compactMap { $0 }) }
+
+        static var writePermissions: Set<HKSampleType> {
+            Set([healthBGObject, healthCarbObject, healthInsulinObject].compactMap { $0 }) }
 
         // link to object in HealthKit
         static let healthBGObject = HKObjectType.quantityType(forIdentifier: .bloodGlucose)
+        static let healthCarbObject = HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
+        static let healthInsulinObject = HKObjectType.quantityType(forIdentifier: .insulinDelivery)
 
         // Meta-data key of FreeASPX data in HealthStore
         static let freeAPSMetaKey = "fromFreeAPSX"
@@ -37,6 +52,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var healthKitStore: HKHealthStore!
     @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var broadcaster: Broadcaster!
 
     private let processQueue = DispatchQueue(label: "BaseHealthKitManager.processQueue")
     private var lifetime = Lifetime()
@@ -47,22 +63,25 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     // last anchor for HKAnchoredQuery
     private var lastBloodGlucoseQueryAnchor: HKQueryAnchor? {
         set {
-            persistedAnchor = try? NSKeyedArchiver.archivedData(withRootObject: newValue as Any, requiringSecureCoding: false)
+            persistedBGAnchor = try? NSKeyedArchiver.archivedData(withRootObject: newValue as Any, requiringSecureCoding: false)
         }
         get {
-            guard let data = persistedAnchor else { return nil }
+            guard let data = persistedBGAnchor else { return nil }
             return try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? HKQueryAnchor
         }
     }
 
-    @Persisted(key: "HealthKitManagerAnchor") private var persistedAnchor: Data? = nil
+    @Persisted(key: "HealthKitManagerAnchor") private var persistedBGAnchor: Data? = nil
 
     var isAvailableOnCurrentDevice: Bool {
         HKHealthStore.isHealthDataAvailable()
     }
 
     var areAllowAllPermissions: Bool {
-        Set(Config.permissions.map { healthKitStore.authorizationStatus(for: $0) })
+        Set(Config.readPermissions.map { healthKitStore.authorizationStatus(for: $0) })
+            .intersection([.notDetermined])
+            .isEmpty &&
+            Set(Config.writePermissions.map { healthKitStore.authorizationStatus(for: $0) })
             .intersection([.sharingDenied, .notDetermined])
             .isEmpty
     }
@@ -91,8 +110,11 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         injectServices(resolver)
         guard isAvailableOnCurrentDevice,
               Config.healthBGObject != nil else { return }
-        createObserver()
+        createBGObserver()
         enableBackgroundDelivery()
+
+        broadcaster.register(CarbsObserver.self, observer: self)
+
         debug(.service, "HealthKitManager did create")
     }
 
@@ -109,12 +131,12 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
             completion?(false, HKError.notAvailableOnCurrentDevice)
             return
         }
-        guard Config.permissions.isNotEmpty else {
+        guard Config.readPermissions.isNotEmpty, Config.writePermissions.isNotEmpty else {
             completion?(false, HKError.dataNotAvailable)
             return
         }
 
-        healthKitStore.requestAuthorization(toShare: Config.permissions, read: Config.permissions) { status, error in
+        healthKitStore.requestAuthorization(toShare: Config.writePermissions, read: Config.readPermissions) { status, error in
             completion?(status, error)
         }
     }
@@ -154,7 +176,123 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
             .store(in: &lifetime)
     }
 
-    func createObserver() {
+    func saveIfNeeded(carbs: [CarbsEntry]) {
+        guard settingsManager.settings.useAppleHealth,
+              let sampleType = Config.healthCarbObject,
+              checkAvailabilitySave(objectTypeToHealthStore: sampleType),
+              carbs.isNotEmpty
+        else { return }
+
+        func save(samples: [HKSample]) {
+            let sampleIDs = samples.compactMap(\.syncIdentifier)
+            let samplesToSave = carbs
+                .filter { !sampleIDs.contains($0.id!.uuidString) }
+                .map {
+                    HKQuantitySample(
+                        type: sampleType,
+                        quantity: HKQuantity(unit: .gram(), doubleValue: Double($0.carbs)),
+                        start: $0.createdAt,
+                        end: $0.createdAt,
+                        metadata: [
+                            HKMetadataKeyExternalUUID: $0.id!.uuidString,
+                            HKMetadataKeySyncIdentifier: $0.id!.uuidString,
+                            HKMetadataKeySyncVersion: 1,
+                            Config.freeAPSMetaKey: true
+                        ]
+                    )
+                }
+
+            healthKitStore.save(samplesToSave) { _, _ in }
+        }
+
+        loadSamplesFromHealth(sampleType: sampleType, withIDs: carbs.compactMap(\.id?.uuidString))
+            .receive(on: processQueue)
+            .sink(receiveValue: save)
+            .store(in: &lifetime)
+    }
+
+    func saveIfNeeded(pumpEvents events: [PumpHistoryEvent]) {
+        guard settingsManager.settings.useAppleHealth,
+              let sampleType = Config.healthInsulinObject,
+              checkAvailabilitySave(objectTypeToHealthStore: sampleType),
+              events.isNotEmpty
+        else { return }
+
+        func save(bolus: [InsulinBolus], basal: [InsulinBasal]) {
+            let bolusSamples = bolus
+                .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
+                        ]
+                    )
+                }
+
+            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
+                        ]
+                    )
+                }
+
+            healthKitStore.save(bolusSamples + basalSamples) { _, _ in }
+        }
+
+        loadSamplesFromHealth(sampleType: sampleType, withIDs: events.map(\.id))
+            .receive(on: processQueue)
+            .compactMap { samples -> ([InsulinBolus], [InsulinBasal]) in
+                let sampleIDs = samples.compactMap(\.syncIdentifier)
+                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) }
+                let basal = basalEvents.enumerated()
+                    .compactMap { item -> InsulinBasal? in
+                        let nextElementEventIndex = item.offset + 1
+                        guard basalEvents.count > nextElementEventIndex else { return nil }
+                        let nextBasalEvent = basalEvents[nextElementEventIndex]
+                        let secondsOfCurrentBasal = nextBasalEvent.timestamp.timeIntervalSince(item.element.timestamp)
+                        let amount = Decimal(secondsOfCurrentBasal / 3600) * (item.element.rate ?? 0)
+                        let id = String(item.element.id.dropFirst())
+                        guard amount > 0,
+                              id != ""
+                        else { return nil }
+                        return InsulinBasal(
+                            id: id,
+                            amount: amount,
+                            startDelivery: item.element.timestamp,
+                            endDelivery: nextBasalEvent.timestamp
+                        )
+                    }
+                return (bolus, basal)
+            }
+            .sink(receiveValue: save)
+            .store(in: &lifetime)
+    }
+
+    func createBGObserver() {
         guard settingsManager.settings.useAppleHealth else { return }
 
         guard let bgType = Config.healthBGObject else {
@@ -243,14 +381,14 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
                 if let bgSamples = addedObjects as? [HKQuantitySample],
                    bgSamples.isNotEmpty
                 {
-                    self.prepareSamplesToPublisherFetch(bgSamples)
+                    self.prepareBGSamplesToPublisherFetch(bgSamples)
                 }
             }
         }
         return query
     }
 
-    private func prepareSamplesToPublisherFetch(_ samples: [HKQuantitySample]) {
+    private func prepareBGSamplesToPublisherFetch(_ samples: [HKQuantitySample]) {
         dispatchPrecondition(condition: .onQueue(processQueue))
         debug(.service, "Start preparing samples: \(String(describing: samples))")
 
@@ -333,7 +471,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         fetch(nil)
     }
 
-    func deleteGlucise(syncID: String) {
+    func deleteGlucose(syncID: String) {
         guard settingsManager.settings.useAppleHealth,
               let sampleType = Config.healthBGObject,
               checkAvailabilitySave(objectTypeToHealthStore: sampleType)
@@ -352,6 +490,54 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
             }
         }
     }
+
+    // - MARK Carbs function
+
+    func deleteCarbs(syncID: String) {
+        guard settingsManager.settings.useAppleHealth,
+              let sampleType = Config.healthCarbObject,
+              checkAvailabilitySave(objectTypeToHealthStore: sampleType)
+        else { return }
+
+        processQueue.async {
+            let predicate = HKQuery.predicateForObjects(
+                withMetadataKey: HKMetadataKeySyncIdentifier,
+                operatorType: .equalTo,
+                value: syncID
+            )
+
+            self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in
+                guard let error = error else { return }
+                warning(.service, "Cannot delete sample with syncID: \(syncID)", error: error)
+            }
+        }
+    }
+
+    func carbsDidUpdate(_ carbs: [CarbsEntry]) {
+        saveIfNeeded(carbs: carbs)
+    }
+
+    // - MARK Insulin function
+
+    func deleteInsulin(syncID: String) {
+        guard settingsManager.settings.useAppleHealth,
+              let sampleType = Config.healthInsulinObject,
+              checkAvailabilitySave(objectTypeToHealthStore: sampleType)
+        else { return }
+
+        processQueue.async {
+            let predicate = HKQuery.predicateForObjects(
+                withMetadataKey: HKMetadataKeySyncIdentifier,
+                operatorType: .equalTo,
+                value: syncID
+            )
+
+            self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in
+                guard let error = error else { return }
+                warning(.service, "Cannot delete sample with syncID: \(syncID)", error: error)
+            }
+        }
+    }
 }
 
 enum HealthKitPermissionRequestStatus {
@@ -365,3 +551,16 @@ enum HKError: Error {
     // Some data can be not available on current iOS-device
     case dataNotAvailable
 }
+
+private struct InsulinBolus {
+    var id: String
+    var amount: Decimal
+    var date: Date
+}
+
+private struct InsulinBasal {
+    var id: String
+    var amount: Decimal
+    var startDelivery: Date
+    var endDelivery: Date
+}

+ 1 - 1
FreeAPS/Sources/Services/WatchManager/WatchManager.swift

@@ -220,7 +220,7 @@ extension BaseWatchManager: WCSessionDelegate {
 
         if let carbs = message["carbs"] as? Double, carbs > 0 {
             carbsStorage.storeCarbs([
-                CarbsEntry(createdAt: Date(), carbs: Decimal(carbs), enteredBy: CarbsEntry.manual)
+                CarbsEntry(id: UUID(), createdAt: Date(), carbs: Decimal(carbs), enteredBy: CarbsEntry.manual)
             ])
 
             if settingsManager.settings.skipBolusScreenAfterCarbs {