From f5ba973d32143f6dba59a6c3f785536b29b0f732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 21 Mar 2026 19:49:55 +0100 Subject: [PATCH 1/3] Add aggressive Nightscout polling after remote commands After a remote command (bolus, carbs, override, temp target) succeeds, poll Nightscout every 3 seconds for up to 30 seconds to quickly reflect the command on the main screen. Polling stops early when fresh data matching the command type is detected. --- .../Controllers/Nightscout/DeviceStatus.swift | 1 + .../Controllers/Nightscout/Treatments.swift | 1 + .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 1 + .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 1 + .../Remote/LoopAPNS/OverridePresetsView.swift | 2 + LoopFollow/Remote/RemoteType.swift | 4 + LoopFollow/Remote/TRC/BolusView.swift | 1 + LoopFollow/Remote/TRC/MealView.swift | 1 + LoopFollow/Remote/TRC/OverrideView.swift | 2 + LoopFollow/Remote/TRC/TempTargetView.swift | 2 + .../TRC/TrioNightscoutRemoteController.swift | 2 + .../ViewControllers/MainViewController.swift | 105 ++++++++++++++++++ 12 files changed, 123 insertions(+) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index f8bc8f867..42689827c 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -220,6 +220,7 @@ extension MainViewController { ) } else if secondsAgo >= (5 * 60) { + self.evaluateRemoteCommandPollingCompletion() TaskScheduler.shared.rescheduleTask( id: .deviceStatus, to: Date().addingTimeInterval(10) 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/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 011fe0e10..1f73b82ce 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -373,6 +373,7 @@ struct LoopAPNSBolusView: View { TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) self.alertMessage = "Insulin sent successfully!" self.alertType = .success + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) LogManager.shared.log( category: .apns, message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U" diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 28b5745d8..1494a40ae 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -403,6 +403,7 @@ struct LoopAPNSCarbsView: View { timeFormatter.timeStyle = .short self.alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!" self.alertType = .success + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) LogManager.shared.log( category: .apns, message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTimeString)h, Time: \(adjustedConsumedDate)" diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index ce88f0b90..c09c964a4 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -407,6 +407,7 @@ class OverridePresetsViewModel: ObservableObject { self.isActivating = false self.statusMessage = "\(preset.name) override activated successfully." self.alertType = .statusSuccess + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) self.showAlert = true } } catch { @@ -430,6 +431,7 @@ class OverridePresetsViewModel: ObservableObject { self.isActivating = false self.statusMessage = "Active override cancelled successfully." self.alertType = .statusSuccess + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) self.showAlert = true } } catch { 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/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 30bfab213..b43af519f 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -272,6 +272,7 @@ struct BolusView: View { category: .apns, message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U" ) + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) alertType = .statusSuccess } else { diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 55dd704e6..bfe03eab2 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -305,6 +305,7 @@ struct MealView: View { category: .apns, message: "sendMealPushNotification succeeded - Carbs: \(carbs.doubleValue(for: .gram())) g, Protein: \(protein.doubleValue(for: .gram())) g, Fat: \(fat.doubleValue(for: .gram())) g, Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U, Scheduled: \(scheduledDate != nil ? formatDate(scheduledDate!) : "now")" ) + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) // Reset meal values and scheduled data after success carbs = HKQuantity(unit: .gram(), doubleValue: 0.0) diff --git a/LoopFollow/Remote/TRC/OverrideView.swift b/LoopFollow/Remote/TRC/OverrideView.swift index 3a402f847..906f3e7d2 100644 --- a/LoopFollow/Remote/TRC/OverrideView.swift +++ b/LoopFollow/Remote/TRC/OverrideView.swift @@ -175,6 +175,7 @@ struct OverrideView: View { self.statusMessage = "Override command sent successfully." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendOverridePushNotification succeeded for override: \(override.name)") + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send override command." self.alertType = .statusFailure @@ -195,6 +196,7 @@ struct OverrideView: View { self.statusMessage = "Cancel override command sent successfully." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelOverridePushNotification succeeded") + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send cancel override command." self.alertType = .statusFailure diff --git a/LoopFollow/Remote/TRC/TempTargetView.swift b/LoopFollow/Remote/TRC/TempTargetView.swift index 3a047e66f..4219caf9c 100644 --- a/LoopFollow/Remote/TRC/TempTargetView.swift +++ b/LoopFollow/Remote/TRC/TempTargetView.swift @@ -255,6 +255,7 @@ struct TempTargetView: View { self.statusMessage = "Temp target command successfully sent." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendTempTargetPushNotification succeeded with target: \(newHKTarget), duration: \(duration)") + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send temp target command." self.alertType = .statusFailure @@ -275,6 +276,7 @@ struct TempTargetView: View { self.statusMessage = "Cancel temp target command successfully sent." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelTempTargetPushNotification succeeded") + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send cancel temp target command." self.alertType = .statusFailure diff --git a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift b/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift index 594619690..e8c8edc13 100644 --- a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift +++ b/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift @@ -19,6 +19,7 @@ class TrioNightscoutRemoteController { let response: [TreatmentCancelResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) Observable.shared.tempTarget.value = nil NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) completion(true) } catch { completion(false) @@ -42,6 +43,7 @@ class TrioNightscoutRemoteController { let response: [TreatmentResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) Observable.shared.tempTarget.value = newTarget NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) completion(true) } catch { completion(false) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index e494ea946..027fe5c7c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -16,6 +16,29 @@ func IsNightscoutEnabled() -> Bool { } class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { + private struct RemoteCommandDataSignature { + let carbTimestamp: TimeInterval? + let bolusTimestamp: TimeInterval? + let overrideTimestamp: TimeInterval? + let overrideStateKey: 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 + } + + return overrideStateKey != baseline.overrideStateKey + } + } + var isPresentedAsModal: Bool = false @IBOutlet var BGText: UILabel! @@ -136,6 +159,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 +267,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 +859,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } deinit { + remoteCommandPollingTimer?.invalidate() NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil) } @@ -864,6 +900,75 @@ 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 success") + } + + 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: String + + if currentOverride != 1.0 || !overrideNote.isEmpty { + overrideStateKey = "\(currentOverride)|\(overrideNote)" + } else { + overrideStateKey = "" + } + + return RemoteCommandDataSignature( + carbTimestamp: carbData.last?.date, + bolusTimestamp: latestBolusTimestamp > 0 ? latestBolusTimestamp : nil, + overrideTimestamp: overrideGraphData.last?.date, + overrideStateKey: overrideStateKey + ) + } + + 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 { From 75d483cc3bcd363efb3d9e520b7d7d294cd739c7 Mon Sep 17 00:00:00 2001 From: codebymini Date: Sun, 29 Mar 2026 21:06:19 +0200 Subject: [PATCH 2/3] Refactor remote command notification handling and enhance logging for command results --- LoopFollow/Application/AppDelegate.swift | 29 ++++++++++++++----- .../Controllers/Nightscout/DeviceStatus.swift | 1 - .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 1 - .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 1 - .../Remote/LoopAPNS/OverridePresetsView.swift | 2 -- LoopFollow/Remote/TRC/BolusView.swift | 1 - LoopFollow/Remote/TRC/MealView.swift | 1 - LoopFollow/Remote/TRC/OverrideView.swift | 2 -- LoopFollow/Remote/TRC/TempTargetView.swift | 2 -- .../TRC/TrioNightscoutRemoteController.swift | 2 -- .../ViewControllers/MainViewController.swift | 27 ++++++++++++----- 11 files changed, 41 insertions(+), 28 deletions(-) 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/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 42689827c..f8bc8f867 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -220,7 +220,6 @@ extension MainViewController { ) } else if secondsAgo >= (5 * 60) { - self.evaluateRemoteCommandPollingCompletion() TaskScheduler.shared.rescheduleTask( id: .deviceStatus, to: Date().addingTimeInterval(10) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 1f73b82ce..011fe0e10 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -373,7 +373,6 @@ struct LoopAPNSBolusView: View { TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) self.alertMessage = "Insulin sent successfully!" self.alertType = .success - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) LogManager.shared.log( category: .apns, message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U" diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 1494a40ae..28b5745d8 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -403,7 +403,6 @@ struct LoopAPNSCarbsView: View { timeFormatter.timeStyle = .short self.alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!" self.alertType = .success - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) LogManager.shared.log( category: .apns, message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTimeString)h, Time: \(adjustedConsumedDate)" diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index c09c964a4..ce88f0b90 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -407,7 +407,6 @@ class OverridePresetsViewModel: ObservableObject { self.isActivating = false self.statusMessage = "\(preset.name) override activated successfully." self.alertType = .statusSuccess - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) self.showAlert = true } } catch { @@ -431,7 +430,6 @@ class OverridePresetsViewModel: ObservableObject { self.isActivating = false self.statusMessage = "Active override cancelled successfully." self.alertType = .statusSuccess - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) self.showAlert = true } } catch { diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index b43af519f..30bfab213 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -272,7 +272,6 @@ struct BolusView: View { category: .apns, message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U" ) - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) alertType = .statusSuccess } else { diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index bfe03eab2..55dd704e6 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -305,7 +305,6 @@ struct MealView: View { category: .apns, message: "sendMealPushNotification succeeded - Carbs: \(carbs.doubleValue(for: .gram())) g, Protein: \(protein.doubleValue(for: .gram())) g, Fat: \(fat.doubleValue(for: .gram())) g, Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U, Scheduled: \(scheduledDate != nil ? formatDate(scheduledDate!) : "now")" ) - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) // Reset meal values and scheduled data after success carbs = HKQuantity(unit: .gram(), doubleValue: 0.0) diff --git a/LoopFollow/Remote/TRC/OverrideView.swift b/LoopFollow/Remote/TRC/OverrideView.swift index 906f3e7d2..3a402f847 100644 --- a/LoopFollow/Remote/TRC/OverrideView.swift +++ b/LoopFollow/Remote/TRC/OverrideView.swift @@ -175,7 +175,6 @@ struct OverrideView: View { self.statusMessage = "Override command sent successfully." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendOverridePushNotification succeeded for override: \(override.name)") - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send override command." self.alertType = .statusFailure @@ -196,7 +195,6 @@ struct OverrideView: View { self.statusMessage = "Cancel override command sent successfully." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelOverridePushNotification succeeded") - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send cancel override command." self.alertType = .statusFailure diff --git a/LoopFollow/Remote/TRC/TempTargetView.swift b/LoopFollow/Remote/TRC/TempTargetView.swift index 4219caf9c..3a047e66f 100644 --- a/LoopFollow/Remote/TRC/TempTargetView.swift +++ b/LoopFollow/Remote/TRC/TempTargetView.swift @@ -255,7 +255,6 @@ struct TempTargetView: View { self.statusMessage = "Temp target command successfully sent." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendTempTargetPushNotification succeeded with target: \(newHKTarget), duration: \(duration)") - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send temp target command." self.alertType = .statusFailure @@ -276,7 +275,6 @@ struct TempTargetView: View { self.statusMessage = "Cancel temp target command successfully sent." self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelTempTargetPushNotification succeeded") - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) } else { self.statusMessage = errorMessage ?? "Failed to send cancel temp target command." self.alertType = .statusFailure diff --git a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift b/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift index e8c8edc13..594619690 100644 --- a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift +++ b/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift @@ -19,7 +19,6 @@ class TrioNightscoutRemoteController { let response: [TreatmentCancelResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) Observable.shared.tempTarget.value = nil NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) completion(true) } catch { completion(false) @@ -43,7 +42,6 @@ class TrioNightscoutRemoteController { let response: [TreatmentResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) Observable.shared.tempTarget.value = newTarget NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) completion(true) } catch { completion(false) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 027fe5c7c..ce8e04342 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -20,7 +20,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele 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) { @@ -35,7 +37,15 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return true } - return overrideStateKey != baseline.overrideStateKey + if let tempTargetTimestamp, tempTargetTimestamp > (baseline.tempTargetTimestamp ?? 0) { + return true + } + + if overrideStateKey != baseline.overrideStateKey { + return true + } + + return tempTargetStateKey != baseline.tempTargetStateKey } } @@ -915,7 +925,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele timer.tolerance = 0.5 remoteCommandPollingTimer = timer - LogManager.shared.log(category: .general, message: "Started aggressive polling after remote command success") + LogManager.shared.log(category: .general, message: "Started aggressive polling after remote command result notification") } private func stopRemoteCommandPolling(reason: String) { @@ -945,19 +955,22 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele private func currentRemoteCommandDataSignature() -> RemoteCommandDataSignature { let latestBolusTimestamp = max(bolusData.last?.date ?? 0, smbData.last?.date ?? 0) let overrideNote = Observable.shared.override.value ?? "" - let overrideStateKey: String + let overrideStateKey = overrideNote + let tempTargetStateKey: String - if currentOverride != 1.0 || !overrideNote.isEmpty { - overrideStateKey = "\(currentOverride)|\(overrideNote)" + if let tempTarget = Observable.shared.tempTarget.value { + tempTargetStateKey = Localizer.formatQuantity(tempTarget) } else { - overrideStateKey = "" + tempTargetStateKey = "" } return RemoteCommandDataSignature( carbTimestamp: carbData.last?.date, bolusTimestamp: latestBolusTimestamp > 0 ? latestBolusTimestamp : nil, overrideTimestamp: overrideGraphData.last?.date, - overrideStateKey: overrideStateKey + tempTargetTimestamp: tempTargetGraphData.last?.date, + overrideStateKey: overrideStateKey, + tempTargetStateKey: tempTargetStateKey ) } From b3c73a902b31d1bb0c091026ffc33d0a0fd885cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 10 Apr 2026 10:31:31 +0200 Subject: [PATCH 3/3] Route remote command polling through TaskScheduler (#592) Replace the parallel Timer with a .remoteCommandPoll TaskScheduler task so the aggressive post-remote-command polling runs through the same scheduler as every other recurring fetch. The tick reschedules .deviceStatus and .treatments to fire immediately, then reschedules itself for the next interval. Parked at .distantFuture when the window closes (timeout or fresh data detected). Drop .fetchBG from the polled set: no remote command produces a BG entry, and the completion signature doesn't look at bgData, so the fetch was dead weight during the window. --- LoopFollow/Task/TaskScheduler.swift | 1 + .../ViewControllers/MainViewController.swift | 25 ++++++++----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/LoopFollow/Task/TaskScheduler.swift b/LoopFollow/Task/TaskScheduler.swift index a18b92aa3..99573160c 100644 --- a/LoopFollow/Task/TaskScheduler.swift +++ b/LoopFollow/Task/TaskScheduler.swift @@ -12,6 +12,7 @@ enum TaskID: CaseIterable { case minAgoUpdate case calendarWrite case alarmCheck + case remoteCommandPoll } struct ScheduledTask { diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ce8e04342..1ae406544 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -169,7 +169,6 @@ 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 @@ -869,7 +868,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } deinit { - remoteCommandPollingTimer?.invalidate() NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil) } @@ -915,26 +913,20 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele remoteCommandPollingBaseline = currentRemoteCommandDataSignature() remoteCommandPollingStartedAt = Date() - remoteCommandPollingTimer?.invalidate() - performRemoteCommandPollingTick() - - let timer = Timer.scheduledTimer(withTimeInterval: remoteCommandPollingInterval, repeats: true) { [weak self] _ in + TaskScheduler.shared.scheduleTask(id: .remoteCommandPoll, nextRun: Date()) { [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 } + guard remoteCommandPollingStartedAt != nil else { return } - remoteCommandPollingTimer?.invalidate() - remoteCommandPollingTimer = nil remoteCommandPollingStartedAt = nil remoteCommandPollingBaseline = nil + TaskScheduler.shared.rescheduleTask(id: .remoteCommandPoll, to: .distantFuture) LogManager.shared.log(category: .general, message: "Stopped aggressive polling: \(reason)") } @@ -947,9 +939,14 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return } - bgTaskAction() - deviceStatusAction() - treatmentsTaskAction() + let now = Date() + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: now) + TaskScheduler.shared.rescheduleTask(id: .treatments, to: now) + + TaskScheduler.shared.rescheduleTask( + id: .remoteCommandPoll, + to: Date().addingTimeInterval(remoteCommandPollingInterval) + ) } private func currentRemoteCommandDataSignature() -> RemoteCommandDataSignature {