NightscoutManager.swift 48 KB

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