diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index cbdbb562b..b53038953 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; }; + AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; }; DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */; }; DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */; }; DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878122C7B750D0048F05C /* TempTargetView.swift */; }; @@ -563,6 +564,7 @@ DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = ""; }; DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = ""; }; + AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = ""; }; DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlViewModel.swift; sourceTree = ""; }; DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlView.swift; sourceTree = ""; }; DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = ""; }; @@ -1088,6 +1090,7 @@ 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */, DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */, DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */, + AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */, ); path = Settings; sourceTree = ""; @@ -2220,6 +2223,7 @@ DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */, DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */, DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */, + AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */, FCFEECA02488157B00402A7F /* Chart.swift in Sources */, DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */, DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */, diff --git a/LoopFollow/Controllers/Nightscout/NSProfile.swift b/LoopFollow/Controllers/Nightscout/NSProfile.swift index eadea9d4a..b916e88f8 100644 --- a/LoopFollow/Controllers/Nightscout/NSProfile.swift +++ b/LoopFollow/Controllers/Nightscout/NSProfile.swift @@ -48,6 +48,7 @@ struct NSProfile: Decodable { let deviceToken: String? let teamID: String? let expirationDate: String? + let startDate: String? struct TrioOverrideEntry: Decodable { let name: String @@ -97,5 +98,6 @@ struct NSProfile: Decodable { case loopSettings case teamID case expirationDate + case startDate } } diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index f76c74a4c..d88c453fa 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -6,9 +6,19 @@ import Foundation extension MainViewController { // NS Profile Web Call func webLoadNSProfile() { - NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result) in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let parameters: [String: String] = [ + "count": "1", + "find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60)), + ] + NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in switch result { - case let .success(profileData): + case let .success(profiles): + guard let profileData = profiles.first else { + LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, no profile records returned") + return + } self.updateProfile(profileData: profileData) case let .failure(error): LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, error fetching profile data: \(error.localizedDescription)") diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 34f8bcb08..04c5ff14b 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -52,7 +52,7 @@ class NightscoutUtils { case .sgv: return "/api/v1/entries.json" case .profile: - return "/api/v1/profile/current.json" + return "/api/v1/profiles.json" case .deviceStatus: return "/api/v1/devicestatus.json" case .temporaryOverride, .temporaryOverrideCancel: diff --git a/LoopFollow/Remote/Settings/RemoteDiagnostics.swift b/LoopFollow/Remote/Settings/RemoteDiagnostics.swift new file mode 100644 index 000000000..9573f0774 --- /dev/null +++ b/LoopFollow/Remote/Settings/RemoteDiagnostics.swift @@ -0,0 +1,36 @@ +// LoopFollow +// RemoteDiagnostics.swift + +import Foundation + +struct RemoteDiagnostics { + enum Status: Equatable { + case unknown + case running + case ok + case failed(String) + } + + var status: Status = .unknown + var bundleMismatch: BundleMismatch? + var bouncingTokens: BouncingTokens? + var futureStartDate: FutureStartDate? + + var hasAnyWarning: Bool { + bundleMismatch != nil || bouncingTokens != nil || futureStartDate != nil + } + + struct BundleMismatch: Equatable { + let expectedDevice: String + let observedBundleId: String + } + + struct BouncingTokens: Equatable { + let distinctCount: Int + let recordsScanned: Int + } + + struct FutureStartDate: Equatable { + let startDate: Date + } +} diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 532061013..7af9b5756 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -29,7 +29,20 @@ struct RemoteSettingsView: View { self.viewModel = viewModel } + private let diagnosticsAnchorID = "remoteDiagnostics" + var body: some View { + ScrollViewReader { proxy in + formContent + .onChange(of: viewModel.diagnostics.status) { _ in + withAnimation { + proxy.scrollTo(diagnosticsAnchorID, anchor: .top) + } + } + } + } + + private var formContent: some View { Form { // MARK: - Remote Type Section (Custom Rows) @@ -175,6 +188,8 @@ struct RemoteSettingsView: View { if Storage.shared.bolusIncrementDetected.value { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } + diagnosticsRows + .id(diagnosticsAnchorID) } } @@ -277,6 +292,8 @@ struct RemoteSettingsView: View { if Storage.shared.bolusIncrementDetected.value { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } + diagnosticsRows + .id(diagnosticsAnchorID) } } } @@ -465,4 +482,84 @@ struct RemoteSettingsView: View { } } } + + // MARK: - Diagnostics + + @ViewBuilder + private var diagnosticsRows: some View { + switch viewModel.diagnostics.status { + case .running: + HStack { + ProgressView() + Text("Checking Nightscout profile history…") + .foregroundColor(.secondary) + } + case .unknown: + Button(action: { viewModel.runDiagnostics() }) { + HStack { + Image(systemName: "stethoscope") + Text("Run diagnostics") + } + } + case let .failed(message): + Button(action: { viewModel.runDiagnostics() }) { + HStack { + Image(systemName: "stethoscope") + Text("Run diagnostics again") + } + } + Text("Diagnostics unavailable: \(message)") + .font(.footnote) + .foregroundColor(.secondary) + case .ok: + Button(action: { viewModel.runDiagnostics() }) { + HStack { + Image(systemName: "stethoscope") + Text("Run diagnostics again") + } + } + if let mismatch = viewModel.diagnostics.bundleMismatch { + diagnosticWarning( + title: "Profile uploaded by a different app", + detail: "The current Nightscout profile was uploaded by \(mismatch.observedBundleId), but you're configured for \(mismatch.expectedDevice). When Loop and Trio share a Nightscout, they overwrite each other's profile." + ) + } + if let bouncing = viewModel.diagnostics.bouncingTokens { + diagnosticWarning( + title: "Multiple devices uploading profiles", + detail: "Device tokens are alternating in the last 14 days of profile uploads (\(bouncing.distinctCount) tokens involved across \(bouncing.recordsScanned) records). This usually means more than one app installation is uploading to the same Nightscout. Remove the app from spare or unused phones." + ) + } + if let future = viewModel.diagnostics.futureStartDate { + diagnosticWarning( + title: "Future-dated profile record found", + detail: "A profile record has startDate \(dateTimeUtils.formattedDate(from: future.startDate)). LoopFollow ignores future-dated records, but it will still appear as the current profile in your Nightscout dashboard. Consider deleting it — it usually means a phone with the wrong system clock is uploading." + ) + } + if !viewModel.diagnostics.hasAnyWarning { + HStack { + Image(systemName: "checkmark.seal") + .foregroundColor(.green) + Text("No issues detected") + .foregroundColor(.secondary) + } + } + } + } + + private func diagnosticWarning(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text(title) + .fontWeight(.semibold) + .foregroundColor(.orange) + } + Text(detail) + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index c05f041a2..bdbdccaf6 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -37,6 +37,13 @@ class RemoteSettingsViewModel: ObservableObject { @Published var shouldPromptForURL: Bool = false @Published var shouldPromptForToken: Bool = false + // MARK: - Diagnostics + + @Published var diagnostics = RemoteDiagnostics() + private let diagnosticsHistoryDays = 14 + private let diagnosticsHistoryCap = 1000 + private let futureStartDateTolerance: TimeInterval = 60 + let loopFollowTeamId: String = BuildDetails.default.teamID ?? "Unknown" /// Determines if the target app's Team ID is different from this app's build Team ID. @@ -233,4 +240,85 @@ class RemoteSettingsViewModel: ObservableObject { isTrioDevice = (storage.device.value == "Trio") isLoopDevice = (storage.device.value == "Loop") } + + // MARK: - Diagnostics + + func runDiagnostics() { + diagnostics = RemoteDiagnostics(status: .running) + + guard !storage.url.value.isEmpty else { + diagnostics = RemoteDiagnostics(status: .ok) + return + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let since = Date().addingTimeInterval(-Double(diagnosticsHistoryDays) * 86400) + let parameters: [String: String] = [ + "count": "\(diagnosticsHistoryCap)", + "find[startDate][$gte]": formatter.string(from: since), + ] + NightscoutUtils.executeRequest( + eventType: .profile, + parameters: parameters + ) { [weak self] (result: Result<[NSProfile], Error>) in + guard let self = self else { return } + switch result { + case let .success(history): + let evaluated = self.evaluateDiagnostics(history: history) + DispatchQueue.main.async { + self.diagnostics = evaluated + LogManager.shared.log( + category: .nightscout, + message: "Remote diagnostics evaluated: records=\(history.count) bundleMismatch=\(evaluated.bundleMismatch != nil) bouncingTokens=\(evaluated.bouncingTokens != nil) futureStartDate=\(evaluated.futureStartDate != nil)" + ) + } + case let .failure(error): + DispatchQueue.main.async { + self.diagnostics = RemoteDiagnostics(status: .failed(error.localizedDescription)) + } + } + } + } + + private func evaluateDiagnostics(history: [NSProfile]) -> RemoteDiagnostics { + var result = RemoteDiagnostics(status: .ok) + let device = storage.device.value + + if let current = history.first, !device.isEmpty { + let topLevel = current.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? "" + let nested = current.loopSettings?.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? "" + + if device == "Loop", nested.isEmpty, !topLevel.isEmpty { + result.bundleMismatch = .init(expectedDevice: "Loop", observedBundleId: topLevel) + } else if device == "Trio", topLevel.isEmpty, !nested.isEmpty { + result.bundleMismatch = .init(expectedDevice: "Trio", observedBundleId: nested) + } + } + + let chronological = history.sorted { lhs, rhs in + let l = lhs.startDate.flatMap(NightscoutUtils.parseDate) ?? .distantPast + let r = rhs.startDate.flatMap(NightscoutUtils.parseDate) ?? .distantPast + return l < r + } + var compressed: [String] = [] + for record in chronological { + guard let token = record.deviceToken ?? record.loopSettings?.deviceToken, + !token.isEmpty else { continue } + if compressed.last != token { + compressed.append(token) + } + } + let distinctTokens = Set(compressed) + if compressed.count > distinctTokens.count { + result.bouncingTokens = .init(distinctCount: distinctTokens.count, recordsScanned: history.count) + } + + let dates = history.compactMap { $0.startDate.flatMap(NightscoutUtils.parseDate) } + if let maxDate = dates.max(), maxDate > Date().addingTimeInterval(futureStartDateTolerance) { + result.futureStartDate = .init(startDate: maxDate) + } + + return result + } } diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index ff61d6eef..e6b49c45b 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -88,9 +88,20 @@ class StatsDataFetcher { return } - NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result) in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let parameters: [String: String] = [ + "count": "1", + "find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60)), + ] + NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in switch result { - case let .success(profileData): + case let .success(profiles): + guard let profileData = profiles.first else { + LogManager.shared.log(category: .nightscout, message: "ensureBasalProfileLoaded, no profile records returned") + DispatchQueue.main.async { completion() } + return + } let profileStore = profileData.store["default"] ?? profileData.store["Default"] ?? profileData.store[profileData.defaultProfile]