PumpHistoryStorage.swift 25 KB

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