Просмотр исходного кода

Remote control override (#1)

* Upload list of overrides to Nightscout by extending the profile document

* Remote overrides

* Fix issues with duplicate override entries
Jonas Björkert 1 год назад
Родитель
Сommit
9dc58c8d96

+ 28 - 0
FreeAPS/Sources/APS/Storage/OverrideStorage.swift

@@ -12,6 +12,7 @@ protocol OverrideStorage {
     func deleteOverridePreset(_ objectID: NSManagedObjectID) async
     func getOverridesNotYetUploadedToNightscout() async -> [NightscoutExercise]
     func getOverrideRunsNotYetUploadedToNightscout() async -> [NightscoutExercise]
+    func getPresetOverridesForNightscout() async -> [NightscoutPresetOverride]
 }
 
 final class BaseOverrideStorage: OverrideStorage, Injectable {
@@ -267,4 +268,31 @@ final class BaseOverrideStorage: OverrideStorage, Injectable {
             }
         }
     }
+
+    func getPresetOverridesForNightscout() async -> [NightscoutPresetOverride] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideStored.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.allOverridePresets,
+            key: "orderPosition",
+            ascending: true
+        )
+
+        return await backgroundContext.perform {
+            guard let fetchedResults = results as? [OverrideStored] else { return [] }
+
+            return fetchedResults.map { overrideStored in
+                let duration = overrideStored.duration as? Decimal != 0 ? overrideStored.duration as? Decimal : nil
+                let percentage = overrideStored.percentage != 0 ? overrideStored.percentage : nil
+                let target = (overrideStored.target as? Decimal) != 0 ? overrideStored.target as? Decimal : nil
+
+                return NightscoutPresetOverride(
+                    name: overrideStored.name ?? "",
+                    duration: duration,
+                    percentage: percentage,
+                    target: target
+                )
+            }
+        }
+    }
 }

+ 8 - 0
FreeAPS/Sources/Models/NightscoutStatus.swift

@@ -55,4 +55,12 @@ struct NightscoutProfileStore: JSON {
     let bundleIdentifier: String
     let deviceToken: String
     let isAPNSProduction: Bool
+    let overrides: [NightscoutPresetOverride]?
+}
+
+struct NightscoutPresetOverride: JSON {
+    let name: String
+    let duration: Decimal?
+    let percentage: Double?
+    let target: Decimal?
 }

+ 41 - 28
FreeAPS/Sources/Modules/OverrideConfig/OverrideStateModel.swift

