NightscoutConfigStateModel.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  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() {
  106. guard let nightscout = nightscoutAPI else {
  107. saveError("Can't access nightscoutAPI")
  108. return
  109. }
  110. let group = DispatchGroup()
  111. group.enter()
  112. var error = ""
  113. let path = "/api/v1/profile.json"
  114. let timeout: TimeInterval = 60
  115. var components = URLComponents()
  116. components.scheme = nightscout.url.scheme
  117. components.host = nightscout.url.host
  118. components.port = nightscout.url.port
  119. components.path = path
  120. components.queryItems = [
  121. URLQueryItem(name: "count", value: "1")
  122. ]
  123. var url = URLRequest(url: components.url!)
  124. url.allowsConstrainedNetworkAccess = false
  125. url.timeoutInterval = timeout
  126. if let secret = nightscout.secret {
  127. url.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  128. }
  129. let task = URLSession.shared.dataTask(with: url) { data, response, error_ in
  130. if let error_ = error_ {
  131. print("Error occured: " + error_.localizedDescription)
  132. // handle error
  133. self.saveError("Error occured: " + error_.localizedDescription)
  134. error = error_.localizedDescription
  135. return
  136. }
  137. guard let httpResponse = response as? HTTPURLResponse,
  138. (200 ... 299).contains(httpResponse.statusCode)
  139. else {
  140. print("Error occured! " + error_.debugDescription)
  141. // handle error
  142. self.saveError(error_.debugDescription)
  143. return
  144. }
  145. let jsonDecoder = JSONCoding.decoder
  146. if let mimeType = httpResponse.mimeType, mimeType == "application/json",
  147. let data = data
  148. {
  149. do {
  150. let fetchedProfileStore = try jsonDecoder.decode([FetchedNightscoutProfileStore].self, from: data)
  151. guard let fetchedProfile: ScheduledNightscoutProfile = fetchedProfileStore.first?.store["default"]
  152. else {
  153. error = "\nCan't find the default Nightscout Profile."
  154. group.leave()
  155. return
  156. }
  157. let shouldConvertToMgdL = fetchedProfile.units.contains("mmol")
  158. var areCRsOK = true
  159. let carbratios = fetchedProfile.carbratio
  160. .map { carbratio -> CarbRatioEntry in
  161. if carbratio.value <= 0 {
  162. error =
  163. "\nInvalid Carb Ratio settings in Nightscout.\n\nImport aborted. Please check your Nightscout Profile Carb Ratios Settings!"
  164. areCRsOK = false
  165. }
  166. return CarbRatioEntry(
  167. start: carbratio.time,
  168. offset: self.offset(carbratio.time) / 60,
  169. ratio: carbratio.value
  170. ) }
  171. let carbratiosProfile = CarbRatios(units: CarbUnit.grams, schedule: carbratios)
  172. guard areCRsOK else {
  173. group.leave()
  174. return
  175. }
  176. var areBasalsOK = true
  177. let pumpName = self.apsManager.pumpName.value
  178. let basals = fetchedProfile.basal
  179. .map { basal -> BasalProfileEntry in
  180. if pumpName != "Omnipod DASH", basal.value <= 0
  181. {
  182. error =
  183. "\nInvalid Nightcsout Basal Settings. Some or all of your basal settings are 0 U/h.\n\nImport aborted. Please check your Nightscout Profile Basal Settings before trying to import again. Import has been aborted.)"
  184. areBasalsOK = false
  185. }
  186. return BasalProfileEntry(
  187. start: basal.time,
  188. minutes: self.offset(basal.time) / 60,
  189. rate: basal.value
  190. ) }
  191. // DASH pumps can have 0U/h basal rates but don't import if total basals (24 hours) amount to 0 U.
  192. if pumpName == "Omnipod DASH", basals.map({ each in each.rate }).reduce(0, +) <= 0
  193. {
  194. error =
  195. "\nYour total Basal insulin amount to 0 U or lower in Nightscout Profile settings.\n\n Please check your Nightscout Profile Basal Settings before trying to import again. Import has been aborted.)"
  196. areBasalsOK = false
  197. }
  198. guard areBasalsOK else {
  199. group.leave()
  200. return
  201. }
  202. let sensitivities = fetchedProfile.sens.map { sensitivity -> InsulinSensitivityEntry in
  203. InsulinSensitivityEntry(
  204. sensitivity: shouldConvertToMgdL ? sensitivity.value.asMgdL : sensitivity.value,
  205. offset: self.offset(sensitivity.time) / 60,
  206. start: sensitivity.time
  207. )
  208. }
  209. if sensitivities.filter({ $0.sensitivity <= 0 }).isNotEmpty {
  210. error =
  211. "\nInvalid Nightcsout Sensitivities Settings. \n\nImport aborted. Please check your Nightscout Profile Sensitivities Settings!"
  212. group.leave()
  213. return
  214. }
  215. let sensitivitiesProfile = InsulinSensitivities(
  216. units: .mgdL,
  217. userPreferredUnits: .mgdL,
  218. sensitivities: sensitivities
  219. )
  220. let targets = fetchedProfile.target_low
  221. .map { target -> BGTargetEntry in
  222. BGTargetEntry(
  223. low: shouldConvertToMgdL ? target.value.asMgdL : target.value,
  224. high: shouldConvertToMgdL ? target.value.asMgdL : target.value,
  225. start: target.time,
  226. offset: self.offset(target.time) / 60
  227. ) }
  228. let targetsProfile = BGTargets(
  229. units: .mgdL,
  230. userPreferredUnits: .mgdL,
  231. targets: targets
  232. )
  233. // IS THERE A PUMP?
  234. guard let pump = self.apsManager.pumpManager else {
  235. self.storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
  236. self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
  237. self.storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
  238. self.storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
  239. debug(
  240. .service,
  241. "Settings were imported but the Basals couldn't be saved to pump (No pump). Check your basal settings and tap ´Save on Pump´ to sync the new basal settings"
  242. )
  243. error =
  244. "\nSettings were imported but the Basals couldn't be saved to pump (No pump). Check your basal settings and tap ´Save on Pump´ to sync the new basal settings"
  245. group.leave()
  246. return
  247. }
  248. let syncValues = basals.map {
  249. RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate))
  250. }
  251. // SSAVE TO STORAGE. SAVE TO PUMP (LoopKit)
  252. pump.syncBasalRateSchedule(items: syncValues) { result in
  253. switch result {
  254. case .success:
  255. self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
  256. self.storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
  257. self.storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
  258. self.storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
  259. debug(.service, "Settings have been imported and the Basals saved to pump!")
  260. // DIA. Save if changed.
  261. let dia = fetchedProfile.dia
  262. print("dia: " + dia.description)
  263. print("pump dia: " + self.dia.description)
  264. if dia != self.dia, dia >= 0 {
  265. let file = PumpSettings(
  266. insulinActionCurve: dia,
  267. maxBolus: self.maxBolus,
  268. maxBasal: self.maxBasal
  269. )
  270. self.storage.save(file, as: OpenAPS.Settings.settings)
  271. debug(
  272. .nightscout,
  273. "DIA setting updated to " + dia.description + " after a NS import."
  274. )
  275. }
  276. group.leave()
  277. case .failure:
  278. error =
  279. "\nSettings were imported but the Basals couldn't be saved to pump (communication error). Check your basal settings and tap ´Save on Pump´ to sync the new basal settings"
  280. debug(.service, "Basals couldn't be save to pump")
  281. group.leave()
  282. }
  283. }
  284. } catch let parsingError {
  285. print(parsingError)
  286. error = parsingError.localizedDescription
  287. group.leave()
  288. }
  289. }
  290. }
  291. task.resume()
  292. group.wait(wallTimeout: .now() + 5)
  293. group.notify(queue: .global(qos: .background)) {
  294. self.saveError(error)
  295. }
  296. }
  297. func offset(_ string: String) -> Int {
  298. let hours = Int(string.prefix(2)) ?? 0
  299. let minutes = Int(string.suffix(2)) ?? 0
  300. return ((hours * 60) + minutes) * 60
  301. }
  302. func saveError(_ string: String) {
  303. coredataContext.performAndWait {
  304. let saveToCoreData = ImportError(context: self.coredataContext)
  305. saveToCoreData.date = Date()
  306. saveToCoreData.error = string
  307. if coredataContext.hasChanges {
  308. try? coredataContext.save()
  309. }
  310. }
  311. }
  312. func backfillGlucose() async {
  313. backfilling = true
  314. let glucose = await nightscoutManager.fetchGlucose(since: Date().addingTimeInterval(-1.days.timeInterval))
  315. if glucose.isNotEmpty {
  316. await MainActor.run {
  317. self.backfilling = false
  318. self.healthKitManager.saveIfNeeded(bloodGlucose: glucose)
  319. self.glucoseStorage.storeGlucose(glucose)
  320. }
  321. } else {
  322. await MainActor.run {
  323. self.backfilling = false
  324. }
  325. }
  326. }
  327. func delete() {
  328. keychain.removeObject(forKey: Config.urlKey)
  329. keychain.removeObject(forKey: Config.secretKey)
  330. url = ""
  331. secret = ""
  332. isConnectedToNS = false
  333. }
  334. }
  335. }
  336. extension NightscoutConfig.StateModel: SettingsObserver {
  337. func settingsDidChange(_: FreeAPSSettings) {
  338. units = settingsManager.settings.units
  339. }
  340. }