Skip to content

Commit ed6ec77

Browse files
bjorkertmarionbarker
authored andcommitted
Add Nightscout WebSocket support for real-time data updates (#606)
* Add Nightscout WebSocket (Socket.IO) support for real-time data updates 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) * 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 * Fix stale WebSocket session on config change, remove redundant staleness timer - 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 * Limit WebSocket to foreground and default-on Disconnect the Nightscout WebSocket when LoopFollow moves to the background and re-establish it when returning to the foreground. Polling continues to handle background updates, so the persistent connection no longer holds the cellular radio out of idle while the app is not in use. With the battery cost bounded to foreground time, the toggle now defaults to on. Inline documentation updated to describe the foreground/background behavior. * Resume Nightscout polling promptly when the WebSocket disconnects While the socket is authenticated, each REST poll is rescheduled with a multiplier on the assumption WS will publish the next reading before the poll fires. Without an explicit catch-up, those long delays carry over when the socket goes away — most visibly on background entry, where the user could sit on stale data for 10–15 minutes after the screen locked. Fire each Nightscout poll immediately on disconnect. Their actions then reschedule on the normal un-multiplied cadence, since connectionState is no longer .authenticated. Gate on the previous state so the reconnect dance in connectIfNeeded() and other no-op disconnect paths don't trigger spurious REST round-trips.
1 parent de98f2e commit ed6ec77

12 files changed

Lines changed: 423 additions & 26 deletions

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,9 @@
440440
FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; };
441441
FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; };
442442
FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; };
443+
DD50C10A2F60A00000000001 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = DD50C10A2F60A00000000003 /* SocketIO */; };
444+
DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */; };
445+
DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */; };
443446
/* End PBXBuildFile section */
444447

445448
/* Begin PBXContainerItemProxy section */
@@ -604,6 +607,8 @@
604607
DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = "<group>"; };
605608
DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = "<group>"; };
606609
DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = "<group>"; };
610+
DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketManager.swift; sourceTree = "<group>"; };
611+
DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketDataHandler.swift; sourceTree = "<group>"; };
607612
DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
608613
DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = "<group>"; };
609614
DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = "<group>"; };
@@ -941,6 +946,7 @@
941946
isa = PBXFrameworksBuildPhase;
942947
buildActionMask = 2147483647;
943948
files = (
949+
DD50C10A2F60A00000000001 /* SocketIO in Frameworks */,
944950
FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */,
945951
3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */,
946952
);
@@ -1209,6 +1215,8 @@
12091215
DD0C0C5F2C415B9D00DBADDF /* ProfileManager.swift */,
12101216
DD0C0C612C4175FD00DBADDF /* NSProfile.swift */,
12111217
DD5334222C60ED3600062F9D /* IAge.swift */,
1218+
DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */,
1219+
DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */,
12121220
);
12131221
path = Nightscout;
12141222
sourceTree = "<group>";
@@ -1844,6 +1852,7 @@
18441852
);
18451853
name = LoopFollow;
18461854
packageProductDependencies = (
1855+
DD50C10A2F60A00000000003 /* SocketIO */,
18471856
);
18481857
productName = LoopFollow;
18491858
productReference = FC9788142485969B00A7906C /* Loop Follow.app */;
@@ -1881,6 +1890,7 @@
18811890
);
18821891
mainGroup = FC97880B2485969B00A7906C;
18831892
packageReferences = (
1893+
DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */,
18841894
);
18851895
productRefGroup = FC9788152485969B00A7906C /* Products */;
18861896
projectDirPath = "";
@@ -2255,6 +2265,8 @@
22552265
DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */,
22562266
374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */,
22572267
DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
2268+
DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */,
2269+
DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */,
22582270
DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
22592271
DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */,
22602272
65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */,
@@ -2870,6 +2882,25 @@
28702882
versionGroupType = wrapper.xcdatamodel;
28712883
};
28722884
/* End XCVersionGroup section */
2885+
2886+
/* Begin XCRemoteSwiftPackageReference section */
2887+
DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */ = {
2888+
isa = XCRemoteSwiftPackageReference;
2889+
repositoryURL = "https://github.com/socketio/socket.io-client-swift";
2890+
requirement = {
2891+
kind = upToNextMajorVersion;
2892+
minimumVersion = 16.1.1;
2893+
};
2894+
};
2895+
/* End XCRemoteSwiftPackageReference section */
2896+
2897+
/* Begin XCSwiftPackageProductDependency section */
2898+
DD50C10A2F60A00000000003 /* SocketIO */ = {
2899+
isa = XCSwiftPackageProductDependency;
2900+
package = DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */;
2901+
productName = SocketIO;
2902+
};
2903+
/* End XCSwiftPackageProductDependency section */
28732904
};
28742905
rootObject = FC97880C2485969B00A7906C /* Project object */;
28752906
}

