GlucoseStorage.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import AVFAudio
  2. import CoreData
  3. import Foundation
  4. import SwiftDate
  5. import SwiftUI
  6. import Swinject
  7. protocol GlucoseStorage {
  8. func storeGlucose(_ glucose: [BloodGlucose])
  9. func syncDate() -> Date
  10. func filterTooFrequentGlucose(_ glucose: [BloodGlucose], at: Date) -> [BloodGlucose]
  11. func lastGlucoseDate() -> Date
  12. func isGlucoseFresh() -> Bool
  13. func getGlucoseNotYetUploadedToNightscout() async -> [BloodGlucose]
  14. func getCGMStateNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  15. func getManualGlucoseNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  16. var alarm: GlucoseAlarm? { get }
  17. }
  18. final class BaseGlucoseStorage: GlucoseStorage, Injectable {
  19. private let processQueue = DispatchQueue(label: "BaseGlucoseStorage.processQueue")
  20. @Injected() private var storage: FileStorage!
  21. @Injected() private var broadcaster: Broadcaster!
  22. @Injected() private var settingsManager: SettingsManager!
  23. let coredataContext = CoreDataStack.shared.newTaskContext()
  24. private enum Config {
  25. static let filterTime: TimeInterval = 3.5 * 60
  26. }
  27. init(resolver: Resolver) {
  28. injectServices(resolver)
  29. }
  30. private var glucoseFormatter: NumberFormatter {
  31. let formatter = NumberFormatter()
  32. formatter.numberStyle = .decimal
  33. formatter.maximumFractionDigits = 0
  34. if settingsManager.settings.units == .mmolL {
  35. formatter.maximumFractionDigits = 1
  36. }
  37. formatter.decimalSeparator = "."
  38. return formatter
  39. }
  40. func storeGlucose(_ glucose: [BloodGlucose]) {
  41. processQueue.sync {
  42. self.coredataContext.perform {
  43. let datesToCheck: Set<Date?> = Set(glucose.compactMap { $0.dateString as Date? })
  44. let fetchRequest: NSFetchRequest<NSFetchRequestResult> = GlucoseStored.fetchRequest()
  45. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
  46. NSPredicate(format: "date IN %@", datesToCheck),
  47. NSPredicate.predicateForOneDayAgo
  48. ])
  49. fetchRequest.propertiesToFetch = ["date"]
  50. fetchRequest.resultType = .dictionaryResultType
  51. var existingDates = Set<Date>()
  52. do {
  53. let results = try self.coredataContext.fetch(fetchRequest) as? [NSDictionary]
  54. existingDates = Set(results?.compactMap({ $0["date"] as? Date }) ?? [])
  55. } catch {
  56. debugPrint("Failed to fetch existing glucose dates: \(error)")
  57. }
  58. var filteredGlucose = glucose.filter { !existingDates.contains($0.dateString) }
  59. // prepare batch insert
  60. let batchInsert = NSBatchInsertRequest(
  61. entity: GlucoseStored.entity(),
  62. managedObjectHandler: { (managedObject: NSManagedObject) -> Bool in
  63. guard let glucoseEntry = managedObject as? GlucoseStored, !filteredGlucose.isEmpty else {
  64. return true // Stop if there are no more items
  65. }
  66. let entry = filteredGlucose.removeFirst()
  67. glucoseEntry.id = UUID()
  68. glucoseEntry.glucose = Int16(entry.glucose ?? 0)
  69. glucoseEntry.date = entry.dateString
  70. glucoseEntry.direction = entry.direction?.rawValue
  71. glucoseEntry.isUploadedToNS = false /// the value is not uploaded to NS (yet)
  72. return false // Continue processing
  73. }
  74. )
  75. // process batch insert
  76. do {
  77. try self.coredataContext.execute(batchInsert)
  78. // debugPrint("Glucose Storage: \(#function) \(DebuggingIdentifiers.succeeded) saved glucose to Core Data")
  79. // Send notification for triggering a fetch in Home State Model to update the Glucose Array
  80. /// This is necessary because changes only get merged automatically into the viewContext because of the Persistent History Tracking
  81. /// But I do not want to fetch on the Main Thread using the @FetchRequest property, I also can not use the FetchedResultsController because of the architecture of the State Model (it must inherit from BaseStateModel and therefore can not inherit from NSObject as well) and because of the fact that I am using a batch insert here there are no notifications sent from the managedObjectContext because changes are directly stored in the persistent container
  82. Foundation.NotificationCenter.default.post(name: .didPerformBatchInsert, object: nil)
  83. } catch {
  84. debugPrint(
  85. "Glucose Storage: \(#function) \(DebuggingIdentifiers.failed) failed to execute batch insert: \(error)"
  86. )
  87. }
  88. debug(.deviceManager, "start storage cgmState")
  89. self.storage.transaction { storage in
  90. let file = OpenAPS.Monitor.cgmState
  91. var treatments = storage.retrieve(file, as: [NightscoutTreatment].self) ?? []
  92. var updated = false
  93. for x in glucose {
  94. debug(.deviceManager, "storeGlucose \(x)")
  95. guard let sessionStartDate = x.sessionStartDate else {
  96. continue
  97. }
  98. if let lastTreatment = treatments.last,
  99. let createdAt = lastTreatment.createdAt,
  100. // When a new Dexcom sensor is started, it produces multiple consecutive
  101. // startDates. Disambiguate them by only allowing a session start per minute.
  102. abs(createdAt.timeIntervalSince(sessionStartDate)) < TimeInterval(60)
  103. {
  104. continue
  105. }
  106. var notes = ""
  107. if let t = x.transmitterID {
  108. notes = t
  109. }
  110. if let a = x.activationDate {
  111. notes = "\(notes) activated on \(a)"
  112. }
  113. let treatment = NightscoutTreatment(
  114. duration: nil,
  115. rawDuration: nil,
  116. rawRate: nil,
  117. absolute: nil,
  118. rate: nil,
  119. eventType: .nsSensorChange,
  120. createdAt: sessionStartDate,
  121. enteredBy: NightscoutTreatment.local,
  122. bolus: nil,
  123. insulin: nil,
  124. notes: notes,
  125. carbs: nil,
  126. fat: nil,
  127. protein: nil,
  128. targetTop: nil,
  129. targetBottom: nil
  130. )
  131. debug(.deviceManager, "CGM sensor change \(treatment)")
  132. treatments.append(treatment)
  133. updated = true
  134. }
  135. if updated {
  136. // We have to keep quite a bit of history as sensors start only every 10 days.
  137. storage.save(
  138. treatments.filter
  139. { $0.createdAt != nil && $0.createdAt!.addingTimeInterval(30.days.timeInterval) > Date() },
  140. as: file
  141. )
  142. }
  143. }
  144. }
  145. }
  146. }
  147. func syncDate() -> Date {
  148. let fr = GlucoseStored.fetchRequest()
  149. fr.predicate = NSPredicate.predicateForOneDayAgo
  150. fr.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
  151. fr.fetchLimit = 1
  152. var date: Date?
  153. coredataContext.performAndWait {
  154. do {
  155. let results = try self.coredataContext.fetch(fr)
  156. date = results.first?.date
  157. } catch let error as NSError {
  158. print("Fetch error: \(DebuggingIdentifiers.failed) \(error.localizedDescription), \(error.userInfo)")
  159. }
  160. }
  161. return date ?? .distantPast
  162. }
  163. func lastGlucoseDate() -> Date {
  164. let fr = GlucoseStored.fetchRequest()
  165. fr.predicate = NSPredicate.predicateForOneDayAgo
  166. fr.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
  167. fr.fetchLimit = 1
  168. var date: Date?
  169. coredataContext.performAndWait {
  170. do {
  171. let results = try self.coredataContext.fetch(fr)
  172. date = results.first?.date
  173. } catch let error as NSError {
  174. print("Fetch error: \(DebuggingIdentifiers.failed) \(error.localizedDescription), \(error.userInfo)")
  175. }
  176. }
  177. return date ?? .distantPast
  178. }
  179. func isGlucoseFresh() -> Bool {
  180. Date().timeIntervalSince(lastGlucoseDate()) <= Config.filterTime
  181. }
  182. func filterTooFrequentGlucose(_ glucose: [BloodGlucose], at date: Date) -> [BloodGlucose] {
  183. var lastDate = date
  184. var filtered: [BloodGlucose] = []
  185. let sorted = glucose.sorted { $0.date < $1.date }
  186. for entry in sorted {
  187. guard entry.dateString.addingTimeInterval(-Config.filterTime) > lastDate else {
  188. continue
  189. }
  190. filtered.append(entry)
  191. lastDate = entry.dateString
  192. }
  193. return filtered
  194. }
  195. func fetchLatestGlucose() -> GlucoseStored? {
  196. let predicate = NSPredicate.predicateFor20MinAgo
  197. return CoreDataStack.shared.fetchEntities(
  198. ofType: GlucoseStored.self,
  199. onContext: coredataContext,
  200. predicate: predicate,
  201. key: "date",
  202. ascending: false,
  203. fetchLimit: 1
  204. ).first
  205. }
  206. // Fetch glucose that is not uploaded to Nightscout yet
  207. /// Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
  208. func getGlucoseNotYetUploadedToNightscout() async -> [BloodGlucose] {
  209. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  210. ofType: GlucoseStored.self,
  211. onContext: coredataContext,
  212. predicate: NSPredicate.glucoseNotYetUploadedToNightscout,
  213. key: "date",
  214. ascending: false,
  215. fetchLimit: 288
  216. )
  217. guard let fetchedResults = results as? [GlucoseStored] else { return [] }
  218. return await coredataContext.perform {
  219. return fetchedResults.map { result in
  220. BloodGlucose(
  221. _id: result.id?.uuidString ?? UUID().uuidString,
  222. sgv: Int(result.glucose),
  223. direction: BloodGlucose.Direction(from: result.direction ?? ""),
  224. date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
  225. dateString: result.date ?? Date(),
  226. unfiltered: Decimal(result.glucose),
  227. filtered: Decimal(result.glucose),
  228. noise: nil,
  229. glucose: Int(result.glucose)
  230. )
  231. }
  232. }
  233. }
  234. // Fetch manual glucose that is not uploaded to Nightscout yet
  235. /// Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
  236. func getManualGlucoseNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  237. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  238. ofType: GlucoseStored.self,
  239. onContext: coredataContext,
  240. predicate: NSPredicate.manualGlucoseNotYetUploadedToNightscout,
  241. key: "date",
  242. ascending: false,
  243. fetchLimit: 288
  244. )
  245. guard let fetchedResults = results as? [GlucoseStored] else { return [] }
  246. return await coredataContext.perform {
  247. return fetchedResults.map { result in
  248. NightscoutTreatment(
  249. duration: nil,
  250. rawDuration: nil,
  251. rawRate: nil,
  252. absolute: nil,
  253. rate: nil,
  254. eventType: .capillaryGlucose,
  255. createdAt: result.date,
  256. enteredBy: "Trio",
  257. bolus: nil,
  258. insulin: nil,
  259. notes: "Trio User",
  260. carbs: nil,
  261. fat: nil,
  262. protein: nil,
  263. foodType: nil,
  264. targetTop: nil,
  265. targetBottom: nil,
  266. glucoseType: "Manual",
  267. glucose: self.settingsManager.settings
  268. .units == .mgdL ? (self.glucoseFormatter.string(from: Int(result.glucose) as NSNumber) ?? "")
  269. : (self.glucoseFormatter.string(from: Decimal(result.glucose).asMmolL as NSNumber) ?? ""),
  270. units: self.settingsManager.settings.units == .mmolL ? "mmol" : "mg/dl",
  271. id: result.id?.uuidString
  272. )
  273. }
  274. }
  275. }
  276. func getCGMStateNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  277. async let alreadyUploaded: [NightscoutTreatment] = storage
  278. .retrieveAsync(OpenAPS.Nightscout.uploadedCGMState, as: [NightscoutTreatment].self) ?? []
  279. async let allValues: [NightscoutTreatment] = storage
  280. .retrieveAsync(OpenAPS.Monitor.cgmState, as: [NightscoutTreatment].self) ?? []
  281. let (alreadyUploadedValues, allValuesSet) = await (alreadyUploaded, allValues)
  282. return Array(Set(allValuesSet).subtracting(Set(alreadyUploadedValues)))
  283. }
  284. var alarm: GlucoseAlarm? {
  285. /// glucose can not be older than 20 minutes due to the predicate in the fetch request
  286. coredataContext.performAndWait {
  287. guard let glucose = fetchLatestGlucose() else { return nil }
  288. let glucoseValue = glucose.glucose
  289. if Decimal(glucoseValue) <= settingsManager.settings.lowGlucose {
  290. return .low
  291. }
  292. if Decimal(glucoseValue) >= settingsManager.settings.highGlucose {
  293. return .high
  294. }
  295. return nil
  296. }
  297. }
  298. }
  299. protocol GlucoseObserver {
  300. func glucoseDidUpdate(_ glucose: [BloodGlucose])
  301. }
  302. enum GlucoseAlarm {
  303. case high
  304. case low
  305. var displayName: String {
  306. switch self {
  307. case .high:
  308. return NSLocalizedString("LOWALERT!", comment: "LOWALERT!")
  309. case .low:
  310. return NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!")
  311. }
  312. }
  313. }