NightscoutManager.swift 43 KB

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