import CoreData import Foundation import Swinject import WatchConnectivity protocol WatchManager {} final class BaseWatchManager: NSObject, WatchManager, Injectable { private let session: WCSession private var state = WatchState() private let processQueue = DispatchQueue(label: "BaseWatchManager.processQueue") @Injected() private var broadcaster: Broadcaster! @Injected() private var settingsManager: SettingsManager! @Injected() private var apsManager: APSManager! @Injected() private var storage: FileStorage! @Injected() private var carbsStorage: CarbsStorage! @Injected() private var tempTargetsStorage: TempTargetsStorage! @Injected() private var garmin: GarminManager! let context = CoreDataStack.shared.newTaskContext() private var lifetime = Lifetime() init(resolver: Resolver, session: WCSession = .default) { self.session = session super.init() injectServices(resolver) if WCSession.isSupported() { session.delegate = self session.activate() } broadcaster.register(GlucoseObserver.self, observer: self) broadcaster.register(SettingsObserver.self, observer: self) broadcaster.register(PumpHistoryObserver.self, observer: self) broadcaster.register(PumpSettingsObserver.self, observer: self) broadcaster.register(BasalProfileObserver.self, observer: self) broadcaster.register(TempTargetsObserver.self, observer: self) broadcaster.register(CarbsObserver.self, observer: self) broadcaster.register(PumpBatteryObserver.self, observer: self) broadcaster.register(PumpReservoirObserver.self, observer: self) garmin.stateRequet = { [weak self] () -> Data in guard let self = self, let data = try? JSONEncoder().encode(self.state) else { warning(.service, "Cannot encode watch state") return Data() } return data } configureState() } private func fetchlastDetermination() -> [OrefDetermination]? { let predicate = NSPredicate.enactedDetermination return CoreDataStack.shared.fetchEntities( ofType: OrefDetermination.self, onContext: context, predicate: predicate, key: "timestamp", ascending: false, fetchLimit: 1, propertiesToFetch: ["timestamp"] ) } private func fetchLatestOverride() -> OverrideStored? { CoreDataStack.shared.fetchEntities( ofType: OverrideStored.self, onContext: context, predicate: NSPredicate.predicateForOneDayAgo, key: "date", ascending: false, fetchLimit: 1 ).first } func fetchAndProcessGlucose() -> (ids: [NSManagedObjectID], glucose: String, trend: String, delta: String, date: Date) { var results: (ids: [NSManagedObjectID], glucose: String, trend: String, delta: String, date: Date) = ( [], "--", "--", "--", Date() ) context.perform { let predicate = NSPredicate.predicateFor120MinAgo let fetchedGlucose = CoreDataStack.shared.fetchEntities( ofType: GlucoseStored.self, onContext: self.context, predicate: predicate, key: "date", ascending: false, fetchLimit: 24, batchSize: 12 ) let ids = fetchedGlucose.map(\.objectID) guard let firstGlucose = fetchedGlucose.first else { return } let glucoseValue = firstGlucose.glucose let date = firstGlucose.date ?? .distantPast let delta = fetchedGlucose.count >= 2 ? glucoseValue - fetchedGlucose[1].glucose : 0 let units = self.settingsManager.settings.units let glucoseFormatter = NumberFormatter() glucoseFormatter.numberStyle = .decimal glucoseFormatter.maximumFractionDigits = (units == .mmolL) ? 1 : 0 let glucoseText = glucoseFormatter .string(from: Double(units == .mmolL ? Decimal(glucoseValue).asMmolL : Decimal(glucoseValue)) as NSNumber) ?? "--" let directionText = firstGlucose.direction ?? "↔︎" let deltaFormatter = NumberFormatter() deltaFormatter.numberStyle = .decimal deltaFormatter.maximumFractionDigits = 1 let deltaText = deltaFormatter .string(from: Double(units == .mmolL ? Decimal(delta).asMmolL : Decimal(delta)) as NSNumber) ?? "--" results = (ids, glucoseText, directionText, deltaText, date) } return results } private func configureState() { processQueue.async { self.context.performAndWait { let glucoseValues = self.fetchAndProcessGlucose() let lastDetermination = self.fetchlastDetermination()?.first self.state.glucose = glucoseValues.glucose self.state.trend = glucoseValues.trend self.state.delta = glucoseValues.delta self.state.trendRaw = glucoseValues.trend self.state.glucoseDate = glucoseValues.date self.state.lastLoopDate = lastDetermination?.timestamp self.state.lastLoopDateInterval = self.state.lastLoopDate.map { guard $0.timeIntervalSince1970 > 0 else { return 0 } return UInt64($0.timeIntervalSince1970) } self.state.bolusIncrement = self.settingsManager.preferences.bolusIncrement self.state.maxCOB = self.settingsManager.preferences.maxCOB self.state.maxBolus = self.settingsManager.pumpSettings.maxBolus self.state.carbsRequired = lastDetermination?.carbsRequired as? Decimal var insulinRequired = lastDetermination?.insulinReq as? Decimal ?? 0 var double: Decimal = 2 if lastDetermination?.manualBolusErrorString == 0 { insulinRequired = lastDetermination?.insulinForManualBolus as? Decimal ?? 0 double = 1 } self.state.useNewCalc = self.settingsManager.settings.useCalc if !(self.state.useNewCalc ?? false) { self.state.bolusRecommended = self.apsManager .roundBolus(amount: max( insulinRequired * (self.settingsManager.settings.insulinReqPercentage / 100) * double, 0 )) } else { let recommended = self.newBolusCalc( ids: glucoseValues.ids, determination: lastDetermination ) self.state.bolusRecommended = self.apsManager .roundBolus(amount: max(recommended, 0)) } self.state.bolusAfterCarbs = !self.settingsManager.settings.skipBolusScreenAfterCarbs self.state.displayOnWatch = self.settingsManager.settings.displayOnWatch self.state.displayFatAndProteinOnWatch = self.settingsManager.settings.displayFatAndProteinOnWatch self.state.confirmBolusFaster = self.settingsManager.settings.confirmBolusFaster self.state.iob = lastDetermination?.iob as? Decimal self.state.cob = lastDetermination?.cob as? Decimal self.state.tempTargets = self.tempTargetsStorage.presets() .map { target -> TempTargetWatchPreset in let untilDate = self.tempTargetsStorage.current().flatMap { currentTarget -> Date? in guard currentTarget.id == target.id else { return nil } let date = currentTarget.createdAt.addingTimeInterval(TimeInterval(currentTarget.duration * 60)) return date > Date() ? date : nil } return TempTargetWatchPreset( name: target.displayName, id: target.id, description: self.descriptionForTarget(target), until: untilDate ) } self.state.bolusAfterCarbs = !self.settingsManager.settings.skipBolusScreenAfterCarbs self.state.displayOnWatch = self.settingsManager.settings.displayOnWatch self.state.displayFatAndProteinOnWatch = self.settingsManager.settings.displayFatAndProteinOnWatch self.state.confirmBolusFaster = self.settingsManager.settings.confirmBolusFaster let eBG = self.evetualBGStraing() self.state.eventualBG = eBG.map { "⇢ " + $0 } self.state.eventualBGRaw = eBG self.state.isf = lastDetermination?.insulinSensitivity as? Decimal let latestOverride = self.fetchLatestOverride() if latestOverride?.enabled ?? false { let percentString = "\((latestOverride?.percentage ?? 100).formatted(.number)) %" self.state.override = percentString } else { self.state.override = "100 %" } } self.sendState() } } private func sendState() { dispatchPrecondition(condition: .onQueue(processQueue)) guard let data = try? JSONEncoder().encode(state) else { warning(.service, "Cannot encode watch state") return } garmin.sendState(data) guard session.isReachable else { return } session.sendMessageData(data, replyHandler: nil) { error in warning(.service, "Cannot send message to watch", error: error) } } private func descriptionForTarget(_ target: TempTarget) -> String { let units = settingsManager.settings.units var low = target.targetBottom var high = target.targetTop if units == .mmolL { low = low?.asMmolL high = high?.asMmolL } let description = "\(targetFormatter.string(from: (low ?? 0) as NSNumber)!) - \(targetFormatter.string(from: (high ?? 0) as NSNumber)!)" + " for \(targetFormatter.string(from: target.duration as NSNumber)!) min" return description } private func evetualBGStraing() -> String? { context.perform { guard let eventualBG = self.fetchlastDetermination()?.first?.eventualBG as? Int else { return nil } let units = self.settingsManager.settings.units return eventualFormatter.string( from: (units == .mmolL ? eventualBG.asMmolL : Decimal(eventualBG)) as NSNumber )! } } private func newBolusCalc(ids: [NSManagedObjectID], determination: OrefDetermination?) -> Decimal { var insulinCalculated: Decimal = 0 context.performAndWait { let glucoseObjects = ids.compactMap { self.context.object(with: $0) as? GlucoseStored } guard let firstGlucose = glucoseObjects.first else { return // If there's no glucose data, exit the block } let bg = firstGlucose.glucose // Make sure to provide a fallback value for glucose // Calculations related to glucose data var bgDelta: Int = 0 if glucoseObjects.count >= 3 { bgDelta = Int(firstGlucose.glucose) - Int(glucoseObjects[2].glucose) } let conversion: Decimal = settingsManager.settings.units == .mmolL ? 0.0555 : 1 let isf = self.state.isf ?? 0 let target = determination?.currentTarget as? Decimal ?? 100 let carbratio = determination?.carbRatio as? Decimal ?? 10 let cob = self.state.cob ?? 0 let iob = self.state.iob ?? 0 let fattyMealFactor = self.settingsManager.settings.fattyMealFactor // Complete bolus calculation logic let targetDifference = Decimal(bg) - target let targetDifferenceInsulin = targetDifference * conversion / isf let fifteenMinInsulin = Decimal(bgDelta) * conversion / isf let wholeCobInsulin = cob / carbratio let iobInsulinReduction = -iob let wholeCalc = targetDifferenceInsulin + iobInsulinReduction + wholeCobInsulin + fifteenMinInsulin let result = wholeCalc * settingsManager.settings.overrideFactor if settingsManager.settings.fattyMeals { insulinCalculated = result * fattyMealFactor } else { insulinCalculated = result } } // Ensure the calculated insulin amount does not exceed the maximum bolus and is not below zero insulinCalculated = max(min(insulinCalculated, settingsManager.pumpSettings.maxBolus), 0) return insulinCalculated // Return the calculated insulin outside of the performAndWait block } private var glucoseFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 if settingsManager.settings.units == .mmolL { formatter.minimumFractionDigits = 1 formatter.maximumFractionDigits = 1 } formatter.roundingMode = .halfUp return formatter } private var eventualFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 1 return formatter } private var deltaFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 1 formatter.positivePrefix = "+" return formatter } private var targetFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 1 return formatter } } extension BaseWatchManager: WCSessionDelegate { func sessionDidBecomeInactive(_: WCSession) {} func sessionDidDeactivate(_: WCSession) {} func session(_: WCSession, activationDidCompleteWith state: WCSessionActivationState, error _: Error?) { debug(.service, "WCSession is activated: \(state == .activated)") } func session(_: WCSession, didReceiveMessage message: [String: Any]) { debug(.service, "WCSession got message: \(message)") if let stateRequest = message["stateRequest"] as? Bool, stateRequest { processQueue.async { self.sendState() } } } func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { debug(.service, "WCSession got message with reply handler: \(message)") if let carbs = message["carbs"] as? Double, let fat = message["fat"] as? Double, let protein = message["protein"] as? Double, carbs > 0 || fat > 0 || protein > 0 { Task { await carbsStorage.storeCarbs( [CarbsEntry( id: UUID().uuidString, createdAt: Date(), actualDate: nil, carbs: Decimal(carbs), fat: Decimal(fat), protein: Decimal(protein), note: nil, enteredBy: CarbsEntry.manual, isFPU: false, fpuID: nil )] ) if settingsManager.settings.skipBolusScreenAfterCarbs { let success = await apsManager.determineBasal() replyHandler(["confirmation": success]) } else { _ = await apsManager.determineBasal() replyHandler(["confirmation": true]) } } return } if let tempTargetID = message["tempTarget"] as? String { Task { if var preset = tempTargetsStorage.presets().first(where: { $0.id == tempTargetID }) { preset.createdAt = Date() await tempTargetsStorage.storeTempTargets([preset]) replyHandler(["confirmation": true]) } else if tempTargetID == "cancel" { let entry = TempTarget( name: TempTarget.cancel, createdAt: Date(), targetTop: 0, targetBottom: 0, duration: 0, enteredBy: TempTarget.manual, reason: TempTarget.cancel ) await tempTargetsStorage.storeTempTargets([entry]) replyHandler(["confirmation": true]) } else { replyHandler(["confirmation": false]) } } return } if let bolus = message["bolus"] as? Double, bolus > 0 { Task { await apsManager.enactBolus(amount: bolus, isSMB: false) replyHandler(["confirmation": true]) } return } replyHandler(["confirmation": false]) } func session(_: WCSession, didReceiveMessageData _: Data) {} func sessionReachabilityDidChange(_ session: WCSession) { if session.isReachable { processQueue.async { self.sendState() } } } } extension BaseWatchManager: GlucoseObserver, SettingsObserver, PumpHistoryObserver, PumpSettingsObserver, BasalProfileObserver, TempTargetsObserver, CarbsObserver, PumpBatteryObserver, PumpReservoirObserver { func glucoseDidUpdate(_: [BloodGlucose]) { configureState() } func settingsDidChange(_: FreeAPSSettings) { configureState() } func pumpHistoryDidUpdate(_: [PumpHistoryEvent]) { // TODO: } func pumpSettingsDidChange(_: PumpSettings) { configureState() } func basalProfileDidChange(_: [BasalProfileEntry]) { // TODO: } func tempTargetsDidUpdate(_: [TempTarget]) { configureState() } func carbsDidUpdate(_: [CarbsEntry]) { // TODO: } func pumpBatteryDidChange(_: Battery) { // TODO: } func pumpReservoirDidChange(_: Decimal) { // TODO: } }