AppGroupSource.swift 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import Combine
  2. import Foundation
  3. import LoopKit
  4. import LoopKitUI
  5. public extension GlucoseTrend {
  6. var direction: String {
  7. switch self {
  8. case .upUpUp:
  9. return "DoubleUp"
  10. case .upUp:
  11. return "SingleUp"
  12. case .up:
  13. return "FortyFiveUp"
  14. case .flat:
  15. return "Flat"
  16. case .down:
  17. return "FortyFiveDown"
  18. case .downDown:
  19. return "SingleDown"
  20. case .downDownDown:
  21. return "DoubleDown"
  22. }
  23. }
  24. }
  25. struct AppGroupSource: GlucoseSource {
  26. var cgmManager: CGMManagerUI?
  27. var glucoseManager: FetchGlucoseManager?
  28. let from: String
  29. var cgmType: CGMType
  30. let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
  31. let cgmProgressHighlight = CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never>(nil)
  32. func fetch(_ heartbeat: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
  33. guard let suiteName = Bundle.main.appGroupSuiteName,
  34. let sharedDefaults = UserDefaults(suiteName: suiteName)
  35. else {
  36. return Just([]).eraseToAnyPublisher()
  37. }
  38. return Just(fetchLastBGs(60, sharedDefaults, heartbeat)).eraseToAnyPublisher()
  39. }
  40. func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
  41. fetch(nil)
  42. }
  43. private func fetchLastBGs(_ count: Int, _ sharedDefaults: UserDefaults, _ heartbeat: DispatchTimer?) -> [BloodGlucose] {
  44. guard let sharedData = sharedDefaults.data(forKey: "latestReadings") else {
  45. return []
  46. }
  47. HeartBeatManager.shared.checkCGMBluetoothTransmitter(sharedUserDefaults: sharedDefaults, heartbeat: heartbeat)
  48. debug(.deviceManager, "APPGROUP : START FETCH LAST BG ")
  49. let decoded = try? JSONSerialization.jsonObject(with: sharedData, options: [])
  50. // Two shapes accepted:
  51. // Legacy (xDrip4iOS today): top-level array of reading dicts.
  52. // Rich (xDrip4iOS extended for CGM lifecycle):
  53. // top-level dict carrying readings under
  54. // `recentReadings` plus sibling keys for CGM status, sensor
  55. // lifecycle, and transmitter info — see `applyRichState`.
  56. let sgvs: [AnyObject]
  57. if let dict = decoded as? [String: Any] {
  58. applyRichState(dict)
  59. sgvs = (dict["recentReadings"] as? [AnyObject]) ?? []
  60. } else if let arr = decoded as? [AnyObject] {
  61. applyRichState(nil)
  62. sgvs = arr
  63. } else {
  64. return []
  65. }
  66. var results: [BloodGlucose] = []
  67. for sgv in sgvs.prefix(count) {
  68. guard
  69. let glucose = sgv["Value"] as? Int,
  70. let timestamp = sgv["DT"] as? String,
  71. let date = parseDate(timestamp)
  72. else { continue }
  73. var direction: String?
  74. // Dexcom changed the format of trend in 2021 so we accept both String/Int types
  75. if let directionString = sgv["direction"] as? String {
  76. direction = directionString
  77. } else if let intTrend = sgv["trend"] as? Int {
  78. direction = GlucoseTrend(rawValue: intTrend)?.direction
  79. } else if let intTrend = sgv["Trend"] as? Int {
  80. direction = GlucoseTrend(rawValue: intTrend)?.direction
  81. } else if let stringTrend = sgv["trend"] as? String, let intTrend = Int(stringTrend) {
  82. direction = GlucoseTrend(rawValue: intTrend)?.direction
  83. }
  84. guard let direction = direction else { continue }
  85. if let from = sgv["from"] as? String {
  86. guard from == self.from else { continue }
  87. }
  88. results.append(
  89. BloodGlucose(
  90. sgv: glucose,
  91. direction: BloodGlucose.Direction(rawValue: direction),
  92. date: Decimal(Int(date.timeIntervalSince1970 * 1000)),
  93. dateString: date,
  94. unfiltered: Decimal(glucose),
  95. filtered: nil,
  96. noise: nil,
  97. glucose: glucose,
  98. type: "sgv"
  99. )
  100. )
  101. }
  102. return results
  103. }
  104. /// Reads the rich top-level dict from xDrip4iOS (when present) and
  105. /// pushes status + lifecycle into the publishers HomeStateModel
  106. /// subscribes to. Defensive on every key — xdrip ships partial dicts
  107. /// during warmup / failure / between sensors.
  108. private func applyRichState(_ payload: [String: Any]?) {
  109. guard let payload else {
  110. cgmDisplayState.value = nil
  111. cgmProgressHighlight.value = nil
  112. return
  113. }
  114. let cgm = payload["cgm"] as? [String: Any]
  115. cgmDisplayState.value = parseStatus(cgm?["status"] as? [String: Any])
  116. cgmProgressHighlight.value = parseSensorLifecycle(cgm?["sensor"] as? [String: Any])
  117. }
  118. private func parseStatus(_ status: [String: Any]?) -> CgmDisplayState? {
  119. guard let status,
  120. let message = status["localizedMessage"] as? String,
  121. !message.isEmpty
  122. else { return nil }
  123. return CgmDisplayState(
  124. localizedMessage: message,
  125. imageName: (status["imageName"] as? String) ?? "",
  126. status: cgmDisplayStatus(forCode: status["displayState"] as? String ?? status["code"] as? String)
  127. )
  128. }
  129. /// xDrip4iOS sends a free-form code string (e.g. "normal", "warning",
  130. /// "critical", "warmup", "calibration_needed", "sensor_failed"). We
  131. /// fold anything unfamiliar into `.warning` so unknown future codes
  132. /// surface visibly instead of going silent.
  133. private func cgmDisplayStatus(forCode code: String?) -> CgmDisplayStatus {
  134. switch code?.lowercased() {
  135. case nil,
  136. "normal",
  137. "ok": return .normal
  138. case "critical",
  139. "expired",
  140. "sensor_failed",
  141. "session_failed",
  142. "stopped": return .critical
  143. default: return .warning
  144. }
  145. }
  146. private func parseSensorLifecycle(_ sensor: [String: Any]?) -> DeviceLifecycleProgress? {
  147. guard let sensor else { return nil }
  148. let percent = (sensor["percentComplete"] as? NSNumber)?.doubleValue
  149. guard let percent else { return nil }
  150. let progressState = lifecycleProgressState(
  151. for: sensor["progressState"] as? String,
  152. isInWarmup: sensor["isInWarmup"] as? Bool ?? false,
  153. isExpired: sensor["isExpired"] as? Bool ?? false
  154. )
  155. return AppGroupLifecycleProgress(
  156. percentComplete: max(0, min(1, percent)),
  157. progressState: progressState
  158. )
  159. }
  160. private func lifecycleProgressState(
  161. for code: String?,
  162. isInWarmup: Bool,
  163. isExpired: Bool
  164. ) -> DeviceLifecycleProgressState {
  165. if isExpired { return .critical }
  166. if isInWarmup { return .normalCGM }
  167. switch code?.lowercased() {
  168. case "critical": return .critical
  169. case "warning": return .warning
  170. default: return .normalCGM
  171. }
  172. }
  173. private func parseDate(_ timestamp: String) -> Date? {
  174. // timestamp looks like "/Date(1462404576000)/"
  175. guard let re = try? NSRegularExpression(pattern: "\\((.*)\\)"),
  176. let match = re.firstMatch(in: timestamp, range: NSMakeRange(0, timestamp.count))
  177. else {
  178. return nil
  179. }
  180. let matchRange = match.range(at: 1)
  181. let epoch = Double((timestamp as NSString).substring(with: matchRange))! / 1000
  182. return Date(timeIntervalSince1970: epoch)
  183. }
  184. func sourceInfo() -> [String: Any]? {
  185. [GlucoseSourceKey.description.rawValue: "Group ID: \(Bundle.main.appGroupSuiteName ?? String(localized: "Not set"))"]
  186. }
  187. }
  188. private struct AppGroupLifecycleProgress: DeviceLifecycleProgress {
  189. let percentComplete: Double
  190. let progressState: DeviceLifecycleProgressState
  191. }
  192. public extension Bundle {
  193. var appGroupSuiteName: String? {
  194. object(forInfoDictionaryKey: "AppGroupID") as? String
  195. }
  196. }