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

New alert system with the storage of the alerts and acknowledge system

avouspierre 3 лет назад
Родитель
Сommit
f40f904cea

+ 8 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -284,6 +284,8 @@
 		CD78BB94E43B249D60CC1A1B /* NotificationsConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */; };
 		CE48C86428CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE48C86328CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift */; };
 		CE48C86628CA6B48007C0598 /* OmniPodManagerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE48C86528CA6B48007C0598 /* OmniPodManagerExtensions.swift */; };
+		CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02428E867BA00473A9C /* AlertStorage.swift */; };
+		CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02628E869DF00473A9C /* AlertEntry.swift */; };
 		CEB434DC28B8F5B900B70274 /* MKRingProgressView.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */; };
 		CEB434DD28B8F5B900B70274 /* MKRingProgressView.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		CEB434DF28B8F5C400B70274 /* OmniBLE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEB434DE28B8F5C400B70274 /* OmniBLE.framework */; };
@@ -710,6 +712,8 @@
 		C8D1A7CA8C10C4403D4BBFA7 /* BolusDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusDataFlow.swift; sourceTree = "<group>"; };
 		CE48C86328CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmniBLEPumpManagerExtensions.swift; sourceTree = "<group>"; };
 		CE48C86528CA6B48007C0598 /* OmniPodManagerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmniPodManagerExtensions.swift; sourceTree = "<group>"; };
+		CE82E02428E867BA00473A9C /* AlertStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStorage.swift; sourceTree = "<group>"; };
+		CE82E02628E869DF00473A9C /* AlertEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEntry.swift; sourceTree = "<group>"; };
 		CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434DE28B8F5C400B70274 /* OmniBLE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmniBLE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = "<group>"; };
@@ -1304,6 +1308,7 @@
 				3871F39B25ED892B0013ECB5 /* TempTarget.swift */,
 				3811DE8E25C9D80400A708ED /* User.swift */,
 				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
+				CE82E02628E869DF00473A9C /* AlertEntry.swift */,
 			);
 			path = Models;
 			sourceTree = "<group>";
@@ -1344,6 +1349,7 @@
 				38A0363A25ECF07E00FCBB52 /* GlucoseStorage.swift */,
 				38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */,
 				38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */,
+				CE82E02428E867BA00473A9C /* AlertStorage.swift */,
 			);
 			path = Storage;
 			sourceTree = "<group>";
@@ -2217,6 +2223,7 @@
 				3811DEAB25C9D88300A708ED /* HTTPResponseStatus.swift in Sources */,
 				3811DE5F25C9D4D500A708ED /* ProgressBar.swift in Sources */,
 				38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */,
+				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				38BF021D25E7E3AF00579895 /* Reservoir.swift in Sources */,
 				38BF021B25E7D06400579895 /* PumpSettingsView.swift in Sources */,
 				3862CC05273D152B00BF832C /* CalibrationService.swift in Sources */,
@@ -2309,6 +2316,7 @@
 				33E198D3039045D98C3DC5D4 /* AddCarbsStateModel.swift in Sources */,
 				28089E07169488CF6DCC2A31 /* AddCarbsRootView.swift in Sources */,
 				D2165E9D78EFF692C1DED1C6 /* AddTempTargetDataFlow.swift in Sources */,
+				CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */,
 				38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */,
 				5BFA1C2208114643B77F8CEB /* AddTempTargetProvider.swift in Sources */,
 				E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */,

+ 1 - 0
FreeAPS/Sources/APS/APSManager.swift

@@ -62,6 +62,7 @@ final class BaseAPSManager: APSManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseAPSManager.processQueue")
     @Injected() private var storage: FileStorage!
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
+    @Injected() private var alertHistoryStorage: AlertHistoryStorage!
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var tempTargetsStorage: TempTargetsStorage!
     @Injected() private var carbsStorage: CarbsStorage!

+ 0 - 1
FreeAPS/Sources/APS/CGM/HeartBeatManager.swift

