NightscoutConfigStateModel.swift 17 KB

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