NightscoutManager.swift 41 KB

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