LoopFollow/Controllers/Nightscout/BGData.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ extension MainViewController {
186186
TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3))
187187
}
188188

189+
if NightscoutSocketManager.shared.connectionState == .authenticated {
190+
delayToSchedule = max(delayToSchedule * 3, 60)
191+
}
192+
189193
TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date().addingTimeInterval(delayToSchedule))
190194

191195
// Evaluate speak conditions if there is a previous value.

LoopFollow/Controllers/Nightscout/DeviceStatus.swift

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -193,37 +193,28 @@ extension MainViewController {
193193
let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0)
194194

195195
DispatchQueue.main.async {
196+
var interval: Double
196197
if secondsAgo >= (20 * 60) {
197-
TaskScheduler.shared.rescheduleTask(
198-
id: .deviceStatus,
199-
to: Date().addingTimeInterval(5 * 60)
200-
)
201-
198+
interval = 5 * 60
202199
} else if secondsAgo >= (10 * 60) {
203-
TaskScheduler.shared.rescheduleTask(
204-
id: .deviceStatus,
205-
to: Date().addingTimeInterval(60)
206-
)
207-
200+
interval = 60
208201
} else if secondsAgo >= (7 * 60) {
209-
TaskScheduler.shared.rescheduleTask(
210-
id: .deviceStatus,
211-
to: Date().addingTimeInterval(30)
212-
)
213-
202+
interval = 30
214203
} else if secondsAgo >= (5 * 60) {
215-
TaskScheduler.shared.rescheduleTask(
216-
id: .deviceStatus,
217-
to: Date().addingTimeInterval(10)
218-
)
204+
interval = 10
219205
} else {
220-
let interval = (310 - secondsAgo)
221-
TaskScheduler.shared.rescheduleTask(
222-
id: .deviceStatus,
223-
to: Date().addingTimeInterval(interval)
224-
)
206+
interval = 310 - secondsAgo
225207
TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3))
226208
}
209+
210+
if NightscoutSocketManager.shared.connectionState == .authenticated {
211+
interval = max(interval * 3, 60)
212+
}
213+
214+
TaskScheduler.shared.rescheduleTask(
215+
id: .deviceStatus,
216+
to: Date().addingTimeInterval(interval)
217+
)
227218
}
228219

229220
evaluateNotLooping()
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// LoopFollow
2+
// NightscoutSocketDataHandler.swift
3+
4+
import Foundation
5+
6+
extension MainViewController {
7+
func setupNightscoutSocket() {
8+
NightscoutSocketManager.shared.onDataUpdate = { [weak self] data in
9+
self?.handleSocketDataUpdate(data)
10+
}
11+
NightscoutSocketManager.shared.connectIfNeeded()
12+
}
13+
14+
func handleSocketDataUpdate(_ data: [String: Any]) {
15+
let isDelta = data["delta"] as? Bool ?? false
16+
17+
if !isDelta {
18+
// Full data on initial connect — trigger all fetches
19+
LogManager.shared.log(category: .websocket, message: "Full data received, triggering all fetches")
20+
TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date())
21+
TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date())
22+
TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date())
23+
TaskScheduler.shared.rescheduleTask(id: .profile, to: Date())
24+
return
25+
}
26+
27+
// Selective: only fetch data types present in the delta
28+
var triggered: [String] = []
29+
30+
if data["sgvs"] != nil || data["mbgs"] != nil {
31+
TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date())
32+
triggered.append("BG")
33+
}
34+
35+
if data["devicestatus"] != nil {
36+
TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date())
37+
triggered.append("DeviceStatus")
38+
}
39+
40+
if data["treatments"] != nil {
41+
TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date())
42+
triggered.append("Treatments")
43+
}
44+
45+
if data["profiles"] != nil {
46+
TaskScheduler.shared.rescheduleTask(id: .profile, to: Date())
47+
triggered.append("Profile")
48+
}
49+
50+
if !triggered.isEmpty {
51+
LogManager.shared.log(category: .websocket, message: "Delta triggered: \(triggered.joined(separator: ", "))", isDebug: true)
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)