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
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -563,6 +564,7 @@
DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = "<group>"; };
AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = "<group>"; };
DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlViewModel.swift; sourceTree = "<group>"; };
DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlView.swift; sourceTree = "<group>"; };
DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1088,6 +1090,7 @@
656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */,
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */,
DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */,
AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */,
);
path = Settings;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Controllers/Nightscout/NSProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,5 +98,6 @@ struct NSProfile: Decodable {
case loopSettings
case teamID
case expirationDate
case startDate
}
}
14 changes: 12 additions & 2 deletions LoopFollow/Controllers/Nightscout/Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ import Foundation
extension MainViewController {
// NS Profile Web Call
func webLoadNSProfile() {
NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result<NSProfile, Error>) 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)")
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Helpers/NightscoutUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions LoopFollow/Remote/Settings/RemoteDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
97 changes: 97 additions & 0 deletions LoopFollow/Remote/Settings/RemoteSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}
88 changes: 88 additions & 0 deletions LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
15 changes: 13 additions & 2 deletions LoopFollow/Stats/StatsDataFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,20 @@ class StatsDataFetcher {
return
}

NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result<NSProfile, Error>) 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]
Expand Down
Loading