@@ -33,7 +33,6 @@ class HeartBeatManager {
         if UserDefaults.standard.cgmTransmitterDeviceAddress != sharedUserDefaults
             .string(forKey: keyForcgmTransmitterDeviceAddress)
         {
-
             // assign local copy of cgmTransmitterDeviceAddress to the value stored in sharedUserDefaults (possibly nil value)
             UserDefaults.standard.cgmTransmitterDeviceAddress = sharedUserDefaults
                 .string(forKey: keyForcgmTransmitterDeviceAddress)

+ 70 - 35
FreeAPS/Sources/APS/DeviceDataManager.swift

@@ -24,7 +24,7 @@ protocol DeviceDataManager: GlucoseSource {
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
     func heartbeat(date: Date)
     func createBolusProgressReporter() -> DoseProgressReporter?
-    var alertStore: [Alert] { get }
+    var alertHistoryStorage: AlertHistoryStorage! { get }
 }
 
 private let staticPumpManagers: [PumpManagerUI.Type] = [
@@ -50,6 +50,7 @@ private let accessLock = NSRecursiveLock(label: "BaseDeviceDataManager.accessLoc
 final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     private let processQueue = DispatchQueue.markedQueue(label: "BaseDeviceDataManager.processQueue")
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
+    @Injected() var alertHistoryStorage: AlertHistoryStorage!
     @Injected() private var storage: FileStorage!
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var glucoseStorage: GlucoseStorage!
@@ -65,7 +66,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     let errorSubject = PassthroughSubject<Error, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
-    var alertStore: [Alert]
     private let router = FreeAPSApp.resolver.resolve(Router.self)!
     @SyncAccess private var pumpUpdateCancellable: AnyCancellable?
     private var pumpUpdatePromise: Future<Bool, Never>.Promise?
@@ -113,10 +113,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     let pumpName = CurrentValueSubject<String, Never>("Pump")
 
     init(resolver: Resolver) {
-        alertStore = []
         injectServices(resolver)
         setupPumpManager()
         UIDevice.current.isBatteryMonitoringEnabled = true
+        broadcaster.register(AlertObserver.self, observer: self)
     }
 
     func setupPumpManager() {
@@ -468,41 +468,22 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
 
 extension BaseDeviceDataManager: DeviceManagerDelegate {
     func issueAlert(_ alert: Alert) {
-        if !alertStore.contains(where: { $0.identifier.alertIdentifier == alert.identifier.alertIdentifier }) {
-            alertStore.append(alert)
-
-            let typeMessage: MessageType
-            let alertUp = alert.identifier.alertIdentifier.uppercased()
-            if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
-                typeMessage = .errorPump
-            } else {
-                typeMessage = .warning
-            }
-
-            DispatchQueue.main.async {
-                let messageCont = MessageContent(content: alert.foregroundContent!.body, type: typeMessage)
-                self.router.alertMessage.send(messageCont)
-                // validation
-                self.pumpManager?.acknowledgeAlert(alertIdentifier: alert.identifier.alertIdentifier) { error in
-                    if let error = error {
-                        debug(.deviceManager, "acknowledge not succeeded with error \(error.localizedDescription)")
-                    }
-                }
-            }
-
-            broadcaster.notify(pumpNotificationObserver.self, on: processQueue) {
-                $0.pumpNotification(alert: alert)
-            }
-        }
+        alertHistoryStorage.storeAlert(
+            AlertEntry(
+                alertIdentifier: alert.identifier.alertIdentifier,
+                primitiveInterruptionLevel: alert.interruptionLevel.storedValue as? Decimal,
+                issuedDate: Date(),
+                managerIdentifier: alert.identifier.managerIdentifier,
+                triggerType: alert.trigger.storedType,
+                triggerInterval: alert.trigger.storedInterval as? Decimal,
+                contentTitle: alert.foregroundContent?.title,
+                contentBody: alert.foregroundContent?.body
+            )
+        )
     }
 
     func retractAlert(identifier: Alert.Identifier) {
-        if let idx = alertStore.firstIndex(where: { $0.identifier.alertIdentifier == identifier.alertIdentifier }) {
-            alertStore.remove(at: idx)
-            broadcaster.notify(pumpNotificationObserver.self, on: processQueue) {
-                $0.pumpRemoveNotification()
-            }
-        }
+        alertHistoryStorage.deleteAlert(identifier: identifier.alertIdentifier)
     }
 
     func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {
@@ -578,6 +559,60 @@ extension BaseDeviceDataManager: CGMManagerDelegate {
 
 // MARK: - AlertPresenter
 
+extension BaseDeviceDataManager: AlertObserver {
+    func AlertDidUpdate(_ alerts: [AlertEntry]) {
+        alerts.forEach { alert in
+            if alert.acknowledgedDate == nil {
+                ackAlert(alert: alert)
+            }
+        }
+    }
+
+    private func ackAlert(alert: AlertEntry) {
+        let typeMessage: MessageType
+        let alertUp = alert.alertIdentifier.uppercased()
+        if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
+            typeMessage = .errorPump
+        } else {
+            typeMessage = .warning
+        }
+
+        let messageCont = MessageContent(content: alert.contentBody ?? "Unknown", type: typeMessage)
+        let alertIssueDate = alert.issuedDate
+
+        processQueue.async {
+            // if not alert in OmniPod/BLE, the acknowledgeAlert didn't do callbacks- Hack to manage this case
+            if let omnipodBLE = self.pumpManager as? OmniBLEPumpManager {
+                if omnipodBLE.state.activeAlerts.isEmpty {
+                    // force to ack alert in the alertStorage
+                    self.alertHistoryStorage.ackAlert(alertIssueDate, nil)
+                }
+            }
+
+            if let omniPod = self.pumpManager as? OmnipodPumpManager {
+                if omniPod.state.activeAlerts.isEmpty {
+                    // force to ack alert in the alertStorage
+                    self.alertHistoryStorage.ackAlert(alertIssueDate, nil)
+                }
+            }
+
+            self.pumpManager?.acknowledgeAlert(alertIdentifier: alert.alertIdentifier) { error in
+                self.router.alertMessage.send(messageCont)
+                if let error = error {
+                    self.alertHistoryStorage.ackAlert(alertIssueDate, error.localizedDescription)
+                    debug(.deviceManager, "acknowledge not succeeded with error \(error.localizedDescription)")
+                } else {
+                    self.alertHistoryStorage.ackAlert(alertIssueDate, nil)
+                }
+            }
+
+            self.broadcaster.notify(pumpNotificationObserver.self, on: self.processQueue) {
+                $0.pumpNotification(alert: alert)
+            }
+        }
+    }
+}
+
 // extension BaseDeviceDataManager: AlertPresenter {
 //    func issueAlert(_: Alert) {}
 //    func retractAlert(identifier _: Alert.Identifier) {}

+ 1 - 0
FreeAPS/Sources/APS/OpenAPS/Constants.swift

@@ -53,6 +53,7 @@ extension OpenAPS {
         static let glucose = "monitor/glucose.json"
         static let iob = "monitor/iob.json"
         static let podAge = "monitor/pod-age.json"
+        static let alertHistory = "monitor/alerthistory.json"
     }
 
     enum Enact {

+ 103 - 0
FreeAPS/Sources/APS/Storage/AlertStorage.swift

@@ -0,0 +1,103 @@
+import Combine
+import Foundation
+import SwiftDate
+import Swinject
+
+protocol AlertObserver {
+    func AlertDidUpdate(_ alerts: [AlertEntry])
+}
+
+protocol AlertHistoryStorage {
+    func storeAlert(_ alerts: AlertEntry)
+    func syncDate() -> Date
+    func recentNotAck() -> [AlertEntry]
+    func deleteAlert(identifier: String)
+    func ackAlert(_ alert: Date, _ error: String?)
+    func forceNotification()
+    var alertNotAck: PassthroughSubject<Bool, Never> { get }
+}
+
+final class BaseAlertHistoryStorage: AlertHistoryStorage, Injectable {
+    private let processQueue = DispatchQueue.markedQueue(label: "BaseAlertsStorage.processQueue")
+    @Injected() private var storage: FileStorage!
+    @Injected() private var broadcaster: Broadcaster!
+
+    let alertNotAck = PassthroughSubject<Bool, Never>()
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        alertNotAck.send(recentNotAck().isNotEmpty)
+    }
+
+    func storeAlert(_ alert: AlertEntry) {
+        processQueue.sync {
+            let file = OpenAPS.Monitor.alertHistory
+            var uniqEvents: [AlertEntry] = []
+            self.storage.transaction { storage in
+                storage.append(alert, to: file, uniqBy: \.issuedDate)
+                uniqEvents = storage.retrieve(file, as: [AlertEntry].self)?
+                    .filter { $0.issuedDate.addingTimeInterval(1.days.timeInterval) > Date() }
+                    .sorted { $0.issuedDate > $1.issuedDate } ?? []
+                storage.save(Array(uniqEvents), as: file)
+            }
+            alertNotAck.send(self.recentNotAck().isNotEmpty)
+            broadcaster.notify(AlertObserver.self, on: processQueue) {
+                $0.AlertDidUpdate(uniqEvents)
+            }
+        }
+    }
+
+    func syncDate() -> Date {
+        Date().addingTimeInterval(-1.days.timeInterval)
+    }
+
+    func recentNotAck() -> [AlertEntry] {
+        storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self)?
+            .filter { $0.issuedDate.addingTimeInterval(1.days.timeInterval) > Date() && $0.acknowledgedDate == nil }
+            .sorted { $0.issuedDate > $1.issuedDate } ?? []
+    }
+
+    func ackAlert(_ alert: Date, _ error: String?) {
+        processQueue.sync {
+            var allValues = storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self) ?? []
+            guard let entryIndex = allValues.firstIndex(where: { $0.issuedDate == alert }) else {
+                return
+            }
+
+            if let error {
+                allValues[entryIndex].errorMessage = error
+            } else {
+                allValues[entryIndex].acknowledgedDate = Date()
+            }
+            storage.save(allValues, as: OpenAPS.Monitor.alertHistory)
+            alertNotAck.send(self.recentNotAck().isNotEmpty)
+        }
+    }
+
+    func deleteAlert(identifier: String) {
+        processQueue.sync {
+            var allValues = storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self) ?? []
+            guard let entryIndex = allValues.firstIndex(where: { $0.alertIdentifier == identifier }) else {
+                return
+            }
+            allValues.remove(at: entryIndex)
+            storage.save(allValues, as: OpenAPS.Monitor.alertHistory)
+            alertNotAck.send(self.recentNotAck().isNotEmpty)
+            broadcaster.notify(AlertObserver.self, on: processQueue) {
+                $0.AlertDidUpdate(allValues)
+            }
+        }
+    }
+
+    func forceNotification() {
+        processQueue.sync {
+            let uniqEvents = storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self)?
+                .filter { $0.issuedDate.addingTimeInterval(1.days.timeInterval) > Date() }
+                .sorted { $0.issuedDate > $1.issuedDate } ?? []
+            alertNotAck.send(self.recentNotAck().isNotEmpty)
+            broadcaster.notify(AlertObserver.self, on: processQueue) {
+                $0.AlertDidUpdate(uniqEvents)
+            }
+        }
+    }
+}

+ 1 - 0
FreeAPS/Sources/Assemblies/StorageAssembly.swift

@@ -14,5 +14,6 @@ final class StorageAssembly: Assembly {
         container.register(AnnouncementsStorage.self) { r in BaseAnnouncementsStorage(resolver: r) }
         container.register(SettingsManager.self) { r in BaseSettingsManager(resolver: r) }
         container.register(Keychain.self) { _ in BaseKeychain() }
+        container.register(AlertHistoryStorage.self) { r in BaseAlertHistoryStorage(resolver: r) }
     }
 }

+ 142 - 0
FreeAPS/Sources/Models/AlertEntry.swift

@@ -0,0 +1,142 @@
+
+import Foundation
+import LoopKit
+import UserNotifications
+
+struct AlertEntry: JSON, Codable, Hashable {
+    let alertIdentifier: String
+    var acknowledgedDate: Date?
+    var primitiveInterruptionLevel: Decimal?
+    let issuedDate: Date
+    let managerIdentifier: String
+    let triggerType: Int16
+    var triggerInterval: Decimal?
+    let contentTitle: String?
+    let contentBody: String?
+    var errorMessage: String?
+
+    static let manual = "freeaps-x"
+
+    static func == (lhs: AlertEntry, rhs: AlertEntry) -> Bool {
+        lhs.issuedDate == rhs.issuedDate
+    }
+
+    func hash(into hasher: inout Hasher) {
+        hasher.combine(issuedDate)
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case alertIdentifier
+        case acknowledgedDate
+        case primitiveInterruptionLevel
+        case issuedDate
+        case managerIdentifier
+        case triggerType
+        case triggerInterval
+        case contentTitle
+        case contentBody
+        case errorMessage
+    }
+}
+
+//
+//  StoredAlert.swift
+//  Loop
+//
+//  Created by Rick Pasetto on 5/11/20.
+//  Copyright © 2020 LoopKit Authors. All rights reserved.
+//
+
+extension Alert.Trigger {
+    enum StorageError: Error {
+        case invalidStoredInterval
+        case invalidStoredType
+    }
+
+    var storedType: Int16 {
+        switch self {
+        case .immediate: return 0
+        case .delayed: return 1
+        case .repeating: return 2
+        }
+    }
+
+    var storedInterval: NSNumber? {
+        switch self {
+        case .immediate: return nil
+        case let .delayed(interval): return NSNumber(value: interval)
+        case let .repeating(repeatInterval): return NSNumber(value: repeatInterval)
+        }
+    }
+
+    init(storedType: Int16, storedInterval: NSNumber?, storageDate: Date? = nil, now: Date = Date()) throws {
+        switch storedType {
+        case 0: self = .immediate
+        case 1:
+            if let storedInterval = storedInterval {
+                if let storageDate = storageDate, storageDate <= now {
+                    let intervalLeft = storedInterval.doubleValue - now.timeIntervalSince(storageDate)
+                    if intervalLeft <= 0 {
+                        self = .immediate
+                    } else {
+                        self = .delayed(interval: intervalLeft)
+                    }
+                } else {
+                    self = .delayed(interval: storedInterval.doubleValue)
+                }
+            } else {
+                throw StorageError.invalidStoredInterval
+            }
+        case 2:
+            // Strange case here: if it is a repeating trigger, we can't really play back exactly
+            // at the right "remaining time" and then repeat at the original period.  So, I think
+            // the best we can do is just use the original trigger
+            if let storedInterval = storedInterval {
+                self = .repeating(repeatInterval: storedInterval.doubleValue)
+            } else {
+                throw StorageError.invalidStoredInterval
+            }
+        default:
+            throw StorageError.invalidStoredType
+        }
+    }
+}
+
+extension Alert.InterruptionLevel {
+    var storedValue: NSNumber {
+        // Since this is arbitrary anyway, might as well make it match iOS's values
+        switch self {
+        case .active:
+            if #available(iOS 15.0, *) {
+                return NSNumber(value: UNNotificationInterruptionLevel.active.rawValue)
+            } else {
+                // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/active
+                return 1
+            }
+        case .timeSensitive:
+            if #available(iOS 15.0, *) {
+                return NSNumber(value: UNNotificationInterruptionLevel.timeSensitive.rawValue)
+            } else {
+                // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/timesensitive
+                return 2
+            }
+        case .critical:
+            if #available(iOS 15.0, *) {
+                return NSNumber(value: UNNotificationInterruptionLevel.critical.rawValue)
+            } else {
+                // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/critical
+                return 3
+            }
+        }
+    }
+
+    init?(storedValue: NSNumber) {
+        switch storedValue {
+        case Self.active.storedValue: self = .active
+        case Self.timeSensitive.storedValue: self = .timeSensitive
+        case Self.critical.storedValue: self = .critical
+        default:
+            return nil
+        }
+    }
+}

