NightscoutConfigStateModel.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import Combine
  2. import CoreData
  3. import LoopKit
  4. import SwiftDate
  5. import SwiftUI
  6. extension NightscoutConfig {
  7. final class StateModel: BaseStateModel<Provider> {
  8. @Injected() private var keychain: Keychain!
  9. @Injected() private var nightscoutManager: NightscoutManager!
  10. @Injected() private var glucoseStorage: GlucoseStorage!
  11. @Injected() private var healthKitManager: HealthKitManager!
  12. @Injected() private var cgmManager: FetchGlucoseManager!
  13. @Injected() private var storage: FileStorage!
  14. @Injected() var apsManager: APSManager!
  15. let coredataContext = CoreDataStack.shared.persistentContainer.viewContext
  16. @Published var url = ""
  17. @Published var secret = ""
  18. @Published var message = ""
  19. @Published var connecting = false
  20. @Published var backfilling = false
  21. @Published var isUploadEnabled = false // Allow uploads
  22. @Published var isDownloadEnabled = false // Allow downloads
  23. @Published var uploadGlucose = true // Upload Glucose
  24. @Published var changeUploadGlucose = true // if plugin, need to be change in CGM configuration
  25. @Published var useLocalSource = false
  26. @Published var localPort: Decimal = 0
  27. @Published var units: GlucoseUnits = .mmolL
  28. @Published var dia: Decimal = 6
  29. @Published var maxBasal: Decimal = 2
  30. @Published var maxBolus: Decimal = 10
  31. @Published var allowAnnouncements: Bool = false
  32. override func subscribe() {
  33. url = keychain.getValue(String.self, forKey: Config.urlKey) ?? ""
  34. secret = keychain.getValue(String.self, forKey: Config.secretKey) ?? ""
  35. units = settingsManager.settings.units
  36. dia = settingsManager.pumpSettings.insulinActionCurve
  37. maxBasal = settingsManager.pumpSettings.maxBasal
  38. maxBolus = settingsManager.pumpSettings.maxBolus
  39. changeUploadGlucose = (cgmManager.cgmGlucoseSourceType != CGMType.plugin)
  40. subscribeSetting(\.allowAnnouncements, on: $allowAnnouncements) { allowAnnouncements = $0 }
  41. subscribeSetting(\.isUploadEnabled, on: $isUploadEnabled) { isUploadEnabled = $0 }
  42. subscribeSetting(\.isDownloadEnabled, on: $isDownloadEnabled) { isDownloadEnabled = $0 }
  43. subscribeSetting(\.useLocalGlucoseSource, on: $useLocalSource) { useLocalSource = $0 }
  44. subscribeSetting(\.localGlucosePort, on: $localPort.map(Int.init)) { localPort = Decimal($0) }
  45. subscribeSetting(\.uploadGlucose, on: $uploadGlucose, initial: { uploadGlucose = $0 })
  46. }
  47. func connect() {
  48. if let CheckURL = url.last, CheckURL == "/" {
  49. let fixedURL = url.dropLast()
  50. url = String(fixedURL)
  51. }
  52. guard let url = URL(string: url) else {
  53. message = "Invalid URL"
  54. return
  55. }
  56. connecting = true
  57. message = ""
  58. provider.checkConnection(url: url, secret: secret.isEmpty ? nil : secret)
  59. .receive(on: DispatchQueue.main)
  60. .sink { completion in
  61. switch completion {
  62. case .finished: break
  63. case let .failure(error):
  64. self.message = "Error: \(error.localizedDescription)"
  65. }
  66. self.connecting = false
  67. } receiveValue: {
  68. self.message = "Connected!"
  69. self.keychain.setValue(self.url, forKey: Config.urlKey)
  70. self.keychain.setValue(self.secret, forKey: Config.secretKey)
  71. }
  72. .store(in: &lifetime)
  73. }
  74. private var nightscoutAPI: NightscoutAPI? {
  75. guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
  76. let url = URL(string: urlString),
  77. let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
  78. else {
  79. return nil
  80. }
  81. return NightscoutAPI(url: url, secret: secret)
  82. }
  83. private func getMedianTarget(
  84. lowTargetValue: Decimal,
  85. lowTargetTime: String,
  86. highTarget: [NightscoutTimevalue],
  87. units: GlucoseUnits
  88. ) -> Decimal {
  89. if let idx = highTarget.firstIndex(where: { $0.time == lowTargetTime }) {
  90. let median = (lowTargetValue + highTarget[idx].value) / 2
  91. switch units {
  92. case .mgdL:
  93. return Decimal(round(Double(median)))
  94. case .mmolL:
  95. return Decimal(round(Double(median) * 10) / 10)
  96. }
  97. }
  98. return lowTargetValue
  99. }
  100. func importSettings() {
  101. guard let nightscout = nightscoutAPI else {
  102. saveError("Can't access nightscoutAPI")
  103. return
  104. }
  105. let group = DispatchGroup()
  106. group.enter()
  107. var error = ""
  108. let path = "/api/v1/profile.json"
  109. let timeout: TimeInterval = 60
  110. var components = URLComponents()
  111. components.scheme = nightscout.url.scheme
  112. components.host = nightscout.url.host
  113. components.port = nightscout.url.port
  114. components.path = path
  115. components.queryItems = [
  116. URLQueryItem(name: "count", value: "1")
  117. ]
  118. var url = URLRequest(url: components.url!)
  119. url.allowsConstrainedNetworkAccess = false
  120. url.timeoutInterval = timeout
  121. if let secret = nightscout.secret {
  122. url.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  123. }
  124. let task = URLSession.shared.dataTask(with: url) { data, response, error_ in
  125. if let error_ = error_ {
  126. print("Error occured: " + error_.localizedDescription)
  127. // handle error
  128. self.saveError("Error occured: " + error_.localizedDescription)
  129. error = error_.localizedDescription
  130. return
  131. }
  132. guard let httpResponse = response as? HTTPURLResponse,
  133. (200 ... 299).contains(httpResponse.statusCode)
  134. else {
  135. print("Error occured! " + error_.debugDescription)
  136. // handle error
  137. self.saveError(error_.debugDescription)
  138. return
  139. }
  140. let jsonDecoder = JSONCoding.decoder
  141. if let mimeType = httpResponse.mimeType, mimeType == "application/json",
  142. let data = data
  143. {
  144. do {
  145. let fetchedProfileStore = try jsonDecoder.decode([FetchedNightscoutProfileStore].self, from: data)
  146. let loop = fetchedProfileStore.first?.enteredBy.contains("Loop")
  147. guard let fetchedProfile: FetchedNightscoutProfile =
  148. (fetchedProfileStore.first?.store["default"] != nil) ?
  149. fetchedProfileStore.first?.store["default"] :
  150. fetchedProfileStore.first?.store["Default"]
  151. else {
  152. error = "\nCan't find the default Nightscout Profile."
  153. group.leave()
  154. return
  155. }
  156. guard fetchedProfile.units.contains(self.units.rawValue.prefix(4)) else {
  157. debug(
  158. .nightscout,
  159. "Mismatching glucose units in Nightscout and Pump Settings. Import settings aborted."
  160. )
  161. error = "\nMismatching glucose units in Nightscout and Pump Settings. Import settings aborted."
  162. group.leave()
  163. return
  164. }
  165. var areCRsOK = true
  166. let carbratios = fetchedProfile.carbratio
  167. .map { carbratio -> CarbRatioEntry in
  168. if carbratio.value <= 0 {
  169. error =
  170. "\nInvalid Carb Ratio settings in Nightscout.\n\nImport aborted. Please check your Nightscout Profile Carb Ratios Settings!"
  171. areCRsOK = false
  172. }
  173. return CarbRatioEntry(
  174. start: carbratio.time,
  175. offset: self.offset(carbratio.time) / 60,
  176. ratio: carbratio.value
  177. ) }
  178. let carbratiosProfile = CarbRatios(units: CarbUnit.grams, schedule: carbratios)
  179. guard areCRsOK else {
  180. group.leave()
  181. return
  182. }
  183. var areBasalsOK = true
  184. let pumpName = self.apsManager.pumpName.value
  185. let basals = fetchedProfile.basal
  186. .map { basal -> BasalProfileEntry in
  187. if pumpName != "Omnipod DASH", basal.value <= 0
  188. {
  189. error =
  190. "\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.)"
  191. areBasalsOK = false
  192. }
  193. return BasalProfileEntry(
  194. start: basal.time,
  195. minutes: self.offset(basal.time) / 60,
  196. rate: basal.value
  197. ) }
  198. // DASH pumps can have 0U/h basal rates but don't import if total basals (24 hours) amount to 0 U.
  199. if pumpName == "Omnipod DASH", basals.map({ each in each.rate }).reduce(0, +) <= 0
  200. {
  201. error =
  202. "\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.)"
  203. areBasalsOK = false
  204. }
  205. guard areBasalsOK else {
  206. group.leave()
  207. return
  208. }
  209. let sensitivities = fetchedProfile.sens.map { sensitivity -> InsulinSensitivityEntry in
  210. InsulinSensitivityEntry(
  211. sensitivity: sensitivity.value,
  212. offset: self.offset(sensitivity.time) / 60,
  213. start: sensitivity.time
  214. )
  215. }
  216. if sensitivities.filter({ $0.sensitivity <= 0 }).isNotEmpty {
  217. error =
  218. "\nInvalid Nightcsout Sensitivities Settings. \n\nImport aborted. Please check your Nightscout Profile Sensitivities Settings!"
  219. group.leave()
  220. return
  221. }
  222. let sensitivitiesProfile = InsulinSensitivities(
  223. units: self.units,
  224. userPrefferedUnits: self.units,
  225. sensitivities: sensitivities
  226. )
  227. let targets = fetchedProfile.target_low
  228. .map { target -> BGTargetEntry in
  229. let median = loop! ? self.getMedianTarget(
  230. lowTargetValue: target.value,
  231. lowTargetTime: target.time,
  232. highTarget: fetchedProfile.target_high,
  233. units: self.units
  234. ) : target.value
  235. return BGTargetEntry(
  236. low: median,
  237. high: median,
  238. start: target.time,
  239. offset: self.offset(target.time) / 60
  240. ) }
  241. let targetsProfile = BGTargets(
  242. units: self.units,
  243. userPrefferedUnits: self.units,
  244. targets: targets
  245. )
  246. // IS THERE A PUMP?
  247. guard let pump = self.apsManager.pumpManager else {
  248. self.storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
  249. self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
  250. self.storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
  251. self.storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
  252. debug(
  253. .service,
  254. "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"
  255. )
  256. error =
  257. "\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"
  258. group.leave()
  259. return
  260. }
  261. let syncValues = basals.map {
  262. RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate))
  263. }
  264. // SSAVE TO STORAGE. SAVE TO PUMP (LoopKit)
  265. pump.syncBasalRateSchedule(items: syncValues) { result in
  266. switch result {
  267. case .success:
  268. self.storage.save(basals, as: OpenAPS.Settings.basalProfile)
  269. self.storage.save(carbratiosProfile, as: OpenAPS.Settings.carbRatios)
  270. self.storage.save(sensitivitiesProfile, as: OpenAPS.Settings.insulinSensitivities)
  271. self.storage.save(targetsProfile, as: OpenAPS.Settings.bgTargets)
  272. debug(.service, "Settings have been imported and the Basals saved to pump!")
  273. // DIA. Save if changed.
  274. let dia = fetchedProfile.dia
  275. print("dia: " + dia.description)
  276. print("pump dia: " + self.dia.description)
  277. if dia != self.dia, dia >= 0 {
  278. let file = PumpSettings(
  279. insulinActionCurve: dia,
  280. maxBolus: self.maxBolus,
  281. maxBasal: self.maxBasal
  282. )
  283. self.storage.save(file, as: OpenAPS.Settings.settings)
  284. debug(.nightscout, "DIA setting updated to " + dia.description + " after a NS import.")
  285. }
  286. group.leave()
  287. case .failure:
  288. error =
  289. "\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"
  290. debug(.service, "Basals couldn't be save to pump")
  291. group.leave()
  292. }
  293. }
  294. } catch let parsingError {
  295. print(parsingError)
  296. error = parsingError.localizedDescription
  297. group.leave()
  298. }
  299. }
  300. }
  301. task.resume()
  302. group.wait(wallTimeout: .now() + 5)
  303. group.notify(queue: .global(qos: .background)) {
  304. self.saveError(error)
  305. }
  306. }
  307. func offset(_ string: String) -> Int {
  308. let hours = Int(string.prefix(2)) ?? 0
  309. let minutes = Int(string.suffix(2)) ?? 0
  310. return ((hours * 60) + minutes) * 60
  311. }
  312. func saveError(_ string: String) {
  313. coredataContext.performAndWait {
  314. let saveToCoreData = ImportError(context: self.coredataContext)
  315. saveToCoreData.date = Date()
  316. saveToCoreData.error = string
  317. if coredataContext.hasChanges {
  318. try? coredataContext.save()
  319. }
  320. }
  321. }
  322. func backfillGlucose() {
  323. backfilling = true
  324. nightscoutManager.fetchGlucose(since: Date().addingTimeInterval(-1.days.timeInterval))
  325. .sink { [weak self] glucose in
  326. guard let self = self else { return }
  327. DispatchQueue.main.async {
  328. self.backfilling = false
  329. }
  330. guard glucose.isNotEmpty else { return }
  331. self.healthKitManager.saveIfNeeded(bloodGlucose: glucose)
  332. self.glucoseStorage.storeGlucose(glucose)
  333. }
  334. .store(in: &lifetime)
  335. }
  336. func delete() {
  337. keychain.removeObject(forKey: Config.urlKey)
  338. keychain.removeObject(forKey: Config.secretKey)
  339. url = ""
  340. secret = ""
  341. }
  342. }
  343. }