TidepoolManager.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  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. let value = (Decimal(duration) / 60.0) * amount
  396. // Find the corresponding temp basal entry in existingTempBasalEntries
  397. if let matchingEntryIndex = existingTempBasalEntries.firstIndex(where: { $0.timestamp == event.timestamp }) {
  398. // Check for a predecessor (the entry before the matching entry)
  399. let predecessorIndex = matchingEntryIndex - 1
  400. if predecessorIndex >= 0 {
  401. let predecessorEntry = existingTempBasalEntries[predecessorIndex]
  402. if let predecessorTimestamp = predecessorEntry.timestamp,
  403. let predecessorEntrySyncIdentifier = predecessorEntry.id
  404. {
  405. let predecessorEndDate = predecessorTimestamp
  406. .addingTimeInterval(TimeInterval(
  407. Int(predecessorEntry.tempBasal?.duration ?? 0) *
  408. 60
  409. )) // parse duration to minutes
  410. // If the predecessor's end date is later than the current event's start date, adjust it
  411. if predecessorEndDate > event.timestamp {
  412. let adjustedEndDate = event.timestamp
  413. let adjustedDuration = adjustedEndDate.timeIntervalSince(predecessorTimestamp)
  414. let adjustedDeliveredUnits = (adjustedDuration / 3600) *
  415. Double(truncating: predecessorEntry.tempBasal?.rate ?? 0)
  416. // Create updated predecessor dose entry
  417. let updatedPredecessorEntry = DoseEntry(
  418. type: .tempBasal,
  419. startDate: predecessorTimestamp,
  420. endDate: adjustedEndDate,
  421. value: adjustedDeliveredUnits,
  422. unit: .units,
  423. deliveredUnits: adjustedDeliveredUnits,
  424. syncIdentifier: predecessorEntrySyncIdentifier,
  425. insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
  426. automatic: true,
  427. manuallyEntered: false,
  428. isMutable: false
  429. )
  430. // Add the updated predecessor entry to the result
  431. insulinDoseEvents.append(updatedPredecessorEntry)
  432. }
  433. }
  434. }
  435. // Create a new dose entry for the current event
  436. let currentEndDate = event.timestamp.addingTimeInterval(TimeInterval(minutes: Double(duration)))
  437. let newDoseEntry = DoseEntry(
  438. type: .tempBasal,
  439. startDate: event.timestamp,
  440. endDate: currentEndDate,
  441. value: Double(value),
  442. unit: .units,
  443. deliveredUnits: Double(value),
  444. syncIdentifier: event.id,
  445. scheduledBasalRate: HKQuantity(
  446. unit: .internationalUnitsPerHour,
  447. doubleValue: Double(currentBasalRate.rate)
  448. ),
  449. insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
  450. automatic: true,
  451. manuallyEntered: false,
  452. isMutable: false
  453. )
  454. // Add the new event entry to the result
  455. insulinDoseEvents.append(newDoseEntry)
  456. }
  457. }
  458. return insulinDoseEvents
  459. }
  460. private func getCurrentBasalRate() -> BasalProfileEntry? {
  461. let now = Date()
  462. let calendar = Calendar.current
  463. let dateFormatter = DateFormatter()
  464. dateFormatter.dateFormat = "HH:mm:ss"
  465. dateFormatter.timeZone = TimeZone.current
  466. let basalEntries = storage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
  467. ?? [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile))
  468. ?? []
  469. var currentRate: BasalProfileEntry = basalEntries[0]
  470. for (index, entry) in basalEntries.enumerated() {
  471. guard let entryTime = dateFormatter.date(from: entry.start) else {
  472. print("Invalid entry start time: \(entry.start)")
  473. continue
  474. }
  475. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  476. let entryStartTime = calendar.date(
  477. bySettingHour: entryComponents.hour!,
  478. minute: entryComponents.minute!,
  479. second: entryComponents.second!,
  480. of: now
  481. )!
  482. let entryEndTime: Date
  483. if index < basalEntries.count - 1,
  484. let nextEntryTime = dateFormatter.date(from: basalEntries[index + 1].start)
  485. {
  486. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  487. entryEndTime = calendar.date(
  488. bySettingHour: nextEntryComponents.hour!,
  489. minute: nextEntryComponents.minute!,
  490. second: nextEntryComponents.second!,
  491. of: now
  492. )!
  493. } else {
  494. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  495. }
  496. if now >= entryStartTime, now < entryEndTime {
  497. currentRate = entry
  498. }
  499. }
  500. return currentRate
  501. }
  502. }
  503. /// Glucose Upload Functionality
  504. extension BaseTidepoolManager {
  505. func uploadGlucose() async {
  506. uploadGlucose(await glucoseStorage.getGlucoseNotYetUploadedToTidepool())
  507. uploadGlucose(
  508. await glucoseStorage
  509. .getManualGlucoseNotYetUploadedToTidepool()
  510. )
  511. }
  512. func uploadGlucose(_ glucose: [StoredGlucoseSample]) {
  513. guard !glucose.isEmpty, let tidepoolService = self.tidepoolService else { return }
  514. let chunks = glucose.chunks(ofCount: tidepoolService.glucoseDataLimit ?? 100)
  515. processQueue.async {
  516. for chunk in chunks {
  517. tidepoolService.uploadGlucoseData(chunk) { result in
  518. switch result {
  519. case .success:
  520. debug(.nightscout, "Success synchronizing glucose data")
  521. // After successful upload, update the isUploadedToTidepool flag in Core Data
  522. Task {
  523. await self.updateGlucoseAsUploaded(glucose)
  524. }
  525. case let .failure(error):
  526. debug(.nightscout, "Error synchronizing glucose data: \(String(describing: error))")
  527. }
  528. }
  529. }
  530. }
  531. }
  532. private func updateGlucoseAsUploaded(_ glucose: [StoredGlucoseSample]) async {
  533. await backgroundContext.perform {
  534. let ids = glucose.map(\.syncIdentifier) as NSArray
  535. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  536. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  537. do {
  538. let results = try self.backgroundContext.fetch(fetchRequest)
  539. for result in results {
  540. result.isUploadedToTidepool = true
  541. }
  542. guard self.backgroundContext.hasChanges else { return }
  543. try self.backgroundContext.save()
  544. } catch let error as NSError {
  545. debugPrint(
  546. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
  547. )
  548. }
  549. }
  550. }
  551. }
  552. extension BaseTidepoolManager: StatefulPluggableDelegate {
  553. func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {}
  554. func pluginWantsDeletion(_: LoopKit.StatefulPluggable) {
  555. tidepoolService = nil
  556. }
  557. }
  558. // Service extension for rawValue
  559. extension Service {
  560. typealias RawValue = [String: Any]
  561. var rawValue: RawValue {
  562. [
  563. "serviceIdentifier": pluginIdentifier,
  564. "state": rawState
  565. ]
  566. }
  567. }