TidepoolManager.swift 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import HealthKit
  5. import LoopKit
  6. import LoopKitUI
  7. import Swinject
  8. protocol TidepoolManager {
  9. func addTidepoolService(service: Service)
  10. func getTidepoolServiceUI() -> ServiceUI?
  11. func getTidepoolPluginHost() -> PluginHost?
  12. func uploadCarbs() async
  13. func deleteCarbs(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String)
  14. func uploadInsulin() async
  15. func deleteInsulin(withSyncId id: String, amount: Decimal, at: Date)
  16. func uploadGlucose() async
  17. func forceTidepoolDataUpload()
  18. }
  19. final class BaseTidepoolManager: TidepoolManager, Injectable {
  20. @Injected() private var broadcaster: Broadcaster!
  21. @Injected() private var pluginManager: PluginManager!
  22. @Injected() private var glucoseStorage: GlucoseStorage!
  23. @Injected() private var carbsStorage: CarbsStorage!
  24. @Injected() private var storage: FileStorage!
  25. @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
  26. @Injected() private var apsManager: APSManager!
  27. private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
  28. private var tidepoolService: RemoteDataService? {
  29. didSet {
  30. if let tidepoolService = tidepoolService {
  31. rawTidepoolManager = tidepoolService.rawValue
  32. } else {
  33. rawTidepoolManager = nil
  34. }
  35. }
  36. }
  37. private var backgroundContext = CoreDataStack.shared.newTaskContext()
  38. private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
  39. private var subscriptions = Set<AnyCancellable>()
  40. @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
  41. init(resolver: Resolver) {
  42. injectServices(resolver)
  43. loadTidepoolManager()
  44. coreDataPublisher =
  45. changedObjectsOnManagedObjectContextDidSavePublisher()
  46. .receive(on: DispatchQueue.global(qos: .background))
  47. .share()
  48. .eraseToAnyPublisher()
  49. glucoseStorage.updatePublisher
  50. .receive(on: DispatchQueue.global(qos: .background))
  51. .sink { [weak self] _ in
  52. guard let self = self else { return }
  53. Task {
  54. await self.uploadGlucose()
  55. }
  56. }
  57. .store(in: &subscriptions)
  58. registerHandlers()
  59. subscribe()
  60. }
  61. /// Loads the Tidepool service from saved state
  62. fileprivate func loadTidepoolManager() {
  63. if let rawTidepoolManager = rawTidepoolManager {
  64. tidepoolService = tidepoolServiceFromRaw(rawTidepoolManager)
  65. tidepoolService?.serviceDelegate = self
  66. tidepoolService?.stateDelegate = self
  67. }
  68. }
  69. /// Returns the Tidepool service UI if available
  70. func getTidepoolServiceUI() -> ServiceUI? {
  71. tidepoolService as? ServiceUI
  72. }
  73. /// Returns the Tidepool plugin host
  74. func getTidepoolPluginHost() -> PluginHost? {
  75. self as PluginHost
  76. }
  77. /// Adds a Tidepool service
  78. func addTidepoolService(service: Service) {
  79. tidepoolService = service as? RemoteDataService
  80. }
  81. /// Loads the Tidepool service from raw stored data
  82. private func tidepoolServiceFromRaw(_ rawValue: [String: Any]) -> RemoteDataService? {
  83. guard let rawState = rawValue["state"] as? Service.RawStateValue,
  84. let serviceType = pluginManager.getServiceTypeByIdentifier("TidepoolService")
  85. else { return nil }
  86. if let service = serviceType.init(rawState: rawState) {
  87. return service as? RemoteDataService
  88. }
  89. return nil
  90. }
  91. /// Registers handlers for Core Data changes
  92. private func registerHandlers() {
  93. coreDataPublisher?.filterByEntityName("PumpEventStored").sink { [weak self] _ in
  94. guard let self = self else { return }
  95. Task { [weak self] in
  96. guard let self = self else { return }
  97. await self.uploadInsulin()
  98. }
  99. }.store(in: &subscriptions)
  100. coreDataPublisher?.filterByEntityName("CarbEntryStored").sink { [weak self] _ in
  101. guard let self = self else { return }
  102. Task { [weak self] in
  103. guard let self = self else { return }
  104. await self.uploadCarbs()
  105. }
  106. }.store(in: &subscriptions)
  107. // This works only for manual Glucose
  108. coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
  109. guard let self = self else { return }
  110. Task { [weak self] in
  111. guard let self = self else { return }
  112. await self.uploadGlucose()
  113. }
  114. }.store(in: &subscriptions)
  115. }
  116. private func subscribe() {
  117. broadcaster.register(TempTargetsObserver.self, observer: self)
  118. }
  119. func sourceInfo() -> [String: Any]? {
  120. nil
  121. }
  122. /// Forces a full data upload to Tidepool
  123. func forceTidepoolDataUpload() {
  124. Task {
  125. await uploadInsulin()
  126. await uploadCarbs()
  127. await uploadGlucose()
  128. }
  129. }
  130. }
  131. extension BaseTidepoolManager: TempTargetsObserver {
  132. func tempTargetsDidUpdate(_: [TempTarget]) {}
  133. }
  134. extension BaseTidepoolManager: ServiceDelegate {
  135. var hostIdentifier: String {
  136. // TODO: shouldn't this rather be `org.nightscout.Trio` ?
  137. "com.loopkit.Loop" // To check
  138. }
  139. var hostVersion: String {
  140. var semanticVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
  141. while semanticVersion.split(separator: ".").count < 3 {
  142. semanticVersion += ".0"
  143. }
  144. semanticVersion += "+\(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String)"
  145. return semanticVersion
  146. }
  147. func issueAlert(_: LoopKit.Alert) {}
  148. func retractAlert(identifier _: LoopKit.Alert.Identifier) {}
  149. func enactRemoteOverride(name _: String, durationTime _: TimeInterval?, remoteAddress _: String) async throws {}
  150. func cancelRemoteOverride() async throws {}
  151. func deliverRemoteCarbs(
  152. amountInGrams _: Double,
  153. absorptionTime _: TimeInterval?,
  154. foodType _: String?,
  155. startDate _: Date?
  156. ) async throws {}
  157. func deliverRemoteBolus(amountInUnits _: Double) async throws {}
  158. }
  159. /// Carb Upload and Deletion Functionality
  160. extension BaseTidepoolManager {
  161. func uploadCarbs() async {
  162. uploadCarbs(await carbsStorage.getCarbsNotYetUploadedToTidepool())
  163. }
  164. func uploadCarbs(_ carbs: [CarbsEntry]) {
  165. guard !carbs.isEmpty, let tidepoolService = self.tidepoolService else { return }
  166. processQueue.async {
  167. carbs.chunks(ofCount: tidepoolService.carbDataLimit ?? 100).forEach { chunk in
  168. let syncCarb: [SyncCarbObject] = Array(chunk).map {
  169. $0.convertSyncCarb()
  170. }
  171. tidepoolService.uploadCarbData(created: syncCarb, updated: [], deleted: []) { result in
  172. switch result {
  173. case let .failure(error):
  174. debug(.nightscout, "Error synchronizing carbs data with Tidepool: \(String(describing: error))")
  175. case .success:
  176. debug(.nightscout, "Success synchronizing carbs data. Upload to Tidepool complete.")
  177. // After successful upload, update the isUploadedToTidepool flag in Core Data
  178. Task {
  179. await self.updateCarbsAsUploaded(carbs)
  180. }
  181. }
  182. }
  183. }
  184. }
  185. }
  186. private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
  187. await backgroundContext.perform {
  188. let ids = carbs.map(\.id) as NSArray
  189. let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
  190. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  191. do {
  192. let results = try self.backgroundContext.fetch(fetchRequest)
  193. for result in results {
  194. result.isUploadedToTidepool = true
  195. }
  196. guard self.backgroundContext.hasChanges else { return }
  197. try self.backgroundContext.save()
  198. } catch let error as NSError {
  199. debugPrint(
  200. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
  201. )
  202. }
  203. }
  204. }
  205. func deleteCarbs(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) {
  206. guard let tidepoolService = self.tidepoolService else { return }
  207. processQueue.async {
  208. let syncCarb: [SyncCarbObject] = [SyncCarbObject(
  209. absorptionTime: nil,
  210. createdByCurrentApp: true,
  211. foodType: nil,
  212. grams: Double(carbs),
  213. startDate: at,
  214. uuid: id,
  215. provenanceIdentifier: enteredBy,
  216. syncIdentifier: id.uuidString,
  217. syncVersion: nil,
  218. userCreatedDate: nil,
  219. userUpdatedDate: nil,
  220. userDeletedDate: nil,
  221. operation: LoopKit.Operation.delete,
  222. addedDate: nil,
  223. supercededDate: nil
  224. )]
  225. tidepoolService.uploadCarbData(created: [], updated: [], deleted: syncCarb) { result in
  226. switch result {
  227. case let .failure(error):
  228. debug(.nightscout, "Error synchronizing carbs data with Tidepool: \(String(describing: error))")
  229. case .success:
  230. debug(.nightscout, "Success synchronizing carbs data. Upload to Tidepool complete.")
  231. }
  232. }
  233. }
  234. }
  235. }
  236. /// Insulin Upload and Deletion Functionality
  237. extension BaseTidepoolManager {
  238. func uploadInsulin() async {
  239. await uploadDose(await pumpHistoryStorage.getPumpHistoryNotYetUploadedToTidepool())
  240. }
  241. func uploadDose(_ events: [PumpHistoryEvent]) async {
  242. guard !events.isEmpty, let tidepoolService = self.tidepoolService else { return }
  243. // Fetch all temp basal entries from Core Data for the last 24 hours
  244. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  245. ofType: PumpEventStored.self,
  246. onContext: backgroundContext,
  247. predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
  248. NSPredicate.pumpHistoryLast24h,
  249. NSPredicate(format: "tempBasal != nil")
  250. ]),
  251. key: "timestamp",
  252. ascending: true,
  253. batchSize: 50
  254. )
  255. // Ensure that the processing happens within the background context for thread safety
  256. await backgroundContext.perform {
  257. guard let existingTempBasalEntries = results as? [PumpEventStored] else { return }
  258. let insulinDoseEvents: [DoseEntry] = events.reduce([]) { result, event in
  259. var result = result
  260. switch event.type {
  261. case .tempBasal:
  262. result
  263. .append(contentsOf: self.processTempBasalEvent(event, existingTempBasalEntries: existingTempBasalEntries))
  264. case .bolus:
  265. let bolusDoseEntry = DoseEntry(
  266. type: .bolus,
  267. startDate: event.timestamp,
  268. endDate: event.timestamp,
  269. value: Double(event.amount!),
  270. unit: .units,
  271. deliveredUnits: nil,
  272. syncIdentifier: event.id,
  273. scheduledBasalRate: nil,
  274. insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
  275. automatic: event.isSMB ?? true,
  276. manuallyEntered: event.isExternal ?? false
  277. )
  278. result.append(bolusDoseEntry)
  279. default:
  280. break
  281. }
  282. return result
  283. }
  284. debug(.service, "TIDEPOOL DOSE ENTRIES: \(insulinDoseEvents)")
  285. let pumpEvents: [PersistedPumpEvent] = events.compactMap { event -> PersistedPumpEvent? in
  286. if let pumpEventType = event.type.mapEventTypeToPumpEventType() {
  287. let dose: DoseEntry? = switch pumpEventType {
  288. case .suspend:
  289. DoseEntry(suspendDate: event.timestamp, automatic: true)
  290. case .resume:
  291. DoseEntry(resumeDate: event.timestamp, automatic: true)
  292. default:
  293. nil
  294. }
  295. return PersistedPumpEvent(
  296. date: event.timestamp,
  297. persistedDate: event.timestamp,
  298. dose: dose,
  299. isUploaded: true,
  300. objectIDURL: URL(string: "x-coredata:///PumpEvent/\(event.id)")!,
  301. raw: event.id.data(using: .utf8),
  302. title: event.note,
  303. type: pumpEventType
  304. )
  305. } else {
  306. return nil
  307. }
  308. }
  309. self.processQueue.async {
  310. tidepoolService.uploadDoseData(created: insulinDoseEvents, deleted: []) { result in
  311. switch result {
  312. case let .failure(error):
  313. debug(.nightscout, "Error synchronizing dose data with Tidepool: \(String(describing: error))")
  314. case .success:
  315. debug(.nightscout, "Success synchronizing dose data. Upload to Tidepool complete.")
  316. Task {
  317. let insulinEvents = events.filter {
  318. $0.type == .tempBasal || $0.type == .tempBasalDuration || $0.type == .bolus
  319. }
  320. await self.updateInsulinAsUploaded(insulinEvents)
  321. }
  322. }
  323. }
  324. tidepoolService.uploadPumpEventData(pumpEvents) { result in
  325. switch result {
  326. case let .failure(error):
  327. debug(.nightscout, "Error synchronizing pump events data: \(String(describing: error))")
  328. case .success:
  329. debug(.nightscout, "Success synchronizing pump events data. Upload to Tidepool complete.")
  330. Task {
  331. let pumpEventType = events.map { $0.type.mapEventTypeToPumpEventType() }
  332. let pumpEvents = events.filter { _ in pumpEventType.contains(pumpEventType) }
  333. await self.updateInsulinAsUploaded(pumpEvents)
  334. }
  335. }
  336. }
  337. }
  338. }
  339. }
  340. private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
  341. await backgroundContext.perform {
  342. let ids = insulin.map(\.id) as NSArray
  343. let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
  344. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  345. do {
  346. let results = try self.backgroundContext.fetch(fetchRequest)
  347. for result in results {
  348. result.isUploadedToTidepool = true
  349. }
  350. guard self.backgroundContext.hasChanges else { return }
  351. try self.backgroundContext.save()
  352. } catch let error as NSError {
  353. debugPrint(
  354. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
  355. )
  356. }
  357. }
  358. }
  359. func deleteInsulin(withSyncId id: String, amount: Decimal, at: Date) {
  360. guard let tidepoolService = self.tidepoolService else { return }
  361. // must be an array here, because `tidepoolService.uploadDoseData` expects a `deleted` array
  362. let doseDataToDelete: [DoseEntry] = [DoseEntry(
  363. type: .bolus,
  364. startDate: at,
  365. value: Double(amount),
  366. unit: .units,
  367. syncIdentifier: id
  368. )]
  369. processQueue.async {
  370. tidepoolService.uploadDoseData(created: [], deleted: doseDataToDelete) { result in
  371. switch result {
  372. case let .failure(error):
  373. debug(.nightscout, "Error synchronizing Dose delete data: \(String(describing: error))")
  374. case .success:
  375. debug(.nightscout, "Success synchronizing Dose delete data")
  376. }
  377. }
  378. }
  379. }
  380. }
  381. /// Insulin Helper Functions
  382. extension BaseTidepoolManager {
  383. private func processTempBasalEvent(
  384. _ event: PumpHistoryEvent,
  385. existingTempBasalEntries: [PumpEventStored]
  386. ) -> [DoseEntry] {
  387. var insulinDoseEvents: [DoseEntry] = []
  388. backgroundContext.performAndWait {
  389. // Loop through the pump history events within the background context
  390. guard let duration = event.duration, let amount = event.amount,
  391. let currentBasalRate = self.getCurrentBasalRate()
  392. else {
  393. return
  394. }
  395. // Calculate the exact insulin delivered based on the duration in seconds
  396. let preciseDurationSeconds = TimeInterval(duration * 60) // Convert duration from minutes to seconds
  397. let preciseDeliveredUnits = (Decimal(preciseDurationSeconds) / 3600) * amount
  398. // Find the corresponding temp basal entry in existingTempBasalEntries
  399. if let matchingEntryIndex = existingTempBasalEntries.firstIndex(where: { $0.timestamp == event.timestamp }) {
  400. // Check for a predecessor (the entry before the matching entry)
  401. let predecessorIndex = matchingEntryIndex - 1
  402. if predecessorIndex >= 0 {
  403. let predecessorEntry = existingTempBasalEntries[predecessorIndex]
  404. if let predecessorTimestamp = predecessorEntry.timestamp,
  405. let predecessorEntrySyncIdentifier = predecessorEntry.id
  406. {
  407. let predecessorDurationSeconds = TimeInterval(predecessorEntry.tempBasal?.duration ?? 0) * 60
  408. let predecessorEndDate = predecessorTimestamp.addingTimeInterval(predecessorDurationSeconds)
  409. // If the predecessor's end date is later than the current event's start date, adjust it
  410. if predecessorEndDate > event.timestamp {
  411. let adjustedEndDate = event.timestamp
  412. let adjustedDuration = adjustedEndDate.timeIntervalSince(predecessorTimestamp)
  413. // Use precise duration in hours and round the basal rate
  414. let adjustedDurationHours = adjustedDuration / 3600
  415. let originalRate = Double(truncating: predecessorEntry.tempBasal?.rate ?? 0)
  416. // Round the rate to a supported basal rate using pumpManager's rounding function
  417. let roundedRate = self.apsManager.pumpManager?
  418. .roundToSupportedBasalRate(unitsPerHour: originalRate) ?? originalRate
  419. // Recalculate the delivered units using the rounded rate
  420. let adjustedDeliveredUnits = adjustedDurationHours * roundedRate
  421. // Create updated predecessor dose entry
  422. let updatedPredecessorEntry = DoseEntry(
  423. type: .tempBasal,
  424. startDate: predecessorTimestamp,
  425. endDate: adjustedEndDate,
  426. value: self.apsManager.pumpManager?
  427. .roundToSupportedBolusVolume(units: adjustedDeliveredUnits) ?? adjustedDeliveredUnits,
  428. unit: .units,
  429. deliveredUnits: adjustedDeliveredUnits,
  430. syncIdentifier: predecessorEntrySyncIdentifier,
  431. insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
  432. automatic: true,
  433. manuallyEntered: false,
  434. isMutable: false
  435. )
  436. // Add the updated predecessor entry to the result
  437. insulinDoseEvents.append(updatedPredecessorEntry)
  438. }
  439. }
  440. }
  441. // Create a new dose entry for the current event
  442. let currentEndDate = event.timestamp.addingTimeInterval(preciseDurationSeconds)
  443. // Round the basal rate for the current event as well
  444. let roundedRate = self.apsManager.pumpManager?
  445. .roundToSupportedBasalRate(unitsPerHour: Double(amount)) ?? Double(amount)
  446. let deliveredAmount = self.apsManager.pumpManager?
  447. .roundToSupportedBolusVolume(units: Double(roundedRate) * (preciseDurationSeconds / 3600)) ??
  448. Double(roundedRate) * (preciseDurationSeconds / 3600)
  449. let newDoseEntry = DoseEntry(
  450. type: .tempBasal,
  451. startDate: event.timestamp,
  452. endDate: currentEndDate,
  453. value: self.apsManager.pumpManager?
  454. .roundToSupportedBolusVolume(units: deliveredAmount) ?? deliveredAmount,
  455. unit: .units,
  456. deliveredUnits: self.apsManager.pumpManager?
  457. .roundToSupportedBolusVolume(units: deliveredAmount) ?? deliveredAmount,
  458. syncIdentifier: event.id,
  459. scheduledBasalRate: HKQuantity(
  460. unit: .internationalUnitsPerHour,
  461. doubleValue: Double(currentBasalRate.rate)
  462. ),
  463. insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
  464. automatic: true,
  465. manuallyEntered: false,
  466. isMutable: false
  467. )
  468. // Add the new event entry to the result
  469. insulinDoseEvents.append(newDoseEntry)
  470. }
  471. }
  472. return insulinDoseEvents
  473. }
  474. private func getCurrentBasalRate() -> BasalProfileEntry? {
  475. let now = Date()
  476. let calendar = Calendar.current
  477. let dateFormatter = DateFormatter()
  478. dateFormatter.dateFormat = "HH:mm:ss"
  479. dateFormatter.timeZone = TimeZone.current
  480. let basalEntries = storage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
  481. ?? [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile))
  482. ?? []
  483. var currentRate: BasalProfileEntry = basalEntries[0]
  484. for (index, entry) in basalEntries.enumerated() {
  485. guard let entryTime = dateFormatter.date(from: entry.start) else {
  486. print("Invalid entry start time: \(entry.start)")
  487. continue
  488. }
  489. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  490. let entryStartTime = calendar.date(
  491. bySettingHour: entryComponents.hour!,
  492. minute: entryComponents.minute!,
  493. second: entryComponents.second!,
  494. of: now
  495. )!
  496. let entryEndTime: Date
  497. if index < basalEntries.count - 1,
  498. let nextEntryTime = dateFormatter.date(from: basalEntries[index + 1].start)
  499. {
  500. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  501. entryEndTime = calendar.date(
  502. bySettingHour: nextEntryComponents.hour!,
  503. minute: nextEntryComponents.minute!,
  504. second: nextEntryComponents.second!,
  505. of: now
  506. )!
  507. } else {
  508. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  509. }
  510. if now >= entryStartTime, now < entryEndTime {
  511. currentRate = entry
  512. }
  513. }
  514. return currentRate
  515. }
  516. }
  517. /// Glucose Upload Functionality
  518. extension BaseTidepoolManager {
  519. func uploadGlucose() async {
  520. uploadGlucose(await glucoseStorage.getGlucoseNotYetUploadedToTidepool())
  521. uploadGlucose(
  522. await glucoseStorage
  523. .getManualGlucoseNotYetUploadedToTidepool()
  524. )
  525. }
  526. func uploadGlucose(_ glucose: [StoredGlucoseSample]) {
  527. guard !glucose.isEmpty, let tidepoolService = self.tidepoolService else { return }
  528. let chunks = glucose.chunks(ofCount: tidepoolService.glucoseDataLimit ?? 100)
  529. processQueue.async {
  530. for chunk in chunks {
  531. tidepoolService.uploadGlucoseData(chunk) { result in
  532. switch result {
  533. case .success:
  534. debug(.nightscout, "Success synchronizing glucose data")
  535. // After successful upload, update the isUploadedToTidepool flag in Core Data
  536. Task {
  537. await self.updateGlucoseAsUploaded(glucose)
  538. }
  539. case let .failure(error):
  540. debug(.nightscout, "Error synchronizing glucose data: \(String(describing: error))")
  541. }
  542. }
  543. }
  544. }
  545. }
  546. private func updateGlucoseAsUploaded(_ glucose: [StoredGlucoseSample]) async {
  547. await backgroundContext.perform {
  548. let ids = glucose.map(\.syncIdentifier) as NSArray
  549. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  550. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  551. do {
  552. let results = try self.backgroundContext.fetch(fetchRequest)
  553. for result in results {
  554. result.isUploadedToTidepool = true
  555. }
  556. guard self.backgroundContext.hasChanges else { return }
  557. try self.backgroundContext.save()
  558. } catch let error as NSError {
  559. debugPrint(
  560. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
  561. )
  562. }
  563. }
  564. }
  565. }
  566. extension BaseTidepoolManager: StatefulPluggableDelegate {
  567. func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {}
  568. func pluginWantsDeletion(_: LoopKit.StatefulPluggable) {
  569. tidepoolService = nil
  570. }
  571. }
  572. // Service extension for rawValue
  573. extension Service {
  574. typealias RawValue = [String: Any]
  575. var rawValue: RawValue {
  576. [
  577. "serviceIdentifier": pluginIdentifier,
  578. "state": rawState
  579. ]
  580. }
  581. }