@@ -9,34 +9,35 @@ extension OverrideConfig {
         @ObservationIgnored @Injected() var storage: TempTargetsStorage!
         @ObservationIgnored @Injected() var apsManager: APSManager!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
-
-        var overrideSliderPercentage: Double = 100
-        var isEnabled = false
-        var indefinite = true
-        var overrideDuration: Decimal = 0
-        var target: Decimal = 0
-        var shouldOverrideTarget: Bool = false
-        var smbIsOff: Bool = false
-        var id = ""
-        var overrideName: String = ""
-        var isPreset: Bool = false
-        var overridePresets: [OverrideStored] = []
-        var advancedSettings: Bool = false
-        var isfAndCr: Bool = true
-        var isf: Bool = true
-        var cr: Bool = true
-        var smbIsAlwaysOff: Bool = false
-        var start: Decimal = 0
-        var end: Decimal = 23
-        var smbMinutes: Decimal = 0
-        var uamMinutes: Decimal = 0
-        var defaultSmbMinutes: Decimal = 0
-        var defaultUamMinutes: Decimal = 0
-        var selectedTab: Tab = .overrides
-        var activeOverrideName: String = ""
-        var currentActiveOverride: OverrideStored?
-        var showOverrideEditSheet = false
-        var showInvalidTargetAlert = false
+        @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
+
+        @Published var overrideSliderPercentage: Double = 100
+        @Published var isEnabled = false
+        @Published var indefinite = true
+        @Published var overrideDuration: Decimal = 0
+        @Published var target: Decimal = 0
+        @Published var shouldOverrideTarget: Bool = false
+        @Published var smbIsOff: Bool = false
+        @Published var id = ""
+        @Published var overrideName: String = ""
+        @Published var isPreset: Bool = false
+        @Published var overridePresets: [OverrideStored] = []
+        @Published var advancedSettings: Bool = false
+        @Published var isfAndCr: Bool = true
+        @Published var isf: Bool = true
+        @Published var cr: Bool = true
+        @Published var smbIsAlwaysOff: Bool = false
+        @Published var start: Decimal = 0
+        @Published var end: Decimal = 23
+        @Published var smbMinutes: Decimal = 0
+        @Published var uamMinutes: Decimal = 0
+        @Published var defaultSmbMinutes: Decimal = 0
+        @Published var defaultUamMinutes: Decimal = 0
+        @Published var selectedTab: Tab = .overrides
+        @Published var activeOverrideName: String = ""
+        @Published var currentActiveOverride: OverrideStored?
+        @Published var showOverrideEditSheet = false
+        @Published var showInvalidTargetAlert = false
 
         var units: GlucoseUnits = .mgdL
 
@@ -123,6 +124,10 @@ extension OverrideConfig.StateModel {
 
             // Update Presets View
             setupOverridePresetsArray()
+
+            Task {
+                await uploadProfiles()
+            }
         } catch {
             debugPrint(
                 "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save after reordering Override Presets with error: \(error.localizedDescription)"
@@ -286,6 +291,8 @@ extension OverrideConfig.StateModel {
 
         // Update Presets View
         setupOverridePresetsArray()
+
+        await uploadProfiles()
     }
 
     // MARK: - Setup Override Presets Array
@@ -318,6 +325,8 @@ extension OverrideConfig.StateModel {
 
         // Update Presets View
         setupOverridePresetsArray()
+
+        await uploadProfiles()
     }
 
     // MARK: - Setup the State variables with the last Override configuration
@@ -621,6 +630,10 @@ extension OverrideConfig.StateModel {
         }
         return Decimal(Double(target))
     }
+
+    func uploadProfiles() async {
+        await nightscoutManager.uploadProfiles()
+    }
 }
 
 extension OverrideConfig.StateModel: SettingsObserver {

+ 3 - 1
FreeAPS/Sources/Modules/OverrideConfig/View/EditOverrideForm.swift

@@ -294,7 +294,9 @@ struct EditOverrideForm: View {
                         guard let moc = override.managedObjectContext else { return }
                         guard moc.hasChanges else { return }
                         try moc.save()
-
+                        Task {
+                            await state.uploadProfiles()
+                        }
                         if let currentActiveOverride = state.currentActiveOverride {
                             Task {
                                 await state.disableAllActiveOverrides(

+ 113 - 18
FreeAPS/Sources/Modules/RemoteControl/PushMessage.swift

@@ -1,8 +1,34 @@
 import Foundation
 
-struct PushMessage: Decodable {
+enum TRCCommandType: String, Codable {
+    case bolus
+    case tempTarget = "temp_target"
+    case cancelTempTarget = "cancel_temp_target"
+    case meal
+    case startOverride = "start_override"
+    case cancelOverride = "cancel_override"
+
+    var description: String {
+        switch self {
+        case .bolus:
+            return "Bolus"
+        case .tempTarget:
+            return "Temporary Target"
+        case .cancelTempTarget:
+            return "Cancel Temporary Target"
+        case .meal:
+            return "Meal"
+        case .startOverride:
+            return "Start Override"
+        case .cancelOverride:
+            return "Cancel Override"
+        }
+    }
+}
+
+struct PushMessage: Codable {
     var user: String
-    var commandType: String
+    var commandType: TRCCommandType
     var bolusAmount: Decimal?
     var target: Int?
     var duration: Int?
@@ -11,8 +37,10 @@ struct PushMessage: Decodable {
     var fat: Int?
     var sharedSecret: String
     var timestamp: TimeInterval
+    var overrideName: String?
 
     enum CodingKeys: String, CodingKey {
+        case aps
         case user
         case commandType = "command_type"
         case bolusAmount = "bolus_amount"
@@ -23,33 +51,100 @@ struct PushMessage: Decodable {
         case fat
         case sharedSecret = "shared_secret"
         case timestamp
+        case overrideName
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(user, forKey: .user)
+        try container.encode(commandType, forKey: .commandType)
+        try container.encodeIfPresent(bolusAmount, forKey: .bolusAmount)
+        try container.encodeIfPresent(target, forKey: .target)
+        try container.encodeIfPresent(duration, forKey: .duration)
+        try container.encodeIfPresent(carbs, forKey: .carbs)
+        try container.encodeIfPresent(protein, forKey: .protein)
+        try container.encodeIfPresent(fat, forKey: .fat)
+        try container.encode(sharedSecret, forKey: .sharedSecret)
+        try container.encode(timestamp, forKey: .timestamp)
+        try container.encodeIfPresent(overrideName, forKey: .overrideName)
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        user = try container.decode(String.self, forKey: .user)
+        commandType = try container.decode(TRCCommandType.self, forKey: .commandType)
+        bolusAmount = try container.decodeIfPresent(Decimal.self, forKey: .bolusAmount)
+        target = try container.decodeIfPresent(Int.self, forKey: .target)
+        duration = try container.decodeIfPresent(Int.self, forKey: .duration)
+        carbs = try container.decodeIfPresent(Int.self, forKey: .carbs)
+        protein = try container.decodeIfPresent(Int.self, forKey: .protein)
+        fat = try container.decodeIfPresent(Int.self, forKey: .fat)
+        sharedSecret = try container.decode(String.self, forKey: .sharedSecret)
+        timestamp = try container.decode(TimeInterval.self, forKey: .timestamp)
+        overrideName = try container.decodeIfPresent(String.self, forKey: .overrideName)
+    }
+
+    init(
+        user: String,
+        commandType: TRCCommandType,
+        bolusAmount: Decimal? = nil,
+        target: Int? = nil,
+        duration: Int? = nil,
+        carbs: Int? = nil,
+        protein: Int? = nil,
+        fat: Int? = nil,
+        sharedSecret: String,
+        timestamp: TimeInterval,
+        overrideName: String? = nil
+    ) {
+        self.user = user
+        self.commandType = commandType
+        self.bolusAmount = bolusAmount
+        self.target = target
+        self.duration = duration
+        self.carbs = carbs
+        self.protein = protein
+        self.fat = fat
+        self.sharedSecret = sharedSecret
+        self.timestamp = timestamp
+        self.overrideName = overrideName
     }
-}
 
-extension PushMessage {
     func humanReadableDescription() -> String {
-        var description = "User: \(user). Command Type: \(commandType). "
+        var description = "User: \(user). Command Type: \(commandType.description). "
+
+        if let override = overrideName {
+            description += "Override Name: \(override). "
+        }
+
         switch commandType {
-        case "bolus":
+        case .bolus:
             if let amount = bolusAmount {
                 description += "Bolus Amount: \(amount) units."
             } else {
                 description += "Bolus Amount: unknown."
             }
-        case "temp_target":
-            let targetDescription = target != nil ? "\(target!) mg/dL" : "unknown target"
-            let durationDescription = duration != nil ? "\(duration!) minutes" : "unknown duration"
-            description += "Temp Target: \(targetDescription), Duration: \(durationDescription)."
-        case "cancel_temp_target":
+        case .tempTarget:
+            let targetDesc = target != nil ? "\(target!) mg/dL" : "unknown target"
+            let durationDesc = duration != nil ? "\(duration!) minutes" : "unknown duration"
+            description += "Temp Target: \(targetDesc), Duration: \(durationDesc)."
+        case .cancelTempTarget:
             description += "Cancel Temp Target command."
-        case "meal":
-            let carbsDescription = carbs != nil ? "\(carbs!)g carbs" : "unknown carbs"
-            let fatDescription = fat != nil ? "\(fat!)g fat" : "unknown fat"
-            let proteinDescription = protein != nil ? "\(protein!)g protein" : "unknown protein"
-            description += "Meal with \(carbsDescription), \(fatDescription), \(proteinDescription)."
-        default:
-            description += "Unsupported command type."
+        case .meal:
+            let carbsDesc = carbs != nil ? "\(carbs!)g carbs" : "unknown carbs"
+            let fatDesc = fat != nil ? "\(fat!)g fat" : "unknown fat"
+            let proteinDesc = protein != nil ? "\(protein!)g protein" : "unknown protein"
+            description += "Meal with \(carbsDesc), \(fatDesc), \(proteinDesc)."
+        case .startOverride:
+            if let override = overrideName {
+                description += "Start Override: \(override)."
+            } else {
+                description += "Start Override: unknown override name."
+            }
+        case .cancelOverride:
+            description += "Cancel Override command."
         }
+
         return description
     }
 }

+ 92 - 4
FreeAPS/Sources/Modules/RemoteControl/TrioRemoteControl.swift

@@ -1,3 +1,4 @@
+import CoreData
 import Foundation
 import Swinject
 
@@ -8,6 +9,7 @@ class TrioRemoteControl: Injectable {
     @Injected() private var carbsStorage: CarbsStorage!
     @Injected() private var nightscoutManager: NightscoutManager!
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
+    @Injected() private var overrideStorage: OverrideStorage!
 
     private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
 
@@ -71,14 +73,18 @@ class TrioRemoteControl: Injectable {
             }
 
             switch pushMessage.commandType {
-            case "bolus":
+            case .bolus:
                 await handleBolusCommand(pushMessage)
-            case "temp_target":
+            case .tempTarget:
                 await handleTempTargetCommand(pushMessage)
-            case "cancel_temp_target":
+            case .cancelTempTarget:
                 await cancelTempTarget()
-            case "meal":
+            case .meal:
                 await handleMealCommand(pushMessage)
+            case .startOverride:
+                await handleStartOverrideCommand(pushMessage)
+            case .cancelOverride:
+                await handleCancelOverrideCommand(pushMessage)
             default:
                 await logError(
                     "Command rejected: unsupported command type '\(pushMessage.commandType)'.",
@@ -241,6 +247,88 @@ class TrioRemoteControl: Injectable {
         debug(.remoteControl, "Temp target cancelled successfully.")
     }
 
+    @MainActor private func handleCancelOverrideCommand(_: PushMessage) async {
+        await disableAllActiveOverrides()
+        debug(.remoteControl, "Active override cancelled successfully.")
+    }
+
+    @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 viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+        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)
+            debug(.remoteControl, "Override '\(overrideName)' started successfully.")
+        } else {
+            await logError("Command rejected: override preset '\(overrideName)' not found.", pushMessage: pushMessage)
+        }
+    }
+
+    @MainActor private func enactOverridePreset(preset: OverrideStored) async {
+        await disableAllActiveOverrides()
+
+        let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+        preset.enabled = true
+        preset.date = Date()
+        preset.isUploadedToNS = false
+
+        do {
+            if viewContext.hasChanges {
+                try viewContext.save()
+            }
+        } catch {
+            debug(.remoteControl, "Failed to enact override preset: \(error.localizedDescription)")
+        }
+    }
+
+    @MainActor func disableAllActiveOverrides() async {
+        let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+        let ids = await overrideStorage.loadLatestOverrideConfigurations(fetchLimit: 0) // 0 = no fetch limit
+
+        await viewContext.perform {
+            do {
+                let results = try ids.compactMap { id in
+                    try viewContext.existingObject(with: id) as? OverrideStored
+                }
+
+                guard !results.isEmpty else { return }
+
+                for canceledOverride in results where canceledOverride.enabled {
+                    let newOverrideRunStored = OverrideRunStored(context: 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 viewContext.hasChanges {
+                    try viewContext.save()
+                }
+            } catch {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to disable active Overrides with error: \(error.localizedDescription)"
+                )
+            }
+        }
+    }
+
     func handleAPNSChanges(deviceToken: String?) async {
         let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
         let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")

+ 16 - 9
FreeAPS/Sources/Services/Network/NightscoutManager.swift

@@ -37,6 +37,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     @Injected() private var reachabilityManager: ReachabilityManager!
     @Injected() var healthkitManager: HealthKitManager!
 
+    private let uploadOverridesSubject = PassthroughSubject<Void, Never>()
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
     private var ping: TimeInterval?
 
@@ -96,6 +97,16 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             }
             .store(in: &subscriptions)
 
+        uploadOverridesSubject
+            .debounce(for: .seconds(1), scheduler: DispatchQueue.global(qos: .background))
+            .sink { [weak self] in
+                guard let self = self else { return }
+                Task {
+                    await self.uploadOverrides()
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
         setupNotification()
     }
@@ -117,17 +128,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }.store(in: &subscriptions)
 
         coreDataPublisher?.filterByEntityName("OverrideStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task.detached {
-                await self.uploadOverrides()
-            }
+            self?.uploadOverridesSubject.send()
         }.store(in: &subscriptions)
 
         coreDataPublisher?.filterByEntityName("OverrideRunStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task.detached {
-                await self.uploadOverrides()
-            }
+            self?.uploadOverridesSubject.send()
         }.store(in: &subscriptions)
 
         coreDataPublisher?.filterByEntityName("PumpEventStored").sink { [weak self] _ in
@@ -624,6 +629,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                 let bundleIdentifier = Bundle.main.bundleIdentifier ?? ""
                 let deviceToken = UserDefaults.standard.string(forKey: "deviceToken") ?? ""
                 let isAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
+                let presetOverrides = await overridesStorage.getPresetOverridesForNightscout()
 
                 let profileStore = NightscoutProfileStore(
                     defaultProfile: defaultProfile,
@@ -634,7 +640,8 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                     store: [defaultProfile: scheduledProfile],
                     bundleIdentifier: bundleIdentifier,
                     deviceToken: deviceToken,
-                    isAPNSProduction: isAPNSProduction
+                    isAPNSProduction: isAPNSProduction,
+                    overrides: presetOverrides
                 )
 
                 guard let nightscout = nightscoutAPI, isNetworkReachable else {