NightscoutConfigStateModel.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import Combine
  2. import CoreData
  3. import G7SensorKit
  4. import LoopKit
  5. import SwiftDate
  6. import SwiftUI
  7. extension NightscoutConfig {
  8. final class StateModel: BaseStateModel<Provider> {
  9. @Injected() private var keychain: Keychain!
  10. @Injected() private var nightscoutManager: NightscoutManager!
  11. @Injected() private var glucoseStorage: GlucoseStorage!
  12. @Injected() private var healthKitManager: HealthKitManager!
  13. @Injected() private var cgmManager: FetchGlucoseManager!
  14. @Injected() private var storage: FileStorage!
  15. @Injected() var apsManager: APSManager!
  16. let coredataContext = CoreDataStack.shared.newTaskContext()
  17. @Published var url = ""
  18. @Published var secret = ""
  19. @Published var message = ""
  20. @Published var connecting = false
  21. @Published var backfilling = false
  22. @Published var isUploadEnabled = false // Allow uploads
  23. @Published var isDownloadEnabled = false // Allow downloads
  24. @Published var uploadGlucose = true // Upload Glucose
  25. @Published var changeUploadGlucose = true // if plugin, need to be change in CGM configuration
  26. @Published var useLocalSource = false
  27. @Published var localPort: Decimal = 0
  28. @Published var units: GlucoseUnits = .mgdL
  29. @Published var dia: Decimal = 6
  30. @Published var maxBasal: Decimal = 2
  31. @Published var maxBolus: Decimal = 10
  32. @Published var allowAnnouncements: Bool = false
  33. @Published var isConnectedToNS: Bool = false
  34. override func subscribe() {
  35. url = keychain.getValue(String.self, forKey: Config.urlKey) ?? ""
  36. secret = keychain.getValue(String.self, forKey: Config.secretKey) ?? ""
  37. units = settingsManager.settings.units
  38. dia = settingsManager.pumpSettings.insulinActionCurve
  39. maxBasal = settingsManager.pumpSettings.maxBasal
  40. maxBolus = settingsManager.pumpSettings.maxBolus
  41. changeUploadGlucose = (cgmManager.cgmGlucoseSourceType != CGMType.plugin)
  42. subscribeSetting(\.allowAnnouncements, on: $allowAnnouncements) { allowAnnouncements = $0 }
  43. subscribeSetting(\.isUploadEnabled, on: $isUploadEnabled) { isUploadEnabled = $0 }
  44. subscribeSetting(\.isDownloadEnabled, on: $isDownloadEnabled) { isDownloadEnabled = $0 }
  45. subscribeSetting(\.useLocalGlucoseSource, on: $useLocalSource) { useLocalSource = $0 }
  46. subscribeSetting(\.localGlucosePort, on: $localPort.map(Int.init)) { localPort = Decimal($0) }
  47. subscribeSetting(\.uploadGlucose, on: $uploadGlucose, initial: { uploadGlucose = $0 })
  48. isConnectedToNS = nightscoutAPI != nil
  49. }
  50. func connect() {
  51. if let CheckURL = url.last, CheckURL == "/" {
  52. let fixedURL = url.dropLast()
  53. url = String(fixedURL)
  54. }
  55. guard let url = URL(string: url) else {
  56. message = "Invalid URL"
  57. return
  58. }
  59. connecting = true
  60. message = ""
  61. provider.checkConnection(url: url, secret: secret.isEmpty ? nil : secret)
  62. .receive(on: DispatchQueue.main)
  63. .sink { completion in
  64. switch completion {
  65. case .finished: break
  66. case let .failure(error):
  67. self.message = "Error: \(error.localizedDescription)"
  68. }
  69. self.connecting = false
  70. } receiveValue: {
  71. self.message = "Connected!"
  72. self.keychain.setValue(self.url, forKey: Config.urlKey)
  73. self.keychain.setValue(self.secret, forKey: Config.secretKey)
  74. self.connecting = true
  75. self.isConnectedToNS = self.nightscoutAPI != nil
  76. }
  77. .store(in: &lifetime)
  78. }
  79. private var nightscoutAPI: NightscoutAPI? {
  80. guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
  81. let url = URL(string: urlString),
  82. let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
  83. else {
  84. return nil
  85. }
  86. return NightscoutAPI(url: url, secret: secret)
  87. }
  88. private func getMedianTarget(
  89. lowTargetValue: Decimal,
  90. lowTargetTime: String,
  91. highTarget: [NightscoutTimevalue],
  92. units: GlucoseUnits
  93. ) -> Decimal {
  94. if let idx = highTarget.firstIndex(where: { $0.time == lowTargetTime }) {
  95. let median = (lowTargetValue + highTarget[idx].value) / 2
  96. switch units {
  97. case .mgdL:
  98. return Decimal(round(Double(median)))
  99. case .mmolL:
  100. return Decimal(round(Double(median) * 10) / 10)
  101. }
  102. }
  103. return lowTargetValue
  104. }
  105. func importSettings() async {
  106. do {
  107. guard let fetchedProfile = await nightscoutManager.importSettings() else {
  108. throw NSError(
  109. domain: "ImportError",
  110. code: 1,
  111. userInfo: [NSLocalizedDescriptionKey: "Can't find the default Nightscout Profile."]
  112. )
  113. }
  114. let shouldConvertToMgdL = fetchedProfile.units.contains("mmol")
  115. // Carb Ratios
  116. let carbratios = fetchedProfile.carbratio.map { carbratio in
  117. CarbRatioEntry(
  118. start: carbratio.time,
  119. offset: offset(carbratio.time) / 60,
  120. ratio: carbratio.value
  121. )
  122. }
  123. if carbratios.contains(where: { $0.ratio <= 0 }) {
  124. throw NSError(
  125. domain: "ImportError",
  126. code: 2,
  127. userInfo: [NSLocalizedDescriptionKey: "Invalid Carb Ratio settings in Nightscout. Import aborted."]
  128. )
  129. }
  130. let carbratiosProfile = CarbRatios(units: .grams, schedule: carbratios)
  131. // Basal Profile
  132. let pumpName = apsManager.pumpName.value
  133. let basals = fetchedProfile.basal.map { basal in
  134. BasalProfileEntry(
  135. start: basal.time,
  136. minutes: offset(basal.time) / 60,
  137. rate: basal.value
  138. )
  139. }
  140. if pumpName != "Omnipod DASH", basals.contains(where: { $0.rate <= 0 }) {
  141. throw NSError(
  142. domain: "ImportError",
  143. code: 3,
  144. userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout Basal Settings. Import aborted."]
  145. )
  146. }
  147. if pumpName == "Omnipod DASH", basals.reduce(0, { $0 + $1.rate }) <= 0 {
  148. throw NSError(
  149. domain: "ImportError",
  150. code: 4,
  151. userInfo: [
  152. NSLocalizedDescriptionKey: "Total Basal insulin amount is 0 or lower in Nightscout Profile settings. Import aborted."
  153. ]
  154. )
  155. }
  156. // Sensitivities
  157. let sensitivities = fetchedProfile.sens.map { sensitivity in
  158. InsulinSensitivityEntry(
  159. sensitivity: shouldConvertToMgdL ? sensitivity.value.asMgdL : sensitivity.value,
  160. offset: offset(sensitivity.time) / 60,
  161. start: sensitivity.time
  162. )
  163. }
  164. if sensitivities.contains(where: { $0.sensitivity <= 0 }) {
  165. throw NSError(
  166. domain: "ImportError",
  167. code: 5,
  168. userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout Sensitivities Settings. Import aborted."]
  169. )
  170. }
  171. let sensitivitiesProfile = InsulinSensitivities(
  172. units: .mgdL,
  173. userPreferredUnits: .mgdL,
  174. sensitivities: sensitivities
  175. )
  176. // Targets
  177. let targets = fetchedProfile.target_low.map { target in
  178. BGTargetEntry(
  179. low: shouldConvertToMgdL ? target.value.asMgdL : target.value,
  180. high: shouldConvertToMgdL ? target.value.asMgdL : target.value,
  181. start: target.time,
  182. offset: offset(target.time) / 60
  183. )
  184. }
  185. let targetsProfile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  186. // Save to storage and pump
  187. if let pump = apsManager.pumpManager {
  188. let syncValues = basals.map {
  189. RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate))
  190. }
  191. pump.syncBasalRateSchedule(items: syncValues) { result in
  192. switch result {
  193. case .success:
  194. self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
  195. self.finalizeImport(
  196. carbratiosProfile: carbratiosProfile,
  197. sensitivitiesProfile: sensitivitiesProfile,
  198. targetsProfile: targetsProfile,
  199. dia: fetchedProfile.dia
  200. )
  201. case .failure:
  202. self.saveError(
  203. "Settings were imported but the Basals couldn't be saved to pump (communication error)."
  204. )
  205. }
  206. }
  207. } else {
  208. storage.save(basals, as: OpenAPS.Settings.basalProfile)
  209. finalizeImport(
  210. carbratiosProfile: carbratiosProfile,
  211. sensitivitiesProfile: sensitivitiesProfile,
  212. targetsProfile: targetsProfile,
  213. dia: fetchedProfile.dia
  214. )
  215. saveError("Settings were imported but the Basals couldn't be saved to pump (No pump).")
  216. }
  217. // Save DIA if different
  218. let dia = fetchedProfile.dia
  219. if dia != self.dia, dia >= 0 {
  220. let file = PumpSettings(insulinActionCurve: dia, maxBolus: maxBolus, maxBasal: maxBasal)
  221. storage.save(file, as: OpenAPS.Settings.settings)
  222. debug(.nightscout, "DIA setting updated to \(dia) after a NS import.")
  223. }
  224. debug(.service, "Settings imported successfully.")
  225. } catch {
  226. saveError(error.localizedDescription)
  227. debug(.service, "Settings import failed with error: \(error.localizedDescription)")
  228. }
  229. }
  230. private func finalizeImport(
  231. carbratiosProfile: CarbRatios,
  232. sensitivitiesProfile: InsulinSensitivities,
  233. targetsProfile: BGTargets,
  234. dia: Decimal
  235. ) {
  236. storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
  237. storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
  238. storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
  239. // Save DIA if different
  240. if dia != self.dia, dia >= 0 {
  241. let file = PumpSettings(insulinActionCurve: dia, maxBolus: maxBolus, maxBasal: maxBasal)
  242. storage.save(file, as: OpenAPS.Settings.settings)
  243. debug(.nightscout, "DIA setting updated to \(dia) after a NS import.")
  244. }
  245. debug(.service, "Settings imported successfully.")
  246. }
  247. func offset(_ string: String) -> Int {
  248. let hours = Int(string.prefix(2)) ?? 0
  249. let minutes = Int(string.suffix(2)) ?? 0
  250. return ((hours * 60) + minutes) * 60
  251. }
  252. func saveError(_ string: String) {
  253. coredataContext.performAndWait {
  254. let saveToCoreData = ImportError(context: self.coredataContext)
  255. saveToCoreData.date = Date()
  256. saveToCoreData.error = string
  257. if coredataContext.hasChanges {
  258. try? coredataContext.save()
  259. }
  260. }
  261. }
  262. func backfillGlucose() async {
  263. backfilling = true
  264. let glucose = await nightscoutManager.fetchGlucose(since: Date().addingTimeInterval(-1.days.timeInterval))
  265. if glucose.isNotEmpty {
  266. await MainActor.run {
  267. self.backfilling = false
  268. self.healthKitManager.saveIfNeeded(bloodGlucose: glucose)
  269. self.glucoseStorage.storeGlucose(glucose)
  270. }
  271. } else {
  272. await MainActor.run {
  273. self.backfilling = false
  274. }
  275. }
  276. }
  277. func delete() {
  278. keychain.removeObject(forKey: Config.urlKey)
  279. keychain.removeObject(forKey: Config.secretKey)
  280. url = ""
  281. secret = ""
  282. isConnectedToNS = false
  283. }
  284. }
  285. }
  286. extension NightscoutConfig.StateModel: SettingsObserver {
  287. func settingsDidChange(_: FreeAPSSettings) {
  288. units = settingsManager.settings.units
  289. }
  290. }