TidepoolManager.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  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(device: HKDevice?) async
  17. func forceTidepoolDataUpload(device: HKDevice?)
  18. }
  19. final class BaseTidepoolManager: TidepoolManager, Injectable, CarbsStoredDelegate, PumpHistoryDelegate {
  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. func carbsStorageHasUpdatedCarbs(_: BaseCarbsStorage) {
  39. Task.detached { [weak self] in
  40. guard let self = self else { return }
  41. await self.uploadCarbs()
  42. }
  43. }
  44. func pumpHistoryHasUpdated(_: BasePumpHistoryStorage) {
  45. Task.detached { [weak self] in
  46. guard let self = self else { return }
  47. await self.uploadInsulin()
  48. }
  49. }
  50. @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
  51. init(resolver: Resolver) {
  52. injectServices(resolver)
  53. loadTidepoolManager()
  54. pumpHistoryStorage.delegate = self
  55. carbsStorage.delegate = self
  56. subscribe()
  57. }
  58. /// load the Tidepool Remote Data Service if available
  59. fileprivate func loadTidepoolManager() {
  60. if let rawTidepoolManager = rawTidepoolManager {
  61. tidepoolService = tidepoolServiceFromRaw(rawTidepoolManager)
  62. tidepoolService?.serviceDelegate = self
  63. tidepoolService?.stateDelegate = self
  64. }
  65. }
  66. /// allows access to tidepoolService as a simple ServiceUI
  67. func getTidepoolServiceUI() -> ServiceUI? {
  68. if let tidepoolService = self.tidepoolService {
  69. return tidepoolService as! any ServiceUI as ServiceUI
  70. } else {
  71. return nil
  72. }
  73. }
  74. /// get the pluginHost of Tidepool
  75. func getTidepoolPluginHost() -> PluginHost? {
  76. self as PluginHost
  77. }
  78. func addTidepoolService(service: Service) {
  79. tidepoolService = service as! any RemoteDataService as RemoteDataService
  80. }
  81. /// load the Tidepool Remote Data Service from raw storage
  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 {
  86. return nil
  87. }
  88. if let service = serviceType.init(rawState: rawState) {
  89. return service as! any RemoteDataService as RemoteDataService
  90. } else { return nil }
  91. }
  92. private func subscribe() {
  93. broadcaster.register(TempTargetsObserver.self, observer: self)
  94. }
  95. func sourceInfo() -> [String: Any]? {
  96. nil
  97. }
  98. func uploadCarbs() async {
  99. uploadCarbs(await carbsStorage.getCarbsNotYetUploadedToHealth())
  100. }
  101. func uploadCarbs(_ carbs: [CarbsEntry]) {
  102. guard !carbs.isEmpty, let tidepoolService = self.tidepoolService else { return }
  103. processQueue.async {
  104. carbs.chunks(ofCount: tidepoolService.carbDataLimit ?? 100).forEach { chunk in
  105. let syncCarb: [SyncCarbObject] = Array(chunk).map {
  106. $0.convertSyncCarb()
  107. }
  108. tidepoolService.uploadCarbData(created: syncCarb, updated: [], deleted: []) { result in
  109. switch result {
  110. case let .failure(error):
  111. debug(.nightscout, "Error synchronizing carbs data: \(String(describing: error))")
  112. case .success:
  113. debug(.nightscout, "Success synchronizing carbs data")
  114. // After successful upload, update the isUploadedToTidepool flag in Core Data
  115. Task {
  116. await self.updateCarbsAsUploaded(carbs)
  117. }
  118. }
  119. }
  120. }
  121. }
  122. }
  123. private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
  124. await backgroundContext.perform {
  125. let ids = carbs.map(\.id) as NSArray
  126. let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
  127. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  128. do {
  129. let results = try self.backgroundContext.fetch(fetchRequest)
  130. for result in results {
  131. result.isUploadedToTidepool = true
  132. }
  133. guard self.backgroundContext.hasChanges else { return }
  134. try self.backgroundContext.save()
  135. } catch let error as NSError {
  136. debugPrint(
  137. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
  138. )
  139. }
  140. }
  141. }
  142. func deleteCarbs(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) {
  143. guard let tidepoolService = self.tidepoolService else { return }
  144. processQueue.async {
  145. let syncCarb: [SyncCarbObject] = [SyncCarbObject(
  146. absorptionTime: nil,
  147. createdByCurrentApp: true,
  148. foodType: nil,
  149. grams: Double(carbs),
  150. startDate: at,
  151. uuid: id,
  152. provenanceIdentifier: enteredBy,
  153. syncIdentifier: id.uuidString,
  154. syncVersion: nil,
  155. userCreatedDate: nil,
  156. userUpdatedDate: nil,
  157. userDeletedDate: nil,
  158. operation: LoopKit.Operation.delete,
  159. addedDate: nil,
  160. supercededDate: nil
  161. )]
  162. tidepoolService.uploadCarbData(created: [], updated: [], deleted: syncCarb) { result in
  163. switch result {
  164. case let .failure(error):
  165. debug(.nightscout, "Error synchronizing carbs data: \(String(describing: error))")
  166. case .success:
  167. debug(.nightscout, "Success synchronizing carbs data.")
  168. }
  169. }
  170. }
  171. }
  172. func uploadInsulin() async {
  173. uploadDose(await pumpHistoryStorage.getPumpHistoryNotYetUploadedToTidepool())
  174. }
  175. func uploadDose(_ events: [PumpHistoryEvent]) {
  176. guard !events.isEmpty, let tidepoolService = self.tidepoolService else { return }
  177. let tempBasalEventCount = events.filter { $0.type == .tempBasal }.count
  178. // Fetch existing entries with an additional +1 count to get the previous entry as well -> need to update
  179. var existingTempBasalEntries: [PumpEventStored] = CoreDataStack.shared.fetchEntities(
  180. ofType: PumpEventStored.self,
  181. onContext: backgroundContext,
  182. predicate: NSPredicate.pumpHistoryLast24h,
  183. key: "timestamp",
  184. ascending: false,
  185. fetchLimit: tempBasalEventCount + 1,
  186. batchSize: 50
  187. ).filter { $0.tempBasal != nil }
  188. // remove first fetched existing entry, so new events and old events are off by 1 index, so the same index is always current event in events and the previous event to that one in existing events array.
  189. existingTempBasalEntries.removeFirst()
  190. let insulinDoseEvents: [DoseEntry] = events.reduce([]) { result, event in
  191. var result = result
  192. switch event.type {
  193. case .tempBasal:
  194. if let duration = event.duration, let amount = event.amount, let currentBasalRate = self.getCurrentBasalRate() {
  195. let value = (Decimal(duration) / 60.0) * amount
  196. // Create updated previous entry, if applicable -> update end date
  197. if let lastEntry = existingTempBasalEntries.first, let lastTimeStamp = lastEntry.timestamp {
  198. let lastEndDate = event.timestamp
  199. let lastDuration = lastEndDate.timeIntervalSince(lastTimeStamp) / 3600
  200. let lastDeliveredUnits = Double(lastDuration / 60.0) *
  201. Double(truncating: lastEntry.tempBasal?.rate ?? 0.0)
  202. let updatedLastEntry = DoseEntry(
  203. type: .tempBasal,
  204. startDate: lastTimeStamp,
  205. endDate: lastEndDate,
  206. value: lastDeliveredUnits,
  207. unit: .units,
  208. deliveredUnits: lastDeliveredUnits,
  209. syncIdentifier: lastEntry.id ?? UUID().uuidString,
  210. // scheduledBasalRate: HKQuantity(
  211. // unit: .internationalUnitsPerHour,
  212. // doubleValue: Double(truncating: lastEntry.tempBasal?.rate ?? 0.0)
  213. // ),
  214. insulinType: apsManager.pumpManager?.status.insulinType ?? nil,
  215. automatic: true,
  216. manuallyEntered: false,
  217. isMutable: false
  218. )
  219. result.append(updatedLastEntry)
  220. }
  221. // Create new entry for current event
  222. let newDoseEntry = DoseEntry(
  223. type: .tempBasal,
  224. startDate: event.timestamp,
  225. endDate: event.timestamp.addingTimeInterval(TimeInterval(minutes: Double(duration))),
  226. value: Double(value),
  227. unit: .units,
  228. deliveredUnits: Double(value),
  229. syncIdentifier: event.id,
  230. scheduledBasalRate: HKQuantity(
  231. unit: .internationalUnitsPerHour,
  232. doubleValue: Double(currentBasalRate.rate)
  233. ),
  234. insulinType: apsManager.pumpManager?.status.insulinType ?? nil,
  235. automatic: true,
  236. manuallyEntered: false,
  237. isMutable: false
  238. )
  239. result.append(newDoseEntry)
  240. // Remove the first element from existingTempBasalEntries so that it does not get processed again
  241. if existingTempBasalEntries.isNotEmpty {
  242. existingTempBasalEntries.removeFirst()
  243. }
  244. }
  245. case .bolus:
  246. let bolusDoseEntry = DoseEntry(
  247. type: .bolus,
  248. startDate: event.timestamp,
  249. endDate: event.timestamp,
  250. value: Double(event.amount!),
  251. unit: .units,
  252. deliveredUnits: nil,
  253. syncIdentifier: event.id,
  254. scheduledBasalRate: nil,
  255. insulinType: apsManager.pumpManager?.status.insulinType ?? nil,
  256. automatic: event.isSMB ?? true,
  257. manuallyEntered: event.isExternal ?? false
  258. )
  259. result.append(bolusDoseEntry)
  260. default:
  261. break
  262. }
  263. return result
  264. }
  265. debug(.service, "TIDEPOOL DOSE ENTRIES: \(insulinDoseEvents)")
  266. let pumpEvents: [PersistedPumpEvent] = events.compactMap { event -> PersistedPumpEvent? in
  267. if let pumpEventType = event.type.mapEventTypeToPumpEventType() {
  268. let dose: DoseEntry? = switch pumpEventType {
  269. case .suspend:
  270. DoseEntry(suspendDate: event.timestamp, automatic: true)
  271. case .resume:
  272. DoseEntry(resumeDate: event.timestamp, automatic: true)
  273. default:
  274. nil
  275. }
  276. return PersistedPumpEvent(
  277. date: event.timestamp,
  278. persistedDate: event.timestamp,
  279. dose: dose,
  280. isUploaded: true,
  281. objectIDURL: URL(string: "x-coredata:///PumpEvent/\(event.id)")!,
  282. raw: event.id.data(using: .utf8),
  283. title: event.note,
  284. type: pumpEventType
  285. )
  286. } else {
  287. return nil
  288. }
  289. }
  290. processQueue.async {
  291. tidepoolService.uploadDoseData(created: insulinDoseEvents, deleted: []) { result in
  292. switch result {
  293. case let .failure(error):
  294. debug(.nightscout, "Error synchronizing Dose data: \(String(describing: error))")
  295. case .success:
  296. debug(.nightscout, "Success synchronizing Dose data")
  297. // After successful upload, update the isUploadedToTidepool flag in Core Data
  298. Task {
  299. let insulinEvents = events.filter {
  300. $0.type == .tempBasal || $0.type == .tempBasalDuration || $0.type == .bolus
  301. }
  302. await self.updateInsulinAsUploaded(insulinEvents)
  303. }
  304. }
  305. }
  306. tidepoolService.uploadPumpEventData(pumpEvents) { result in
  307. switch result {
  308. case let .failure(error):
  309. debug(.nightscout, "Error synchronizing Pump Event data: \(String(describing: error))")
  310. case .success:
  311. debug(.nightscout, "Success synchronizing Pump Event data")
  312. // After successful upload, update the isUploadedToTidepool flag in Core Data
  313. Task {
  314. let pumpEventType = events.map { $0.type.mapEventTypeToPumpEventType() }
  315. let pumpEvents = events.filter { _ in pumpEventType.contains(pumpEventType) }
  316. await self.updateInsulinAsUploaded(pumpEvents)
  317. }
  318. }
  319. }
  320. }
  321. }
  322. private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
  323. await backgroundContext.perform {
  324. let ids = insulin.map(\.id) as NSArray
  325. let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
  326. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  327. do {
  328. let results = try self.backgroundContext.fetch(fetchRequest)
  329. for result in results {
  330. result.isUploadedToTidepool = true
  331. }
  332. guard self.backgroundContext.hasChanges else { return }
  333. try self.backgroundContext.save()
  334. } catch let error as NSError {
  335. debugPrint(
  336. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
  337. )
  338. }
  339. }
  340. }
  341. func deleteInsulin(withSyncId id: String, amount: Decimal, at: Date) {
  342. guard let tidepoolService = self.tidepoolService else { return }
  343. // must be an array here, because `tidepoolService.uploadDoseData` expects a `deleted` array
  344. let doseDataToDelete: [DoseEntry] = [DoseEntry(
  345. type: .bolus,
  346. startDate: at,
  347. value: Double(amount),
  348. unit: .units,
  349. syncIdentifier: id
  350. )]
  351. processQueue.async {
  352. tidepoolService.uploadDoseData(created: [], deleted: doseDataToDelete) { result in
  353. switch result {
  354. case let .failure(error):
  355. debug(.nightscout, "Error synchronizing Dose delete data: \(String(describing: error))")
  356. case .success:
  357. debug(.nightscout, "Success synchronizing Dose delete data")
  358. }
  359. }
  360. }
  361. }
  362. func uploadGlucose(device: HKDevice?) async {
  363. // TODO: get correct glucose values
  364. let glucose: [BloodGlucose] = await glucoseStorage.getGlucoseNotYetUploadedToNightscout()
  365. guard !glucose.isEmpty, let tidepoolService = self.tidepoolService else { return }
  366. let glucoseWithoutCorrectID = glucose.filter { UUID(uuidString: $0._id ?? UUID().uuidString) != nil }
  367. let chunks = glucoseWithoutCorrectID.chunks(ofCount: tidepoolService.glucoseDataLimit ?? 100)
  368. processQueue.async {
  369. for chunk in chunks {
  370. // Link all glucose values with the current device
  371. let chunkStoreGlucose = chunk.map { $0.convertStoredGlucoseSample(device: device) }
  372. tidepoolService.uploadGlucoseData(chunkStoreGlucose) { result in
  373. switch result {
  374. case .success:
  375. debug(.nightscout, "Success synchronizing glucose data")
  376. // After successful upload, update the isUploadedToTidepool flag in Core Data
  377. Task {
  378. await self.updateGlucoseAsUploaded(glucose)
  379. }
  380. case let .failure(error):
  381. debug(.nightscout, "Error synchronizing glucose data: \(String(describing: error))")
  382. }
  383. }
  384. }
  385. }
  386. }
  387. private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
  388. await backgroundContext.perform {
  389. let ids = glucose.map(\.id) as NSArray
  390. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  391. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  392. do {
  393. let results = try self.backgroundContext.fetch(fetchRequest)
  394. for result in results {
  395. result.isUploadedToTidepool = true
  396. }
  397. guard self.backgroundContext.hasChanges else { return }
  398. try self.backgroundContext.save()
  399. } catch let error as NSError {
  400. debugPrint(
  401. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
  402. )
  403. }
  404. }
  405. }
  406. /// force to uploads all data in Tidepool Service
  407. func forceTidepoolDataUpload(device: HKDevice?) {
  408. Task {
  409. await uploadInsulin()
  410. await uploadCarbs()
  411. await uploadGlucose(device: device)
  412. }
  413. }
  414. }
  415. extension BaseTidepoolManager: TempTargetsObserver {
  416. func tempTargetsDidUpdate(_: [TempTarget]) {}
  417. }
  418. extension BaseTidepoolManager: ServiceDelegate {
  419. var hostIdentifier: String {
  420. // TODO: shouldn't this rather be `org.nightscout.Trio` ?
  421. "com.loopkit.Loop" // To check
  422. }
  423. var hostVersion: String {
  424. var semanticVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
  425. while semanticVersion.split(separator: ".").count < 3 {
  426. semanticVersion += ".0"
  427. }
  428. semanticVersion += "+\(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String)"
  429. return semanticVersion
  430. }
  431. func issueAlert(_: LoopKit.Alert) {}
  432. func retractAlert(identifier _: LoopKit.Alert.Identifier) {}
  433. func enactRemoteOverride(name _: String, durationTime _: TimeInterval?, remoteAddress _: String) async throws {}
  434. func cancelRemoteOverride() async throws {}
  435. func deliverRemoteCarbs(
  436. amountInGrams _: Double,
  437. absorptionTime _: TimeInterval?,
  438. foodType _: String?,
  439. startDate _: Date?
  440. ) async throws {}
  441. func deliverRemoteBolus(amountInUnits _: Double) async throws {}
  442. }
  443. extension BaseTidepoolManager {
  444. private func getCurrentBasalRate() -> BasalProfileEntry? {
  445. let now = Date()
  446. let calendar = Calendar.current
  447. let dateFormatter = DateFormatter()
  448. dateFormatter.dateFormat = "HH:mm:ss"
  449. dateFormatter.timeZone = TimeZone.current
  450. let basalEntries = storage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
  451. ?? [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile))
  452. ?? []
  453. var currentRate: BasalProfileEntry = basalEntries[0]
  454. for (index, entry) in basalEntries.enumerated() {
  455. guard let entryTime = dateFormatter.date(from: entry.start) else {
  456. print("Invalid entry start time: \(entry.start)")
  457. continue
  458. }
  459. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  460. let entryStartTime = calendar.date(
  461. bySettingHour: entryComponents.hour!,
  462. minute: entryComponents.minute!,
  463. second: entryComponents.second!,
  464. of: now
  465. )!
  466. let entryEndTime: Date
  467. if index < basalEntries.count - 1,
  468. let nextEntryTime = dateFormatter.date(from: basalEntries[index + 1].start)
  469. {
  470. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  471. entryEndTime = calendar.date(
  472. bySettingHour: nextEntryComponents.hour!,
  473. minute: nextEntryComponents.minute!,
  474. second: nextEntryComponents.second!,
  475. of: now
  476. )!
  477. } else {
  478. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  479. }
  480. if now >= entryStartTime, now < entryEndTime {
  481. currentRate = entry
  482. }
  483. }
  484. return currentRate
  485. }
  486. }
  487. extension BaseTidepoolManager: StatefulPluggableDelegate {
  488. func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {}
  489. func pluginWantsDeletion(_: LoopKit.StatefulPluggable) {
  490. tidepoolService = nil
  491. }
  492. }
  493. // Service extension for rawValue
  494. extension Service {
  495. typealias RawValue = [String: Any]
  496. var rawValue: RawValue {
  497. [
  498. "serviceIdentifier": pluginIdentifier,
  499. "state": rawState
  500. ]
  501. }
  502. }