diff --git a/Loop/Managers/CGM/CGMManager.swift b/Loop/Managers/CGM/CGMManager.swift new file mode 100644 index 0000000000..2abc5f04e6 --- /dev/null +++ b/Loop/Managers/CGM/CGMManager.swift @@ -0,0 +1,58 @@ +// +// CGMManager.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopUI + + +/// Describes the result of a CGM manager operation +/// +/// - noData: No new data was available or retrieved +/// - newData: New glucose data was received and stored +/// - error: An error occurred while receiving or store data +enum CGMResult { + case noData + case newData([(quantity: HKQuantity, date: Date, isDisplayOnly: Bool)]) + case error(Error) +} + + +protocol CGMManagerDelegate: class { + /// Asks the delegate for a date with which to filter incoming glucose data + /// + /// - Parameter manager: The manager instance + /// - Returns: The date data occuring on or after which should be kept + func startDateToFilterNewData(for manager: CGMManager) -> Date? + + /// Informs the delegate that the device has updated with a new result + /// + /// - Parameters: + /// - manager: The manager instance + /// - result: The result of the update + func cgmManager(_ manager: CGMManager, didUpdateWith result: CGMResult) -> Void +} + + +protocol CGMManager: CustomDebugStringConvertible { + weak var delegate: CGMManagerDelegate? { get set } + + /// Whether the device is capable of waking the app + var providesBLEHeartbeat: Bool { get } + + var sensorState: SensorDisplayable? { get } + + /// The representation of the device for use in HealthKit + var device: HKDevice? { get } + + /// Performs a manual fetch of glucose data from the device, if necessary + /// + /// - Parameters: + /// - deviceManager: The device manager instance to use for fetching + /// - completion: A closure called when operation has completed + func fetchNewDataIfNeeded(with deviceManager: DeviceDataManager, _ completion: @escaping (CGMResult) -> Void) -> Void +} + diff --git a/Loop/Managers/CGM/DexCGMManager.swift b/Loop/Managers/CGM/DexCGMManager.swift new file mode 100644 index 0000000000..5919a52d58 --- /dev/null +++ b/Loop/Managers/CGM/DexCGMManager.swift @@ -0,0 +1,255 @@ +// +// DexCGMManager.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import G4ShareSpy +import HealthKit +import LoopUI +import ShareClient +import xDripG5 + + +class DexCGMManager: CGMManager { + var providesBLEHeartbeat: Bool = true + + weak var delegate: CGMManagerDelegate? { + didSet { + shareManager?.delegate = delegate + } + } + + func fetchNewDataIfNeeded(with deviceManager: DeviceDataManager, _ completion: @escaping (CGMResult) -> Void) { + guard let shareManager = shareManager else { + completion(.noData) + return + } + + shareManager.fetchNewDataIfNeeded(with: deviceManager, completion) + } + + var sensorState: SensorDisplayable? { + return shareManager?.sensorState + } + + fileprivate var shareManager: ShareClientManager? = ShareClientManager() + + var device: HKDevice? { + return nil + } + + var debugDescription: String { + return [ + "## DexCGMManager", + "shareManager: \(String(reflecting: shareManager))", + "" + ].joined(separator: "\n") + } +} + + +final class ShareClientManager: CGMManager { + weak var delegate: CGMManagerDelegate? + + var providesBLEHeartbeat = false + + var sensorState: SensorDisplayable? { + return latestBackfill + } + + private var latestBackfill: ShareGlucose? + + func fetchNewDataIfNeeded(with deviceManager: DeviceDataManager, _ completion: @escaping (CGMResult) -> Void) { + guard let shareClient = deviceManager.remoteDataManager.shareService.client else { + completion(.noData) + return + } + + // If our last glucose was less than 4.5 minutes ago, don't fetch. + if let latestGlucose = latestBackfill, latestGlucose.startDate.timeIntervalSinceNow > -TimeInterval(minutes: 4.5) { + completion(.noData) + return + } + + shareClient.fetchLast(6) { (error, glucose) in + if let error = error { + completion(.error(error)) + return + } + guard let glucose = glucose else { + completion(.noData) + return + } + + // Ignore glucose values that are up to a minute newer than our previous value, to account for possible time shifting in Share data + let startDate = self.delegate?.startDateToFilterNewData(for: self)?.addingTimeInterval(TimeInterval(minutes: 1)) + let newGlucose = glucose.filterDateRange(startDate, nil).map { + return (quantity: $0.quantity, date: $0.startDate, isDisplayOnly: false) + } + + self.latestBackfill = glucose.first + + completion(.newData(newGlucose)) + } + } + + var device: HKDevice? = nil + + var debugDescription: String { + return [ + "## ShareClientManager", + "latestBackfill: \(latestBackfill)", + "" + ].joined(separator: "\n") + } +} + + +final class G5CGMManager: DexCGMManager, TransmitterDelegate { + private let transmitter: Transmitter + + init?(transmitterID: String?) { + guard let transmitterID = transmitterID else { + return nil + } + + self.transmitter = Transmitter(ID: transmitterID, passiveModeEnabled: true) + + super.init() + + self.transmitter.delegate = self + } + + override var sensorState: SensorDisplayable? { + return latestReading ?? super.sensorState + } + + private var latestReading: Glucose? { + didSet { + // Once we have our first reading, disable backfill + shareManager = nil + } + } + + override var device: HKDevice? { + return HKDevice( + name: "xDripG5", + manufacturer: "Dexcom", + model: "G5 Mobile", + hardwareVersion: nil, + firmwareVersion: nil, + softwareVersion: String(xDripG5VersionNumber), + localIdentifier: nil, + udiDeviceIdentifier: "00386270000002" + ) + } + + override var debugDescription: String { + return [ + "## G5CGMManager", + "latestReading: \(latestReading)", + "transmitter: \(transmitter)", + super.debugDescription, + "" + ].joined(separator: "\n") + } + + // MARK: - TransmitterDelegate + + func transmitter(_ transmitter: Transmitter, didError error: Error) { + delegate?.cgmManager(self, didUpdateWith: .error(error)) + } + + func transmitter(_ transmitter: Transmitter, didRead glucose: Glucose) { + guard glucose != latestReading, let quantity = glucose.glucose else { + delegate?.cgmManager(self, didUpdateWith: .noData) + return + } + latestReading = glucose + + self.delegate?.cgmManager(self, didUpdateWith: .newData([ + (quantity: quantity, date: glucose.readDate, isDisplayOnly: glucose.isDisplayOnly) + ])) + } + + func transmitter(_ transmitter: Transmitter, didReadUnknownData data: Data) { + // This can be used for protocol discovery, but isn't necessary for normal operation + } +} + + +final class G4CGMManager: DexCGMManager, ReceiverDelegate { + private let receiver = Receiver() + + override init() { + super.init() + + receiver.delegate = self + } + + override var sensorState: SensorDisplayable? { + return latestReading ?? super.sensorState + } + + private var latestReading: GlucoseG4? { + didSet { + // Once we have our first reading, disable backfill + shareManager = nil + } + } + + override var device: HKDevice? { + // "Dexcom G4 Platinum Transmitter (Retail) US" - see https://accessgudid.nlm.nih.gov/devices/search?query=dexcom+g4 + return HKDevice( + name: "G4ShareSpy", + manufacturer: "Dexcom", + model: "G4 Share", + hardwareVersion: nil, + firmwareVersion: nil, + softwareVersion: String(G4ShareSpyVersionNumber), + localIdentifier: nil, + udiDeviceIdentifier: "40386270000048" + ) + } + + override var debugDescription: String { + return [ + "## G4CGMManager", + "latestReading: \(latestReading)", + "receiver: \(receiver)", + super.debugDescription, + "" + ].joined(separator: "\n") + } + + // MARK: - ReceiverDelegate + + func receiver(_ receiver: Receiver, didReadGlucoseHistory glucoseHistory: [GlucoseG4]) { + guard let latest = glucoseHistory.sorted(by: { $0.sequence < $1.sequence }).last, latest != latestReading else { + return + } + latestReading = latest + + // In the event that some of the glucose history was already backfilled from Share, don't overwrite it. + let includeAfter = delegate?.startDateToFilterNewData(for: self)?.addingTimeInterval(TimeInterval(minutes: 1)) + + let validGlucose = glucoseHistory.filter({ + $0.isStateValid + }).filterDateRange(includeAfter, nil).map({ + (quantity: $0.quantity, date: $0.startDate, isDisplayOnly: $0.isDisplayOnly) + }) + + self.delegate?.cgmManager(self, didUpdateWith: .newData(validGlucose)) + } + + func receiver(_ receiver: Receiver, didError error: Error) { + delegate?.cgmManager(self, didUpdateWith: .error(error)) + } + + func receiver(_ receiver: Receiver, didLogBluetoothEvent event: String) { + // Uncomment to debug communication + // NSLog(["event": "\(event)", "collectedAt": NSDateFormatter.ISO8601StrictDateFormatter().stringFromDate(NSDate())]) + } +} diff --git a/Loop/Managers/CGM/EnliteCGMManager.swift b/Loop/Managers/CGM/EnliteCGMManager.swift new file mode 100644 index 0000000000..59a8d45e96 --- /dev/null +++ b/Loop/Managers/CGM/EnliteCGMManager.swift @@ -0,0 +1,69 @@ +// +// EnliteCGMManager.swift +// Loop +// +// Created by Nate Racklyeft on 3/12/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopUI +import MinimedKit + + +final class EnliteCGMManager: CGMManager { + var providesBLEHeartbeat = false + + weak var delegate: CGMManagerDelegate? + + var sensorState: SensorDisplayable? + + func fetchNewDataIfNeeded(with deviceManager: DeviceDataManager, _ completion: @escaping (CGMResult) -> Void) { + guard let device = deviceManager.rileyLinkManager.firstConnectedDevice?.ops + else { + completion(.noData) + return + } + + let latestGlucoseDate = self.delegate?.startDateToFilterNewData(for: self) ?? Date(timeIntervalSinceNow: TimeInterval(hours: -24)) + + guard latestGlucoseDate.timeIntervalSinceNow <= TimeInterval(minutes: -4.5) else { + completion(.noData) + return + } + + device.getGlucoseHistoryEvents(since: latestGlucoseDate.addingTimeInterval(TimeInterval(minutes: 1))) { (result) in + switch result { + case .success(let events): + if let latestSensorEvent = events.flatMap({ $0.glucoseEvent as? RelativeTimestampedGlucoseEvent }).last { + self.sensorState = EnliteSensorDisplayable(latestSensorEvent) + } + + let unit = HKUnit.milligramsPerDeciliterUnit() + let glucoseValues = events + // TODO: Is the { $0.date > latestGlucoseDate } filter duplicative? + .filter({ $0.glucoseEvent is SensorValueGlucoseEvent && $0.date > latestGlucoseDate }) + .map({ (e:TimestampedGlucoseEvent) -> (quantity: HKQuantity, date: Date, isDisplayOnly: Bool) in + let glucoseEvent = e.glucoseEvent as! SensorValueGlucoseEvent + let quantity = HKQuantity(unit: unit, doubleValue: Double(glucoseEvent.sgv)) + return (quantity: quantity, date: e.date, isDisplayOnly: false) + }) + + completion(.newData(glucoseValues)) + case .failure(let error): + completion(.error(error)) + } + } + } + + var device: HKDevice? = nil + + var debugDescription: String { + return [ + "## EnliteCGMManager", + "sensorState: \(sensorState)", + "" + ].joined(separator: "\n") + } +} + diff --git a/Loop/Models/CGM.swift b/Loop/Models/CGM.swift new file mode 100644 index 0000000000..ea0488b5be --- /dev/null +++ b/Loop/Models/CGM.swift @@ -0,0 +1,105 @@ +// +// CGM.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation + + +enum CGM { + case g5(transmitterID: String?) + case g4 + case enlite + + var appURL: URL? { + switch self { + case .g4: + return URL(string: "dexcomshare://") + case .g5: + return URL(string: "dexcomcgm://") + case .enlite: + return nil + } + } + + func createManager() -> CGMManager? { + switch self { + case .enlite: + return EnliteCGMManager() + case .g4: + return G4CGMManager() + case .g5(let transmitterID): + return G5CGMManager(transmitterID: transmitterID) + } + } +} + + +extension CGM: RawRepresentable { + typealias RawValue = [String: Any] + private static let version = 1 + + init?(rawValue: RawValue) { + guard + let version = rawValue["version"] as? Int, + version == CGM.version, + let type = rawValue["type"] as? String + else { + return nil + } + + switch CGMType(rawValue: type) { + case .g5?: + self = .g5(transmitterID: rawValue["transmitterID"] as? String) + case .g4?: + self = .g4 + case .enlite?: + self = .enlite + case .none: + return nil + } + } + + private enum CGMType: String { + case g5 + case g4 + case enlite + } + + private var type: CGMType { + switch self { + case .g5: return .g5 + case .g4: return .g4 + case .enlite: return .enlite + } + } + + var rawValue: [String: Any] { + var raw: RawValue = [ + "version": CGM.version, + "type": type.rawValue + ] + + if case .g5(let transmitterID) = self { + raw["transmitterID"] = transmitterID + } + + return raw + } +} + + +extension CGM: Equatable { + static func ==(lhs: CGM, rhs: CGM) -> Bool { + switch (lhs, rhs) { + case (.g4, .g4), (.enlite, .enlite): + return true + case (.g5(let a), .g5(let b)): + return a == b + default: + return false + } + } +}