Просмотр исходного кода

Import Profile Settings from Nightcsout (#238)

Import Basal settings, carb ratios, sensitivities and glucose targets from Nightscout manually when tapping "Import Settings from Nightcsout"in the iAPS Nightscout settings. 

co-author @dnzxy
Jon B Mårtensson 2 лет назад
Родитель
Сommit
ee787b2938

+ 4 - 0
Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents

@@ -31,6 +31,10 @@
         <attribute name="hba1c_7" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <attribute name="hba1c_30" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
     </entity>
+    <entity name="ImportError" representedClassName="ImportError" syncable="YES" codeGenerationType="class">
+        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+        <attribute name="error" optional="YES" attributeType="String"/>
+    </entity>
     <entity name="InsulinDistribution" representedClassName="InsulinDistribution" syncable="YES" codeGenerationType="class">
         <attribute name="bolus" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>

+ 4 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -302,6 +302,7 @@
 		BF1667ADE69E4B5B111CECAE /* ManualTempBasalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C4420C9A345D46D90D06C /* ManualTempBasalProvider.swift */; };
 		C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; };
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
+		CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; };
 		CD78BB94E43B249D60CC1A1B /* NotificationsConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */; };
 		CE2FAD38297D69E1001A872C /* ShareClient.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE398D1A297D69A900DF218F /* ShareClient.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		CE2FAD3A297D93F0001A872C /* BloodGlucoseExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2FAD39297D93F0001A872C /* BloodGlucoseExtensions.swift */; };
@@ -816,6 +817,7 @@
 		C19984D62EFC0035A9E9644D /* BolusProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusProvider.swift; sourceTree = "<group>"; };
 		C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalRootView.swift; sourceTree = "<group>"; };
 		C8D1A7CA8C10C4403D4BBFA7 /* BolusDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusDataFlow.swift; sourceTree = "<group>"; };
+		CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawFetchedProfile.swift; sourceTree = "<group>"; };
 		CE2FAD39297D93F0001A872C /* BloodGlucoseExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloodGlucoseExtensions.swift; sourceTree = "<group>"; };
 		CE398D012977349800DF218F /* CryptoKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoKit.framework; path = System/Library/Frameworks/CryptoKit.framework; sourceTree = SDKROOT; };
 		CE398D15297C9D1D00DF218F /* dexcomSourceG7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dexcomSourceG7.swift; sourceTree = "<group>"; };
@@ -1603,6 +1605,7 @@
 				19D4E4EA29FC6A9F00351451 /* TIRforChart.swift */,
 				19A910352A24D6D700C8951B /* DateFilter.swift */,
 				193F6CDC2A512C8F001240FD /* Loops.swift */,
+				CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */,
 			);
 			path = Models;
 			sourceTree = "<group>";
@@ -2689,6 +2692,7 @@
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				CE398D16297C9D1D00DF218F /* dexcomSourceG7.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
+				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
 				385CEA8225F23DFD002D6D5B /* NightscoutStatus.swift in Sources */,
 				F90692AA274B7AAE0037068D /* HealthKitManager.swift in Sources */,
 				38887CCE25F5725200944304 /* IOBEntry.swift in Sources */,

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
FreeAPS/Resources/javascript/bundle/determine-basal.js


+ 2 - 1
FreeAPS/Sources/APS/OpenAPS/Constants.swift

