NightscoutManager.swift 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082
  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 percent = percent, let voltage = voltage, let status = status, let display = display {
  289. debugPrint(
  290. "Home State Model: \(#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.normal,
  296. display: display
  297. )
  298. }
  299. }
  300. return Battery(percent: 100, voltage: 100, string: BatteryState.normal, display: false)
  301. } catch {
  302. debugPrint(
  303. "Home State Model: \(#function) \(DebuggingIdentifiers.failed) failed to setup battery from core data"
  304. )
  305. return Battery(percent: 100, voltage: 100, string: BatteryState.normal, display: false)
  306. }
  307. }
  308. }
  309. func uploadStatus() async {
  310. guard let nightscout = nightscoutAPI, isUploadEnabled else {
  311. debug(.nightscout, "NS API not available or upload disabled. Aborting NS Status upload.")
  312. return
  313. }
  314. // Suggested / Enacted
  315. async let enactedDeterminationID = determinationStorage
  316. .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDeterminationsNotYetUploadedToNightscout)
  317. async let suggestedDeterminationID = determinationStorage
  318. .fetchLastDeterminationObjectID(predicate: NSPredicate.suggestedDeterminationsNotYetUploadedToNightscout)
  319. // OpenAPS Status
  320. async let fetchedBattery = fetchBattery()
  321. async let fetchedReservoir = Decimal(from: storage.retrieveRawAsync(OpenAPS.Monitor.reservoir) ?? "0")
  322. async let fetchedIOBEntry = storage.retrieveAsync(OpenAPS.Monitor.iob, as: [IOBEntry].self)
  323. async let fetchedPumpStatus = storage.retrieveAsync(OpenAPS.Monitor.status, as: PumpStatus.self)
  324. var (fetchedEnactedDetermination, fetchedSuggestedDetermination) = await (
  325. determinationStorage.getOrefDeterminationNotYetUploadedToNightscout(enactedDeterminationID),
  326. determinationStorage.getOrefDeterminationNotYetUploadedToNightscout(suggestedDeterminationID)
  327. )
  328. // Guard to ensure both determinations are not nil
  329. guard fetchedEnactedDetermination != nil || fetchedSuggestedDetermination != nil else {
  330. debug(
  331. .nightscout,
  332. "Both fetchedEnactedDetermination and fetchedSuggestedDetermination are nil. Aborting NS Status upload."
  333. )
  334. return
  335. }
  336. // Unwrap fetchedSuggestedDetermination and manipulate the timestamp field to ensure deliverAt and timestamp for a suggestion truly match!
  337. var modifiedSuggestedDetermination = fetchedSuggestedDetermination
  338. if var suggestion = fetchedSuggestedDetermination {
  339. suggestion.timestamp = suggestion.deliverAt
  340. if settingsManager.settings.units == .mmolL {
  341. suggestion.reason = parseReasonGlucoseValuesToMmolL(suggestion.reason)
  342. }
  343. // Check whether the last suggestion that was uploaded is the same that is fetched again when we are attempting to upload the enacted determination
  344. // Apparently we are too fast; so the flag update is not fast enough to have the predicate filter last suggestion out
  345. // If this check is truthy, set suggestion to nil so it's not uploaded again
  346. if let lastSuggested = lastSuggestedDetermination, lastSuggested.deliverAt == suggestion.deliverAt {
  347. modifiedSuggestedDetermination = nil
  348. } else {
  349. modifiedSuggestedDetermination = suggestion
  350. }
  351. }
  352. if let fetchedEnacted = fetchedEnactedDetermination, settingsManager.settings.units == .mmolL {
  353. var modifiedFetchedEnactedDetermination = fetchedEnactedDetermination
  354. modifiedFetchedEnactedDetermination?
  355. .reason = parseReasonGlucoseValuesToMmolL(fetchedEnacted.reason)
  356. modifiedFetchedEnactedDetermination?.bg = fetchedEnacted.bg?.asMmolL
  357. modifiedFetchedEnactedDetermination?.current_target = fetchedEnacted.current_target?.asMmolL
  358. modifiedFetchedEnactedDetermination?.minGuardBG = fetchedEnacted.minGuardBG?.asMmolL
  359. modifiedFetchedEnactedDetermination?.minPredBG = fetchedEnacted.minPredBG?.asMmolL
  360. modifiedFetchedEnactedDetermination?.threshold = fetchedEnacted.threshold?.asMmolL
  361. fetchedEnactedDetermination = modifiedFetchedEnactedDetermination
  362. }
  363. // Gather all relevant data for OpenAPS Status
  364. let iob = await fetchedIOBEntry
  365. let openapsStatus = OpenAPSStatus(
  366. iob: iob?.first,
  367. suggested: modifiedSuggestedDetermination,
  368. enacted: settingsManager.settings.closedLoop ? fetchedEnactedDetermination : nil,
  369. version: "0.7.1"
  370. )
  371. // Gather all relevant data for NS Status
  372. let battery = await fetchedBattery
  373. let reservoir = await fetchedReservoir
  374. let pumpStatus = await fetchedPumpStatus
  375. let pump = NSPumpStatus(
  376. clock: Date(),
  377. battery: battery,
  378. reservoir: reservoir != 0xDEAD_BEEF ? reservoir : nil,
  379. status: pumpStatus
  380. )
  381. let device = await UIDevice.current
  382. let uploader = await Uploader(batteryVoltage: nil, battery: Int(device.batteryLevel * 100))
  383. let status = NightscoutStatus(
  384. device: NightscoutTreatment.local,
  385. openaps: openapsStatus,
  386. pump: pump,
  387. uploader: uploader
  388. )
  389. do {
  390. try await nightscout.uploadStatus(status)
  391. debug(.nightscout, "Status uploaded")
  392. if let enacted = fetchedEnactedDetermination {
  393. await updateOrefDeterminationAsUploaded([enacted])
  394. }
  395. if let suggested = fetchedSuggestedDetermination {
  396. await updateOrefDeterminationAsUploaded([suggested])
  397. }
  398. lastEnactedDetermination = fetchedEnactedDetermination
  399. lastSuggestedDetermination = fetchedSuggestedDetermination
  400. debug(.nightscout, "NSDeviceStatus with Determination uploaded")
  401. } catch {
  402. debug(.nightscout, error.localizedDescription)
  403. }
  404. Task.detached {
  405. await self.uploadPodAge()
  406. }
  407. }
  408. private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
  409. await backgroundContext.perform {
  410. let ids = determination.map(\.id) as NSArray
  411. let fetchRequest: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
  412. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  413. do {
  414. let results = try self.backgroundContext.fetch(fetchRequest)
  415. for result in results {
  416. result.isUploadedToNS = true
  417. }
  418. guard self.backgroundContext.hasChanges else { return }
  419. try self.backgroundContext.save()
  420. } catch let error as NSError {
  421. debugPrint(
  422. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  423. )
  424. }
  425. }
  426. }
  427. func uploadPodAge() async {
  428. let uploadedPodAge = storage.retrieve(OpenAPS.Nightscout.uploadedPodAge, as: [NightscoutTreatment].self) ?? []
  429. if let podAge = storage.retrieve(OpenAPS.Monitor.podAge, as: Date.self),
  430. uploadedPodAge.last?.createdAt == nil || podAge != uploadedPodAge.last!.createdAt!
  431. {
  432. let siteTreatment = NightscoutTreatment(
  433. duration: nil,
  434. rawDuration: nil,
  435. rawRate: nil,
  436. absolute: nil,
  437. rate: nil,
  438. eventType: .nsSiteChange,
  439. createdAt: podAge,
  440. enteredBy: NightscoutTreatment.local,
  441. bolus: nil,
  442. insulin: nil,
  443. notes: nil,
  444. carbs: nil,
  445. fat: nil,
  446. protein: nil,
  447. targetTop: nil,
  448. targetBottom: nil
  449. )
  450. await uploadTreatments([siteTreatment], fileToSave: OpenAPS.Nightscout.uploadedPodAge)
  451. }
  452. }
  453. func uploadProfiles() async {
  454. if isUploadEnabled {
  455. do {
  456. guard let sensitivities = await storage.retrieveAsync(
  457. OpenAPS.Settings.insulinSensitivities,
  458. as: InsulinSensitivities.self
  459. ) else {
  460. debug(.nightscout, "NightscoutManager uploadProfile: error loading insulinSensitivities")
  461. return
  462. }
  463. guard let targets = await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self) else {
  464. debug(.nightscout, "NightscoutManager uploadProfile: error loading bgTargets")
  465. return
  466. }
  467. guard let carbRatios = await storage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self) else {
  468. debug(.nightscout, "NightscoutManager uploadProfile: error loading carbRatios")
  469. return
  470. }
  471. guard let basalProfile = await storage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
  472. else {
  473. debug(.nightscout, "NightscoutManager uploadProfile: error loading basalProfile")
  474. return
  475. }
  476. let shouldParseToMmolL = settingsManager.settings.units == .mmolL
  477. let sens = sensitivities.sensitivities.map { item in
  478. NightscoutTimevalue(
  479. time: String(item.start.prefix(5)),
  480. value: !shouldParseToMmolL ? item.sensitivity : item.sensitivity.asMmolL,
  481. timeAsSeconds: item.offset * 60
  482. )
  483. }
  484. let targetLow = targets.targets.map { item in
  485. NightscoutTimevalue(
  486. time: String(item.start.prefix(5)),
  487. value: !shouldParseToMmolL ? item.low : item.low.asMmolL,
  488. timeAsSeconds: item.offset * 60
  489. )
  490. }
  491. let targetHigh = targets.targets.map { item in
  492. NightscoutTimevalue(
  493. time: String(item.start.prefix(5)),
  494. value: !shouldParseToMmolL ? item.high : item.high.asMmolL,
  495. timeAsSeconds: item.offset * 60
  496. )
  497. }
  498. let cr = carbRatios.schedule.map { item in
  499. NightscoutTimevalue(
  500. time: String(item.start.prefix(5)),
  501. value: item.ratio,
  502. timeAsSeconds: item.offset * 60
  503. )
  504. }
  505. let basal = basalProfile.map { item in
  506. NightscoutTimevalue(
  507. time: String(item.start.prefix(5)),
  508. value: item.rate,
  509. timeAsSeconds: item.minutes * 60
  510. )
  511. }
  512. let nsUnits: String = {
  513. switch settingsManager.settings.units {
  514. case .mgdL:
  515. return "mg/dl"
  516. case .mmolL:
  517. return "mmol"
  518. }
  519. }()
  520. var carbsHr: Decimal = 0
  521. if let isf = sensitivities.sensitivities.map(\.sensitivity).first,
  522. let cr = carbRatios.schedule.map(\.ratio).first,
  523. isf > 0, cr > 0
  524. {
  525. carbsHr = settingsManager.preferences.min5mCarbimpact * 12 / isf * cr
  526. if settingsManager.settings.units == .mmolL {
  527. carbsHr *= GlucoseUnits.exchangeRate
  528. }
  529. carbsHr = Decimal(round(Double(carbsHr) * 10.0)) / 10
  530. }
  531. let scheduledProfile = ScheduledNightscoutProfile(
  532. dia: settingsManager.pumpSettings.insulinActionCurve,
  533. carbs_hr: Int(carbsHr),
  534. delay: 0,
  535. timezone: TimeZone.current.identifier,
  536. target_low: targetLow,
  537. target_high: targetHigh,
  538. sens: sens,
  539. basal: basal,
  540. carbratio: cr,
  541. units: nsUnits
  542. )
  543. let defaultProfile = "default"
  544. let now = Date()
  545. let bundleIdentifier = Bundle.main.bundleIdentifier ?? ""
  546. let deviceToken = UserDefaults.standard.string(forKey: "deviceToken") ?? ""
  547. let isAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
  548. let presetOverrides = await overridesStorage.getPresetOverridesForNightscout()
  549. let teamID = Bundle.main.object(forInfoDictionaryKey: "TeamID") as? String ?? ""
  550. let profileStore = NightscoutProfileStore(
  551. defaultProfile: defaultProfile,
  552. startDate: now,
  553. mills: Int(now.timeIntervalSince1970) * 1000,
  554. units: nsUnits,
  555. enteredBy: NightscoutTreatment.local,
  556. store: [defaultProfile: scheduledProfile],
  557. bundleIdentifier: bundleIdentifier,
  558. deviceToken: deviceToken,
  559. isAPNSProduction: isAPNSProduction,
  560. overridePresets: presetOverrides,
  561. teamID: teamID
  562. )
  563. guard let nightscout = nightscoutAPI, isNetworkReachable else {
  564. if !isNetworkReachable {
  565. debug(.nightscout, "Network issues; aborting upload")
  566. }
  567. debug(.nightscout, "Nightscout API service not available; aborting upload")
  568. return
  569. }
  570. do {
  571. try await nightscout.uploadProfile(profileStore)
  572. debug(.nightscout, "Profile uploaded")
  573. } catch {
  574. debug(.nightscout, "NightscoutManager uploadProfile: \(error.localizedDescription)")
  575. }
  576. }
  577. } else {
  578. debug(.nightscout, "Upload to NS disabled; aborting profile uploaded")
  579. }
  580. }
  581. func importSettings() async -> ScheduledNightscoutProfile? {
  582. guard let nightscout = nightscoutAPI else {
  583. debug(.nightscout, "NS API not available. Aborting NS Status upload.")
  584. return nil
  585. }
  586. do {
  587. return try await nightscout.importSettings()
  588. } catch {
  589. debug(.nightscout, error.localizedDescription)
  590. return nil
  591. }
  592. }
  593. func uploadGlucose() async {
  594. await uploadGlucose(glucoseStorage.getGlucoseNotYetUploadedToNightscout())
  595. await uploadTreatments(
  596. glucoseStorage.getCGMStateNotYetUploadedToNightscout(),
  597. fileToSave: OpenAPS.Nightscout.uploadedCGMState
  598. )
  599. }
  600. func uploadManualGlucose() async {
  601. await uploadManualGlucose(glucoseStorage.getManualGlucoseNotYetUploadedToNightscout())
  602. }
  603. func uploadPumpHistory() async {
  604. await uploadTreatments(
  605. pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout(),
  606. fileToSave: OpenAPS.Nightscout.uploadedPumphistory
  607. )
  608. }
  609. func uploadCarbs() async {
  610. await uploadCarbs(carbsStorage.getCarbsNotYetUploadedToNightscout())
  611. await uploadCarbs(carbsStorage.getFPUsNotYetUploadedToNightscout())
  612. }
  613. func uploadOverrides() async {
  614. await uploadOverrides(overridesStorage.getOverridesNotYetUploadedToNightscout())
  615. await uploadOverrideRuns(overridesStorage.getOverrideRunsNotYetUploadedToNightscout())
  616. }
  617. func uploadTempTargets() async {
  618. await uploadTreatments(
  619. tempTargetsStorage.nightscoutTreatmentsNotUploaded(),
  620. fileToSave: OpenAPS.Nightscout.uploadedTempTargets
  621. )
  622. }
  623. private func uploadGlucose(_ glucose: [BloodGlucose]) async {
  624. guard !glucose.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled, isUploadGlucoseEnabled else {
  625. return
  626. }
  627. do {
  628. // Upload in Batches of 100
  629. for chunk in glucose.chunks(ofCount: 100) {
  630. try await nightscout.uploadGlucose(Array(chunk))
  631. }
  632. // If successful, update the isUploadedToNS property of the GlucoseStored objects
  633. await updateGlucoseAsUploaded(glucose)
  634. debug(.nightscout, "Glucose uploaded")
  635. } catch {
  636. debug(.nightscout, "Upload of glucose failed: \(error.localizedDescription)")
  637. }
  638. }
  639. private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
  640. await backgroundContext.perform {
  641. let ids = glucose.map(\.id) as NSArray
  642. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  643. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  644. do {
  645. let results = try self.backgroundContext.fetch(fetchRequest)
  646. for result in results {
  647. result.isUploadedToNS = true
  648. }
  649. guard self.backgroundContext.hasChanges else { return }
  650. try self.backgroundContext.save()
  651. } catch let error as NSError {
  652. debugPrint(
  653. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  654. )
  655. }
  656. }
  657. }
  658. private func uploadTreatments(_ treatments: [NightscoutTreatment], fileToSave _: String) async {
  659. guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  660. return
  661. }
  662. do {
  663. for chunk in treatments.chunks(ofCount: 100) {
  664. try await nightscout.uploadTreatments(Array(chunk))
  665. }
  666. // If successful, update the isUploadedToNS property of the PumpEventStored objects
  667. await updateTreatmentsAsUploaded(treatments)
  668. debug(.nightscout, "Treatments uploaded")
  669. } catch {
  670. debug(.nightscout, error.localizedDescription)
  671. }
  672. }
  673. private func updateTreatmentsAsUploaded(_ treatments: [NightscoutTreatment]) async {
  674. await backgroundContext.perform {
  675. let ids = treatments.map(\.id) as NSArray
  676. let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
  677. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  678. do {
  679. let results = try self.backgroundContext.fetch(fetchRequest)
  680. for result in results {
  681. result.isUploadedToNS = true
  682. }
  683. guard self.backgroundContext.hasChanges else { return }
  684. try self.backgroundContext.save()
  685. } catch let error as NSError {
  686. debugPrint(
  687. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  688. )
  689. }
  690. }
  691. }
  692. private func uploadManualGlucose(_ treatments: [NightscoutTreatment]) async {
  693. guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  694. return
  695. }
  696. do {
  697. for chunk in treatments.chunks(ofCount: 100) {
  698. try await nightscout.uploadTreatments(Array(chunk))
  699. }
  700. // If successful, update the isUploadedToNS property of the GlucoseStored objects
  701. await updateManualGlucoseAsUploaded(treatments)
  702. debug(.nightscout, "Treatments uploaded")
  703. } catch {
  704. debug(.nightscout, error.localizedDescription)
  705. }
  706. }
  707. private func updateManualGlucoseAsUploaded(_ treatments: [NightscoutTreatment]) async {
  708. await backgroundContext.perform {
  709. let ids = treatments.map(\.id) as NSArray
  710. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  711. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  712. do {
  713. let results = try self.backgroundContext.fetch(fetchRequest)
  714. for result in results {
  715. result.isUploadedToNS = true
  716. }
  717. guard self.backgroundContext.hasChanges else { return }
  718. try self.backgroundContext.save()
  719. } catch let error as NSError {
  720. debugPrint(
  721. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  722. )
  723. }
  724. }
  725. }
  726. private func uploadCarbs(_ treatments: [NightscoutTreatment]) async {
  727. guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  728. return
  729. }
  730. do {
  731. for chunk in treatments.chunks(ofCount: 100) {
  732. try await nightscout.uploadTreatments(Array(chunk))
  733. }
  734. // If successful, update the isUploadedToNS property of the CarbEntryStored objects
  735. await updateCarbsAsUploaded(treatments)
  736. debug(.nightscout, "Treatments uploaded")
  737. } catch {
  738. debug(.nightscout, error.localizedDescription)
  739. }
  740. }
  741. private func updateCarbsAsUploaded(_ treatments: [NightscoutTreatment]) async {
  742. await backgroundContext.perform {
  743. let ids = treatments.map(\.id) as NSArray
  744. let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
  745. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  746. do {
  747. let results = try self.backgroundContext.fetch(fetchRequest)
  748. for result in results {
  749. result.isUploadedToNS = true
  750. }
  751. guard self.backgroundContext.hasChanges else { return }
  752. try self.backgroundContext.save()
  753. } catch let error as NSError {
  754. debugPrint(
  755. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  756. )
  757. }
  758. }
  759. }
  760. private func uploadOverrides(_ overrides: [NightscoutExercise]) async {
  761. guard !overrides.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  762. return
  763. }
  764. do {
  765. for chunk in overrides.chunks(ofCount: 100) {
  766. try await nightscout.uploadOverrides(Array(chunk))
  767. }
  768. // If successful, update the isUploadedToNS property of the OverrideStored objects
  769. await updateOverridesAsUploaded(overrides)
  770. debug(.nightscout, "Overrides uploaded")
  771. } catch {
  772. debug(.nightscout, error.localizedDescription)
  773. }
  774. }
  775. private func updateOverridesAsUploaded(_ overrides: [NightscoutExercise]) async {
  776. await backgroundContext.perform {
  777. let ids = overrides.map(\.id) as NSArray
  778. let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
  779. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  780. do {
  781. let results = try self.backgroundContext.fetch(fetchRequest)
  782. for result in results {
  783. result.isUploadedToNS = true
  784. }
  785. guard self.backgroundContext.hasChanges else { return }
  786. try self.backgroundContext.save()
  787. } catch let error as NSError {
  788. debugPrint(
  789. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  790. )
  791. }
  792. }
  793. }
  794. private func uploadOverrideRuns(_ overrideRuns: [NightscoutExercise]) async {
  795. guard !overrideRuns.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
  796. return
  797. }
  798. do {
  799. for chunk in overrideRuns.chunks(ofCount: 100) {
  800. try await nightscout.uploadOverrides(Array(chunk))
  801. }
  802. // If successful, update the isUploadedToNS property of the OverrideRunStored objects
  803. await updateOverrideRunsAsUploaded(overrideRuns)
  804. debug(.nightscout, "Overrides uploaded")
  805. } catch {
  806. debug(.nightscout, error.localizedDescription)
  807. }
  808. }
  809. private func updateOverrideRunsAsUploaded(_ overrideRuns: [NightscoutExercise]) async {
  810. await backgroundContext.perform {
  811. let ids = overrideRuns.map(\.id) as NSArray
  812. let fetchRequest: NSFetchRequest<OverrideRunStored> = OverrideRunStored.fetchRequest()
  813. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  814. do {
  815. let results = try self.backgroundContext.fetch(fetchRequest)
  816. for result in results {
  817. result.isUploadedToNS = true
  818. }
  819. guard self.backgroundContext.hasChanges else { return }
  820. try self.backgroundContext.save()
  821. } catch let error as NSError {
  822. debugPrint(
  823. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
  824. )
  825. }
  826. }
  827. }
  828. func uploadNoteTreatment(note: String) async {
  829. let uploadedNotes = storage.retrieve(OpenAPS.Nightscout.uploadedNotes, as: [NightscoutTreatment].self) ?? []
  830. let now = Date()
  831. if uploadedNotes.last?.notes != note || (uploadedNotes.last?.createdAt ?? .distantPast) != now {
  832. let noteTreatment = NightscoutTreatment(
  833. eventType: .nsNote,
  834. createdAt: now,
  835. enteredBy: NightscoutTreatment.local,
  836. notes: note,
  837. targetTop: nil,
  838. targetBottom: nil
  839. )
  840. await uploadTreatments([noteTreatment], fileToSave: OpenAPS.Nightscout.uploadedNotes)
  841. }
  842. }
  843. }
  844. extension Array {
  845. func chunks(ofCount count: Int) -> [[Element]] {
  846. stride(from: 0, to: self.count, by: count).map {
  847. Array(self[$0 ..< Swift.min($0 + count, self.count)])
  848. }
  849. }
  850. }
  851. extension BaseNightscoutManager: TempTargetsObserver {
  852. func tempTargetsDidUpdate(_: [TempTarget]) {
  853. Task.detached {
  854. await self.uploadTempTargets()
  855. }
  856. }
  857. }
  858. extension BaseNightscoutManager {
  859. /**
  860. 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`).
  861. - Parameters:
  862. - reason: The string containing glucose-related values to be converted.
  863. - Returns:
  864. A string with glucose values converted to mmol/L.
  865. - Glucose tags handled: `ISF:`, `Target:`, `minPredBG`, `minGuardBG`, `IOBpredBG`, `COBpredBG`, `UAMpredBG`, `Dev:`, `maxDelta`, `BG`.
  866. */
  867. func parseReasonGlucoseValuesToMmolL(_ reason: String) -> String {
  868. // Updated pattern to handle cases like minGuardBG 34, minGuardBG 34<70, and "maxDelta 37 > 20% of BG 95", and ensure "Target:" is handled correctly
  869. let pattern =
  870. "(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+)?)"
  871. let regex = try! NSRegularExpression(pattern: pattern)
  872. func convertToMmolL(_ value: String) -> String {
  873. if let glucoseValue = Double(value.replacingOccurrences(of: "[^\\d.-]", with: "", options: .regularExpression)) {
  874. return glucoseValue.asMmolL.description
  875. }
  876. return value
  877. }
  878. let matches = regex.matches(in: reason, range: NSRange(reason.startIndex..., in: reason))
  879. var updatedReason = reason
  880. for match in matches.reversed() {
  881. if let range = Range(match.range, in: reason) {
  882. let glucoseValueString = String(reason[range])
  883. if glucoseValueString.contains("→") {
  884. // Handle ISF case with an arrow (e.g., ISF: 54→54)
  885. let values = glucoseValueString.components(separatedBy: "→")
  886. let firstValue = convertToMmolL(values[0])
  887. let secondValue = convertToMmolL(values[1])
  888. let formattedGlucoseValueString = "\(values[0].components(separatedBy: ":")[0]): \(firstValue)→\(secondValue)"
  889. updatedReason.replaceSubrange(range, with: formattedGlucoseValueString)
  890. } else if glucoseValueString.contains("<") {
  891. // Handle range case for minGuardBG like "minGuardBG 34<70"
  892. let values = glucoseValueString.components(separatedBy: "<")
  893. let firstValue = convertToMmolL(values[0])
  894. let secondValue = convertToMmolL(values[1])
  895. let formattedGlucoseValueString = "\(values[0].components(separatedBy: ":")[0]) \(firstValue)<\(secondValue)"
  896. updatedReason.replaceSubrange(range, with: formattedGlucoseValueString)
  897. } else if glucoseValueString.contains(">"), glucoseValueString.contains("BG") {
  898. // Handle cases like "maxDelta 37 > 20% of BG 95"
  899. let pattern = "(\\d+) > \\d+% of BG (\\d+)"
  900. let matches = try! NSRegularExpression(pattern: pattern)
  901. .matches(in: glucoseValueString, range: NSRange(glucoseValueString.startIndex..., in: glucoseValueString))
  902. if let match = matches.first, match.numberOfRanges == 3 {
  903. let firstValueRange = Range(match.range(at: 1), in: glucoseValueString)!
  904. let secondValueRange = Range(match.range(at: 2), in: glucoseValueString)!
  905. let firstValue = convertToMmolL(String(glucoseValueString[firstValueRange]))
  906. let secondValue = convertToMmolL(String(glucoseValueString[secondValueRange]))
  907. let formattedGlucoseValueString = glucoseValueString.replacingOccurrences(
  908. of: "\(glucoseValueString[firstValueRange]) > 20% of BG \(glucoseValueString[secondValueRange])",
  909. with: "\(firstValue) > 20% of BG \(secondValue)"
  910. )
  911. updatedReason.replaceSubrange(range, with: formattedGlucoseValueString)
  912. }
  913. } else {
  914. // General case for single glucose values like "Target: 100" or "minGuardBG 34"
  915. let parts = glucoseValueString.components(separatedBy: CharacterSet(charactersIn: ": "))
  916. let formattedValue = convertToMmolL(parts.last!.trimmingCharacters(in: .whitespaces))
  917. updatedReason.replaceSubrange(range, with: "\(parts[0]): \(formattedValue)")
  918. }
  919. }
  920. }
  921. return updatedReason
  922. }
  923. }