|
|
@@ -5,16 +5,16 @@ import Swinject
|
|
|
class TrioRemoteControl: Injectable {
|
|
|
static let shared = TrioRemoteControl()
|
|
|
|
|
|
- @Injected() private var tempTargetsStorage: TempTargetsStorage!
|
|
|
- @Injected() private var carbsStorage: CarbsStorage!
|
|
|
- @Injected() private var nightscoutManager: NightscoutManager!
|
|
|
- @Injected() private var overrideStorage: OverrideStorage!
|
|
|
- @Injected() private var settings: SettingsManager!
|
|
|
+ @Injected() internal var tempTargetsStorage: TempTargetsStorage!
|
|
|
+ @Injected() internal var carbsStorage: CarbsStorage!
|
|
|
+ @Injected() internal var nightscoutManager: NightscoutManager!
|
|
|
+ @Injected() internal var overrideStorage: OverrideStorage!
|
|
|
+ @Injected() internal var settings: SettingsManager!
|
|
|
|
|
|
private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
|
|
|
|
|
|
- private let pumpHistoryFetchContext: NSManagedObjectContext
|
|
|
- private let viewContext: NSManagedObjectContext
|
|
|
+ internal let pumpHistoryFetchContext: NSManagedObjectContext
|
|
|
+ internal let viewContext: NSManagedObjectContext
|
|
|
|
|
|
private init() {
|
|
|
pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
|
|
|
@@ -22,15 +22,6 @@ class TrioRemoteControl: Injectable {
|
|
|
injectServices(FreeAPSApp.resolver)
|
|
|
}
|
|
|
|
|
|
- private func logError(_ errorMessage: String, pushMessage: PushMessage? = nil) async {
|
|
|
- var note = errorMessage
|
|
|
- if let pushMessage = pushMessage {
|
|
|
- note += " Details: \(pushMessage.humanReadableDescription())"
|
|
|
- }
|
|
|
- debug(.remoteControl, note)
|
|
|
- await nightscoutManager.uploadNoteTreatment(note: note)
|
|
|
- }
|
|
|
-
|
|
|
func handleRemoteNotification(pushMessage: PushMessage) async {
|
|
|
let isTrioRemoteControlEnabled = UserDefaults.standard.bool(forKey: "isTrioRemoteControlEnabled")
|
|
|
guard isTrioRemoteControlEnabled else {
|
|
|
@@ -89,366 +80,6 @@ class TrioRemoteControl: Injectable {
|
|
|
await handleCancelOverrideCommand(pushMessage)
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- private func handleMealCommand(_ pushMessage: PushMessage) async {
|
|
|
- guard pushMessage.carbs != nil || pushMessage.fat != nil || pushMessage.protein != nil else {
|
|
|
- await logError("Command rejected: meal data is incomplete or invalid.", pushMessage: pushMessage)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let carbsDecimal = pushMessage.carbs != nil ? Decimal(pushMessage.carbs!) : nil
|
|
|
- let fatDecimal = pushMessage.fat != nil ? Decimal(pushMessage.fat!) : nil
|
|
|
- let proteinDecimal = pushMessage.protein != nil ? Decimal(pushMessage.protein!) : nil
|
|
|
-
|
|
|
- let settings = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.settings
|
|
|
- let maxCarbs = settings?.maxCarbs ?? Decimal(0)
|
|
|
- let maxFat = settings?.maxFat ?? Decimal(0)
|
|
|
- let maxProtein = settings?.maxProtein ?? Decimal(0)
|
|
|
-
|
|
|
- if let carbs = carbsDecimal, carbs > maxCarbs {
|
|
|
- await logError(
|
|
|
- "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g).",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if let fat = fatDecimal, fat > maxFat {
|
|
|
- await logError(
|
|
|
- "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g).",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if let protein = proteinDecimal, protein > maxProtein {
|
|
|
- await logError(
|
|
|
- "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g).",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
|
|
|
- let recentCarbEntries = carbsStorage.recent()
|
|
|
- let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
|
|
|
-
|
|
|
- if !carbsAfterPushMessage.isEmpty {
|
|
|
- await logError(
|
|
|
- "Command rejected: newer carb entries have been logged since the command was sent.",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let actualDate: Date?
|
|
|
- if let scheduledTime = pushMessage.scheduledTime {
|
|
|
- actualDate = Date(timeIntervalSince1970: scheduledTime)
|
|
|
- } else {
|
|
|
- actualDate = nil
|
|
|
- }
|
|
|
-
|
|
|
- let mealEntry = CarbsEntry(
|
|
|
- id: UUID().uuidString,
|
|
|
- createdAt: Date(),
|
|
|
- actualDate: actualDate,
|
|
|
- carbs: carbsDecimal ?? 0,
|
|
|
- fat: fatDecimal,
|
|
|
- protein: proteinDecimal,
|
|
|
- note: "Remote meal command",
|
|
|
- enteredBy: CarbsEntry.manual,
|
|
|
- isFPU: false,
|
|
|
- fpuID: nil
|
|
|
- )
|
|
|
-
|
|
|
- await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
|
|
|
-
|
|
|
- debug(
|
|
|
- .remoteControl,
|
|
|
- "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- private func handleBolusCommand(_ pushMessage: PushMessage) async {
|
|
|
- guard let bolusAmount = pushMessage.bolusAmount else {
|
|
|
- await logError("Command rejected: bolus amount is missing or invalid.", pushMessage: pushMessage)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let maxBolus = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
|
|
|
-
|
|
|
- if bolusAmount > maxBolus {
|
|
|
- await logError(
|
|
|
- "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let maxIOB = settings.preferences.maxIOB
|
|
|
- let currentIOB = await fetchCurrentIOB()
|
|
|
- if (currentIOB + bolusAmount) > maxIOB {
|
|
|
- await logError(
|
|
|
- "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let totalRecentBolusAmount = await fetchTotalRecentBolusAmount(since: Date(timeIntervalSince1970: pushMessage.timestamp))
|
|
|
-
|
|
|
- if totalRecentBolusAmount >= bolusAmount * 0.2 {
|
|
|
- await logError(
|
|
|
- "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.")
|
|
|
-
|
|
|
- guard let apsManager = await FreeAPSApp.resolver.resolve(APSManager.self) else {
|
|
|
- await logError(
|
|
|
- "Error: unable to process bolus command because the APS Manager is not available.",
|
|
|
- pushMessage: pushMessage
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false)
|
|
|
-
|
|
|
- debug(
|
|
|
- .remoteControl,
|
|
|
- "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- private func fetchCurrentIOB() async -> Decimal {
|
|
|
- let predicate = NSPredicate.predicateFor30MinAgoForDetermination
|
|
|
-
|
|
|
- let determinations = await CoreDataStack.shared.fetchEntitiesAsync(
|
|
|
- ofType: OrefDetermination.self,
|
|
|
- onContext: pumpHistoryFetchContext,
|
|
|
- predicate: predicate,
|
|
|
- key: "timestamp",
|
|
|
- ascending: false,
|
|
|
- fetchLimit: 1,
|
|
|
- propertiesToFetch: ["iob"]
|
|
|
- )
|
|
|
-
|
|
|
- guard let fetchedResults = determinations as? [[String: Any]],
|
|
|
- let firstResult = fetchedResults.first,
|
|
|
- let iob = firstResult["iob"] as? Decimal
|
|
|
- else {
|
|
|
- await logError("Failed to fetch current IOB.")
|
|
|
- return Decimal(0)
|
|
|
- }
|
|
|
-
|
|
|
- return iob
|
|
|
- }
|
|
|
-
|
|
|
- private func fetchTotalRecentBolusAmount(since date: Date) async -> Decimal {
|
|
|
- let predicate = NSPredicate(
|
|
|
- format: "type == %@ AND timestamp > %@",
|
|
|
- PumpEventStored.EventType.bolus.rawValue,
|
|
|
- date as NSDate
|
|
|
- )
|
|
|
-
|
|
|
- let results: Any = await CoreDataStack.shared.fetchEntitiesAsync(
|
|
|
- ofType: PumpEventStored.self,
|
|
|
- onContext: pumpHistoryFetchContext,
|
|
|
- predicate: predicate,
|
|
|
- key: "timestamp",
|
|
|
- ascending: true,
|
|
|
- fetchLimit: nil,
|
|
|
- propertiesToFetch: ["bolus.amount"]
|
|
|
- )
|
|
|
-
|
|
|
- guard let bolusDictionaries = results as? [[String: Any]] else {
|
|
|
- await logError("Failed to cast fetched bolus events. Fetched entities type: \(type(of: results))")
|
|
|
- return 0
|
|
|
- }
|
|
|
-
|
|
|
- let totalAmount = bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
|
|
|
-
|
|
|
- return totalAmount
|
|
|
- }
|
|
|
-
|
|
|
- private func handleTempTargetCommand(_ pushMessage: PushMessage) async {
|
|
|
- guard let targetValue = pushMessage.target,
|
|
|
- let durationValue = pushMessage.duration
|
|
|
- else {
|
|
|
- await logError("Command rejected: temp target data is incomplete or invalid.", pushMessage: pushMessage)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let durationInMinutes = Int(durationValue)
|
|
|
- let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
|
|
|
-
|
|
|
- let tempTarget = TempTarget(
|
|
|
- name: TempTarget.custom,
|
|
|
- createdAt: pushMessageDate,
|
|
|
- targetTop: Decimal(targetValue),
|
|
|
- targetBottom: Decimal(targetValue),
|
|
|
- duration: Decimal(durationInMinutes),
|
|
|
- enteredBy: TempTarget.manual,
|
|
|
- reason: TempTarget.custom
|
|
|
- )
|
|
|
-
|
|
|
- tempTargetsStorage.storeTempTargets([tempTarget])
|
|
|
-
|
|
|
- debug(
|
|
|
- .remoteControl,
|
|
|
- "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- func cancelTempTarget(_ pushMessage: PushMessage) async {
|
|
|
- debug(.remoteControl, "Cancelling temp target.")
|
|
|
-
|
|
|
- guard tempTargetsStorage.current() != nil else {
|
|
|
- await logError("Command rejected: no active temp target to cancel.")
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let cancelEntry = TempTarget.cancel(at: Date())
|
|
|
- tempTargetsStorage.storeTempTargets([cancelEntry])
|
|
|
-
|
|
|
- debug(
|
|
|
- .remoteControl,
|
|
|
- "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- @MainActor private func handleCancelOverrideCommand(_ pushMessage: PushMessage) async {
|
|
|
- await disableAllActiveOverrides()
|
|
|
-
|
|
|
- debug(
|
|
|
- .remoteControl,
|
|
|
- "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- @MainActor private func handleStartOverrideCommand(_ pushMessage: PushMessage) async {
|
|
|
- guard let overrideName = pushMessage.overrideName, !overrideName.isEmpty else {
|
|
|
- await logError("Command rejected: override name is missing.", pushMessage: pushMessage)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let presetIDs = await overrideStorage.fetchForOverridePresets()
|
|
|
-
|
|
|
- let presets = presetIDs.compactMap { id in
|
|
|
- try? viewContext.existingObject(with: id) as? OverrideStored
|
|
|
- }
|
|
|
-
|
|
|
- if let preset = presets.first(where: { $0.name == overrideName }) {
|
|
|
- await enactOverridePreset(preset: preset, pushMessage: pushMessage)
|
|
|
- } else {
|
|
|
- await logError("Command rejected: override preset '\(overrideName)' not found.", pushMessage: pushMessage)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- @MainActor private func enactOverridePreset(preset: OverrideStored, pushMessage: PushMessage) async {
|
|
|
- await disableAllActiveOverrides()
|
|
|
-
|
|
|
- preset.enabled = true
|
|
|
- preset.date = Date()
|
|
|
- preset.isUploadedToNS = false
|
|
|
-
|
|
|
- do {
|
|
|
- if viewContext.hasChanges {
|
|
|
- try viewContext.save()
|
|
|
-
|
|
|
- Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
|
|
|
- await awaitNotification(.didUpdateOverrideConfiguration)
|
|
|
-
|
|
|
- debug(
|
|
|
- .remoteControl,
|
|
|
- "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
|
|
|
- )
|
|
|
- }
|
|
|
- } catch {
|
|
|
- debug(.remoteControl, "Failed to enact override preset: \(error.localizedDescription)")
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- @MainActor func disableAllActiveOverrides() async {
|
|
|
- let ids = await overrideStorage.loadLatestOverrideConfigurations(fetchLimit: 0) // 0 = no fetch limit
|
|
|
-
|
|
|
- let didPostNotification = await viewContext.perform { () -> Bool in
|
|
|
- do {
|
|
|
- let results = try ids.compactMap { id in
|
|
|
- try self.viewContext.existingObject(with: id) as? OverrideStored
|
|
|
- }
|
|
|
-
|
|
|
- guard !results.isEmpty else { return false }
|
|
|
-
|
|
|
- for canceledOverride in results where canceledOverride.enabled {
|
|
|
- let newOverrideRunStored = OverrideRunStored(context: self.viewContext)
|
|
|
- newOverrideRunStored.id = UUID()
|
|
|
- newOverrideRunStored.name = canceledOverride.name
|
|
|
- newOverrideRunStored.startDate = canceledOverride.date ?? .distantPast
|
|
|
- newOverrideRunStored.endDate = Date()
|
|
|
- newOverrideRunStored
|
|
|
- .target = NSDecimalNumber(decimal: self.overrideStorage.calculateTarget(override: canceledOverride))
|
|
|
- newOverrideRunStored.override = canceledOverride
|
|
|
- newOverrideRunStored.isUploadedToNS = false
|
|
|
-
|
|
|
- canceledOverride.enabled = false
|
|
|
- }
|
|
|
-
|
|
|
- if self.viewContext.hasChanges {
|
|
|
- try self.viewContext.save()
|
|
|
- Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
|
|
|
- return true
|
|
|
- } else {
|
|
|
- return false
|
|
|
- }
|
|
|
- } catch {
|
|
|
- debugPrint(
|
|
|
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to disable active Overrides with error: \(error.localizedDescription)"
|
|
|
- )
|
|
|
- return false
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if didPostNotification {
|
|
|
- await awaitNotification(.didUpdateOverrideConfiguration)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- func handleAPNSChanges(deviceToken: String?) async {
|
|
|
- let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
|
|
|
- let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
|
|
|
-
|
|
|
- let isAPNSProduction = isRunningInAPNSProductionEnvironment()
|
|
|
- var shouldUploadProfiles = false
|
|
|
-
|
|
|
- if let token = deviceToken, token != previousDeviceToken {
|
|
|
- UserDefaults.standard.set(token, forKey: "deviceToken")
|
|
|
- debug(.remoteControl, "Device token updated: \(token)")
|
|
|
- shouldUploadProfiles = true
|
|
|
- }
|
|
|
-
|
|
|
- if previousIsAPNSProduction != isAPNSProduction {
|
|
|
- UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction")
|
|
|
- debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")")
|
|
|
- shouldUploadProfiles = true
|
|
|
- }
|
|
|
-
|
|
|
- if shouldUploadProfiles {
|
|
|
- await nightscoutManager.uploadProfiles()
|
|
|
- } else {
|
|
|
- debug(.remoteControl, "No changes detected in device token or APNS environment.")
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private func isRunningInAPNSProductionEnvironment() -> Bool {
|
|
|
- if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
|
|
|
- return appStoreReceiptURL.lastPathComponent != "sandboxReceipt"
|
|
|
- }
|
|
|
- return false
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
// MARK: - CommandType Enum
|