NightscoutConfigStateModel.swift 18 KB

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