NightscoutConfigStateModel.swift 14 KB

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