NightscoutManager.swift 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import LoopKitUI
  5. import Swinject
  6. import UIKit
  7. protocol NightscoutManager: GlucoseSource {
  8. func fetchGlucose(since date: Date) async -> [BloodGlucose]
  9. func fetchCarbs() async -> [CarbsEntry]
  10. func fetchTempTargets() async -> [TempTarget]
  11. func deleteCarbs(withID id: String) async
  12. func deleteInsulin(withID id: String) async
  13. func deleteManualGlucose(withID id: String) async
  14. func uploadStatus() async
  15. func uploadGlucose() async
  16. func uploadCarbs() async
  17. func uploadPumpHistory() async
  18. func uploadOverrides() async
  19. func uploadTempTargets() async
  20. func uploadManualGlucose() async
  21. func uploadProfiles() async
  22. func uploadNoteTreatment(note: String) async
  23. func importSettings() async -> ScheduledNightscoutProfile?
  24. var cgmURL: URL? { get }
  25. }
  26. final class BaseNightscoutManager: NightscoutManager, Injectable {
  27. @Injected() private var keychain: Keychain!
  28. @Injected() private var determinationStorage: DeterminationStorage!
  29. @Injected() private var glucoseStorage: GlucoseStorage!
  30. @Injected() private var tempTargetsStorage: TempTargetsStorage!
  31. @Injected() private var overridesStorage: OverrideStorage!
  32. @Injected() private var carbsStorage: CarbsStorage!
  33. @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
  34. @Injected() private var storage: FileStorage!
  35. @Injected() private var settingsManager: SettingsManager!
  36. @Injected() private var broadcaster: Broadcaster!
  37. @Injected() private var reachabilityManager: ReachabilityManager!
  38. @Injected() var healthkitManager: HealthKitManager!
  39. private let uploadOverridesSubject = PassthroughSubject<Void, Never>()
  40. private let uploadPumpHistorySubject = PassthroughSubject<Void, Never>()
  41. private let uploadCarbsSubject = PassthroughSubject<Void, Never>()
  42. private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
  43. private var ping: TimeInterval?
  44. private var backgroundContext = CoreDataStack.shared.newTaskContext()
  45. private var lifetime = Lifetime()
  46. private var isNetworkReachable: Bool {
  47. reachabilityManager.isReachable
  48. }
  49. private var isUploadEnabled: Bool {
  50. settingsManager.settings.isUploadEnabled
  51. }
  52. private var isDownloadEnabled: Bool {
  53. settingsManager.settings.isDownloadEnabled
  54. }
  55. private var isUploadGlucoseEnabled: Bool {
  56. settingsManager.settings.uploadGlucose
  57. }
  58. private var nightscoutAPI: NightscoutAPI? {
  59. guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
  60. let url = URL(string: urlString),
  61. let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
  62. else {
  63. return nil
  64. }
  65. return NightscoutAPI(url: url, secret: secret)
  66. }
  67. private var lastEnactedDetermination: Determination?
  68. private var lastSuggestedDetermination: Determination?
  69. private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
  70. private var subscriptions = Set<AnyCancellable>()
  71. init(resolver: Resolver) {
  72. injectServices(resolver)
  73. subscribe()
  74. coreDataPublisher =
  75. changedObjectsOnManagedObjectContextDidSavePublisher()
  76. .receive(on: DispatchQueue.global(qos: .background))
  77. .share()
  78. .eraseToAnyPublisher()
  79. glucoseStorage.updatePublisher
  80. .receive(on: DispatchQueue.global(qos: .background))
  81. .sink { [weak self] _ in
  82. guard let self = self else { return }
  83. Task {
  84. await self.uploadGlucose()
  85. }
  86. }
  87. .store(in: &subscriptions)
  88. uploadOverridesSubject
  89. .debounce(for: .seconds(1), scheduler: DispatchQueue.global(qos: .background))
  90. .sink { [weak self] in
  91. guard let self = self else { return }
  92. Task {
  93. await self.uploadOverrides()
  94. }
  95. }
  96. .store(in: &subscriptions)
  97. uploadPumpHistorySubject
  98. .debounce(for: .seconds(1), scheduler: DispatchQueue.global(qos: .background))
  99. .sink { [weak self] in
  100. guard let self = self else { return }
  101. Task {
  102. await self.uploadPumpHistory()
  103. }
  104. }
  105. .store(in: &subscriptions)
  106. uploadCarbsSubject
  107. .debounce(for: .seconds(1), scheduler: DispatchQueue.global(qos: .background))
  108. .sink { [weak self] in
  109. guard let self = self else { return }
  110. Task {
  111. await self.uploadCarbs()
  112. }
  113. }
  114. .store(in: &subscriptions)
  115. registerHandlers()
  116. setupNotification()
  117. /// Ensure that Nightscout Manager holds the `lastEnactedDetermination`, if one exists, on initialization.
  118. /// We have to set this here in `init()`, so there's a `lastEnactedDetermination` available after an app restart
  119. /// for `uploadStatus()`, as within that fuction `lastEnactedDetermination` is reassigned at the very end of the function.
  120. /// This way, we ensure the latest enacted determination is always part of `devicestatus` and avoid having instances
  121. /// where the first uploaded non-enacted determination (i.e., "suggested"), lacks the "enacted" data.
  122. Task {
  123. async let lastEnactedDeterminationID = determinationStorage
  124. .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDetermination)
  125. self.lastEnactedDetermination = await determinationStorage
  126. .getOrefDeterminationNotYetUploadedToNightscout(lastEnactedDeterminationID)
  127. }
  128. }
  129. private func subscribe() {
  130. _ = reachabilityManager.startListening(onQueue: processQueue) { status in
  131. debug(.nightscout, "Network status: \(status)")
  132. }
  133. }
  134. private func registerHandlers() {
  135. coreDataPublisher?.filterByEntityName("OrefDetermination").sink { [weak self] _ in
  136. guard let self = self else { return }
  137. Task.detached {
  138. await self.uploadStatus()
  139. }
  140. }.store(in: &subscriptions)
  141. coreDataPublisher?.filterByEntityName("OverrideStored").sink { [weak self] _ in
  142. self?.uploadOverridesSubject.send()
  143. }.store(in: &subscriptions)
  144. coreDataPublisher?.filterByEntityName("OverrideRunStored").sink { [weak self] _ in
  145. self?.uploadOverridesSubject.send()
  146. }.store(in: &subscriptions)
  147. coreDataPublisher?.filterByEntityName("TempTargetStored").sink { [weak self] _ in
  148. guard let self = self else { return }
  149. Task.detached {
  150. await self.uploadTempTargets()
  151. }
  152. }.store(in: &subscriptions)
  153. coreDataPublisher?.filterByEntityName("TempTargetRunStored").sink { [weak self] _ in
  154. guard let self = self else { return }
  155. Task.detached {
  156. await self.uploadTempTargets()
  157. }
  158. }.store(in: &subscriptions)
  159. coreDataPublisher?.filterByEntityName("PumpEventStored").sink { [weak self] _ in
  160. self?.uploadPumpHistorySubject.send()
  161. }.store(in: &subscriptions)
  162. coreDataPublisher?.filterByEntityName("CarbEntryStored").sink { [weak self] _ in
  163. self?.uploadCarbsSubject.send()
  164. }.store(in: &subscriptions)
  165. coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
  166. guard let self = self else { return }
  167. Task.detached {
  168. await self.uploadManualGlucose()
  169. }
  170. }.store(in: &subscriptions)
  171. }
  172. func setupNotification() {
  173. Foundation.NotificationCenter.default.publisher(for: .willUpdateOverrideConfiguration)
  174. .sink { [weak self] _ in
  175. guard let self = self else { return }
  176. Task {
  177. await self.uploadOverrides()
  178. // Post a notification indicating that the upload has finished and that we can end the background task in the OverridePresetsIntentRequest
  179. Foundation.NotificationCenter.default.post(name: .didUpdateOverrideConfiguration, object: nil)
  180. }
  181. }
  182. .store(in: &subscriptions)
  183. Foundation.NotificationCenter.default.publisher(for: .willUpdateTempTargetConfiguration)
  184. .sink { [weak self] _ in
  185. guard let self = self else { return }
  186. Task {
  187. await self.uploadTempTargets()
  188. // Post a notification indicating that the upload has finished and that we can end the background task in the TempTargetPresetsIntentRequest
  189. Foundation.NotificationCenter.default.post(name: .didUpdateTempTargetConfiguration, object: nil)
  190. }
  191. }
  192. .store(in: &subscriptions)
  193. }
  194. func sourceInfo() -> [String: Any]? {
  195. if let ping = ping {
  196. return [GlucoseSourceKey.nightscoutPing.rawValue: ping]
  197. }
  198. return nil
  199. }
  200. var cgmURL: URL? {
  201. if let url = settingsManager.settings.cgm.appURL {
  202. return url
  203. }
  204. let useLocal = settingsManager.settings.useLocalGlucoseSource
  205. let maybeNightscout = useLocal
  206. ? NightscoutAPI(url: URL(string: "http://127.0.0.1:\(settingsManager.settings.localGlucosePort)")!)
  207. : nightscoutAPI
  208. return maybeNightscout?.url
  209. }
  210. func fetchGlucose(since date: Date) async -> [BloodGlucose] {
  211. let useLocal = settingsManager.settings.useLocalGlucoseSource
  212. ping = nil
  213. if !useLocal {
  214. guard isNetworkReachable else {
  215. return []
  216. }
  217. }
  218. let maybeNightscout = useLocal
  219. ? NightscoutAPI(url: URL(string: "http://127.0.0.1:\(settingsManager.settings.localGlucosePort)")!)
  220. : nightscoutAPI
  221. guard let nightscout = maybeNightscout else {
  222. return []
  223. }
  224. let startDate = Date()
  225. do {
  226. let glucose = try await nightscout.fetchLastGlucose(sinceDate: date)
  227. if glucose.isNotEmpty {
  228. ping = Date().timeIntervalSince(startDate)
  229. }
  230. return glucose
  231. } catch {
  232. print(error.localizedDescription)
  233. return []
  234. }
  235. }
  236. // MARK: - GlucoseSource
  237. var glucoseManager: FetchGlucoseManager?
  238. var cgmManager: CGMManagerUI?
  239. func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
  240. Future { promise in
  241. Task {
  242. let glucoseData = await self.fetchGlucose(since: self.glucoseStorage.syncDate())
  243. promise(.success(glucoseData))
  244. }
  245. }
  246. .eraseToAnyPublisher()
  247. }
  248. func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
  249. fetch(nil)
  250. }
  251. func fetchCarbs() async -> [CarbsEntry] {
  252. guard let nightscout = nightscoutAPI, isNetworkReachable, isDownloadEnabled else {
  253. return []
  254. }
  255. let since = carbsStorage.syncDate()
  256. do {
  257. let carbs = try await nightscout.fetchCarbs(sinceDate: since)
  258. return carbs
  259. } catch {
  260. debug(.nightscout, "Error fetching carbs: \(error.localizedDescription)")
  261. return []
  262. }
  263. }
  264. func fetchTempTargets() async -> [TempTarget] {
  265. guard let nightscout = nightscoutAPI, isNetworkReachable, isDownloadEnabled else {
  266. return []
  267. }
  268. let since = tempTargetsStorage.syncDate()
  269. do {
  270. let tempTargets = try await nightscout.fetchTempTargets(sinceDate: since)
  271. return tempTargets
  272. } catch {
  273. debug(.nightscout, "Error fetching temp targets: \(error.localizedDescription)")
  274. return []
  275. }
  276. }
  277. func deleteCarbs(withID id: String) async {
  278. guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
  279. do {
  280. try await nightscout.deleteCarbs(withId: id)
  281. debug(.nightscout, "Carbs deleted")
  282. } catch {
  283. debug(
  284. .nightscout,
  285. "\(DebuggingIdentifiers.failed) Failed to delete Carbs from Nightscout with error: \(error.localizedDescription)"
  286. )
  287. }
  288. }
  289. func deleteInsulin(withID id: String) async {
  290. guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
  291. do {
  292. try await nightscout.deleteInsulin(withId: id)
  293. debug(.nightscout, "Insulin deleted")
  294. } catch {
  295. debug(
  296. .nightscout,
  297. "\(DebuggingIdentifiers.failed) Failed to delete Insulin from Nightscout with error: \(error.localizedDescription)"
  298. )
  299. }
  300. }
  301. func deleteManualGlucose(withID id: String) async {
  302. guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
  303. do {
  304. try await nightscout.deleteManualGlucose(withId: id)
  305. } catch {
  306. debug(
  307. .nightscout,
  308. "\(DebuggingIdentifiers.failed) Failed to delete Manual Glucose from Nightscout with error: \(error.localizedDescription)"
  309. )
  310. }
  311. }
  312. private func fetchBattery() async -> Battery {
  313. await backgroundContext.perform {
  314. do {
  315. let results = try self.backgroundContext.fetch(OpenAPS_Battery.fetch(NSPredicate.predicateFor30MinAgo))
  316. if let last = results.first {
  317. let percent: Int? = Int(last.percent)
  318. let voltage: Decimal? = last.voltage as Decimal?
  319. let status: String? = last.status
  320. let display: Bool? = last.display
  321. if let status {
  322. debugPrint(
  323. "NightscoutManager: \(#function) \(DebuggingIdentifiers.succeeded) setup battery from core data successfully"
  324. )
  325. return Battery(
  326. percent: percent,
  327. voltage: voltage,
  328. string: BatteryState(rawValue: status) ?? BatteryState.unknown,
  329. display: display
  330. )
  331. }
  332. }
  333. debugPrint(
  334. "NightscoutManager: \(#function) \(DebuggingIdentifiers.succeeded) successfully fetched; but no battery data available. Returning fallback default."
  335. )
  336. return Battery(percent: nil, voltage: nil, string: BatteryState.error, display: nil)
  337. } catch {
  338. debugPrint(
  339. "NightscoutManager: \(#function) \(DebuggingIdentifiers.failed) failed to setup battery from core data"
  340. )
  341. return Battery(percent: nil, voltage: nil, string: BatteryState.error, display: nil)
  342. }
  343. }
  344. }
  345. /// Asynchronously uploads the current status to Nightscout, including OpenAPS status, pump status, and uploader details.
  346. ///
  347. /// This function gathers and processes various pieces of information such as the "enacted" and "suggested" determinations,
  348. /// pump battery and reservoir levels, insulin-on-board (IOB), and the uploader's battery status. It ensures that only
  349. /// valid determinations are uploaded by filtering out duplicates and handling unit conversions based on the user's
  350. /// settings. If the status upload is successful, it updates the determination storage to mark them as uploaded.
  351. ///
  352. /// Key steps:
  353. /// - Fetch the last unuploaded enacted and suggested determinations from the storage.
  354. /// - Retrieve pump-related data such as battery, reservoir, and status.
  355. /// - Parse determinations to ensure they are properly formatted for Nightscout, including unit conversions if needed.
  356. /// - Construct an `OpenAPSStatus` object with relevant information for upload.
  357. /// - Construct a `NightscoutStatus` object with all gathered data.
  358. /// - Attempt to upload the status to Nightscout. On success, update the storage to mark determinations as uploaded.
  359. /// - Schedule a task to upload pod age data separately.
  360. ///
  361. /// - Note: Ensure `nightscoutAPI` is initialized and `isUploadEnabled` is set to `true` before invoking this function.
  362. /// - Returns: Nothing.
  363. func uploadStatus() async {
  364. guard let nightscout = nightscoutAPI, isUploadEnabled else {
  365. debug(.nightscout, "NS API not available or upload disabled. Aborting NS Status upload.")
  366. return
  367. }
  368. // Suggested / Enacted
  369. async let enactedDeterminationID = determinationStorage
  370. .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDeterminationsNotYetUploadedToNightscout)
  371. async let suggestedDeterminationID = determinationStorage
  372. .fetchLastDeterminationObjectID(predicate: NSPredicate.suggestedDeterminationsNotYetUploadedToNightscout)
  373. // OpenAPS Status
  374. async let fetchedBattery = fetchBattery()
  375. async let fetchedReservoir = Decimal(from: storage.retrieveRawAsync(OpenAPS.Monitor.reservoir) ?? "0")
  376. async let fetchedIOBEntry = storage.retrieveAsync(OpenAPS.Monitor.iob, as: [IOBEntry].self)
  377. async let fetchedPumpStatus = storage.retrieveAsync(OpenAPS.Monitor.status, as: PumpStatus.self)
  378. var (fetchedEnactedDetermination, fetchedSuggestedDetermination) = await (
  379. determinationStorage.getOrefDeterminationNotYetUploadedToNightscout(enactedDeterminationID),
  380. determinationStorage.getOrefDeterminationNotYetUploadedToNightscout(suggestedDeterminationID)
  381. )
  382. // Guard to ensure both determinations are not nil
  383. guard fetchedEnactedDetermination != nil || fetchedSuggestedDetermination != nil else {
  384. debug(
  385. .nightscout,
  386. "Both fetchedEnactedDetermination and fetchedSuggestedDetermination are nil. Aborting NS Status upload."
  387. )
  388. return
  389. }
  390. // Unwrap fetchedSuggestedDetermination and manipulate the timestamp field to ensure deliverAt and timestamp for a suggestion truly match!
  391. var modifiedSuggestedDetermination = fetchedSuggestedDetermination
  392. if var suggestion = fetchedSuggestedDetermination {
  393. suggestion.timestamp = suggestion.deliverAt
  394. if settingsManager.settings.units == .mmolL {
  395. suggestion.reason = parseReasonGlucoseValuesToMmolL(suggestion.reason)
  396. // TODO: verify that these parsings are needed for 3rd party apps, e.g., LoopFollow
  397. suggestion.current_target = suggestion.current_target?.asMmolL
  398. suggestion.minGuardBG = suggestion.minGuardBG?.asMmolL
  399. suggestion.minPredBG = suggestion.minPredBG?.asMmolL
  400. suggestion.threshold = suggestion.threshold?.asMmolL
  401. }
  402. // Check whether the last suggestion that was uploaded is the same that is fetched again when we are attempting to upload the enacted determination
  403. // Apparently we are too fast; so the flag update is not fast enough to have the predicate filter last suggestion out
  404. // If this check is truthy, set suggestion to nil so it's not uploaded again
  405. if let lastSuggested = lastSuggestedDetermination, lastSuggested.deliverAt == suggestion.deliverAt {
  406. modifiedSuggestedDetermination = nil
  407. } else {
  408. modifiedSuggestedDetermination = suggestion
  409. }
  410. }
  411. if let fetchedEnacted = fetchedEnactedDetermination, settingsManager.settings.units == .mmolL {
  412. var modifiedFetchedEnactedDetermination = fetchedEnactedDetermination
  413. modifiedFetchedEnactedDetermination?
  414. .reason = parseReasonGlucoseValuesToMmolL(fetchedEnacted.reason)
  415. // TODO: verify that these parsings are needed for 3rd party apps, e.g., LoopFollow
  416. modifiedFetchedEnactedDetermination?.current_target = fetchedEnacted.current_target?.asMmolL
  417. modifiedFetchedEnactedDetermination?.minGuardBG = fetchedEnacted.minGuardBG?.asMmolL
  418. modifiedFetchedEnactedDetermination?.minPredBG = fetchedEnacted.minPredBG?.asMmolL
  419. modifiedFetchedEnactedDetermination?.threshold = fetchedEnacted.threshold?.asMmolL
  420. fetchedEnactedDetermination = modifiedFetchedEnactedDetermination
  421. }
  422. // Gather all relevant data for OpenAPS Status
  423. let iob = await fetchedIOBEntry
  424. let suggestedToUpload = modifiedSuggestedDetermination ?? lastSuggestedDetermination
  425. let enactedToUpload = fetchedEnactedDetermination ?? lastEnactedDetermination
  426. let openapsStatus = OpenAPSStatus(
  427. iob: iob?.first,
  428. suggested: suggestedToUpload,
  429. enacted: settingsManager.settings.closedLoop ? enactedToUpload : nil,
  430. version: Bundle.main.releaseVersionNumber ?? "Unknown"
  431. )
  432. debug(.nightscout, "To be uploaded openapsStatus: \(openapsStatus)")
  433. // Gather all relevant data for NS Status
  434. let battery = await fetchedBattery
  435. let reservoir = await fetchedReservoir
  436. let pumpStatus = await fetchedPumpStatus
  437. let pump = NSPumpStatus(
  438. clock: Date(),
  439. battery: battery,
  440. reservoir: reservoir != 0xDEAD_BEEF ? reservoir : nil,
  441. status: pumpStatus
  442. )
  443. let batteryLevel = await UIDevice.current.batteryLevel
  444. let batteryState = await UIDevice.current.batteryState
  445. let uploader = Uploader(
  446. batteryVoltage: nil,
  447. battery: Int(batteryLevel * 100),
  448. isCharging: batteryState == .charging || batteryState == .full
  449. )
  450. let status = NightscoutStatus(
  451. device: NightscoutTreatment.local,
  452. openaps: openapsStatus,
  453. pump: pump,
  454. uploader: uploader
  455. )
  456. do {
  457. try await nightscout.uploadStatus(status)
  458. debug(.nightscout, "Status uploaded")
  459. if let enacted = fetchedEnactedDetermination {
  460. await updateOrefDeterminationAsUploaded([enacted])
  461. }
  462. if let suggested = fetchedSuggestedDetermination {
  463. await updateOrefDeterminationAsUploaded([suggested])
  464. }
  465. if let lastEnactedDetermination = fetchedEnactedDetermination {
  466. self.lastEnactedDetermination = lastEnactedDetermination
  467. }
  468. if let lastSuggestedDetermination = fetchedSuggestedDetermination {
  469. self.lastSuggestedDetermination = lastSuggestedDetermination
  470. }
  471. debug(.nightscout, "NSDeviceStatus with Determination uploaded")
  472. } catch {
  473. debug(.nightscout, error.localizedDescription)
  474. }
  475. Task.detached {
  476. await self.uploadPodAge()
  477. }
  478. }
  479. private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
  480. await backgroundContext.perform {
  481. let ids = determination.map(\.id) as NSArray
  482. let fetchRequest: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
  483. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  484. do {
  485. let results = try self.backgroundContext.fetch(fetchRequest)
  486. for result in results {
  487. result.isUploadedToNS = true
  488. }
  489. guard self.backgroundContext.hasChanges else { return }
  490. try self.backgroundContext.save()
  491. } catch let error as NSError {
  492. debugPrint(
  493. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  494. )
  495. }
  496. }
  497. }
  498. func uploadPodAge() async {
  499. let uploadedPodAge = storage.retrieve(OpenAPS.Nightscout.uploadedPodAge, as: [NightscoutTreatment].self) ?? []
  500. if let podAge = storage.retrieve(OpenAPS.Monitor.podAge, as: Date.self),
  501. uploadedPodAge.last?.createdAt == nil || podAge != uploadedPodAge.last!.createdAt!
  502. {
  503. let siteTreatment = NightscoutTreatment(
  504. duration: nil,
  505. rawDuration: nil,
  506. rawRate: nil,
  507. absolute: nil,
  508. rate: nil,
  509. eventType: .nsSiteChange,
  510. createdAt: podAge,
  511. enteredBy: NightscoutTreatment.local,
  512. bolus: nil,
  513. insulin: nil,
  514. notes: nil,
  515. carbs: nil,
  516. fat: nil,
  517. protein: nil,
  518. targetTop: nil,
  519. targetBottom: nil
  520. )
  521. await uploadNonCoreDataTreatments([siteTreatment])
  522. }
  523. }
  524. func uploadProfiles() async {
  525. if isUploadEnabled {
  526. do {
  527. guard let sensitivities = await storage.retrieveAsync(
  528. OpenAPS.Settings.insulinSensitivities,
  529. as: InsulinSensitivities.self
  530. ) else {
  531. debug(.nightscout, "NightscoutManager uploadProfile: error loading insulinSensitivities")
  532. return
  533. }
  534. guard let targets = await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self) else {
  535. debug(.nightscout, "NightscoutManager uploadProfile: error loading bgTargets")
  536. return
  537. }
  538. guard let carbRatios = await storage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self) else {
  539. debug(.nightscout, "NightscoutManager uploadProfile: error loading carbRatios")
  540. return
  541. }
  542. guard let basalProfile = await storage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
  543. else {
  544. debug(.nightscout, "NightscoutManager uploadProfile: error loading basalProfile")
  545. return
  546. }
  547. let shouldParseToMmolL = settingsManager.settings.units == .mmolL
  548. let sens = sensitivities.sensitivities.map { item in
  549. NightscoutTimevalue(
  550. time: String(item.start.prefix(5)),
  551. value: !shouldParseToMmolL ? item.sensitivity : item.sensitivity.asMmolL,
  552. timeAsSeconds: item.offset * 60
  553. )
  554. }
  555. let targetLow = targets.targets.map { item in
  556. NightscoutTimevalue(
  557. time: String(item.start.prefix(5)),
  558. value: !shouldParseToMmolL ? item.low : item.low.asMmolL,
  559. timeAsSeconds: item.offset * 60
  560. )
  561. }
  562. let targetHigh = targets.targets.map { item in
  563. NightscoutTimevalue(
  564. time: String(item.start.prefix(5)),
  565. value: !shouldParseToMmolL ? item.high : item.high.asMmolL,
  566. timeAsSeconds: item.offset * 60
  567. )
  568. }
  569. let cr = carbRatios.schedule.map { item in
  570. NightscoutTimevalue(
  571. time: String(item.start.prefix(5)),
  572. value: item.ratio,
  573. timeAsSeconds: item.offset * 60
  574. )
  575. }
  576. let basal = basalProfile.map { item in
  577. NightscoutTimevalue(
  578. time: String(item.start.prefix(5)),
  579. value: item.rate,
  580. timeAsSeconds: item.minutes * 60
  581. )
  582. }
  583. let nsUnits: String = {
  584. switch settingsManager.settings.units {
  585. case .mgdL:
  586. return "mg/dl"
  587. case .mmolL:
  588. return "mmol"
  589. }
  590. }()
  591. var carbsHr: Decimal = 0
  592. if let isf = sensitivities.sensitivities.map(\.sensitivity).first,
  593. let cr = carbRatios.schedule.map(\.ratio).first,
  594. isf > 0, cr > 0
  595. {
  596. carbsHr = settingsManager.preferences.min5mCarbimpact * 12 / isf * cr
  597. if settingsManager.settings.units == .mmolL {
  598. carbsHr *= GlucoseUnits.exchangeRate
  599. }
  600. carbsHr = Decimal(round(Double(carbsHr) * 10.0)) / 10
  601. }
  602. let scheduledProfile = ScheduledNightscoutProfile(
  603. dia: settingsManager.pumpSettings.insulinActionCurve,
  604. carbs_hr: Int(carbsHr),
  605. delay: 0,
  606. timezone: TimeZone.current.identifier,
  607. target_low: targetLow,
  608. target_high: targetHigh,
  609. sens: sens,
  610. basal: basal,
  611. carbratio: cr,
  612. units: nsUnits
  613. )
  614. let defaultProfile = "default"
  615. let now = Date()
  616. let bundleIdentifier = Bundle.main.bundleIdentifier ?? ""
  617. let deviceToken = UserDefaults.standard.string(forKey: "deviceToken") ?? ""
  618. let isAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
  619. let presetOverrides = await overridesStorage.getPresetOverridesForNightscout()
  620. let teamID = Bundle.main.object(forInfoDictionaryKey: "TeamID") as? String ?? ""
  621. let profileStore = NightscoutProfileStore(
  622. defaultProfile: defaultProfile,
  623. startDate: now,
  624. mills: Int(now.timeIntervalSince1970) * 1000,
  625. units: nsUnits,
  626. enteredBy: NightscoutTreatment.local,
  627. store: [defaultProfile: scheduledProfile],
  628. bundleIdentifier: bundleIdentifier,
  629. deviceToken: deviceToken,
  630. isAPNSProduction: isAPNSProduction,
  631. overridePresets: presetOverrides,
  632. teamID: teamID
  633. )
  634. guard let nightscout = nightscoutAPI, isNetworkReachable else {
  635. if !isNetworkReachable {
  636. debug(.nightscout, "Network issues; aborting upload")
  637. }
  638. debug(.nightscout, "Nightscout API service not available; aborting upload")
  639. return
  640. }
  641. do {
  642. try await nightscout.uploadProfile(profileStore)
  643. debug(.nightscout, "Profile uploaded")
  644. } catch {
  645. debug(.nightscout, "NightscoutManager uploadProfile: \(error.localizedDescription)")
  646. }
  647. }
  648. } else {
  649. debug(.nightscout, "Upload to NS disabled; aborting profile uploaded")
  650. }
  651. }
  652. func importSettings() async -> ScheduledNightscoutProfile? {
  653. guard let nightscout = nightscoutAPI else {
  654. debug(.nightscout, "NS API not available. Aborting NS Status upload.")
  655. return nil
  656. }
  657. do {
  658. return try await nightscout.importSettings()
  659. } catch {
  660. debug(.nightscout, error.localizedDescription)
  661. return nil
  662. }
  663. }
  664. func uploadGlucose() async {
  665. await uploadGlucose(glucoseStorage.getGlucoseNotYetUploadedToNightscout())
  666. await uploadNonCoreDataTreatments(glucoseStorage.getCGMStateNotYetUploadedToNightscout())
  667. }
  668. func uploadManualGlucose() async {
  669. await uploadManualGlucose(glucoseStorage.getManualGlucoseNotYetUploadedToNightscout())
  670. }
  671. func uploadPumpHistory() async {
  672. await uploadPumpHistory(pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout())
  673. }
  674. func uploadCarbs() async {
  675. await uploadCarbs(carbsStorage.getCarbsNotYetUploadedToNightscout())
  676. await uploadCarbs(carbsStorage.getFPUsNotYetUploadedToNightscout())
  677. }
  678. func uploadOverrides() async {
  679. await uploadOverrides(overridesStorage.getOverridesNotYetUploadedToNightscout())
  680. await uploadOverrideRuns(overridesStorage.getOverrideRunsNotYetUploadedToNightscout())
  681. }
  682. func uploadTempTargets() async {
  683. await uploadTempTargets(await tempTargetsStorage.getTempTargetsNotYetUploadedToNightscout())
  684. await uploadTempTargetRuns(await tempTargetsStorage.getTempTargetRunsNotYetUploadedToNightscout())
  685. }
  686. private func uploadGlucose(_ glucose: [BloodGlucose]) async {
  687. guard !glucose.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled, isUploadGlucoseEnabled else {
  688. return
  689. }
  690. do {
  691. // Upload in Batches of 100
  692. for chunk in glucose.chunks(ofCount: 100) {
  693. try await nightscout.uploadGlucose(Array(chunk))
  694. }
  695. // If successful, update the isUploadedToNS property of the GlucoseStored objects
  696. await updateGlucoseAsUploaded(glucose)
  697. debug(.nightscout, "Glucose uploaded")
  698. } catch {
  699. debug(.nightscout, "Upload of glucose failed: \(error.localizedDescription)")
  700. }
  701. }
  702. private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
  703. await backgroundContext.perform {
  704. let ids = glucose.map(\.id) as NSArray
  705. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  706. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  707. do {
  708. let results = try self.backgroundContext.fetch(fetchRequest)
  709. for result in results {
  710. result.isUploadedToNS = true
  711. }
  712. guard self.backgroundContext.hasChanges else { return }
  713. try self.backgroundContext.save()
  714. } catch let error as NSError {
  715. debugPrint(
  716. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  717. )
  718. }
  719. }
  720. }
  721. private func uploadNonCoreDataTreatments(_ treatments: [NightscoutTreatment]) async {
  722. guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  723. return
  724. }
  725. do {
  726. for chunk in treatments.chunks(ofCount: 100) {
  727. try await nightscout.uploadTreatments(Array(chunk))
  728. }
  729. debug(.nightscout, "Treatments uploaded")
  730. } catch {
  731. debug(.nightscout, error.localizedDescription)
  732. }
  733. }
  734. private func uploadPumpHistory(_ treatments: [NightscoutTreatment]) async {
  735. guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  736. return
  737. }
  738. do {
  739. for chunk in treatments.chunks(ofCount: 100) {
  740. try await nightscout.uploadTreatments(Array(chunk))
  741. }
  742. await updatePumpEventStoredsAsUploaded(treatments)
  743. debug(.nightscout, "Treatments uploaded")
  744. } catch {
  745. debug(.nightscout, error.localizedDescription)
  746. }
  747. }
  748. private func updatePumpEventStoredsAsUploaded(_ treatments: [NightscoutTreatment]) async {
  749. await backgroundContext.perform {
  750. let ids = treatments.map(\.id) as NSArray
  751. let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
  752. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  753. do {
  754. let results = try self.backgroundContext.fetch(fetchRequest)
  755. for result in results {
  756. result.isUploadedToNS = true
  757. }
  758. guard self.backgroundContext.hasChanges else { return }
  759. try self.backgroundContext.save()
  760. } catch let error as NSError {
  761. debugPrint(
  762. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  763. )
  764. }
  765. }
  766. }
  767. private func uploadManualGlucose(_ treatments: [NightscoutTreatment]) async {
  768. guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  769. return
  770. }
  771. do {
  772. for chunk in treatments.chunks(ofCount: 100) {
  773. try await nightscout.uploadTreatments(Array(chunk))
  774. }
  775. // If successful, update the isUploadedToNS property of the GlucoseStored objects
  776. await updateManualGlucoseAsUploaded(treatments)
  777. debug(.nightscout, "Treatments uploaded")
  778. } catch {
  779. debug(.nightscout, error.localizedDescription)
  780. }
  781. }
  782. private func updateManualGlucoseAsUploaded(_ treatments: [NightscoutTreatment]) async {
  783. await backgroundContext.perform {
  784. let ids = treatments.map(\.id) as NSArray
  785. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  786. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  787. do {
  788. let results = try self.backgroundContext.fetch(fetchRequest)
  789. for result in results {
  790. result.isUploadedToNS = true
  791. }
  792. guard self.backgroundContext.hasChanges else { return }
  793. try self.backgroundContext.save()
  794. } catch let error as NSError {
  795. debugPrint(
  796. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  797. )
  798. }
  799. }
  800. }
  801. private func uploadCarbs(_ treatments: [NightscoutTreatment]) async {
  802. guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  803. return
  804. }
  805. do {
  806. for chunk in treatments.chunks(ofCount: 100) {
  807. try await nightscout.uploadTreatments(Array(chunk))
  808. }
  809. // If successful, update the isUploadedToNS property of the CarbEntryStored objects
  810. await updateCarbsAsUploaded(treatments)
  811. debug(.nightscout, "Treatments uploaded")
  812. } catch {
  813. debug(.nightscout, error.localizedDescription)
  814. }
  815. }
  816. private func updateCarbsAsUploaded(_ treatments: [NightscoutTreatment]) async {
  817. await backgroundContext.perform {
  818. let ids = treatments.map(\.id) as NSArray
  819. let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
  820. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  821. do {
  822. let results = try self.backgroundContext.fetch(fetchRequest)
  823. for result in results {
  824. result.isUploadedToNS = true
  825. }
  826. guard self.backgroundContext.hasChanges else { return }
  827. try self.backgroundContext.save()
  828. } catch let error as NSError {
  829. debugPrint(
  830. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  831. )
  832. }
  833. }
  834. }
  835. private func uploadOverrides(_ overrides: [NightscoutExercise]) async {
  836. guard !overrides.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  837. return
  838. }
  839. do {
  840. for chunk in overrides.chunks(ofCount: 100) {
  841. try await nightscout.uploadOverrides(Array(chunk))
  842. }
  843. // If successful, update the isUploadedToNS property of the OverrideStored objects
  844. await updateOverridesAsUploaded(overrides)
  845. debug(.nightscout, "Overrides uploaded")
  846. } catch {
  847. debug(.nightscout, error.localizedDescription)
  848. }
  849. }
  850. private func updateOverridesAsUploaded(_ overrides: [NightscoutExercise]) async {
  851. await backgroundContext.perform {
  852. let ids = overrides.map(\.id) as NSArray
  853. let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
  854. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  855. do {
  856. let results = try self.backgroundContext.fetch(fetchRequest)
  857. for result in results {
  858. result.isUploadedToNS = true
  859. }
  860. guard self.backgroundContext.hasChanges else { return }
  861. try self.backgroundContext.save()
  862. } catch let error as NSError {
  863. debugPrint(
  864. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  865. )
  866. }
  867. }
  868. }
  869. private func uploadOverrideRuns(_ overrideRuns: [NightscoutExercise]) async {
  870. guard !overrideRuns.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  871. return
  872. }
  873. do {
  874. for chunk in overrideRuns.chunks(ofCount: 100) {
  875. try await nightscout.uploadOverrides(Array(chunk))
  876. }
  877. // If successful, update the isUploadedToNS property of the OverrideRunStored objects
  878. await updateOverrideRunsAsUploaded(overrideRuns)
  879. debug(.nightscout, "Overrides uploaded")
  880. } catch {
  881. debug(.nightscout, error.localizedDescription)
  882. }
  883. }
  884. private func updateOverrideRunsAsUploaded(_ overrideRuns: [NightscoutExercise]) async {
  885. await backgroundContext.perform {
  886. let ids = overrideRuns.map(\.id) as NSArray
  887. let fetchRequest: NSFetchRequest<OverrideRunStored> = OverrideRunStored.fetchRequest()
  888. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  889. do {
  890. let results = try self.backgroundContext.fetch(fetchRequest)
  891. for result in results {
  892. result.isUploadedToNS = true
  893. }
  894. guard self.backgroundContext.hasChanges else { return }
  895. try self.backgroundContext.save()
  896. } catch let error as NSError {
  897. debugPrint(
  898. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  899. )
  900. }
  901. }
  902. }
  903. private func uploadTempTargets(_ tempTargets: [NightscoutTreatment]) async {
  904. guard !tempTargets.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  905. return
  906. }
  907. do {
  908. for chunk in tempTargets.chunks(ofCount: 100) {
  909. try await nightscout.uploadTreatments(Array(chunk))
  910. }
  911. // If successful, update the isUploadedToNS property of the TempTargetStored objects
  912. await updateTempTargetsAsUploaded(tempTargets)
  913. debug(.nightscout, "Temp Targets uploaded")
  914. } catch {
  915. debug(.nightscout, error.localizedDescription)
  916. }
  917. }
  918. private func updateTempTargetsAsUploaded(_ tempTargets: [NightscoutTreatment]) async {
  919. await backgroundContext.perform {
  920. let ids = tempTargets.map(\.id) as NSArray
  921. let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
  922. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  923. do {
  924. let results = try self.backgroundContext.fetch(fetchRequest)
  925. for result in results {
  926. result.isUploadedToNS = true
  927. }
  928. guard self.backgroundContext.hasChanges else { return }
  929. try self.backgroundContext.save()
  930. } catch let error as NSError {
  931. debugPrint(
  932. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetStored: \(error.userInfo)"
  933. )
  934. }
  935. }
  936. }
  937. private func uploadTempTargetRuns(_ tempTargetRuns: [NightscoutTreatment]) async {
  938. guard !tempTargetRuns.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  939. return
  940. }
  941. do {
  942. for chunk in tempTargetRuns.chunks(ofCount: 100) {
  943. try await nightscout.uploadTreatments(Array(chunk))
  944. }
  945. // If successful, update the isUploadedToNS property of the TempTargetRunStored objects
  946. await updateTempTargetRunsAsUploaded(tempTargetRuns)
  947. debug(.nightscout, "Temp Target Runs uploaded")
  948. } catch {
  949. debug(.nightscout, error.localizedDescription)
  950. }
  951. }
  952. private func updateTempTargetRunsAsUploaded(_ tempTargetRuns: [NightscoutTreatment]) async {
  953. await backgroundContext.perform {
  954. let ids = tempTargetRuns.map(\.id) as NSArray
  955. let fetchRequest: NSFetchRequest<TempTargetRunStored> = TempTargetRunStored.fetchRequest()
  956. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  957. do {
  958. let results = try self.backgroundContext.fetch(fetchRequest)
  959. for result in results {
  960. result.isUploadedToNS = true
  961. }
  962. guard self.backgroundContext.hasChanges else { return }
  963. try self.backgroundContext.save()
  964. } catch let error as NSError {
  965. debugPrint(
  966. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetRunStored: \(error.userInfo)"
  967. )
  968. }
  969. }
  970. }
  971. // TODO: have this checked; this has never actually written anything to file; the entire logic of this function seems broken
  972. func uploadNoteTreatment(note: String) async {
  973. let uploadedNotes = storage.retrieve(OpenAPS.Nightscout.uploadedNotes, as: [NightscoutTreatment].self) ?? []
  974. let now = Date()
  975. if uploadedNotes.last?.notes != note || (uploadedNotes.last?.createdAt ?? .distantPast) != now {
  976. let noteTreatment = NightscoutTreatment(
  977. eventType: .nsNote,
  978. createdAt: now,
  979. enteredBy: NightscoutTreatment.local,
  980. notes: note,
  981. targetTop: nil,
  982. targetBottom: nil
  983. )
  984. await uploadNonCoreDataTreatments([noteTreatment])
  985. // TODO: fix/adjust, if necessary
  986. // await uploadTreatments([noteTreatment], fileToSave: OpenAPS.Nightscout.uploadedNotes)
  987. }
  988. }
  989. }
  990. extension Array {
  991. func chunks(ofCount count: Int) -> [[Element]] {
  992. stride(from: 0, to: self.count, by: count).map {
  993. Array(self[$0 ..< Swift.min($0 + count, self.count)])
  994. }
  995. }
  996. }
  997. extension BaseNightscoutManager {
  998. /**
  999. Converts glucose-related values in the given `reason` string to mmol/L, including ranges (e.g., `ISF: 54→54`), comparisons (e.g., `maxDelta 37 > 20% of BG 95`), and both positive and negative values (e.g., `Dev: -36`).
  1000. - Parameters:
  1001. - reason: The string containing glucose-related values to be converted.
  1002. - Returns:
  1003. A string with glucose values converted to mmol/L.
  1004. - Glucose tags handled: `ISF:`, `Target:`, `minPredBG`, `minGuardBG`, `IOBpredBG`, `COBpredBG`, `UAMpredBG`, `Dev:`, `maxDelta`, `BGI`.
  1005. */
  1006. // TODO: Consolidate all mmol parsing methods (in TagCloudView, NightscoutManager and HomeRootView) to one central func
  1007. func parseReasonGlucoseValuesToMmolL(_ reason: String) -> String {
  1008. let patterns = [
  1009. "ISF:\\s*-?\\d+\\.?\\d*→-?\\d+\\.?\\d*", // ISF with arrow
  1010. "Dev:\\s*-?\\d+\\.?\\d*", // Dev pattern
  1011. "BGI:\\s*-?\\d+\\.?\\d*", // BGI pattern
  1012. "Target:\\s*-?\\d+\\.?\\d*", // Target pattern
  1013. "(?:minPredBG|minGuardBG|IOBpredBG|COBpredBG|UAMpredBG)\\s+-?\\d+\\.?\\d*(?:<-?\\d+\\.?\\d*)?", // minPredBG, etc.
  1014. "minGuardBG\\s+-?\\d+\\.?\\d*<-?\\d+\\.?\\d*", // minGuardBG x<y
  1015. "Eventual BG\\s+-?\\d+\\.?\\d*\\s*>=\\s*-?\\d+\\.?\\d*", // Eventual BG x >= target
  1016. "Eventual BG\\s+-?\\d+\\.?\\d*\\s*<\\s*-?\\d+\\.?\\d*", // Eventual BG x < target
  1017. "\\S+\\s+\\d+\\s*>\\s*\\d+%\\s+of\\s+BG\\s+\\d+" // maxDelta x > y% of BG z
  1018. ]
  1019. let pattern = patterns.joined(separator: "|")
  1020. let regex = try! NSRegularExpression(pattern: pattern)
  1021. func convertToMmolL(_ value: String) -> String {
  1022. if let glucoseValue = Double(value.replacingOccurrences(of: "[^\\d.-]", with: "", options: .regularExpression)) {
  1023. let mmolValue = Decimal(glucoseValue).asMmolL
  1024. return mmolValue.description
  1025. }
  1026. return value
  1027. }
  1028. let matches = regex.matches(in: reason, range: NSRange(reason.startIndex..., in: reason))
  1029. var updatedReason = reason
  1030. for match in matches.reversed() {
  1031. guard let range = Range(match.range, in: reason) else { continue }
  1032. let glucoseValueString = String(reason[range])
  1033. if glucoseValueString.contains("→") {
  1034. // Handle ISF: X→Y
  1035. let values = glucoseValueString.components(separatedBy: "→")
  1036. let firstNumber = values[0].components(separatedBy: ":")[1].trimmingCharacters(in: .whitespaces)
  1037. let secondNumber = values[1].trimmingCharacters(in: .whitespaces)
  1038. let firstValue = convertToMmolL(firstNumber)
  1039. let secondValue = convertToMmolL(secondNumber)
  1040. let formattedString = "ISF: \(firstValue)→\(secondValue)"
  1041. updatedReason.replaceSubrange(range, with: formattedString)
  1042. } else if glucoseValueString.contains("Eventual BG"), glucoseValueString.contains("<") {
  1043. // Handle Eventual BG XX < target
  1044. let parts = glucoseValueString.components(separatedBy: "<")
  1045. if parts.count == 2 {
  1046. let bgPart = parts[0].replacingOccurrences(of: "Eventual BG", with: "").trimmingCharacters(in: .whitespaces)
  1047. let targetValue = parts[1].trimmingCharacters(in: .whitespaces)
  1048. let formattedBGPart = convertToMmolL(bgPart)
  1049. let formattedTargetValue = convertToMmolL(targetValue)
  1050. let formattedString = "Eventual BG \(formattedBGPart)<\(formattedTargetValue)"
  1051. updatedReason.replaceSubrange(range, with: formattedString)
  1052. }
  1053. } else if glucoseValueString.contains("<") {
  1054. // Handle minGuardBG (or minPredBG, etc.) x < y
  1055. let parts = glucoseValueString.components(separatedBy: "<")
  1056. if parts.count == 2 {
  1057. let firstValue = parts[0].trimmingCharacters(in: .whitespaces)
  1058. let secondValue = parts[1].trimmingCharacters(in: .whitespaces)
  1059. let formattedFirstValue = convertToMmolL(firstValue)
  1060. let formattedSecondValue = convertToMmolL(secondValue)
  1061. let formattedString = "minGuardBG \(formattedFirstValue)<\(formattedSecondValue)"
  1062. updatedReason.replaceSubrange(range, with: formattedString)
  1063. }
  1064. } else if glucoseValueString.contains(">=") {
  1065. // Handle "Eventual BG X >= Y"
  1066. let parts = glucoseValueString.components(separatedBy: " >= ")
  1067. if parts.count == 2 {
  1068. let firstValue = parts[0].replacingOccurrences(of: "Eventual BG", with: "")
  1069. .trimmingCharacters(in: .whitespaces)
  1070. let secondValue = parts[1].trimmingCharacters(in: .whitespaces)
  1071. let formattedFirstValue = convertToMmolL(firstValue)
  1072. let formattedSecondValue = convertToMmolL(secondValue)
  1073. let formattedString = "Eventual BG \(formattedFirstValue) >= \(formattedSecondValue)"
  1074. updatedReason.replaceSubrange(range, with: formattedString)
  1075. }
  1076. } else if glucoseValueString.contains(">"), glucoseValueString.contains("BG") {
  1077. // Handle "maxDelta 37 > 20% of BG 95" style
  1078. let localPattern = "(\\d+) > (\\d+)% of BG (\\d+)"
  1079. let localRegex = try! NSRegularExpression(pattern: localPattern)
  1080. let localMatches = localRegex.matches(
  1081. in: glucoseValueString,
  1082. range: NSRange(glucoseValueString.startIndex..., in: glucoseValueString)
  1083. )
  1084. if let localMatch = localMatches.first, localMatch.numberOfRanges == 4 {
  1085. let range1 = Range(localMatch.range(at: 1), in: glucoseValueString)!
  1086. let range2 = Range(localMatch.range(at: 2), in: glucoseValueString)!
  1087. let range3 = Range(localMatch.range(at: 3), in: glucoseValueString)!
  1088. let firstValue = convertToMmolL(String(glucoseValueString[range1]))
  1089. let thirdValue = convertToMmolL(String(glucoseValueString[range3]))
  1090. let oldSnippet =
  1091. "\(glucoseValueString[range1]) > \(glucoseValueString[range2])% of BG \(glucoseValueString[range3])"
  1092. let newSnippet = "\(firstValue) > \(glucoseValueString[range2])% of BG \(thirdValue)"
  1093. let replaced = glucoseValueString.replacingOccurrences(of: oldSnippet, with: newSnippet)
  1094. updatedReason.replaceSubrange(range, with: replaced)
  1095. }
  1096. } else {
  1097. // Handle everything else, e.g., "minPredBG 39", "Dev: 5", etc.
  1098. let parts = glucoseValueString.components(separatedBy: .whitespaces)
  1099. if parts.count >= 2 {
  1100. var metric = parts[0]
  1101. let value = parts[1]
  1102. // Add ":" to the metric only if it doesn't already end with ":"
  1103. if !metric.hasSuffix(":") {
  1104. metric += ":"
  1105. }
  1106. let formattedValue = convertToMmolL(value)
  1107. let formattedString = "\(metric) \(formattedValue)"
  1108. updatedReason.replaceSubrange(range, with: formattedString)
  1109. }
  1110. }
  1111. }
  1112. return updatedReason
  1113. }
  1114. }