NightscoutConfigStateModel.swift 17 KB

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