Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -574,6 +577,8 @@
DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = "<group>"; };
DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = "<group>"; };
DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = "<group>"; };
DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketManager.swift; sourceTree = "<group>"; };
DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketDataHandler.swift; sourceTree = "<group>"; };
DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = "<group>"; };
DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -911,6 +916,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DD50C10A2F60A00000000001 /* SocketIO in Frameworks */,
FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */,
3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */,
);
Expand Down Expand Up @@ -1155,6 +1161,8 @@
DD0C0C5F2C415B9D00DBADDF /* ProfileManager.swift */,
DD0C0C612C4175FD00DBADDF /* NSProfile.swift */,
DD5334222C60ED3600062F9D /* IAge.swift */,
DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */,
DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */,
);
path = Nightscout;
sourceTree = "<group>";
Expand Down Expand Up @@ -1783,6 +1791,7 @@
);
name = LoopFollow;
packageProductDependencies = (
DD50C10A2F60A00000000003 /* SocketIO */,
);
productName = LoopFollow;
productReference = FC9788142485969B00A7906C /* Loop Follow.app */;
Expand Down Expand Up @@ -1820,6 +1829,7 @@
);
mainGroup = FC97880B2485969B00A7906C;
packageReferences = (
DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */,
);
productRefGroup = FC9788152485969B00A7906C /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */;
}
4 changes: 4 additions & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 15 additions & 24 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
187 changes: 187 additions & 0 deletions LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// 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 var manager: SocketManager?
private var socket: SocketIOClient?
private var currentURL: String = ""
private var currentToken: String = ""

var onDataUpdate: (([String: Any]) -> Void)?

private init() {}

// 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

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 Storage.shared.webSocketEnabled.value, !currentURL.isEmpty else { return }

if connectionState == .disconnected || connectionState == .error {
connect()
}
}

func disconnect() {
socket?.removeAllHandlers()
socket?.disconnect()
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
// Immediately restore normal polling intervals
NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
}

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
}

socket.on("dataUpdate") { [weak self] data, _ in
guard let self = self,
let payload = data.first as? [String: Any]
else { return }

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)
}
}

extension Notification.Name {
static let nightscoutSocketStateChanged = Notification.Name("nightscoutSocketStateChanged")
}
Loading