NightscoutConfigStateModel.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  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 isConnectedToNS: Bool = false
  33. @Published var isImportResultReviewPresented: Bool = false
  34. @Published var importErrors: [String] = []
  35. @Published var importStatus: ImportStatus = .finished
  36. @Published var importedInsulinActionCurve: Decimal = 6
  37. var pumpSettings: PumpSettings {
  38. provider.getPumpSettings()
  39. }
  40. var isPumpSettingUnchanged: Bool {
  41. pumpSettings.insulinActionCurve == importedInsulinActionCurve
  42. }
  43. override func subscribe() {
  44. url = keychain.getValue(String.self, forKey: Config.urlKey) ?? ""
  45. secret = keychain.getValue(String.self, forKey: Config.secretKey) ?? ""
  46. units = settingsManager.settings.units
  47. dia = settingsManager.pumpSettings.insulinActionCurve
  48. maxBasal = settingsManager.pumpSettings.maxBasal
  49. maxBolus = settingsManager.pumpSettings.maxBolus
  50. changeUploadGlucose = (cgmManager.cgmGlucoseSourceType != CGMType.plugin)
  51. subscribeSetting(\.isUploadEnabled, on: $isUploadEnabled) { isUploadEnabled = $0 }
  52. subscribeSetting(\.isDownloadEnabled, on: $isDownloadEnabled) { isDownloadEnabled = $0 }
  53. subscribeSetting(\.useLocalGlucoseSource, on: $useLocalSource) { useLocalSource = $0 }
  54. subscribeSetting(\.localGlucosePort, on: $localPort.map(Int.init)) { localPort = Decimal($0) }
  55. subscribeSetting(\.uploadGlucose, on: $uploadGlucose, initial: { uploadGlucose = $0 })
  56. importedInsulinActionCurve = pumpSettings.insulinActionCurve
  57. isConnectedToNS = nightscoutAPI != nil
  58. $isUploadEnabled
  59. .dropFirst()
  60. .removeDuplicates()
  61. .sink { [weak self] enabled in
  62. guard let self = self else { return }
  63. if enabled {
  64. debug(.nightscout, "Upload has been enabled by the user.")
  65. Task {
  66. await self.nightscoutManager.uploadProfiles()
  67. }
  68. } else {
  69. debug(.nightscout, "Upload has been disabled by the user.")
  70. }
  71. }
  72. .store(in: &lifetime)
  73. }
  74. func connect() {
  75. if let CheckURL = url.last, CheckURL == "/" {
  76. let fixedURL = url.dropLast()
  77. url = String(fixedURL)
  78. }
  79. guard let url = URL(string: url) else {
  80. message = "Invalid URL"
  81. return
  82. }
  83. connecting = true
  84. message = ""
  85. provider.checkConnection(url: url, secret: secret.isEmpty ? nil : secret)
  86. .receive(on: DispatchQueue.main)
  87. .sink { completion in
  88. switch completion {
  89. case .finished: break
  90. case let .failure(error):
  91. self.message = "Error: \(error.localizedDescription)"
  92. }
  93. self.connecting = false
  94. } receiveValue: {
  95. self.message = "Connected!"
  96. self.keychain.setValue(self.url, forKey: Config.urlKey)
  97. self.keychain.setValue(self.secret, forKey: Config.secretKey)
  98. self.connecting = true
  99. self.isConnectedToNS = self.nightscoutAPI != nil
  100. }
  101. .store(in: &lifetime)
  102. }
  103. private var nightscoutAPI: NightscoutAPI? {
  104. guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
  105. let url = URL(string: urlString),
  106. let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
  107. else {
  108. return nil
  109. }
  110. return NightscoutAPI(url: url, secret: secret)
  111. }
  112. private func getMedianTarget(
  113. lowTargetValue: Decimal,
  114. lowTargetTime: String,
  115. highTarget: [NightscoutTimevalue],
  116. units: GlucoseUnits
  117. ) -> Decimal {
  118. if let idx = highTarget.firstIndex(where: { $0.time == lowTargetTime }) {
  119. let median = (lowTargetValue + highTarget[idx].value) / 2
  120. switch units {
  121. case .mgdL:
  122. return Decimal(round(Double(median)))
  123. case .mmolL:
  124. return Decimal(round(Double(median) * 10) / 10)
  125. }
  126. }
  127. return lowTargetValue
  128. }
  129. func correctUnitParsingOffsets(_ parsedValue: Decimal) -> Decimal {
  130. Int(parsedValue) % 2 == 0 ? parsedValue : parsedValue + 1
  131. }
  132. func importSettings() async {
  133. importStatus = .running
  134. do {
  135. guard let fetchedProfile = await nightscoutManager.importSettings() else {
  136. importStatus = .failed
  137. throw NSError(
  138. domain: "ImportError",
  139. code: 1,
  140. userInfo: [NSLocalizedDescriptionKey: "Cannot find the default Nightscout Profile."]
  141. )
  142. }
  143. // determine, i.e. guesstimate, whether fetched values are mmol/L or mg/dL values
  144. let shouldConvertToMgdL = fetchedProfile.units.contains("mmol") || fetchedProfile.target_low
  145. .contains(where: { $0.value <= 39 }) || fetchedProfile.target_high.contains(where: { $0.value <= 39 })
  146. // Carb Ratios
  147. let carbratios = fetchedProfile.carbratio.map { carbratio in
  148. CarbRatioEntry(
  149. start: carbratio.time,
  150. offset: offset(carbratio.time) / 60,
  151. ratio: carbratio.value
  152. )
  153. }
  154. if carbratios.contains(where: { $0.ratio <= 0 }) {
  155. importStatus = .failed
  156. throw NSError(
  157. domain: "ImportError",
  158. code: 2,
  159. userInfo: [NSLocalizedDescriptionKey: "Invalid Carb Ratio settings in Nightscout. Import aborted."]
  160. )
  161. }
  162. let carbratiosProfile = CarbRatios(units: .grams, schedule: carbratios)
  163. // Basal Profile
  164. let pumpName = apsManager.pumpName.value
  165. let basals = fetchedProfile.basal.map { basal in
  166. BasalProfileEntry(
  167. start: basal.time,
  168. minutes: offset(basal.time) / 60,
  169. rate: basal.value
  170. )
  171. }
  172. if pumpName != "Omnipod DASH", basals.contains(where: { $0.rate <= 0 }) {
  173. importStatus = .failed
  174. throw NSError(
  175. domain: "ImportError",
  176. code: 3,
  177. userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Import aborted."]
  178. )
  179. }
  180. if pumpName == "Omnipod DASH", basals.reduce(0, { $0 + $1.rate }) <= 0 {
  181. importStatus = .failed
  182. throw NSError(
  183. domain: "ImportError",
  184. code: 4,
  185. userInfo: [
  186. NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Basal rate total cannot be 0 U/hr. Import aborted."
  187. ]
  188. )
  189. }
  190. // Sensitivities
  191. let sensitivities = fetchedProfile.sens.map { sensitivity in
  192. InsulinSensitivityEntry(
  193. sensitivity: shouldConvertToMgdL ? correctUnitParsingOffsets(sensitivity.value.asMgdL) : sensitivity
  194. .value,
  195. offset: offset(sensitivity.time) / 60,
  196. start: sensitivity.time
  197. )
  198. }
  199. if sensitivities.contains(where: { $0.sensitivity <= 0 }) {
  200. importStatus = .failed
  201. throw NSError(
  202. domain: "ImportError",
  203. code: 5,
  204. userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout insulin sensitivity profile. Import aborted."]
  205. )
  206. }
  207. let sensitivitiesProfile = InsulinSensitivities(
  208. units: .mgdL,
  209. userPreferredUnits: .mgdL,
  210. sensitivities: sensitivities
  211. )
  212. // Targets
  213. let targets = fetchedProfile.target_low.map { target in
  214. BGTargetEntry(
  215. low: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
  216. high: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
  217. start: target.time,
  218. offset: offset(target.time) / 60
  219. )
  220. }
  221. let targetsProfile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  222. // Save to storage and pump
  223. if let pump = apsManager.pumpManager {
  224. let syncValues = basals.map {
  225. RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate))
  226. }
  227. pump.syncBasalRateSchedule(items: syncValues) { result in
  228. switch result {
  229. case .success:
  230. self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
  231. self.finalizeImport(
  232. carbratiosProfile: carbratiosProfile,
  233. sensitivitiesProfile: sensitivitiesProfile,
  234. targetsProfile: targetsProfile,
  235. dia: fetchedProfile.dia
  236. )
  237. case .failure:
  238. self.importErrors.append(
  239. "Settings were imported but the basal rates could not be saved to pump (communication error)."
  240. )
  241. self.importStatus = .failed
  242. }
  243. }
  244. if importErrors.isNotEmpty, importStatus == .failed {
  245. throw NSError(
  246. domain: "ImportError",
  247. code: 6,
  248. userInfo: [
  249. NSLocalizedDescriptionKey: "Settings were imported but the basal rates could not be saved to pump (communication error)."
  250. ]
  251. )
  252. }
  253. } else {
  254. storage.save(basals, as: OpenAPS.Settings.basalProfile)
  255. finalizeImport(
  256. carbratiosProfile: carbratiosProfile,
  257. sensitivitiesProfile: sensitivitiesProfile,
  258. targetsProfile: targetsProfile,
  259. dia: fetchedProfile.dia
  260. )
  261. }
  262. } catch {
  263. DispatchQueue.main.async {
  264. self.importErrors.append(error.localizedDescription)
  265. debug(.service, "Settings import failed with error: \(error.localizedDescription)")
  266. }
  267. }
  268. }
  269. private func finalizeImport(
  270. carbratiosProfile: CarbRatios,
  271. sensitivitiesProfile: InsulinSensitivities,
  272. targetsProfile: BGTargets,
  273. dia: Decimal
  274. ) {
  275. storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
  276. storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
  277. storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
  278. // Save DIA if different
  279. if dia != self.dia, dia >= 0 {
  280. let file = PumpSettings(insulinActionCurve: dia, maxBolus: maxBolus, maxBasal: maxBasal)
  281. storage.save(file, as: OpenAPS.Settings.settings)
  282. debug(.nightscout, "DIA setting updated to \(dia) after a NS import.")
  283. }
  284. debug(.service, "Settings imported successfully.")
  285. DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
  286. // stop blur
  287. self.importStatus = .finished
  288. // display next import rewview step
  289. self.isImportResultReviewPresented = true
  290. }
  291. }
  292. func offset(_ string: String) -> Int {
  293. let hours = Int(string.prefix(2)) ?? 0
  294. let minutes = Int(string.suffix(2)) ?? 0
  295. return ((hours * 60) + minutes) * 60
  296. }
  297. func backfillGlucose() async {
  298. backfilling = true
  299. let glucose = await nightscoutManager.fetchGlucose(since: Date().addingTimeInterval(-1.days.timeInterval))
  300. if glucose.isNotEmpty {
  301. await MainActor.run {
  302. self.backfilling = false
  303. }
  304. glucoseStorage.storeGlucose(glucose)
  305. Task.detached {
  306. await self.healthKitManager.uploadGlucose()
  307. }
  308. } else {
  309. await MainActor.run {
  310. self.backfilling = false
  311. debug(.nightscout, "No glucose values found or fetched to backfill.")
  312. }
  313. }
  314. }
  315. func delete() {
  316. keychain.removeObject(forKey: Config.urlKey)
  317. keychain.removeObject(forKey: Config.secretKey)
  318. url = ""
  319. secret = ""
  320. isConnectedToNS = false
  321. }
  322. func saveReviewedInsulinAction() {
  323. if !isPumpSettingUnchanged {
  324. let settings = PumpSettings(
  325. insulinActionCurve: importedInsulinActionCurve,
  326. maxBolus: pumpSettings.maxBolus,
  327. maxBasal: pumpSettings.maxBasal
  328. )
  329. provider.savePumpSettings(settings: settings)
  330. .receive(on: DispatchQueue.main)
  331. .sink { _ in
  332. let settings = self.provider.getPumpSettings()
  333. self.importedInsulinActionCurve = settings.insulinActionCurve
  334. Task.detached(priority: .low) {
  335. debug(.nightscout, "Attempting to upload DIA to Nightscout after import review")
  336. await self.nightscoutManager.uploadProfiles()
  337. }
  338. } receiveValue: {}
  339. .store(in: &lifetime)
  340. }
  341. }
  342. }
  343. }
  344. extension NightscoutConfig.StateModel: SettingsObserver {
  345. func settingsDidChange(_: FreeAPSSettings) {
  346. units = settingsManager.settings.units
  347. }
  348. }
  349. extension NightscoutConfig.StateModel {
  350. enum ImportStatus {
  351. case running
  352. case finished
  353. case failed
  354. case noPumpConnected
  355. }
  356. }