@@ -54,7 +54,6 @@ extension OpenAPS {
         static let iob = "monitor/iob.json"
         static let cgmState = "monitor/cgm-state.json"
         static let podAge = "monitor/pod-age.json"
-        // static let tdd = "monitor/tdd.json"
         static let oref2_variables = "monitor/oref2_variables.json"
         static let alertHistory = "monitor/alerthistory.json"
         static let statistics = "monitor/statistics.json"
@@ -86,6 +85,8 @@ extension OpenAPS {
         static let uploadedCGMState = "upload/uploaded-cgm-state.json"
         static let uploadedPodAge = "upload/uploaded-pod-age.json"
         static let uploadedProfile = "upload/uploaded-profile.json"
+        static let profile = "fetched/profile.json"
+        static let test = "upload/test.json"
     }
 
     enum FreeAPS {

+ 30 - 0
FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings

@@ -314,6 +314,36 @@ Enact a temp Basal or a temp target */
 /* Allow remote control from NS */
 "Remote control" = "Remote control";
 
+/* Imported Profiles Alert */
+"\nNow please verify all of your new settings thoroughly:\n\n* Basal Settings\n * Carb Ratios\n * Glucose Targets\n * Insulin Sensitivities\n\n in iAPS Settings > Configuration.\n\nBad or invalid profile settings could have disatrous effects." = "\nNow please verify all of your new settings thoroughly:\n\n* Basal Settings\n * Carb Ratios\n * Glucose Targets\n * Insulin Sensitivities\n\n in iAPS Settings > Configuration.\n\nBad or invalid profile settings could have disatrous effects.";
+
+/* Failed Profile Import Alert */
+"\nImport failed:\n\n*" = "\nImport failed:\n\n*";
+
+/* Profile Import Alert */
+"This will replace some or all of your current pump settings. Are you sure you want to import profile settings from Nightscout?" = "This will replace some or all of your current pump settings. Are you sure you want to import profile settings from Nightscout?";
+
+/* */
+"Yes, Import" = "Yes, Import";
+
+/* */
+"Import settings from Nightscout" = "Import settings from Nightscout";
+
+/* */
+"Import settings?" = "Import settings?";
+
+/* */
+"Import from Nightscout" = "Import from Nightscout";
+
+/* */
+"Settings imported" = "Settings imported";
+
+/* Import Error */
+"Mismatching glucose units in Nightscout and Pump Settings. Import settings aborted." = "Mismatching glucose units in Nightscout and Pump Settings. Import settings aborted.";
+
+/* Import Error */
+"Can't find the default Nightscout Profile." = "Can't find the default Nightscout Profile.";
+
 /* Add Medtronic pump */
 "Add Medtronic" = "Add Medtronic";
 

Разница между файлами не показана из-за своего большого размера
+ 30 - 0
FreeAPS/Sources/Localizations/Main/sv.lproj/Localizable.strings


+ 24 - 0
FreeAPS/Sources/Models/FetchedProfile.swift

@@ -0,0 +1,24 @@
+import Foundation
+
+struct FetchedNightscoutProfileStore: JSON {
+    let _id: String
+    let defaultProfile: String
+    let startDate: String
+    let mills: Decimal
+    let enteredBy: String
+    let store: [String: ScheduledNightscoutProfile]
+    let created_at: String
+}
+
+struct FetchedNightscoutProfile: JSON {
+    let dia: Decimal
+    let carbs_hr: Int
+    let delay: Decimal
+    let timezone: String
+    let target_low: [NightscoutTimevalue]
+    let target_high: [NightscoutTimevalue]
+    let sens: [NightscoutTimevalue]
+    let basal: [NightscoutTimevalue]
+    let carbratio: [NightscoutTimevalue]
+    let units: String
+}

+ 1 - 1
FreeAPS/Sources/Models/NightscoutStatus.swift

@@ -29,7 +29,7 @@ struct Uploader: JSON {
 struct NightscoutTimevalue: JSON {
     let time: String
     let value: Decimal
-    let timeAsSeconds: Int
+    let timeAsSeconds: Int?
 }
 
 struct ScheduledNightscoutProfile: JSON {

+ 24 - 0
FreeAPS/Sources/Models/RawFetchedProfile.swift

@@ -0,0 +1,24 @@
+import Foundation
+
+struct FetchedNightscoutProfileStore: JSON {
+    let _id: String
+    let defaultProfile: String
+    let startDate: String
+    let mills: Decimal
+    let enteredBy: String
+    let store: [String: ScheduledNightscoutProfile]
+    let created_at: String
+}
+
+struct FetchedNightscoutProfile: JSON {
+    let dia: Decimal
+    let carbs_hr: Int
+    let delay: Decimal
+    let timezone: String
+    let target_low: [NightscoutTimevalue]
+    let target_high: [NightscoutTimevalue]
+    let sens: [NightscoutTimevalue]
+    let basal: [NightscoutTimevalue]
+    let carbratio: [NightscoutTimevalue]
+    let units: String
+}

+ 161 - 0
FreeAPS/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -1,5 +1,6 @@
 import CGMBLEKit
 import Combine
+import CoreData
 import G7SensorKit
 import SwiftDate
 import SwiftUI
@@ -11,6 +12,9 @@ extension NightscoutConfig {
         @Injected() private var glucoseStorage: GlucoseStorage!
         @Injected() private var healthKitManager: HealthKitManager!
         @Injected() private var cgmManager: FetchGlucoseManager!
+        @Injected() private var storage: FileStorage!
+
+        let coredataContext = CoreDataStack.shared.persistentContainer.viewContext
 
         @Published var url = ""
         @Published var secret = ""
@@ -22,10 +26,12 @@ extension NightscoutConfig {
         @Published var uploadGlucose = true // Upload Glucose
         @Published var useLocalSource = false
         @Published var localPort: Decimal = 0
+        @Published var units: GlucoseUnits = .mmolL
 
         override func subscribe() {
             url = keychain.getValue(String.self, forKey: Config.urlKey) ?? ""
             secret = keychain.getValue(String.self, forKey: Config.secretKey) ?? ""
+            units = settingsManager.settings.units
 
             subscribeSetting(\.isUploadEnabled, on: $isUploadEnabled) { isUploadEnabled = $0 }
             subscribeSetting(\.useLocalGlucoseSource, on: $useLocalSource) { useLocalSource = $0 }
@@ -68,6 +74,161 @@ extension NightscoutConfig {
                 .store(in: &lifetime)
         }
 
+        private var nightscoutAPI: NightscoutAPI? {
+            guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
+                  let url = URL(string: urlString),
+                  let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
+            else {
+                return nil
+            }
+            return NightscoutAPI(url: url, secret: secret)
+        }
+
+        func importSettings() {
+            guard let nightscout = nightscoutAPI else {
+                saveError("Can't access nightscoutAPI")
+                return
+            }
+            let group = DispatchGroup()
+            group.enter()
+            var error = ""
+            let path = "/api/v1/profile.json"
+            let timeout: TimeInterval = 60
+
+            var components = URLComponents()
+            components.scheme = nightscout.url.scheme
+            components.host = nightscout.url.host
+            components.port = nightscout.url.port
+            components.path = path
+            components.queryItems = [
+                URLQueryItem(name: "count", value: "1")
+            ]
+            var url = URLRequest(url: components.url!)
+            url.allowsConstrainedNetworkAccess = false
+            url.timeoutInterval = timeout
+
+            if let secret = nightscout.secret {
+                url.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
+            }
+            let task = URLSession.shared.dataTask(with: url) { data, response, error_ in
+                if let error_ = error_ {
+                    print("Error occured: " + error_.localizedDescription)
+                    // handle error
+                    self.saveError("Error occured: " + error_.localizedDescription)
+                    error = error_.localizedDescription
+                    return
+                }
+                guard let httpResponse = response as? HTTPURLResponse,
+                      (200 ... 299).contains(httpResponse.statusCode)
+                else {
+                    print("Error occured! " + error_.debugDescription)
+                    // handle error
+                    self.saveError(error_.debugDescription)
+                    return
+                }
+                let jsonDecoder = JSONCoding.decoder
+
+                if let mimeType = httpResponse.mimeType, mimeType == "application/json",
+                   let data = data
+                {
+                    do {
+                        let fetchedProfileStore = try jsonDecoder.decode([FetchedNightscoutProfileStore].self, from: data)
+                        guard let fetchedProfile: ScheduledNightscoutProfile = fetchedProfileStore.first?.store["default"]
+                        else {
+                            error = "Can't find the default Nightscout Profile."
+                            group.leave()
+                            return
+                        }
+
+                        guard fetchedProfile.units.contains(self.units.rawValue.prefix(4)) else {
+                            debug(
+                                .nightscout,
+                                "Mismatching glucose units in Nightscout and Pump Settings. Import settings aborted."
+                            )
+                            error = "Mismatching glucose units in Nightscout and Pump Settings. Import settings aborted."
+                            group.leave()
+                            return
+                        }
+
+                        let carbratios = fetchedProfile.carbratio
+                            .map { carbratio -> CarbRatioEntry in
+                                CarbRatioEntry(
+                                    start: carbratio.time,
+                                    offset: (carbratio.timeAsSeconds ?? self.offset(carbratio.time)) / 60,
+                                    ratio: carbratio.value
+                                ) }
+                        let carbratiosProfile = CarbRatios(units: CarbUnit.grams, schedule: carbratios)
+
+                        let basals = fetchedProfile.basal
+                            .map { basal -> BasalProfileEntry in
+                                BasalProfileEntry(
+                                    start: basal.time,
+                                    minutes: (basal.timeAsSeconds ?? self.offset(basal.time)) / 60,
+                                    rate: basal.value
+                                ) }
+
+                        let sensitivities = fetchedProfile.sens.map { sensitivity -> InsulinSensitivityEntry in
+                            InsulinSensitivityEntry(
+                                sensitivity: self.units == .mmolL ? sensitivity.value : sensitivity.value.asMgdL,
+                                offset: (sensitivity.timeAsSeconds ?? self.offset(sensitivity.time)) / 60,
+                                start: sensitivity.time
+                            ) }
+                        let sensitivitiesProfile = InsulinSensitivities(
+                            units: self.units,
+                            userPrefferedUnits: self.units,
+                            sensitivities: sensitivities
+                        )
+
+                        let targets = fetchedProfile.target_low
+                            .map { target -> BGTargetEntry in
+                                BGTargetEntry(
+                                    low: self.units == .mmolL ? target.value : target.value.asMgdL,
+                                    high: self.units == .mmolL ? target.value : target.value.asMgdL,
+                                    start: target.time,
+                                    offset: (target.timeAsSeconds ?? self.offset(target.time)) / 60
+                                ) }
+                        let targetsProfile = BGTargets(
+                            units: self.units,
+                            userPrefferedUnits: self.units,
+                            targets: targets
+                        )
+
+                        self.storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
+                        self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
+                        self.storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
+                        self.storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
+
+                        group.leave()
+
+                    } catch let parsingError {
+                        print(parsingError)
+                    }
+                }
+            }
+            task.resume()
+            group.wait(wallTimeout: .now() + 5)
+            group.notify(queue: .global(qos: .background)) {
+                self.saveError(error)
+            }
+        }
+
+        func offset(_ string: String) -> Int {
+            let hours = Int(string.prefix(2)) ?? 0
+            let minutes = Int(string.suffix(2)) ?? 0
+            return hours * 60 + minutes * 60
+        }
+
+        func saveError(_ string: String) {
+            coredataContext.performAndWait {
+                let saveToCoreData = ImportError(context: self.coredataContext)
+                saveToCoreData.date = Date()
+                saveToCoreData.error = string
+                if coredataContext.hasChanges {
+                    try? coredataContext.save()
+                }
+            }
+        }
+
         func backfillGlucose() {
             backfilling = true
             nightscoutManager.fetchGlucose(since: Date().addingTimeInterval(-1.days.timeInterval))

+ 61 - 3
FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift

@@ -1,3 +1,4 @@
+import CoreData
 import SwiftUI
 import Swinject
 
@@ -5,6 +6,16 @@ extension NightscoutConfig {
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
+        @State var importAlert: Alert?
+        @State var isImportAlertPresented = false
+        @State var importedHasRun = false
+
+        @FetchRequest(
+            entity: ImportError.entity(),
+            sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)], predicate: NSPredicate(
+                format: "date > %@", Date().addingTimeInterval(-1.minutes.timeInterval) as NSDate
+            )
+        ) var fetchedErrors: FetchedResults<ImportError>
 
         private var portFormater: NumberFormatter {
             let formatter = NumberFormatter()
@@ -53,14 +64,58 @@ extension NightscoutConfig {
                     Text("Allow Uploads")
                 }
 
-                Section(header: Text("Local glucose source")) {
+                Section {
+                    Button("Import settings from Nightscout") {
+                        importAlert = Alert(
+                            title: Text("Import settings?"),
+                            message: Text(
+                                "\n" +
+                                    NSLocalizedString(
+                                        "This will replace some or all of your current pump settings. Are you sure you want to import profile settings from Nightscout?",
+                                        comment: "Profile Import Alert"
+                                    ) +
+                                    "\n"
+                            ),
+                            primaryButton: .destructive(
+                                Text("Yes, Import"),
+                                action: {
+                                    state.importSettings()
+                                    importedHasRun = true
+                                }
+                            ),
+                            secondaryButton: .cancel()
+                        )
+                        isImportAlertPresented.toggle()
+                    }.disabled(state.url.isEmpty || state.connecting)
+
+                } header: { Text("Import from Nightscout") }
+
+                    .alert(isPresented: $importedHasRun) {
+                        Alert(
+                            title: Text("Settings imported"),
+                            message: Text(
+                                (fetchedErrors.first?.error ?? "").count < 4 ?
+                                    NSLocalizedString(
+                                        "\nNow please verify all of your new settings thoroughly:\n\n* Basal Settings\n * Carb Ratios\n * Glucose Targets\n * Insulin Sensitivities\n\n in iAPS Settings > Configuration.\n\nBad or invalid profile settings could have disatrous effects.",
+                                        comment: "Imported Profiles Alert"
+                                    ) :
+                                    NSLocalizedString("\nImport failed:\n\n*", comment: "Failed Profile Import Alert") +
+                                    NSLocalizedString(fetchedErrors.first?.error ?? "", comment: "Import Error")
+                            ),
+                            primaryButton: .destructive(
+                                Text("OK")
+                            ),
+                            secondaryButton: .cancel()
+                        )
+                    }
+
+                Section {
                     Toggle("Use local glucose server", isOn: $state.useLocalSource)
                     HStack {
                         Text("Port")
                         DecimalTextField("", value: $state.localPort, formatter: portFormater)
                     }
-                }
-
+                } header: { Text("Local glucose source") }
                 Section {
                     Button("Backfill glucose") { state.backfillGlucose() }
                         .disabled(state.url.isEmpty || state.connecting || state.backfilling)
@@ -69,6 +124,9 @@ extension NightscoutConfig {
             .onAppear(perform: configureView)
             .navigationBarTitle("Nightscout Config")
             .navigationBarTitleDisplayMode(.automatic)
+            .alert(isPresented: $isImportAlertPresented) {
+                importAlert!
+            }
         }
     }
 }

+ 4 - 0
FreeAPS/Sources/Services/Network/NightscoutAPI.swift

@@ -1,6 +1,8 @@
 import Combine
 import CommonCrypto
 import Foundation
+import JavaScriptCore
+import Swinject
 
 class NightscoutAPI {
     init(url: URL, secret: String? = nil) {
@@ -27,6 +29,8 @@ class NightscoutAPI {
     let secret: String?
 
     private let service = NetworkService()
+
+    @Injected() private var settingsManager: SettingsManager!
 }
 
 extension NightscoutAPI {

+ 3 - 1
FreeAPS/Sources/Services/Network/NightscoutManager.swift

@@ -12,9 +12,9 @@ protocol NightscoutManager: GlucoseSource {
     func deleteCarbs(at date: Date, isFPU: Bool?, fpuID: String?, syncID: String)
     func deleteInsulin(at date: Date)
     func uploadStatus()
+    func uploadGlucose()
     func uploadStatistics(dailystat: Statistics)
     func uploadPreferences()
-    func uploadGlucose()
     func uploadProfile()
     var cgmURL: URL? { get }
 }
@@ -557,6 +557,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                     switch completion {
                     case .finished:
                         self.storage.save(glucose, as: fileToSave)
+                        debug(.nightscout, "Glucose uploaded")
                     case let .failure(error):
                         debug(.nightscout, error.localizedDescription)
                     }
@@ -586,6 +587,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                     switch completion {
                     case .finished:
                         self.storage.save(treatments, as: fileToSave)
+                        debug(.nightscout, "Treatments uploaded")
                     case let .failure(error):
                         debug(.nightscout, error.localizedDescription)
                     }

+ 1 - 1
FreeAPS/Sources/Views/TagCloudView.swift

@@ -67,7 +67,7 @@ struct TagCloudView: View {
                  textTag where textTag.contains("Autosens/Dynamic Limit:"),
                  textTag where textTag.contains("Dynamic ISF/CR"),
                  textTag where textTag.contains("Basal ratio"),
-                 textTag where textTag.contains("SMB Ratio:"):
+                 textTag where textTag.contains("SMB Ratio"):
                 return .zt
             default:
                 return .insulin