diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 604cf3e9e..b943690c0 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -11,6 +11,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() + private func logRemoteCommandNotificationDetails(userInfo: [AnyHashable: Any]) -> Bool { + let commandStatus = userInfo["command_status"] as? String + let commandType = userInfo["command_type"] as? String + + if let commandStatus { + LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)") + } + + if let commandType { + LogManager.shared.log(category: .general, message: "Command type: \(commandType)") + } + + return commandStatus != nil || commandType != nil + } + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() @@ -82,14 +97,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Handle silent notification (content-available) if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 { // This is a silent push, nothing implemented but logging for now - - if let commandStatus = userInfo["command_status"] as? String { - LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)") - } - - if let commandType = userInfo["command_type"] as? String { - LogManager.shared.log(category: .general, message: "Command type: \(commandType)") - } + _ = logRemoteCommandNotificationDetails(userInfo: userInfo) } } @@ -199,6 +207,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let userInfo = notification.request.content.userInfo LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)") + if logRemoteCommandNotificationDetails(userInfo: userInfo) { + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) + LogManager.shared.log(category: .general, message: "Started remote command polling from foreground result notification") + } + // Show the notification even when app is in foreground completionHandler([.banner, .sound, .badge]) } diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 8ff20df87..4643cd1e3 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -184,5 +184,6 @@ extension MainViewController { } } processCage(entries: pumpSiteChange) + evaluateRemoteCommandPollingCompletion() } } diff --git a/LoopFollow/Remote/RemoteType.swift b/LoopFollow/Remote/RemoteType.swift index 1e4b958dd..bbc7ad8db 100644 --- a/LoopFollow/Remote/RemoteType.swift +++ b/LoopFollow/Remote/RemoteType.swift @@ -9,3 +9,7 @@ enum RemoteType: String, Codable { case trc = "Trio Remote Control" case loopAPNS = "Loop APNS" } + +extension Notification.Name { + static let remoteCommandSucceeded = Notification.Name("remoteCommandSucceeded") +} diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index e494ea946..ce8e04342 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -16,6 +16,39 @@ func IsNightscoutEnabled() -> Bool { } class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { + private struct RemoteCommandDataSignature { + let carbTimestamp: TimeInterval? + let bolusTimestamp: TimeInterval? + let overrideTimestamp: TimeInterval? + let tempTargetTimestamp: TimeInterval? + let overrideStateKey: String + let tempTargetStateKey: String + + func detectsFreshData(comparedTo baseline: RemoteCommandDataSignature) -> Bool { + if let carbTimestamp, carbTimestamp > (baseline.carbTimestamp ?? 0) { + return true + } + + if let bolusTimestamp, bolusTimestamp > (baseline.bolusTimestamp ?? 0) { + return true + } + + if let overrideTimestamp, overrideTimestamp > (baseline.overrideTimestamp ?? 0) { + return true + } + + if let tempTargetTimestamp, tempTargetTimestamp > (baseline.tempTargetTimestamp ?? 0) { + return true + } + + if overrideStateKey != baseline.overrideStateKey { + return true + } + + return tempTargetStateKey != baseline.tempTargetStateKey + } + } + var isPresentedAsModal: Bool = false @IBOutlet var BGText: UILabel! @@ -136,6 +169,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele "deviceStatus": false, ] private var loadingTimeoutTimer: Timer? + private var remoteCommandPollingTimer: Timer? + private var remoteCommandPollingStartedAt: Date? + private var remoteCommandPollingBaseline: RemoteCommandDataSignature? + private let remoteCommandPollingInterval: TimeInterval = 3 + private let remoteCommandPollingDuration: TimeInterval = 30 override func viewDidLoad() { super.viewDidLoad() @@ -239,6 +277,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele refreshScrollView.delegate = self NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) + NotificationCenter.default.publisher(for: .remoteCommandSucceeded) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.startRemoteCommandPolling() + } + .store(in: &cancellables) + Observable.shared.bgText.$value .receive(on: DispatchQueue.main) .sink { [weak self] newValue in @@ -824,6 +869,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } deinit { + remoteCommandPollingTimer?.invalidate() NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil) } @@ -864,6 +910,78 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele refreshControl.endRefreshing() } + private func startRemoteCommandPolling() { + guard IsNightscoutEnabled() else { return } + + remoteCommandPollingBaseline = currentRemoteCommandDataSignature() + remoteCommandPollingStartedAt = Date() + remoteCommandPollingTimer?.invalidate() + + performRemoteCommandPollingTick() + + let timer = Timer.scheduledTimer(withTimeInterval: remoteCommandPollingInterval, repeats: true) { [weak self] _ in + self?.performRemoteCommandPollingTick() + } + timer.tolerance = 0.5 + remoteCommandPollingTimer = timer + + LogManager.shared.log(category: .general, message: "Started aggressive polling after remote command result notification") + } + + private func stopRemoteCommandPolling(reason: String) { + guard remoteCommandPollingTimer != nil || remoteCommandPollingStartedAt != nil else { return } + + remoteCommandPollingTimer?.invalidate() + remoteCommandPollingTimer = nil + remoteCommandPollingStartedAt = nil + remoteCommandPollingBaseline = nil + + LogManager.shared.log(category: .general, message: "Stopped aggressive polling: \(reason)") + } + + private func performRemoteCommandPollingTick() { + guard let remoteCommandPollingStartedAt else { return } + + if Date().timeIntervalSince(remoteCommandPollingStartedAt) >= remoteCommandPollingDuration { + stopRemoteCommandPolling(reason: "timeout reached") + return + } + + bgTaskAction() + deviceStatusAction() + treatmentsTaskAction() + } + + private func currentRemoteCommandDataSignature() -> RemoteCommandDataSignature { + let latestBolusTimestamp = max(bolusData.last?.date ?? 0, smbData.last?.date ?? 0) + let overrideNote = Observable.shared.override.value ?? "" + let overrideStateKey = overrideNote + let tempTargetStateKey: String + + if let tempTarget = Observable.shared.tempTarget.value { + tempTargetStateKey = Localizer.formatQuantity(tempTarget) + } else { + tempTargetStateKey = "" + } + + return RemoteCommandDataSignature( + carbTimestamp: carbData.last?.date, + bolusTimestamp: latestBolusTimestamp > 0 ? latestBolusTimestamp : nil, + overrideTimestamp: overrideGraphData.last?.date, + tempTargetTimestamp: tempTargetGraphData.last?.date, + overrideStateKey: overrideStateKey, + tempTargetStateKey: tempTargetStateKey + ) + } + + func evaluateRemoteCommandPollingCompletion() { + guard let remoteCommandPollingBaseline else { return } + + if currentRemoteCommandDataSignature().detectsFreshData(comparedTo: remoteCommandPollingBaseline) { + stopRemoteCommandPolling(reason: "fresh remote data received") + } + } + // Scroll down BGText when refreshing func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView == refreshScrollView {