NightscoutManager.swift 43 KB

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