+ 8 - 0
FreeAPS/Sources/Modules/PumpConfig/PumpConfigProvider.swift

@@ -24,5 +24,13 @@ extension PumpConfig {
                 ?? PumpSettings(from: OpenAPS.defaults(for: OpenAPS.Settings.settings))
                 ?? PumpSettings(insulinActionCurve: 5, maxBolus: 10, maxBasal: 2)
         }
+
+        var alertNotAck: AnyPublisher<Bool, Never> {
+            deviceManager.alertHistoryStorage.alertNotAck.eraseToAnyPublisher()
+        }
+
+        func initialAlertNotAck() -> Bool {
+            deviceManager.alertHistoryStorage.recentNotAck().isNotEmpty
+        }
     }
 }

+ 11 - 0
FreeAPS/Sources/Modules/PumpConfig/PumpConfigStateModel.swift

@@ -9,6 +9,7 @@ extension PumpConfig {
         private(set) var setupPumpType: PumpType = .minimed
         @Published var pumpState: PumpDisplayState?
         private(set) var initialSettings: PumpInitialSettings = .default
+        @Published var alertNotAck: Bool = false
 
         override func subscribe() {
             provider.pumpDisplayState
@@ -16,6 +17,12 @@ extension PumpConfig {
                 .assign(to: \.pumpState, on: self)
                 .store(in: &lifetime)
 
+            alertNotAck = provider.initialAlertNotAck()
+            provider.alertNotAck
+                .receive(on: DispatchQueue.main)
+                .assign(to: \.alertNotAck, on: self)
+                .store(in: &lifetime)
+
             let basalSchedule = BasalRateSchedule(
                 dailyItems: provider.basalProfile().map {
                     RepeatingScheduleValue(startTime: $0.minutes.minutes.timeInterval, value: Double($0.rate))
@@ -35,6 +42,10 @@ extension PumpConfig {
             setupPump = true
             setupPumpType = type
         }
+
+        func ack() {
+            provider.deviceManager.alertHistoryStorage.forceNotification()
+        }
     }
 }
 

+ 4 - 0
FreeAPS/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift

@@ -19,6 +19,10 @@ extension PumpConfig {
                                     Text(pumpState.name)
                                 }
                             }
+                            if state.alertNotAck {
+                                Spacer()
+                                Button("Acknowledge all alerts") { state.ack() }
+                            }
                         } else {
                             Button("Add Medtronic") { state.addPump(.minimed) }
                             Button("Add Omnipod") { state.addPump(.omnipod) }

+ 2 - 0
FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -94,6 +94,8 @@ extension Settings {
                         }
 
                         Group {
+                            Text("Alerts")
+                                .navigationLink(to: .configEditor(file: OpenAPS.Monitor.alertHistory), from: self)
                             Text("Target presets")
                                 .navigationLink(to: .configEditor(file: OpenAPS.FreeAPS.tempTargetsPresets), from: self)
                             Text("Calibrations")

+ 4 - 5
FreeAPS/Sources/Services/UserNotifiactions/UserNotificationsManager.swift

@@ -25,7 +25,7 @@ protocol BolusFailureObserver {
 }
 
 protocol pumpNotificationObserver {
-    func pumpNotification(alert: LoopKit.Alert)
+    func pumpNotification(alert: AlertEntry)
     func pumpRemoveNotification()
 }
 
@@ -405,12 +405,11 @@ extension BaseUserNotificationsManager: GlucoseObserver {
 }
 
 extension BaseUserNotificationsManager: pumpNotificationObserver {
-    func pumpNotification(alert: LoopKit.Alert) {
+    func pumpNotification(alert: AlertEntry) {
         ensureCanSendNotification {
-            let contentAlert = alert.foregroundContent!
             let content = UNMutableNotificationContent()
-            content.title = contentAlert.title
-            content.body = contentAlert.body
+            content.title = alert.contentTitle ?? "Unknown"
+            content.body = alert.contentBody ?? "Unknown"
             content.sound = .default
             self.addRequest(
                 identifier: .pumpNotification,