From d0671d30799fab8e266d32bbb2f8efd3151e92c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 12 Apr 2026 23:06:02 +0200 Subject: [PATCH 1/3] Add Nightscout WebSocket (Socket.IO) support for real-time data updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect to Nightscout's Socket.IO endpoint to receive push notifications when new data arrives, instead of waiting for the next poll cycle. The WebSocket acts as a smart trigger: when a dataUpdate event arrives, only the relevant data types (BG, treatments, device status, profile) are fetched based on which keys are present in the delta payload. When WebSocket is connected and authenticated, polling intervals are extended (BG 3x, device status 3x, treatments 2→10 min, profile 10→30 min) so HTTP polling becomes a safety net. On disconnect, polling immediately reverts to normal intervals. The feature is always-on when Nightscout is configured — no user setting needed. A read-only connection status is shown in Nightscout settings. - Add Socket.IO-Client-Swift 16.1.1 via SPM - Add NightscoutSocketManager for connection lifecycle - Add NightscoutSocketDataHandler for selective push-trigger logic - Extend polling intervals when WebSocket is authenticated - Show WebSocket status in Nightscout settings - Wire up lifecycle in MainViewController (init, foreground, refresh) - Add staleness detection (10 min fallback to polling) --- LoopFollow.xcodeproj/project.pbxproj | 31 +++ .../Controllers/Nightscout/BGData.swift | 4 + .../Controllers/Nightscout/DeviceStatus.swift | 39 ++-- .../NightscoutSocketDataHandler.swift | 54 +++++ .../Nightscout/NightscoutSocketManager.swift | 204 ++++++++++++++++++ LoopFollow/Log/LogManager.swift | 1 + .../Nightscout/NightscoutSettingsView.swift | 12 ++ .../NightscoutSettingsViewModel.swift | 32 +++ LoopFollow/Task/ProfileTask.swift | 6 +- LoopFollow/Task/TreatmentsTask.swift | 6 +- .../ViewControllers/MainViewController.swift | 3 + 11 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift create mode 100644 LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 9a11022d6..d06c4b829 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -425,6 +425,9 @@ FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; }; FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEECA1248857A600402A7F /* SettingsViewController.swift */; }; + DD50C10A2F60A00000000001 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = DD50C10A2F60A00000000003 /* SocketIO */; }; + DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */; }; + DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -574,6 +577,8 @@ DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = ""; }; DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = ""; }; DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = ""; }; + DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketManager.swift; sourceTree = ""; }; + DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketDataHandler.swift; sourceTree = ""; }; DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = ""; }; DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = ""; }; @@ -911,6 +916,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DD50C10A2F60A00000000001 /* SocketIO in Frameworks */, FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, ); @@ -1155,6 +1161,8 @@ DD0C0C5F2C415B9D00DBADDF /* ProfileManager.swift */, DD0C0C612C4175FD00DBADDF /* NSProfile.swift */, DD5334222C60ED3600062F9D /* IAge.swift */, + DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */, + DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */, ); path = Nightscout; sourceTree = ""; @@ -1783,6 +1791,7 @@ ); name = LoopFollow; packageProductDependencies = ( + DD50C10A2F60A00000000003 /* SocketIO */, ); productName = LoopFollow; productReference = FC9788142485969B00A7906C /* Loop Follow.app */; @@ -1820,6 +1829,7 @@ ); mainGroup = FC97880B2485969B00A7906C; packageReferences = ( + DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; @@ -2190,6 +2200,8 @@ DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, + DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */, + DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */, @@ -2802,6 +2814,25 @@ versionGroupType = wrapper.xcdatamodel; }; /* End XCVersionGroup section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/socketio/socket.io-client-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 16.1.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DD50C10A2F60A00000000003 /* SocketIO */ = { + isa = XCSwiftPackageProductDependency; + package = DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */; + productName = SocketIO; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = FC97880C2485969B00A7906C /* Project object */; } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index d97aba24c..f7f1eda52 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -176,6 +176,10 @@ extension MainViewController { TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } + if NightscoutSocketManager.shared.connectionState == .authenticated { + delayToSchedule = max(delayToSchedule * 3, 60) + } + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date().addingTimeInterval(delayToSchedule)) // Evaluate speak conditions if there is a previous value. diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ff2b13a78..fce835b54 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -213,37 +213,28 @@ extension MainViewController { let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0) DispatchQueue.main.async { + var interval: Double if secondsAgo >= (20 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(5 * 60) - ) - + interval = 5 * 60 } else if secondsAgo >= (10 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(60) - ) - + interval = 60 } else if secondsAgo >= (7 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(30) - ) - + interval = 30 } else if secondsAgo >= (5 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(10) - ) + interval = 10 } else { - let interval = (310 - secondsAgo) - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(interval) - ) + interval = 310 - secondsAgo TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } + + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = max(interval * 3, 60) + } + + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(interval) + ) } evaluateNotLooping() diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift new file mode 100644 index 000000000..054491c68 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift @@ -0,0 +1,54 @@ +// LoopFollow +// NightscoutSocketDataHandler.swift + +import Foundation + +extension MainViewController { + func setupNightscoutSocket() { + NightscoutSocketManager.shared.onDataUpdate = { [weak self] data in + self?.handleSocketDataUpdate(data) + } + NightscoutSocketManager.shared.connectIfNeeded() + } + + func handleSocketDataUpdate(_ data: [String: Any]) { + let isDelta = data["delta"] as? Bool ?? false + + if !isDelta { + // Full data on initial connect — trigger all fetches + LogManager.shared.log(category: .websocket, message: "Full data received, triggering all fetches") + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date()) + return + } + + // Selective: only fetch data types present in the delta + var triggered: [String] = [] + + if data["sgvs"] != nil || data["mbgs"] != nil { + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + triggered.append("BG") + } + + if data["devicestatus"] != nil { + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + triggered.append("DeviceStatus") + } + + if data["treatments"] != nil { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + triggered.append("Treatments") + } + + if data["profiles"] != nil { + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date()) + triggered.append("Profile") + } + + if !triggered.isEmpty { + LogManager.shared.log(category: .websocket, message: "Delta triggered: \(triggered.joined(separator: ", "))", isDebug: true) + } + } +} diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift new file mode 100644 index 000000000..fc25404c1 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift @@ -0,0 +1,204 @@ +// LoopFollow +// NightscoutSocketManager.swift + +import Foundation +import SocketIO + +class NightscoutSocketManager { + static let shared = NightscoutSocketManager() + + enum ConnectionState: String { + case disconnected + case connecting + case connected + case authenticated + case error + } + + private(set) var connectionState: ConnectionState = .disconnected { + didSet { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .nightscoutSocketStateChanged, object: nil) + } + } + } + + private(set) var lastDataReceived: Date? + + private var manager: SocketManager? + private var socket: SocketIOClient? + private var stalenessTimer: Timer? + private var currentURL: String = "" + private var currentToken: String = "" + + var onDataUpdate: (([String: Any]) -> Void)? + + private init() {} + + // MARK: - Public API + + func connectIfNeeded() { + let url = Storage.shared.url.value + let token = Storage.shared.token.value + + guard !url.isEmpty else { + disconnect() + return + } + + // Already connected to the same URL + if connectionState == .authenticated || connectionState == .connecting || connectionState == .connected { + if url == currentURL, token == currentToken { + return + } + // URL or token changed, reconnect + disconnect() + } + + currentURL = url + currentToken = token + connect() + } + + func reconnectIfNeeded() { + guard !currentURL.isEmpty else { return } + + if connectionState == .disconnected || connectionState == .error { + connect() + } + } + + func disconnect() { + stalenessTimer?.invalidate() + stalenessTimer = nil + socket?.disconnect() + socket?.removeAllHandlers() + manager?.disconnect() + manager = nil + socket = nil + connectionState = .disconnected + currentURL = "" + currentToken = "" + } + + // MARK: - Private + + private func connect() { + guard let url = URL(string: currentURL) else { + LogManager.shared.log(category: .websocket, message: "Invalid Nightscout URL for WebSocket") + connectionState = .error + return + } + + connectionState = .connecting + + var config: SocketIOClientConfiguration = [ + .log(false), + .compress, + .forceWebsockets(false), + .reconnects(true), + .reconnectWait(5), + .reconnectWaitMax(30), + ] + + if !currentToken.isEmpty { + config.insert(.connectParams(["token": currentToken])) + } + + manager = SocketManager(socketURL: url, config: config) + + guard let mgr = manager else { return } + socket = mgr.defaultSocket + + setupEventHandlers() + socket?.connect() + + LogManager.shared.log(category: .websocket, message: "Connecting to Nightscout WebSocket at \(currentURL)") + } + + private func setupEventHandlers() { + guard let socket = socket else { return } + + socket.on(clientEvent: .connect) { [weak self] _, _ in + guard let self = self else { return } + LogManager.shared.log(category: .websocket, message: "Socket connected, authorizing...") + self.connectionState = .connected + self.authorize() + } + + socket.on(clientEvent: .disconnect) { [weak self] data, _ in + guard let self = self else { return } + let reason = (data.first as? String) ?? "unknown" + LogManager.shared.log(category: .websocket, message: "Socket disconnected: \(reason)") + self.connectionState = .disconnected + self.stalenessTimer?.invalidate() + self.stalenessTimer = nil + // Immediately restore normal polling + TaskScheduler.shared.checkTasksNow() + } + + socket.on(clientEvent: .reconnect) { _, _ in + LogManager.shared.log(category: .websocket, message: "Socket reconnecting...") + } + + socket.on(clientEvent: .error) { [weak self] data, _ in + let errorMsg = (data.first as? String) ?? "unknown error" + LogManager.shared.log(category: .websocket, message: "Socket error: \(errorMsg)") + self?.connectionState = .error + } + + socket.on("connected") { [weak self] _, _ in + guard let self = self else { return } + LogManager.shared.log(category: .websocket, message: "Authorized and receiving data") + self.connectionState = .authenticated + self.startStalenessTimer() + } + + socket.on("dataUpdate") { [weak self] data, _ in + guard let self = self, + let payload = data.first as? [String: Any] + else { return } + + self.lastDataReceived = Date() + LogManager.shared.log(category: .websocket, message: "Received dataUpdate (delta: \(payload["delta"] as? Bool ?? false))", isDebug: true) + + DispatchQueue.main.async { + self.onDataUpdate?(payload) + } + } + } + + private func authorize() { + var authPayload: [String: Any] = [ + "client": "LoopFollow", + "history": 1, + ] + + // Nightscout's authorization.resolve() expects: + // - "token" field for JWT tokens (verified via verifyJWT) + // - "secret" field for access tokens (checked via doesAccessTokenExist) + // LoopFollow uses access tokens (e.g. "readable-xxxx"), so pass as "secret". + if !currentToken.isEmpty { + authPayload["secret"] = currentToken + } + + socket?.emit("authorize", authPayload) + } + + private func startStalenessTimer() { + stalenessTimer?.invalidate() + stalenessTimer = Timer.scheduledTimer(withTimeInterval: 600, repeats: true) { [weak self] _ in + guard let self = self else { return } + if let lastData = self.lastDataReceived, + Date().timeIntervalSince(lastData) > 600 + { + LogManager.shared.log(category: .websocket, message: "No data received in 10 minutes, triggering poll fallback") + TaskScheduler.shared.checkTasksNow() + } + } + } +} + +extension Notification.Name { + static let nightscoutSocketStateChanged = Notification.Name("nightscoutSocketStateChanged") +} diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 22403cd0f..c31747edf 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -29,6 +29,7 @@ class LogManager { case calendar = "Calendar" case deviceStatus = "Device Status" case remote = "Remote" + case websocket = "WebSocket" } init() { diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index 7c08e756f..b704608d2 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -12,6 +12,7 @@ struct NightscoutSettingsView: View { urlSection tokenSection statusSection + webSocketSection importSection } .onDisappear { @@ -56,6 +57,17 @@ struct NightscoutSettingsView: View { } } + private var webSocketSection: some View { + Section(header: Text("Real-time Updates")) { + HStack { + Text("WebSocket") + Spacer() + Text(viewModel.webSocketStatus) + .foregroundColor(viewModel.webSocketStatusColor) + } + } + } + private var importSection: some View { Section(header: Text("Import Settings")) { NavigationLink(destination: ImportExportSettingsView()) { diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 299a73a7d..1f758901c 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -3,6 +3,7 @@ import Combine import Foundation +import SwiftUI protocol NightscoutSettingsViewModelDelegate: AnyObject { func nightscoutSettingsDidFinish() @@ -33,6 +34,16 @@ class NightscoutSettingsViewModel: ObservableObject { } @Published var nightscoutStatus: String = "Checking..." + @Published var webSocketStatus: String = "Disconnected" + + var webSocketStatusColor: Color { + switch NightscoutSocketManager.shared.connectionState { + case .authenticated: return .green + case .connecting, .connected: return .orange + case .disconnected: return .secondary + case .error: return .red + } + } private var cancellables = Set() private var checkStatusSubject = PassthroughSubject() @@ -44,6 +55,7 @@ class NightscoutSettingsViewModel: ObservableObject { setupDebounce() checkNightscoutStatus() + observeWebSocketState() } private func setupDebounce() { @@ -142,4 +154,24 @@ class NightscoutSettingsViewModel: ObservableObject { func dismiss() { delegate?.nightscoutSettingsDidFinish() } + + private func observeWebSocketState() { + updateWebSocketStatus() + NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateWebSocketStatus() + } + .store(in: &cancellables) + } + + private func updateWebSocketStatus() { + switch NightscoutSocketManager.shared.connectionState { + case .disconnected: webSocketStatus = "Disconnected" + case .connecting: webSocketStatus = "Connecting..." + case .connected: webSocketStatus = "Connected" + case .authenticated: webSocketStatus = "Connected" + case .error: webSocketStatus = "Error" + } + } } diff --git a/LoopFollow/Task/ProfileTask.swift b/LoopFollow/Task/ProfileTask.swift index 72de336fb..6b4358ca7 100644 --- a/LoopFollow/Task/ProfileTask.swift +++ b/LoopFollow/Task/ProfileTask.swift @@ -21,6 +21,10 @@ extension MainViewController { webLoadNSProfile() - TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(10 * 60)) + var interval: TimeInterval = 10 * 60 + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = 30 * 60 + } + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(interval)) } } diff --git a/LoopFollow/Task/TreatmentsTask.swift b/LoopFollow/Task/TreatmentsTask.swift index d3a0c620e..4e7aa0bc9 100644 --- a/LoopFollow/Task/TreatmentsTask.swift +++ b/LoopFollow/Task/TreatmentsTask.swift @@ -21,7 +21,11 @@ extension MainViewController { WebLoadNSTreatments() - TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(2 * 60)) + var interval: TimeInterval = 2 * 60 + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = 10 * 60 + } + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(interval)) TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ac1f19a24..1b733ae30 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -206,6 +206,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele showHideNSDetails() scheduleAllTasks() + setupNightscoutSocket() // Set up refreshScrollView for BGText refreshScrollView = UIScrollView() @@ -882,6 +883,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele MinAgoText.text = "Refreshing" Observable.shared.minAgoText.value = "Refreshing" scheduleAllTasks() + NightscoutSocketManager.shared.connectIfNeeded() currentCage = nil currentSage = nil @@ -1081,6 +1083,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } TaskScheduler.shared.checkTasksNow() + NightscoutSocketManager.shared.reconnectIfNeeded() checkAndNotifyVersionStatus() checkAppExpirationStatus() From 99d82c08b6d59a11db8f6493f50b7e84a6d9691e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 13 Apr 2026 18:27:36 +0200 Subject: [PATCH 2/3] Add opt-in WebSocket toggle with info sheet and refresh on disconnect - Add webSocketEnabled storage property (default off) so users opt in - Replace read-only status with toggle + info button in Nightscout settings - Info sheet explains real-time updates, polling fallback, and battery impact - On toggle off: disconnect socket and trigger full refresh to restore normal polling intervals immediately - On unexpected socket disconnect: post refresh notification so extended polling intervals revert to normal without waiting for them to expire --- .../Nightscout/NightscoutSocketManager.swift | 11 ++-- .../Nightscout/NightscoutSettingsView.swift | 54 ++++++++++++++++--- .../NightscoutSettingsViewModel.swift | 17 ++++++ LoopFollow/Storage/Storage.swift | 1 + 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift index fc25404c1..c41ee80c9 100644 --- a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift @@ -38,6 +38,11 @@ class NightscoutSocketManager { // MARK: - Public API func connectIfNeeded() { + guard Storage.shared.webSocketEnabled.value else { + disconnect() + return + } + let url = Storage.shared.url.value let token = Storage.shared.token.value @@ -61,7 +66,7 @@ class NightscoutSocketManager { } func reconnectIfNeeded() { - guard !currentURL.isEmpty else { return } + guard Storage.shared.webSocketEnabled.value, !currentURL.isEmpty else { return } if connectionState == .disconnected || connectionState == .error { connect() @@ -133,8 +138,8 @@ class NightscoutSocketManager { self.connectionState = .disconnected self.stalenessTimer?.invalidate() self.stalenessTimer = nil - // Immediately restore normal polling - TaskScheduler.shared.checkTasksNow() + // Immediately restore normal polling intervals + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) } socket.on(clientEvent: .reconnect) { _, _ in diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index b704608d2..187bd8176 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -57,14 +57,56 @@ struct NightscoutSettingsView: View { } } + @State private var showWebSocketInfo = false + private var webSocketSection: some View { - Section(header: Text("Real-time Updates")) { - HStack { - Text("WebSocket") - Spacer() - Text(viewModel.webSocketStatus) - .foregroundColor(viewModel.webSocketStatusColor) + Section(header: webSocketSectionHeader) { + Toggle("Enable WebSocket", isOn: $viewModel.webSocketEnabled) + if viewModel.webSocketEnabled { + HStack { + Text("Status") + Spacer() + Text(viewModel.webSocketStatus) + .foregroundColor(viewModel.webSocketStatusColor) + } + } + } + .sheet(isPresented: $showWebSocketInfo) { + NavigationStack { + ScrollView { + Text(""" + When enabled, LoopFollow maintains a live connection to your Nightscout server using WebSocket. This allows data updates (new glucose readings, treatments, device status) to arrive within seconds instead of waiting for the next polling cycle. + + Polling continues at reduced frequency as a safety net. If the WebSocket connection drops, normal polling resumes immediately. + + This feature may affect battery usage. On WiFi, impact is minimal. On cellular, the persistent connection may prevent the radio from entering idle mode. + """) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Real-time Updates") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showWebSocketInfo = false } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } + + private var webSocketSectionHeader: some View { + HStack(spacing: 4) { + Text("Real-time Updates") + Button { + showWebSocketInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) } + .buttonStyle(.plain) } } diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 1f758901c..e17eb672c 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -34,6 +34,19 @@ class NightscoutSettingsViewModel: ObservableObject { } @Published var nightscoutStatus: String = "Checking..." + + @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value { + didSet { + Storage.shared.webSocketEnabled.value = webSocketEnabled + if webSocketEnabled { + NightscoutSocketManager.shared.connectIfNeeded() + } else { + NightscoutSocketManager.shared.disconnect() + triggerRefresh() + } + } + } + @Published var webSocketStatus: String = "Disconnected" var webSocketStatusColor: Color { @@ -155,6 +168,10 @@ class NightscoutSettingsViewModel: ObservableObject { delegate?.nightscoutSettingsDidFinish() } + private func triggerRefresh() { + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + private func observeWebSocketState() { updateWebSocketStatus() NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 11997da35..24dc245df 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -192,6 +192,7 @@ class Storage { var device = StorageValue(key: "device", defaultValue: "") var nsWriteAuth = StorageValue(key: "nsWriteAuth", defaultValue: false) var nsAdminAuth = StorageValue(key: "nsAdminAuth", defaultValue: false) + var webSocketEnabled = StorageValue(key: "webSocketEnabled", defaultValue: false) var migrationStep = StorageValue(key: "migrationStep", defaultValue: 0) From 9a1abc3f073150b0976966cdef43b5abae120e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 14 Apr 2026 19:35:59 +0200 Subject: [PATCH 3/3] Fix stale WebSocket session on config change, remove redundant staleness timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Disconnect WebSocket when Nightscout URL/token validation fails, preventing the old session from streaming data from a previous server while polling has switched to the new config - Reorder removeAllHandlers() before disconnect() so intentional disconnects don't fire the event handler that would reconnect to an invalid URL - Remove staleness timer — the extended polling intervals (3x/5x) already serve as the safety net when WebSocket data stops flowing --- .../Nightscout/NightscoutSocketManager.swift | 24 +------------------ .../NightscoutSettingsViewModel.swift | 1 + 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift index c41ee80c9..2831d7ef3 100644 --- a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift @@ -23,11 +23,8 @@ class NightscoutSocketManager { } } - private(set) var lastDataReceived: Date? - private var manager: SocketManager? private var socket: SocketIOClient? - private var stalenessTimer: Timer? private var currentURL: String = "" private var currentToken: String = "" @@ -74,10 +71,8 @@ class NightscoutSocketManager { } func disconnect() { - stalenessTimer?.invalidate() - stalenessTimer = nil - socket?.disconnect() socket?.removeAllHandlers() + socket?.disconnect() manager?.disconnect() manager = nil socket = nil @@ -136,8 +131,6 @@ class NightscoutSocketManager { let reason = (data.first as? String) ?? "unknown" LogManager.shared.log(category: .websocket, message: "Socket disconnected: \(reason)") self.connectionState = .disconnected - self.stalenessTimer?.invalidate() - self.stalenessTimer = nil // Immediately restore normal polling intervals NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) } @@ -156,7 +149,6 @@ class NightscoutSocketManager { guard let self = self else { return } LogManager.shared.log(category: .websocket, message: "Authorized and receiving data") self.connectionState = .authenticated - self.startStalenessTimer() } socket.on("dataUpdate") { [weak self] data, _ in @@ -164,7 +156,6 @@ class NightscoutSocketManager { let payload = data.first as? [String: Any] else { return } - self.lastDataReceived = Date() LogManager.shared.log(category: .websocket, message: "Received dataUpdate (delta: \(payload["delta"] as? Bool ?? false))", isDebug: true) DispatchQueue.main.async { @@ -189,19 +180,6 @@ class NightscoutSocketManager { socket?.emit("authorize", authPayload) } - - private func startStalenessTimer() { - stalenessTimer?.invalidate() - stalenessTimer = Timer.scheduledTimer(withTimeInterval: 600, repeats: true) { [weak self] _ in - guard let self = self else { return } - if let lastData = self.lastDataReceived, - Date().timeIntervalSince(lastData) > 600 - { - LogManager.shared.log(category: .websocket, message: "No data received in 10 minutes, triggering poll fallback") - TaskScheduler.shared.checkTasksNow() - } - } - } } extension Notification.Name { diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index e17eb672c..07cdccf7f 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -148,6 +148,7 @@ class NightscoutSettingsViewModel: ObservableObject { case .emptyAddress: nightscoutStatus = "Address Empty" } + NightscoutSocketManager.shared.disconnect() } else { let authStatus: String if Storage.shared.nsAdminAuth.value {