From 7404c16614421673cb2df6dc8bec8b48f25bcde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 16 Jul 2026 21:36:17 +0200 Subject: [PATCH 1/3] Only clear the forecast when the data source changes The refresh handler used to wipe the Loop and Trio forecasts on every refresh, including every websocket disconnect, which made the Loop forecast disappear and only come back on the next loop cycle. Move the clearing so it only happens when the active system actually changes: when a device status flips between Loop and Trio, and when Nightscout is turned off so there is no device status source at all, such as Dexcom only. --- LoopFollow/Controllers/Graphs.swift | 24 +++++++++++++++++++ .../Controllers/Nightscout/DeviceStatus.swift | 12 ++++++++++ LoopFollow/Task/DeviceStatusTask.swift | 4 ++++ .../ViewControllers/MainViewController.swift | 23 ------------------ 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index 184b67b8f..141addbb2 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -967,6 +967,30 @@ extension MainViewController { BGChart.notifyDataSetChanged() } + // Removes the Loop forecast line. Used when the active system switches away from Loop. + func clearLoopPredictionGraph() { + guard !predictionData.isEmpty else { return } + predictionData.removeAll() + updatePredictionGraph() + } + + // Removes the Trio/OpenAPS forecast lines (ZT/IOB/COB/UAM). Used when the active system switches away from Trio/OpenAPS. + func clearOpenAPSPredictionGraph() { + let openAPSDataIndices = [12, 13, 14, 15] + for dataIndex in openAPSDataIndices { + let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet + let smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet + if !mainChart.entries.isEmpty || !smallChart.entries.isEmpty { + updatePredictionGraphGeneric( + dataIndex: dataIndex, + predictionData: [], + chartLabel: "", + color: UIColor.systemGray + ) + } + } + } + func updatePredictionGraph(color: UIColor? = nil) { let dataIndex = 1 var mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index d2d6920d8..49cbd8bb5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -66,6 +66,7 @@ extension MainViewController { // NS Device Status Response Processor func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) { let previousIOBText = Observable.shared.iobText.value + let previousDeviceWasLoop = Storage.shared.device.value == "Loop" infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd]) // For Loop, clear the current override here - For Trio, it is handled using treatments @@ -188,6 +189,17 @@ extension MainViewController { DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord) } + // If the active looping system flipped (Loop ⇄ Trio/OpenAPS), drop the previous + // system's forecast so it doesn't linger next to the one just drawn above. + let currentDeviceIsLoop = Storage.shared.device.value == "Loop" + if currentDeviceIsLoop != previousDeviceWasLoop { + if currentDeviceIsLoop { + clearOpenAPSPredictionGraph() + } else { + clearLoopPredictionGraph() + } + } + // Start the timer based on the timestamp let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0) diff --git a/LoopFollow/Task/DeviceStatusTask.swift b/LoopFollow/Task/DeviceStatusTask.swift index 0273937d1..412ffbf0f 100644 --- a/LoopFollow/Task/DeviceStatusTask.swift +++ b/LoopFollow/Task/DeviceStatusTask.swift @@ -15,6 +15,10 @@ extension MainViewController { func deviceStatusAction() { // If no NS config, we wait 60s before trying again: guard IsNightscoutEnabled() else { + // No device-status source (e.g. Dexcom-only): drop any forecast left over + // from a previous Loop/Trio source so it doesn't linger on the chart. + clearLoopPredictionGraph() + clearOpenAPSPredictionGraph() TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(60)) return } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 105873f1c..3ec2dab87 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -565,29 +565,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio @objc func refresh() { LogManager.shared.log(category: .general, message: "Refreshing") - // Clear prediction for both Loop or OpenAPS - - // Check if Loop prediction data exists and clear it if necessary - if !predictionData.isEmpty { - predictionData.removeAll() - updatePredictionGraph() - } - - // Check if OpenAPS prediction data exists and clear it if necessary - let openAPSDataIndices = [12, 13, 14, 15] - for dataIndex in openAPSDataIndices { - let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet - let smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet - if !mainChart.entries.isEmpty || !smallChart.entries.isEmpty { - updatePredictionGraphGeneric( - dataIndex: dataIndex, - predictionData: [], - chartLabel: "", - color: UIColor.systemGray - ) - } - } - Observable.shared.minAgoText.value = "Refreshing" scheduleAllTasks() NightscoutSocketManager.shared.connectIfNeeded() From 9eea98e8ebaa4b0579b0a6171df7bb1d5f22e2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 17 Jul 2026 17:20:36 +0200 Subject: [PATCH 2/3] Rebuild the Loop forecast when it is empty The forecast was only rebuilt when the loop cycle advanced (previousLastLoopTime < lastLoopTime), so any time the prediction was cleared within the same cycle it stayed blank until the next loop cycle. Also rebuild when predictionData is empty, so the forecast is restored on the next device-status poll regardless of cycle timing. --- LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 771fdc68d..d3fe72e3b 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -68,7 +68,7 @@ extension MainViewController { let prediction = predictdata["values"] as! [Double] Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) Observable.shared.predictionColor.value = .purple - if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime { + if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty { predictionData.removeAll() var predictionTime = lastLoopTime let toLoad = Int(Storage.shared.predictionToLoad.value * 12) From 20145a6857e8e08359ab2dc86d694103a1c3fe4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 17 Jul 2026 18:05:45 +0200 Subject: [PATCH 3/3] Fall back to the loop timestamp when the pump reports no clock A pump that omits pump.clock (e.g. some Omnipod configurations) left alertLastLoopTime at 0, so the Loop forecast anchored to epoch 0 and stretched the graph across decades. Use the loop cycle timestamp as the loop time when no pump clock is present, so the forecast anchors correctly and refreshes each cycle. --- LoopFollow/Controllers/Nightscout/DeviceStatus.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 49cbd8bb5..04f6bfa15 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -156,6 +156,17 @@ extension MainViewController { // Loop - handle new data if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? { + // Some pumps report no `pump.clock`; without it alertLastLoopTime stays 0 + // and the forecast anchors to epoch 0. Fall back to the loop cycle timestamp. + if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] == nil, + let loopTimestampString = lastLoopRecord["timestamp"] as? String, + let loopTimestamp = formatter.date(from: loopTimestampString)?.timeIntervalSince1970, + loopTimestamp > (Observable.shared.alertLastLoopTime.value ?? 0) + { + Observable.shared.alertLastLoopTime.value = loopTimestamp + Storage.shared.lastLoopTime.value = loopTimestamp + } + DeviceStatusLoop(formatter: formatter, lastLoopRecord: lastLoopRecord) var oText = ""