NightscoutManager.swift 57 KB

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