// // LibreTransmitterManager.swift // Created by Bjørn Inge Berg on 25/02/2019. // Copyright © 2019 Bjørn Inge Berg. All rights reserved. // import Foundation import UserNotifications import Combine import UIKit import CoreBluetooth import HealthKit import os.log public protocol LibreTransmitterManagerDelegate: AnyObject { var queue: DispatchQueue { get } func startDateToFilterNewData(for: LibreTransmitterManager) -> Date? func cgmManager(_:LibreTransmitterManager, hasNew result: Result<[LibreGlucose], Error>) func overcalibration(for: LibreTransmitterManager) -> ((Double) -> (Double))? } public final class LibreTransmitterManager: LibreTransmitterDelegate { public typealias GlucoseArrayWithPrediction = (glucose:[LibreGlucose], prediction:[LibreGlucose]) public lazy var logger = Logger(forType: Self.self) public let isOnboarded = true // No distinction between created and onboarded public var hasValidSensorSession: Bool { lastConnected != nil } public var glucoseDisplay: GlucoseDisplayable? public var trend: GlucoseTrend? public func libreManagerDidRestoreState(found peripherals: [CBPeripheral], connected to: CBPeripheral?) { let devicename = to?.name ?? "no device" let id = to?.identifier.uuidString ?? "null" let msg = String(format: NSLocalizedString("Bluetooth State restored (APS restarted?). Found %d peripherals, and connected to %@ with identifier %@", comment: "Restored state message"), peripherals.count, devicename, id) NotificationHelper.sendRestoredStateNotification(msg: msg) } public var batteryLevel: Double? { let batt = self.proxy?.metadata?.battery logger.debug("dabear:: LibreTransmitterManager was asked to return battery: \(batt.debugDescription)") //convert from 8% -> 0.8 if let battery = proxy?.metadata?.battery { return Double(battery) / 100 } return nil } public var cgmManagerDelegate: LibreTransmitterManagerDelegate? { get { return delegate.delegate } set { delegate.delegate = newValue } } public var delegateQueue: DispatchQueue! { get { return delegate.queue } set { delegate.queue = newValue } } public let delegate = WeakSynchronizedDelegate() public var managedDataInterval: TimeInterval? private func getPersistedSensorDataForDebug() -> String { guard let data = UserDefaults.standard.queuedSensorData else { return "nil" } let c = self.calibrationData?.description ?? "no calibrationdata" return data.array.map { "SensorData(uuid: \"0123\".data(using: .ascii)!, bytes: \($0.bytes))!" } .joined(separator: ",\n") + ",\n Calibrationdata: \(c)" } public var debugDescription: String { return [ "## LibreTransmitterManager", "Testdata: foo", "lastConnected: \(String(describing: lastConnected))", "Connection state: \(connectionState)", "Sensor state: \(sensorStateDescription)", "transmitterbattery: \(batteryString)", "SensorData: \(getPersistedSensorDataForDebug())", "Metainfo::\n\(AppMetaData.allProperties)", "" ].joined(separator: "\n") } public private(set) var lastConnected: Date? public private(set) var latestPrediction: LibreGlucose? public private(set) var latestBackfill: LibreGlucose? { willSet(newValue) { guard let newValue = newValue else { return } var trend: GlucoseTrend? let oldValue = latestBackfill logger.debug("dabear:: latestBackfill set, newvalue is \(newValue.description)") if let oldValue = oldValue { // the idea here is to use the diff between the old and the new glucose to calculate slope and direction, rather than using trend from the glucose value. // this is because the old and new glucose values represent earlier readouts, while the trend buffer contains somewhat more jumpy (noisy) values. let timediff = LibreGlucose.timeDifference(oldGlucose: oldValue, newGlucose: newValue) logger.debug("dabear:: timediff is \(timediff)") let oldIsRecentEnough = timediff <= TimeInterval.minutes(15) trend = oldIsRecentEnough ? newValue.GetGlucoseTrend(last: oldValue) : nil var batteries : [(name: String, percentage: Int)]? if let metaData = metaData, let battery = battery { batteries = [(name: metaData.name, percentage: battery)] } self.glucoseDisplay = ConcreteGlucoseDisplayable(isStateValid: newValue.isStateValid, trendType: trend, isLocal: true, batteries: batteries) } else { //could consider setting this to ConcreteSensorDisplayable with trendtype GlucoseTrend.flat, but that would be kinda lying self.glucoseDisplay = nil } } } static public var managerIdentifier : String { Self.className } static public let localizedTitle = LocalizedString("Libre Bluetooth", comment: "Title for the CGMManager option") public init() { lastConnected = nil //let isui = (self is CGMManagerUI) //self.miaomiaoService = MiaomiaoService(keychainManager: keychain) logger.debug("dabear: LibreTransmitterManager will be created now") //proxy = MiaoMiaoBluetoothManager() proxy?.delegate = self } public func disconnect() { logger.debug("dabear:: LibreTransmitterManager disconnect called") proxy?.disconnectManually() proxy?.delegate = nil } deinit { logger.debug("dabear:: LibreTransmitterManager deinit called") //cleanup any references to events to this class disconnect() } //lazy because we don't want to scan immediately private lazy var proxy: LibreTransmitterProxyManager? = LibreTransmitterProxyManager() /* These properties are mostly useful for swiftui */ public var transmitterInfoObservable = TransmitterInfo() public var sensorInfoObservable = SensorInfo() public var glucoseInfoObservable = GlucoseInfo() var longDateFormatter : DateFormatter = ({ let df = DateFormatter() df.dateStyle = .long df.timeStyle = .long df.doesRelativeDateFormatting = true return df })() var dateFormatter : DateFormatter = ({ let df = DateFormatter() df.dateStyle = .long df.timeStyle = .full df.locale = Locale.current return df })() //when was the libre2 direct ble update last received? var lastDirectUpdate : Date? = nil private var countTimesWithoutData: Int = 0 } // MARK: - Convenience functions extension LibreTransmitterManager { func setObservables(sensorData: SensorData?, bleData: Libre2.LibreBLEResponse?, metaData: LibreTransmitterMetadata?) { logger.debug("dabear:: setObservables called") DispatchQueue.main.async { if let metaData=metaData { self.logger.debug("dabear::will set transmitterInfoObservable") self.transmitterInfoObservable.battery = metaData.batteryString self.transmitterInfoObservable.hardware = metaData.hardware self.transmitterInfoObservable.firmware = metaData.firmware self.transmitterInfoObservable.sensorType = metaData.sensorType()?.description ?? "Unknown" self.transmitterInfoObservable.transmitterIdentifier = metaData.macAddress ?? UserDefaults.standard.preSelectedDevice ?? "Unknown" } self.transmitterInfoObservable.connectionState = self.proxy?.connectionStateString ?? "n/a" self.transmitterInfoObservable.transmitterType = self.proxy?.shortTransmitterName ?? "Unknown" if let sensorData = sensorData { self.logger.debug("dabear::will set sensorInfoObservable") self.sensorInfoObservable.sensorAge = sensorData.humanReadableSensorAge self.sensorInfoObservable.sensorAgeLeft = sensorData.humanReadableTimeLeft self.sensorInfoObservable.sensorState = sensorData.state.description self.sensorInfoObservable.sensorSerial = sensorData.serialNumber self.glucoseInfoObservable.checksum = String(sensorData.footerCrc.byteSwapped) if let sensorEndTime = sensorData.sensorEndTime { self.sensorInfoObservable.sensorEndTime = self.dateFormatter.string(from: sensorEndTime ) } else { self.sensorInfoObservable.sensorEndTime = "Unknown or ended" } } else if let bleData = bleData, let sensor = UserDefaults.standard.preSelectedSensor { let aday = 86_400.0 //in seconds var humanReadableSensorAge: String { let days = TimeInterval(bleData.age * 60) / aday return String(format: "%.2f", days) + NSLocalizedString(" day(s)", comment: "Sensor day(s)") } var maxMinutesWearTime : Int{ sensor.maxAge } var minutesLeft: Int { maxMinutesWearTime - bleData.age } var humanReadableTimeLeft: String { let days = TimeInterval(minutesLeft * 60) / aday return String(format: "%.2f", days) + NSLocalizedString(" day(s)", comment: "Sensor day(s)") } //once the sensor has ended we don't know the exact date anymore var sensorEndTime: Date? { if minutesLeft <= 0 { return nil } // we can assume that the libre2 direct bluetooth packet is received immediately // after the sensor has been done a new measurement, so using Date() should be fine here return Date().addingTimeInterval(TimeInterval(minutes: Double(minutesLeft))) } self.sensorInfoObservable.sensorAge = humanReadableSensorAge self.sensorInfoObservable.sensorAgeLeft = humanReadableTimeLeft self.sensorInfoObservable.sensorState = "Operational" self.sensorInfoObservable.sensorState = "Operational" self.sensorInfoObservable.sensorSerial = SensorSerialNumber(withUID: sensor.uuid)?.serialNumber ?? "-" if let mapping = UserDefaults.standard.calibrationMapping, let calibration = self.calibrationData , mapping.uuid == sensor.uuid && calibration.isValidForFooterWithReverseCRCs == mapping.reverseFooterCRC { self.glucoseInfoObservable.checksum = "\(mapping.reverseFooterCRC)" } if let sensorEndTime = sensorEndTime { self.sensorInfoObservable.sensorEndTime = self.dateFormatter.string(from: sensorEndTime ) } else { self.sensorInfoObservable.sensorEndTime = "Unknown or ended" } } let formatter = QuantityFormatter() let preferredUnit = UserDefaults.standard.mmGlucoseUnit ?? .millimolesPerLiter if let d = self.latestBackfill { self.logger.debug("dabear::will set glucoseInfoObservable") formatter.setPreferredNumberFormatter(for: .millimolesPerLiter) self.glucoseInfoObservable.glucoseMMOL = formatter.string(from: d.quantity, for: .millimolesPerLiter) ?? "-" formatter.setPreferredNumberFormatter(for: .milligramsPerDeciliter) self.glucoseInfoObservable.glucoseMGDL = formatter.string(from: d.quantity, for: .milligramsPerDeciliter) ?? "-" //backward compat if preferredUnit == .millimolesPerLiter { self.glucoseInfoObservable.glucose = self.glucoseInfoObservable.glucoseMMOL } else if preferredUnit == .milligramsPerDeciliter { self.glucoseInfoObservable.glucose = self.glucoseInfoObservable.glucoseMGDL } self.glucoseInfoObservable.date = self.longDateFormatter.string(from: d.timestamp) } if let d = self.latestPrediction { formatter.setPreferredNumberFormatter(for: .millimolesPerLiter) self.glucoseInfoObservable.predictionMMOL = formatter.string(from: d.quantity, for: .millimolesPerLiter) ?? "-" formatter.setPreferredNumberFormatter(for: .milligramsPerDeciliter) self.glucoseInfoObservable.predictionMGDL = formatter.string(from: d.quantity, for: .milligramsPerDeciliter) ?? "-" self.glucoseInfoObservable.predictionDate = self.longDateFormatter.string(from: d.timestamp) } } } func getStartDateForFilter() -> Date?{ var startDate: Date? self.delegateQueue.sync { startDate = self.cgmManagerDelegate?.startDateToFilterNewData(for: self) ?? self.latestBackfill?.startDate } // add one second to startdate to make this an exclusive (non overlapping) match return startDate?.addingTimeInterval(1) } func glucosesToSamplesFilter(_ array: [LibreGlucose], startDate: Date?) -> [LibreGlucose] { array .filterDateRange(startDate, nil) .filter { $0.isStateValid } .compactMap { $0 } } public var calibrationData: SensorData.CalibrationInfo? { KeychainManagerWrapper.standard.getLibreNativeCalibrationData() } } // MARK: - Direct bluetooth updates extension LibreTransmitterManager { public func libreSensorDidUpdate(with bleData: Libre2.LibreBLEResponse, and device: LibreTransmitterMetadata) { self.logger.debug("dabear:: got sensordata: \(String(describing: bleData))") let typeDesc = device.sensorType().debugDescription let now = Date() //only once per mins minute let mins = 4.5 if let earlierplus = lastDirectUpdate?.addingTimeInterval(mins * 60), earlierplus >= now { logger.debug("last ble update was less than \(mins) minutes ago, aborting loop update") return } logger.debug("Directly connected to libresensor of type \(typeDesc). Details: \(device.description)") guard let mapping = UserDefaults.standard.calibrationMapping, let calibrationData = calibrationData, let sensor = UserDefaults.standard.preSelectedSensor else { logger.error("calibrationdata, sensor uid or mapping missing, could not continue") self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .failure(LibreError.noCalibrationData)) } return } guard mapping.reverseFooterCRC == calibrationData.isValidForFooterWithReverseCRCs && mapping.uuid == sensor.uuid else { logger.error("Calibrationdata was not correct for these bluetooth packets. This is a fatal error, we cannot calibrate without re-pairing") self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .failure(LibreError.noCalibrationData)) } return } guard bleData.crcVerified else { self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .failure(LibreError.checksumValidationError)) } logger.debug("did not get bledata with valid crcs") return } if sensor.maxAge > 0 { let minutesLeft = Double(sensor.maxAge - bleData.age) NotificationHelper.sendSensorExpireAlertIfNeeded(minutesLeft: minutesLeft) } let sensorStartDate = Date().addingTimeInterval(-1 * TimeInterval(minutes: Double(bleData.age))) NSLog("Libre age \(bleData.age), Start Date calculated: \(sensorStartDate)") // let device = self.proxy?.device let sortedTrends = bleData.trend.sorted{ $0.date > $1.date} let glucose = LibreGlucose.fromTrendMeasurements(sortedTrends, nativeCalibrationData: calibrationData, returnAll: UserDefaults.standard.mmBackfillFromTrend, sensorStartDate: sensorStartDate) //glucose += LibreGlucose.fromHistoryMeasurements(bleData.history, nativeCalibrationData: calibrationData) // while libre2 fram scans contains historymeasurements for the last 8 hours, // history from bledata contains just a couple of data points, so we don't bother /*if UserDefaults.standard.mmBackfillFromHistory { let sortedHistory = bleData.history.sorted{ $0.date > $1.date} glucose += LibreGlucose.fromHistoryMeasurements(sortedHistory, nativeCalibrationData: calibrationData) }*/ var newGlucose = glucosesToSamplesFilter(glucose, startDate: getStartDateForFilter()) if newGlucose.isEmpty { self.countTimesWithoutData &+= 1 } else { self.latestBackfill = glucose.max { $0.startDate < $1.startDate } self.logger.debug("dabear:: latestbackfill set to \(self.latestBackfill.debugDescription)") self.countTimesWithoutData = 0 } //todo: predictions also for libre2 bluetooth data //self.latestPrediction = prediction?.first var predictions: [LibreGlucose] = [] overcalibrate(entries: &newGlucose, prediction: &predictions) self.setObservables(sensorData: nil, bleData: bleData, metaData: device) self.logger.debug("dabear:: handleGoodReading returned with \(newGlucose.count) entries") self.delegateQueue.async { var result: Result<[LibreGlucose], Error> // If several readings from a valid and running sensor come out empty, // we have (with a large degree of confidence) a sensor that has been // ripped off the body if self.countTimesWithoutData > 1 { result = .failure(LibreError.noValidSensorData) } else { result = .success(newGlucose) } self.cgmManagerDelegate?.cgmManager(self, hasNew: result) } lastDirectUpdate = Date() } } // MARK: - Bluetooth transmitter data extension LibreTransmitterManager { public func noLibreTransmitterSelected() { NotificationHelper.sendNoTransmitterSelectedNotification() } public func libreTransmitterDidUpdate(with sensorData: SensorData, and device: LibreTransmitterMetadata) { self.logger.debug("dabear:: got sensordata: \(String(describing: sensorData)), bytescount: \( sensorData.bytes.count), bytes: \(sensorData.bytes)") var sensorData = sensorData NotificationHelper.sendLowBatteryNotificationIfNeeded(device: device) self.setObservables(sensorData: sensorData, bleData: nil, metaData: device) if !sensorData.isLikelyLibre1FRAM { if let patchInfo = device.patchInfo, let sensorType = SensorType(patchInfo: patchInfo) { let needsDecryption = [SensorType.libre2, .libreUS14day].contains(sensorType) if needsDecryption, let uid = device.uid { sensorData.decrypt(patchInfo: patchInfo, uid: uid) } } else { logger.debug("Sensor type was incorrect, and no decryption of sensor was possible") self.cgmManagerDelegate?.cgmManager(self, hasNew: .failure(LibreError.encryptedSensor)) return } } let typeDesc = device.sensorType().debugDescription logger.debug("Transmitter connected to libresensor of type \(typeDesc). Details: \(device.description)") tryPersistSensorData(with: sensorData) NotificationHelper.sendInvalidSensorNotificationIfNeeded(sensorData: sensorData) NotificationHelper.sendInvalidChecksumIfDeveloper(sensorData) guard sensorData.hasValidCRCs else { self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .failure(LibreError.checksumValidationError)) } logger.debug("did not get sensordata with valid crcs") return } NotificationHelper.sendSensorExpireAlertIfNeeded(sensorData: sensorData) guard sensorData.state == .ready || sensorData.state == .starting else { logger.debug("dabear:: got sensordata with valid crcs, but sensor is either expired or failed") self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .failure(LibreError.expiredSensor)) } return } logger.debug("dabear:: got sensordata with valid crcs, sensor was ready") // self.lastValidSensorData = sensorData self.handleGoodReading(data: sensorData) { [weak self] error, glucoseArrayWithPrediction in guard let self = self else { print("dabear:: handleGoodReading could not lock on self, aborting") return } if let error = error { self.logger.error("dabear:: handleGoodReading returned with error: \(error.errorDescription)") self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .failure(error)) } return } guard let glucose = glucoseArrayWithPrediction?.glucose else { self.logger.debug("dabear:: handleGoodReading returned with no data") self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .success([])) } return } let prediction = glucoseArrayWithPrediction?.prediction // let device = self.proxy?.device let newGlucose = self.glucosesToSamplesFilter(glucose, startDate: self.getStartDateForFilter()) if newGlucose.isEmpty { self.countTimesWithoutData &+= 1 } else { self.latestBackfill = glucose.max { $0.startDate < $1.startDate } self.logger.debug("dabear:: latestbackfill set to \(self.latestBackfill.debugDescription)") self.countTimesWithoutData = 0 } self.latestPrediction = prediction?.first //must be inside this handler as setobservables "depend" on latestbackfill self.setObservables(sensorData: sensorData, bleData: nil, metaData: nil) self.logger.debug("dabear:: handleGoodReading returned with \(newGlucose.count) entries") self.delegateQueue.async { var result: Result<[LibreGlucose], Error> // If several readings from a valid and running sensor come out empty, // we have (with a large degree of confidence) a sensor that has been // ripped off the body if self.countTimesWithoutData > 1 { result = .failure(LibreError.noValidSensorData) } else { result = .success(newGlucose) } self.cgmManagerDelegate?.cgmManager(self, hasNew: result) } } } private func readingToGlucose(_ data: SensorData, calibration: SensorData.CalibrationInfo) -> GlucoseArrayWithPrediction { var entries: [LibreGlucose] = [] var prediction: [LibreGlucose] = [] let predictGlucose = true // Increase to up to 15 to move closer to real blood sugar // The cost is slightly more noise on consecutive readings let glucosePredictionMinutes : Double = 10 if predictGlucose { // We cheat here by forcing the loop to think that the predicted glucose value is the current blood sugar value. logger.debug("Predicting glucose value") if let predicted = data.predictBloodSugar(glucosePredictionMinutes){ let currentBg = predicted.roundedGlucoseValueFromRaw2(calibrationInfo: calibration) let bgDate = predicted.date.addingTimeInterval(60 * -glucosePredictionMinutes) prediction.append(LibreGlucose(unsmoothedGlucose: currentBg, glucoseDouble: currentBg, timestamp: bgDate)) logger.debug("Predicted glucose (not used) was: \(currentBg)") } else { logger.debug("Tried to predict glucose value but failed!") } } let trends = data.trendMeasurements() let firstTrend = trends.first?.roundedGlucoseValueFromRaw2(calibrationInfo: calibration) logger.debug("first trend was: \(String(describing: firstTrend))") entries = LibreGlucose.fromTrendMeasurements(trends, nativeCalibrationData: calibration, returnAll: UserDefaults.standard.mmBackfillFromTrend) if UserDefaults.standard.mmBackfillFromHistory { let history = data.historyMeasurements() entries += LibreGlucose.fromHistoryMeasurements(history, nativeCalibrationData: calibration) } overcalibrate(entries: &entries, prediction: &prediction) return (glucose: entries, prediction: prediction) } private func overcalibrate(entries: inout [LibreGlucose], prediction: inout [LibreGlucose]) { // overcalibrate var overcalibration: ((Double) -> (Double))? = nil delegateQueue.sync { overcalibration = cgmManagerDelegate?.overcalibration(for: self) } if let overcalibration = overcalibration { func overcalibrate(entries: [LibreGlucose]) -> [LibreGlucose] { entries.map { entry in var entry = entry entry.glucoseDouble = overcalibration(entry.glucoseDouble) return entry } } entries = overcalibrate(entries: entries) prediction = overcalibrate(entries: prediction) } } public func handleGoodReading(data: SensorData?, _ callback: @escaping (LibreError?, GlucoseArrayWithPrediction?) -> Void) { //only care about the once per minute readings here, historical data will not be considered guard let data = data else { callback(.noSensorData, nil) return } if let calibrationdata = calibrationData { logger.debug("dabear:: calibrationdata loaded") if calibrationdata.isValidForFooterWithReverseCRCs == data.footerCrc.byteSwapped { logger.debug("dabear:: calibrationdata correct for this sensor, returning last values") callback(nil, readingToGlucose(data, calibration: calibrationdata)) return } else { logger.debug("dabear:: calibrationdata incorrect for this sensor, calibrationdata.isValidForFooterWithReverseCRCs: \(calibrationdata.isValidForFooterWithReverseCRCs), data.footerCrc.byteSwapped: \(data.footerCrc.byteSwapped)") } } else { logger.debug("dabear:: calibrationdata was nil") } calibrateSensor(sensordata: data) { [weak self] calibrationparams in do { try KeychainManagerWrapper.standard.setLibreNativeCalibrationData(calibrationparams) } catch { NotificationHelper.sendCalibrationNotification(.invalidCalibrationData) callback(.invalidCalibrationData, nil) return } //here we assume success, data is not changed, //and we trust that the remote endpoint returns correct data for the sensor NotificationHelper.sendCalibrationNotification(.success) callback(nil, self?.readingToGlucose(data, calibration: calibrationparams)) } } //will be called on utility queue public func libreTransmitterStateChanged(_ state: BluetoothmanagerState) { DispatchQueue.main.async { self.transmitterInfoObservable.connectionState = self.proxy?.connectionStateString ?? "n/a" self.transmitterInfoObservable.transmitterType = self.proxy?.shortTransmitterName ?? "Unknown" } switch state { case .Connected: lastConnected = Date() case .powerOff: NotificationHelper.sendBluetoothPowerOffNotification() default: break } return } //will be called on utility queue public func libreTransmitterReceivedMessage(_ messageIdentifier: UInt16, txFlags: UInt8, payloadData: Data) { guard let packet = MiaoMiaoResponseState(rawValue: txFlags) else { // Incomplete package? // this would only happen if delegate is called manually with an unknown txFlags value // this was the case for readouts that were not yet complete // but that was commented out in MiaoMiaoManager.swift, see comment there: // "dabear-edit: don't notify on incomplete readouts" logger.debug("dabear:: incomplete package or unknown response state") return } switch packet { case .newSensor: logger.debug("dabear:: new libresensor detected") NotificationHelper.sendSensorChangeNotificationIfNeeded() NotificationCenter.default.post(name: .newSensorDetected, object: nil) case .noSensor: logger.debug("dabear:: no libresensor detected") NotificationHelper.sendSensorNotDetectedNotificationIfNeeded(noSensor: true) case .frequencyChangedResponse: logger.debug("dabear:: transmitter readout interval has changed!") default: //we don't care about the rest! break } return } func tryPersistSensorData(with sensorData: SensorData) { guard UserDefaults.standard.shouldPersistSensorData else { return } //yeah, we really really need to persist any changes right away var data = UserDefaults.standard.queuedSensorData ?? LimitedQueue() data.enqueue(sensorData) UserDefaults.standard.queuedSensorData = data } } // MARK: - conventience properties to access the enclosed proxy's properties extension LibreTransmitterManager { public var device: HKDevice? { //proxy?.OnQueue_device proxy?.device } static var className: String { String(describing: Self.self) } //cannot be called from managerQueue public var identifier: String { //proxy?.OnQueue_identifer?.uuidString ?? "n/a" proxy?.identifier?.uuidString ?? "n/a" } public var metaData: LibreTransmitterMetadata? { //proxy?.OnQueue_metadata proxy?.metadata } //cannot be called from managerQueue public var connectionState: String { //proxy?.connectionStateString ?? "n/a" proxy?.connectionStateString ?? "n/a" } //cannot be called from managerQueue public var sensorSerialNumber: String { //proxy?.OnQueue_sensorData?.serialNumber ?? "n/a" proxy?.sensorData?.serialNumber ?? "n/a" } public var sensorStartDate: Date? { proxy?.sensorData?.sensorStartTime } //cannot be called from managerQueue public var sensorAge: String { //proxy?.OnQueue_sensorData?.humanReadableSensorAge ?? "n/a" proxy?.sensorData?.humanReadableSensorAge ?? "n/a" } public var sensorEndTime : String { if let endtime = proxy?.sensorData?.sensorEndTime { let mydf = DateFormatter() mydf.dateStyle = .long mydf.timeStyle = .full mydf.locale = Locale.current return mydf.string(from: endtime) } return "Unknown or Ended" } public var sensorTimeLeft: String { //proxy?.OnQueue_sensorData?.humanReadableSensorAge ?? "n/a" proxy?.sensorData?.humanReadableTimeLeft ?? "n/a" } //cannot be called from managerQueue public var sensorFooterChecksums: String { //(proxy?.OnQueue_sensorData?.footerCrc.byteSwapped).map(String.init) (proxy?.sensorData?.footerCrc.byteSwapped).map(String.init) ?? "n/a" } //cannot be called from managerQueue public var sensorStateDescription: String { //proxy?.OnQueue_sensorData?.state.description ?? "n/a" proxy?.sensorData?.state.description ?? "n/a" } //cannot be called from managerQueue public var firmwareVersion: String { proxy?.metadata?.firmware ?? "n/a" } //cannot be called from managerQueue public var hardwareVersion: String { proxy?.metadata?.hardware ?? "n/a" } //cannot be called from managerQueue public var batteryString: String { proxy?.metadata?.batteryString ?? "n/a" } public var battery: Int? { proxy?.metadata?.battery } public func getDeviceType() -> String { proxy?.shortTransmitterName ?? "Unknown" } public func getSmallImage() -> UIImage? { proxy?.activePluginType?.smallImage ?? UIImage(named: "libresensor", in: Bundle.module, compatibleWith: nil) } }