NightscoutConfigStateModel.swift 15 KB

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