PumpHistoryStorage.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import LoopKit
  5. import SwiftDate
  6. import Swinject
  7. protocol PumpHistoryObserver {
  8. func pumpHistoryDidUpdate(_ events: [PumpHistoryEvent])
  9. }
  10. protocol PumpHistoryStorage {
  11. var updatePublisher: AnyPublisher<Void, Never> { get }
  12. func getPumpHistory() async throws -> [PumpHistoryEvent]
  13. func storePumpEvents(_ events: [NewPumpEvent]) async throws
  14. func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async
  15. func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
  16. func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent]
  17. func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent]
  18. }
  19. final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
  20. private let processQueue = DispatchQueue(label: "BasePumpHistoryStorage.processQueue")
  21. @Injected() private var storage: FileStorage!
  22. @Injected() private var broadcaster: Broadcaster!
  23. @Injected() private var settings: SettingsManager!
  24. private let updateSubject = PassthroughSubject<Void, Never>()
  25. var updatePublisher: AnyPublisher<Void, Never> {
  26. updateSubject.eraseToAnyPublisher()
  27. }
  28. private let makeContext: () -> NSManagedObjectContext
  29. init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
  30. makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
  31. injectServices(resolver)
  32. }
  33. typealias PumpEvent = PumpEventStored.EventType
  34. typealias TempType = PumpEventStored.TempType
  35. private func roundDose(_ dose: Double, toIncrement increment: Double) -> Decimal {
  36. let roundedValue = (dose / increment).rounded() * increment
  37. return Decimal(roundedValue)
  38. }
  39. func storePumpEvents(_ events: [NewPumpEvent]) async throws {
  40. let context = makeContext()
  41. context.name = "storePumpEvents"
  42. try await context.perform {
  43. for event in events {
  44. let existingEvents: [PumpEventStored] = try CoreDataStack.shared.fetchEntities(
  45. ofType: PumpEventStored.self,
  46. onContext: context,
  47. predicate: NSPredicate.duplicates(event.date),
  48. key: "timestamp",
  49. ascending: false,
  50. batchSize: 50
  51. ) as? [PumpEventStored] ?? []
  52. switch event.type {
  53. case .bolus:
  54. guard let dose = event.dose else { continue }
  55. let amount = self.roundDose(
  56. dose.unitsInDeliverableIncrements,
  57. toIncrement: Double(self.settings.preferences.bolusIncrement)
  58. )
  59. guard existingEvents.isEmpty else {
  60. // Duplicate found, do not store the event
  61. debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
  62. if let existingEvent = existingEvents.first(where: { $0.type == EventType.bolus.rawValue }) {
  63. if existingEvent.timestamp == event.date {
  64. if let existingAmount = existingEvent.bolus?.amount, amount < existingAmount as Decimal {
  65. // Update existing event with new smaller value
  66. existingEvent.bolus?.amount = amount as NSDecimalNumber
  67. existingEvent.bolus?.isSMB = dose.automatic ?? true
  68. existingEvent.isUploadedToNS = false
  69. existingEvent.isUploadedToHealth = false
  70. existingEvent.isUploadedToTidepool = false
  71. debug(.coreData, "Updated existing event with smaller value: \(amount)")
  72. }
  73. }
  74. }
  75. continue
  76. }
  77. let newPumpEvent = PumpEventStored(context: context)
  78. newPumpEvent.id = UUID().uuidString
  79. // restrict entry to now or past
  80. newPumpEvent.timestamp = event.date > Date() ? Date() : event.date
  81. newPumpEvent.type = PumpEvent.bolus.rawValue
  82. newPumpEvent.isUploadedToNS = false
  83. newPumpEvent.isUploadedToHealth = false
  84. newPumpEvent.isUploadedToTidepool = false
  85. let newBolusEntry = BolusStored(context: context)
  86. newBolusEntry.pumpEvent = newPumpEvent
  87. newBolusEntry.amount = NSDecimalNumber(decimal: amount)
  88. newBolusEntry.isExternal = dose.manuallyEntered
  89. newBolusEntry.isSMB = dose.automatic ?? true
  90. case .tempBasal:
  91. guard let dose = event.dose else { continue }
  92. guard existingEvents.isEmpty else {
  93. // Duplicate found, do not store the event
  94. debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
  95. continue
  96. }
  97. let rate = Decimal(dose.unitsPerHour)
  98. let minutes = (dose.endDate - dose.startDate).timeInterval / 60
  99. let delivered = dose.deliveredUnits
  100. let date = event.date
  101. let isCancel = delivered != nil
  102. guard !isCancel else { continue }
  103. let newPumpEvent = PumpEventStored(context: context)
  104. newPumpEvent.id = UUID().uuidString
  105. newPumpEvent.timestamp = date
  106. newPumpEvent.type = PumpEvent.tempBasal.rawValue
  107. newPumpEvent.isUploadedToNS = false
  108. newPumpEvent.isUploadedToHealth = false
  109. newPumpEvent.isUploadedToTidepool = false
  110. let newTempBasal = TempBasalStored(context: context)
  111. newTempBasal.pumpEvent = newPumpEvent
  112. newTempBasal.duration = Int16(round(minutes))
  113. newTempBasal.rate = rate as NSDecimalNumber
  114. newTempBasal.tempType = TempType.absolute.rawValue
  115. case .suspend:
  116. guard existingEvents.isEmpty else {
  117. // Duplicate found, do not store the event
  118. debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
  119. continue
  120. }
  121. let newPumpEvent = PumpEventStored(context: context)
  122. newPumpEvent.id = UUID().uuidString
  123. newPumpEvent.timestamp = event.date
  124. newPumpEvent.type = PumpEvent.pumpSuspend.rawValue
  125. newPumpEvent.isUploadedToNS = false
  126. newPumpEvent.isUploadedToHealth = false
  127. newPumpEvent.isUploadedToTidepool = false
  128. case .resume:
  129. guard existingEvents.isEmpty else {
  130. // Duplicate found, do not store the event
  131. debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
  132. continue
  133. }
  134. let newPumpEvent = PumpEventStored(context: context)
  135. newPumpEvent.id = UUID().uuidString
  136. newPumpEvent.timestamp = event.date
  137. newPumpEvent.type = PumpEvent.pumpResume.rawValue
  138. newPumpEvent.isUploadedToNS = false
  139. newPumpEvent.isUploadedToHealth = false
  140. newPumpEvent.isUploadedToTidepool = false
  141. case .rewind:
  142. guard existingEvents.isEmpty else {
  143. // Duplicate found, do not store the event
  144. debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
  145. continue
  146. }
  147. let newPumpEvent = PumpEventStored(context: context)
  148. newPumpEvent.id = UUID().uuidString
  149. newPumpEvent.timestamp = event.date
  150. newPumpEvent.type = PumpEvent.rewind.rawValue
  151. newPumpEvent.isUploadedToNS = false
  152. newPumpEvent.isUploadedToHealth = false
  153. newPumpEvent.isUploadedToTidepool = false
  154. case .prime:
  155. guard existingEvents.isEmpty else {
  156. // Duplicate found, do not store the event
  157. debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
  158. continue
  159. }
  160. let newPumpEvent = PumpEventStored(context: context)
  161. newPumpEvent.id = UUID().uuidString
  162. newPumpEvent.timestamp = event.date
  163. newPumpEvent.type = PumpEvent.prime.rawValue
  164. newPumpEvent.isUploadedToNS = false
  165. newPumpEvent.isUploadedToHealth = false
  166. newPumpEvent.isUploadedToTidepool = false
  167. case .alarm:
  168. guard existingEvents.isEmpty else {
  169. // Duplicate found, do not store the event
  170. debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
  171. continue
  172. }
  173. let newPumpEvent = PumpEventStored(context: context)
  174. newPumpEvent.id = UUID().uuidString
  175. newPumpEvent.timestamp = event.date
  176. newPumpEvent.type = PumpEvent.pumpAlarm.rawValue
  177. newPumpEvent.isUploadedToNS = false
  178. newPumpEvent.isUploadedToHealth = false
  179. newPumpEvent.isUploadedToTidepool = false
  180. newPumpEvent.note = event.title
  181. default:
  182. continue
  183. }
  184. }
  185. do {
  186. guard context.hasChanges else { return }
  187. try context.save()
  188. self.updateSubject.send(())
  189. debug(.coreData, "\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
  190. } catch let error as NSError {
  191. debug(.coreData, "\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
  192. throw error
  193. }
  194. }
  195. }
  196. func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async {
  197. let context = makeContext()
  198. context.name = "storeExternalInsulinEvent"
  199. await context.perform {
  200. // create pump event
  201. let newPumpEvent = PumpEventStored(context: context)
  202. newPumpEvent.id = UUID().uuidString
  203. // restrict entry to now or past
  204. newPumpEvent.timestamp = timestamp > Date() ? Date() : timestamp
  205. newPumpEvent.type = PumpEvent.bolus.rawValue
  206. newPumpEvent.isUploadedToNS = false
  207. newPumpEvent.isUploadedToHealth = false
  208. newPumpEvent.isUploadedToTidepool = false
  209. // create bolus entry and specify relationship to pump event
  210. let newBolusEntry = BolusStored(context: context)
  211. newBolusEntry.pumpEvent = newPumpEvent
  212. newBolusEntry.amount = amount as NSDecimalNumber
  213. newBolusEntry.isExternal = true // we are creating an external dose
  214. newBolusEntry.isSMB = false // the dose is manually administered
  215. do {
  216. guard context.hasChanges else { return }
  217. try context.save()
  218. debug(.coreData, "External insulin saved")
  219. self.updateSubject.send(())
  220. } catch {
  221. debug(.coreData, "Failed to store external insulin in context: \(error)")
  222. }
  223. }
  224. }
  225. func getPumpHistory() async throws -> [PumpHistoryEvent] {
  226. let context = makeContext()
  227. context.name = "getPumpHistory"
  228. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  229. ofType: PumpEventStored.self,
  230. onContext: context,
  231. predicate: NSPredicate.pumpHistoryLast24h,
  232. key: "timestamp",
  233. ascending: false,
  234. fetchLimit: 288
  235. )
  236. return await context.perform {
  237. guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
  238. return fetchedPumpEvents.map { event in
  239. switch event.type {
  240. case PumpEventStored.EventType.bolus.rawValue:
  241. return PumpHistoryEvent(
  242. id: event.id ?? UUID().uuidString,
  243. type: .bolus,
  244. timestamp: event.timestamp ?? Date(),
  245. amount: event.bolus?.amount as Decimal?
  246. )
  247. case PumpEventStored.EventType.tempBasal.rawValue:
  248. return PumpHistoryEvent(
  249. id: event.id ?? UUID().uuidString,
  250. type: .tempBasal,
  251. timestamp: event.timestamp ?? Date(),
  252. amount: event.tempBasal?.rate as Decimal?,
  253. duration: Int(event.tempBasal?.duration ?? 0)
  254. )
  255. default:
  256. return nil
  257. }
  258. }.compactMap { $0 }
  259. }
  260. }
  261. func determineBolusEventType(for event: PumpEventStored) -> PumpEventStored.EventType {
  262. guard let bolus = event.bolus else {
  263. return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus
  264. }
  265. if bolus.isSMB {
  266. return .smb
  267. }
  268. if bolus.isExternal {
  269. return .isExternal
  270. }
  271. return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus
  272. }
  273. func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
  274. let context = makeContext()
  275. context.name = "getPumpHistoryNotYetUploadedToNightscout"
  276. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  277. ofType: PumpEventStored.self,
  278. onContext: context,
  279. predicate: NSPredicate.pumpEventsNotYetUploadedToNightscout,
  280. key: "timestamp",
  281. ascending: false
  282. )
  283. return try await context.perform { [self] in
  284. guard let fetchedPumpEvents = results as? [PumpEventStored] else {
  285. throw CoreDataError.fetchError(function: #function, file: #file)
  286. }
  287. return fetchedPumpEvents.map { event in
  288. switch event.type {
  289. case PumpEvent.bolus.rawValue:
  290. // eventType determines whether bolus is external, smb or manual (=administered via app by user)
  291. let eventType = determineBolusEventType(for: event)
  292. return NightscoutTreatment(
  293. duration: nil,
  294. rawDuration: nil,
  295. rawRate: nil,
  296. absolute: nil,
  297. rate: nil,
  298. eventType: eventType,
  299. createdAt: event.timestamp,
  300. enteredBy: NightscoutTreatment.local,
  301. bolus: nil,
  302. insulin: event.bolus?.amount as Decimal?,
  303. notes: nil,
  304. carbs: nil,
  305. fat: nil,
  306. protein: nil,
  307. targetTop: nil,
  308. targetBottom: nil,
  309. id: event.id
  310. )
  311. case PumpEvent.tempBasal.rawValue:
  312. return NightscoutTreatment(
  313. duration: Int(event.tempBasal?.duration ?? 0),
  314. rawDuration: nil,
  315. rawRate: nil,
  316. absolute: event.tempBasal?.rate as Decimal?,
  317. rate: event.tempBasal?.rate as Decimal?,
  318. eventType: .nsTempBasal,
  319. createdAt: event.timestamp,
  320. enteredBy: NightscoutTreatment.local,
  321. bolus: nil,
  322. insulin: nil,
  323. notes: nil,
  324. carbs: nil,
  325. fat: nil,
  326. protein: nil,
  327. targetTop: nil,
  328. targetBottom: nil,
  329. id: event.id
  330. )
  331. case PumpEvent.pumpSuspend.rawValue:
  332. return NightscoutTreatment(
  333. duration: nil,
  334. rawDuration: nil,
  335. rawRate: nil,
  336. absolute: nil,
  337. rate: nil,
  338. eventType: .nsNote,
  339. createdAt: event.timestamp,
  340. enteredBy: NightscoutTreatment.local,
  341. bolus: nil,
  342. insulin: nil,
  343. notes: PumpEvent.pumpSuspend.rawValue,
  344. carbs: nil,
  345. fat: nil,
  346. protein: nil,
  347. targetTop: nil,
  348. targetBottom: nil
  349. )
  350. case PumpEvent.pumpResume.rawValue:
  351. return NightscoutTreatment(
  352. duration: nil,
  353. rawDuration: nil,
  354. rawRate: nil,
  355. absolute: nil,
  356. rate: nil,
  357. eventType: .nsNote,
  358. createdAt: event.timestamp,
  359. enteredBy: NightscoutTreatment.local,
  360. bolus: nil,
  361. insulin: nil,
  362. notes: PumpEvent.pumpResume.rawValue,
  363. carbs: nil,
  364. fat: nil,
  365. protein: nil,
  366. targetTop: nil,
  367. targetBottom: nil
  368. )
  369. case PumpEvent.rewind.rawValue:
  370. return NightscoutTreatment(
  371. duration: nil,
  372. rawDuration: nil,
  373. rawRate: nil,
  374. absolute: nil,
  375. rate: nil,
  376. eventType: .nsInsulinChange,
  377. createdAt: event.timestamp,
  378. enteredBy: NightscoutTreatment.local,
  379. bolus: nil,
  380. insulin: nil,
  381. notes: nil,
  382. carbs: nil,
  383. fat: nil,
  384. protein: nil,
  385. targetTop: nil,
  386. targetBottom: nil
  387. )
  388. case PumpEvent.prime.rawValue:
  389. return NightscoutTreatment(
  390. duration: nil,
  391. rawDuration: nil,
  392. rawRate: nil,
  393. absolute: nil,
  394. rate: nil,
  395. eventType: .nsSiteChange,
  396. createdAt: event.timestamp,
  397. enteredBy: NightscoutTreatment.local,
  398. bolus: nil,
  399. insulin: nil,
  400. notes: nil,
  401. carbs: nil,
  402. fat: nil,
  403. protein: nil,
  404. targetTop: nil,
  405. targetBottom: nil
  406. )
  407. case PumpEvent.pumpAlarm.rawValue:
  408. return NightscoutTreatment(
  409. duration: 30, // minutes
  410. rawDuration: nil,
  411. rawRate: nil,
  412. absolute: nil,
  413. rate: nil,
  414. eventType: .nsAnnouncement,
  415. createdAt: event.timestamp,
  416. enteredBy: NightscoutTreatment.local,
  417. bolus: nil,
  418. insulin: nil,
  419. notes: "Alarm \(String(describing: event.note)) \(PumpEvent.pumpAlarm.rawValue)",
  420. carbs: nil,
  421. fat: nil,
  422. protein: nil,
  423. targetTop: nil,
  424. targetBottom: nil
  425. )
  426. default:
  427. return nil
  428. }
  429. }.compactMap { $0 }
  430. }
  431. }
  432. func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent] {
  433. let context = makeContext()
  434. context.name = "getPumpHistoryNotYetUploadedToHealth"
  435. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  436. ofType: PumpEventStored.self,
  437. onContext: context,
  438. predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
  439. key: "timestamp",
  440. ascending: false
  441. )
  442. return try await context.perform {
  443. guard let fetchedPumpEvents = results as? [PumpEventStored] else {
  444. throw CoreDataError.fetchError(function: #function, file: #file)
  445. }
  446. return fetchedPumpEvents.map { event in
  447. switch event.type {
  448. case PumpEvent.bolus.rawValue:
  449. return PumpHistoryEvent(
  450. id: event.id ?? UUID().uuidString,
  451. type: .bolus,
  452. timestamp: event.timestamp ?? Date(),
  453. amount: event.bolus?.amount as Decimal?
  454. )
  455. case PumpEvent.tempBasal.rawValue:
  456. if let id = event.id, let timestamp = event.timestamp, let tempBasal = event.tempBasal,
  457. let tempBasalRate = tempBasal.rate
  458. {
  459. return PumpHistoryEvent(
  460. id: id,
  461. type: .tempBasal,
  462. timestamp: timestamp,
  463. amount: tempBasalRate as Decimal,
  464. duration: Int(tempBasal.duration)
  465. )
  466. } else {
  467. return nil
  468. }
  469. default:
  470. return nil
  471. }
  472. }.compactMap { $0 }
  473. }
  474. }
  475. func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent] {
  476. let context = makeContext()
  477. context.name = "getPumpHistoryNotYetUploadedToTidepool"
  478. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  479. ofType: PumpEventStored.self,
  480. onContext: context,
  481. predicate: NSPredicate.pumpEventsNotYetUploadedToTidepool,
  482. key: "timestamp",
  483. ascending: false
  484. )
  485. return try await context.perform {
  486. guard let fetchedPumpEvents = results as? [PumpEventStored] else {
  487. throw CoreDataError.fetchError(function: #function, file: #file)
  488. }
  489. return fetchedPumpEvents.map { event in
  490. switch event.type {
  491. case PumpEvent.bolus.rawValue:
  492. return PumpHistoryEvent(
  493. id: event.id ?? UUID().uuidString,
  494. type: .bolus,
  495. timestamp: event.timestamp ?? Date(),
  496. amount: event.bolus?.amount as Decimal?,
  497. isSMB: event.bolus?.isSMB ?? true,
  498. isExternal: event.bolus?.isExternal ?? false
  499. )
  500. case PumpEvent.tempBasal.rawValue:
  501. if let id = event.id, let timestamp = event.timestamp, let tempBasal = event.tempBasal,
  502. let tempBasalRate = tempBasal.rate
  503. {
  504. return PumpHistoryEvent(
  505. id: id,
  506. type: .tempBasal,
  507. timestamp: timestamp,
  508. amount: tempBasalRate as Decimal,
  509. duration: Int(tempBasal.duration)
  510. )
  511. } else {
  512. return nil
  513. }
  514. default:
  515. return nil
  516. }
  517. }.compactMap { $0 }
  518. }
  519. }
  520. }