| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- import Combine
- import CoreData
- import G7SensorKit
- import LoopKit
- import SwiftDate
- import SwiftUI
- extension NightscoutConfig {
- final class StateModel: BaseStateModel<Provider> {
- @Injected() private var keychain: Keychain!
- @Injected() private var nightscoutManager: NightscoutManager!
- @Injected() private var glucoseStorage: GlucoseStorage!
- @Injected() private var healthKitManager: HealthKitManager!
- @Injected() private var cgmManager: FetchGlucoseManager!
- @Injected() private var storage: FileStorage!
- @Injected() var apsManager: APSManager!
- let coredataContext = CoreDataStack.shared.newTaskContext()
- @Published var url = ""
- @Published var secret = ""
- @Published var message = ""
- @Published var isValidURL: Bool = false
- @Published var connecting = false
- @Published var backfilling = false
- @Published var isUploadEnabled = false // Allow uploads
- @Published var isDownloadEnabled = false // Allow downloads
- @Published var uploadGlucose = true // Upload Glucose
- @Published var changeUploadGlucose = true // if plugin, need to be change in CGM configuration
- @Published var useLocalSource = false
- @Published var localPort: Decimal = 0
- @Published var units: GlucoseUnits = .mgdL
- @Published var dia: Decimal = 6
- @Published var maxBasal: Decimal = 2
- @Published var maxBolus: Decimal = 10
- @Published var isConnectedToNS: Bool = false
- @Published var isImportResultReviewPresented: Bool = false
- @Published var importErrors: [String] = []
- @Published var importStatus: ImportStatus = .finished
- @Published var importedInsulinActionCurve: Decimal = 6
- var pumpSettings: PumpSettings {
- provider.getPumpSettings()
- }
- var isPumpSettingUnchanged: Bool {
- pumpSettings.insulinActionCurve == importedInsulinActionCurve
- }
- override func subscribe() {
- url = keychain.getValue(String.self, forKey: Config.urlKey) ?? ""
- secret = keychain.getValue(String.self, forKey: Config.secretKey) ?? ""
- units = settingsManager.settings.units
- dia = settingsManager.pumpSettings.insulinActionCurve
- maxBasal = settingsManager.pumpSettings.maxBasal
- maxBolus = settingsManager.pumpSettings.maxBolus
- changeUploadGlucose = (cgmManager.cgmGlucoseSourceType != CGMType.plugin)
- subscribeSetting(\.isUploadEnabled, on: $isUploadEnabled) { isUploadEnabled = $0 }
- subscribeSetting(\.isDownloadEnabled, on: $isDownloadEnabled) { isDownloadEnabled = $0 }
- subscribeSetting(\.useLocalGlucoseSource, on: $useLocalSource) { useLocalSource = $0 }
- subscribeSetting(\.localGlucosePort, on: $localPort.map(Int.init)) { localPort = Decimal($0) }
- subscribeSetting(\.uploadGlucose, on: $uploadGlucose, initial: { uploadGlucose = $0 })
- importedInsulinActionCurve = pumpSettings.insulinActionCurve
- isConnectedToNS = nightscoutAPI != nil
- $isUploadEnabled
- .dropFirst()
- .removeDuplicates()
- .sink { [weak self] enabled in
- guard let self = self else { return }
- if enabled {
- debug(.nightscout, "Upload has been enabled by the user.")
- Task {
- await self.nightscoutManager.uploadProfiles()
- }
- } else {
- debug(.nightscout, "Upload has been disabled by the user.")
- }
- }
- .store(in: &lifetime)
- }
- func connect() {
- if let CheckURL = url.last, CheckURL == "/" {
- let fixedURL = url.dropLast()
- url = String(fixedURL)
- }
-
- guard let url = URL(string: url), self.url.hasPrefix("https://") else {
- message = "Invalid URL"
- isValidURL = false
- return
- }
-
- connecting = true
- isValidURL = true
- message = ""
-
- provider.checkConnection(url: url, secret: secret.isEmpty ? nil : secret)
- .receive(on: DispatchQueue.main)
- .sink { completion in
- switch completion {
- case .finished: break
- case let .failure(error):
- self.message = "Error: \(error.localizedDescription)"
- }
- self.connecting = false
- } receiveValue: {
- self.message = "Connected!"
- self.keychain.setValue(self.url, forKey: Config.urlKey)
- self.keychain.setValue(self.secret, forKey: Config.secretKey)
- self.connecting = true
- self.isConnectedToNS = self.nightscoutAPI != nil
- }
- .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)
- }
- private func getMedianTarget(
- lowTargetValue: Decimal,
- lowTargetTime: String,
- highTarget: [NightscoutTimevalue],
- units: GlucoseUnits
- ) -> Decimal {
- if let idx = highTarget.firstIndex(where: { $0.time == lowTargetTime }) {
- let median = (lowTargetValue + highTarget[idx].value) / 2
- switch units {
- case .mgdL:
- return Decimal(round(Double(median)))
- case .mmolL:
- return Decimal(round(Double(median) * 10) / 10)
- }
- }
- return lowTargetValue
- }
- func correctUnitParsingOffsets(_ parsedValue: Decimal) -> Decimal {
- Int(parsedValue) % 2 == 0 ? parsedValue : parsedValue + 1
- }
- func importSettings() async {
- importStatus = .running
- do {
- guard let fetchedProfile = await nightscoutManager.importSettings() else {
- importStatus = .failed
- throw NSError(
- domain: "ImportError",
- code: 1,
- userInfo: [NSLocalizedDescriptionKey: "Cannot find the default Nightscout Profile."]
- )
- }
- // determine, i.e. guesstimate, whether fetched values are mmol/L or mg/dL values
- let shouldConvertToMgdL = fetchedProfile.units.contains("mmol") || fetchedProfile.target_low
- .contains(where: { $0.value <= 39 }) || fetchedProfile.target_high.contains(where: { $0.value <= 39 })
- // Carb Ratios
- let carbratios = fetchedProfile.carbratio.map { carbratio in
- CarbRatioEntry(
- start: carbratio.time,
- offset: offset(carbratio.time) / 60,
- ratio: carbratio.value
- )
- }
- if carbratios.contains(where: { $0.ratio <= 0 }) {
- importStatus = .failed
- throw NSError(
- domain: "ImportError",
- code: 2,
- userInfo: [NSLocalizedDescriptionKey: "Invalid Carb Ratio settings in Nightscout. Import aborted."]
- )
- }
- let carbratiosProfile = CarbRatios(units: .grams, schedule: carbratios)
- // Basal Profile
- let pumpName = apsManager.pumpName.value
- let basals = fetchedProfile.basal.map { basal in
- BasalProfileEntry(
- start: basal.time,
- minutes: offset(basal.time) / 60,
- rate: basal.value
- )
- }
- if pumpName != "Omnipod DASH", basals.contains(where: { $0.rate <= 0 }) {
- importStatus = .failed
- throw NSError(
- domain: "ImportError",
- code: 3,
- userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Import aborted."]
- )
- }
- if pumpName == "Omnipod DASH", basals.reduce(0, { $0 + $1.rate }) <= 0 {
- importStatus = .failed
- throw NSError(
- domain: "ImportError",
- code: 4,
- userInfo: [
- NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Basal rate total cannot be 0 U/hr. Import aborted."
- ]
- )
- }
- // Sensitivities
- let sensitivities = fetchedProfile.sens.map { sensitivity in
- InsulinSensitivityEntry(
- sensitivity: shouldConvertToMgdL ? correctUnitParsingOffsets(sensitivity.value.asMgdL) : sensitivity
- .value,
- offset: offset(sensitivity.time) / 60,
- start: sensitivity.time
- )
- }
- if sensitivities.contains(where: { $0.sensitivity <= 0 }) {
- importStatus = .failed
- throw NSError(
- domain: "ImportError",
- code: 5,
- userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout insulin sensitivity profile. Import aborted."]
- )
- }
- let sensitivitiesProfile = InsulinSensitivities(
- units: .mgdL,
- userPreferredUnits: .mgdL,
- sensitivities: sensitivities
- )
- // Targets
- let targets = fetchedProfile.target_low.map { target in
- BGTargetEntry(
- low: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
- high: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
- start: target.time,
- offset: offset(target.time) / 60
- )
- }
- let targetsProfile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
- // Save to storage and pump
- if let pump = apsManager.pumpManager {
- let syncValues = basals.map {
- RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate))
- }
- pump.syncBasalRateSchedule(items: syncValues) { result in
- switch result {
- case .success:
- self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
- self.finalizeImport(
- carbratiosProfile: carbratiosProfile,
- sensitivitiesProfile: sensitivitiesProfile,
- targetsProfile: targetsProfile,
- dia: fetchedProfile.dia
- )
- case .failure:
- self.importErrors.append(
- "Settings were imported but the basal rates could not be saved to pump (communication error)."
- )
- self.importStatus = .failed
- }
- }
- if importErrors.isNotEmpty, importStatus == .failed {
- throw NSError(
- domain: "ImportError",
- code: 6,
- userInfo: [
- NSLocalizedDescriptionKey: "Settings were imported but the basal rates could not be saved to pump (communication error)."
- ]
- )
- }
- } else {
- storage.save(basals, as: OpenAPS.Settings.basalProfile)
- finalizeImport(
- carbratiosProfile: carbratiosProfile,
- sensitivitiesProfile: sensitivitiesProfile,
- targetsProfile: targetsProfile,
- dia: fetchedProfile.dia
- )
- }
- } catch {
- DispatchQueue.main.async {
- self.importErrors.append(error.localizedDescription)
- debug(.service, "Settings import failed with error: \(error.localizedDescription)")
- }
- }
- }
- private func finalizeImport(
- carbratiosProfile: CarbRatios,
- sensitivitiesProfile: InsulinSensitivities,
- targetsProfile: BGTargets,
- dia: Decimal
- ) {
- storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
- storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
- storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
- // Save DIA if different
- if dia != self.dia, dia >= 0 {
- let file = PumpSettings(insulinActionCurve: dia, maxBolus: maxBolus, maxBasal: maxBasal)
- storage.save(file, as: OpenAPS.Settings.settings)
- debug(.nightscout, "DIA setting updated to \(dia) after a NS import.")
- }
- debug(.service, "Settings imported successfully.")
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- // stop blur
- self.importStatus = .finished
- // display next import rewview step
- self.isImportResultReviewPresented = true
- }
- }
- 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 backfillGlucose() async {
- backfilling = true
- let glucose = await nightscoutManager.fetchGlucose(since: Date().addingTimeInterval(-1.days.timeInterval))
- if glucose.isNotEmpty {
- await MainActor.run {
- self.backfilling = false
- }
- glucoseStorage.storeGlucose(glucose)
- Task.detached {
- await self.healthKitManager.uploadGlucose()
- }
- } else {
- await MainActor.run {
- self.backfilling = false
- debug(.nightscout, "No glucose values found or fetched to backfill.")
- }
- }
- }
- func delete() {
- keychain.removeObject(forKey: Config.urlKey)
- keychain.removeObject(forKey: Config.secretKey)
- url = ""
- secret = ""
- isConnectedToNS = false
- }
- func saveReviewedInsulinAction() {
- if !isPumpSettingUnchanged {
- let settings = PumpSettings(
- insulinActionCurve: importedInsulinActionCurve,
- maxBolus: pumpSettings.maxBolus,
- maxBasal: pumpSettings.maxBasal
- )
- provider.savePumpSettings(settings: settings)
- .receive(on: DispatchQueue.main)
- .sink { _ in
- let settings = self.provider.getPumpSettings()
- self.importedInsulinActionCurve = settings.insulinActionCurve
- Task.detached(priority: .low) {
- debug(.nightscout, "Attempting to upload DIA to Nightscout after import review")
- await self.nightscoutManager.uploadProfiles()
- }
- } receiveValue: {}
- .store(in: &lifetime)
- }
- }
- }
- }
- extension NightscoutConfig.StateModel: SettingsObserver {
- func settingsDidChange(_: FreeAPSSettings) {
- units = settingsManager.settings.units
- }
- }
- extension NightscoutConfig.StateModel {
- enum ImportStatus {
- case running
- case finished
- case failed
- case noPumpConnected
- }
- }
|