Kaynağa Gözat

Diagnose and skip rogue Nightscout profile records (#635)

* Diagnose and skip rogue Nightscout profile records

Profile fetch now uses /api/v1/profiles?count=1 with find[startDate][$lte]=now,
so future-dated records can no longer block the active profile. Adds a
"Run diagnostics" button in the Remote Settings Debug section that fetches
14 days of profile history and surfaces three failure modes:

- Bundle ID mismatch when Loop and Trio share a Nightscout
- Alternating device tokens from multiple installations
- Future-dated profile records left over from a wrong-clock uploader

The bouncing-tokens check compresses consecutive same-token runs and only
warns on actual token alternation, not normal token rotation.

* Widen bouncing-token check and surface shift history

Three changes to the profile diagnostics:

- Drop the 14-day find[startDate][$gte] filter. A slow A→B→A pattern
  spread across months only registers as one transition inside a 14-day
  window, so the bouncing-tokens check would silently miss it on servers
  that honor the filter. The existing 1000-record cap now defines the
  scope, which goes back as far as upload frequency allows.
- Fall back to created_at when sorting profile records, so uploaders
  that omit startDate don't cluster at .distantPast and corrupt the
  run-length compression.
- Include the chronological list of token shifts in the bouncing-tokens
  warning. Each row shows when the shift happened and the abbreviated
  from→to tokens, so users can see at a glance which devices are
  competing instead of just "3 tokens involved across N records".
Jonas Björkert 2 ay önce
ebeveyn
işleme
ce983deef5

+ 4 - 0
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 */; };
@@ -564,6 +565,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>"; };
@@ -1090,6 +1092,7 @@
 				656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */,
 				DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */,
 				DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */,
+				AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */,
 			);
 			path = Settings;
 			sourceTree = "<group>";
@@ -2223,6 +2226,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 */,

+ 4 - 0
LoopFollow/Controllers/Nightscout/NSProfile.swift

@@ -48,6 +48,8 @@ struct NSProfile: Decodable {
     let deviceToken: String?
     let teamID: String?
     let expirationDate: String?
+    let startDate: String?
+    let createdAt: String?
 
     struct TrioOverrideEntry: Decodable {
         let name: String
@@ -97,5 +99,7 @@ struct NSProfile: Decodable {
         case loopSettings
         case teamID
         case expirationDate
+        case startDate
+        case createdAt = "created_at"
     }
 }

+ 12 - 2
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<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)")

+ 1 - 1
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:

+ 44 - 0
LoopFollow/Remote/Settings/RemoteDiagnostics.swift

@@ -0,0 +1,44 @@
+// 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
+        let shifts: [TokenShift]
+    }
+
+    struct TokenShift: Equatable {
+        let when: Date
+        let fromToken: String
+        let toToken: String
+        let bundleIdentifier: String?
+    }
+
+    struct FutureStartDate: Equatable {
+        let startDate: Date
+    }
+}

+ 132 - 0
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,119 @@ 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 {
+                bouncingTokensWarning(bouncing)
+            }
+            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)
+    }
+
+    @ViewBuilder
+    private func bouncingTokensWarning(_ bouncing: RemoteDiagnostics.BouncingTokens) -> some View {
+        VStack(alignment: .leading, spacing: 4) {
+            HStack {
+                Image(systemName: "exclamationmark.triangle")
+                    .foregroundColor(.orange)
+                Text("Multiple devices uploading profiles")
+                    .fontWeight(.semibold)
+                    .foregroundColor(.orange)
+            }
+            Text("Device tokens are alternating in recent 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.")
+                .font(.footnote)
+                .foregroundColor(.secondary)
+            if !bouncing.shifts.isEmpty {
+                VStack(alignment: .leading, spacing: 2) {
+                    ForEach(Array(bouncing.shifts.enumerated()), id: \.offset) { _, shift in
+                        Text("\(shiftTimestampFormatter.string(from: shift.when))  \(abbreviateToken(shift.fromToken)) → \(abbreviateToken(shift.toToken))")
+                            .font(.system(.caption2, design: .monospaced))
+                            .foregroundColor(.secondary)
+                    }
+                }
+                .padding(.top, 2)
+            }
+        }
+        .padding(.vertical, 4)
+    }
+
+    private var shiftTimestampFormatter: DateFormatter {
+        let f = DateFormatter()
+        f.dateFormat = "yyyy-MM-dd HH:mm"
+        return f
+    }
+
+    private func abbreviateToken(_ token: String) -> String {
+        guard token.count > 16 else { return token }
+        return "\(token.prefix(7))…\(token.suffix(6))"
+    }
 }

+ 113 - 0
LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift

@@ -37,6 +37,12 @@ class RemoteSettingsViewModel: ObservableObject {
     @Published var shouldPromptForURL: Bool = false
     @Published var shouldPromptForToken: Bool = false
 
+    // MARK: - Diagnostics
+
+    @Published var diagnostics = RemoteDiagnostics()
+    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 +239,111 @@ 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 parameters: [String: String] = [
+            "count": "\(diagnosticsHistoryCap)",
+        ]
+        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
+            profileTimestamp(lhs) < profileTimestamp(rhs)
+        }
+        struct CompressedEntry {
+            let token: String
+            let when: Date
+            let bundle: String?
+        }
+        var compressed: [CompressedEntry] = []
+        for record in chronological {
+            guard let token = record.deviceToken ?? record.loopSettings?.deviceToken,
+                  !token.isEmpty else { continue }
+            if compressed.last?.token != token {
+                compressed.append(
+                    CompressedEntry(
+                        token: token,
+                        when: profileTimestamp(record),
+                        bundle: record.bundleIdentifier ?? record.loopSettings?.bundleIdentifier
+                    )
+                )
+            }
+        }
+        let distinctTokens = Set(compressed.map { $0.token })
+        if compressed.count > distinctTokens.count {
+            var shifts: [RemoteDiagnostics.TokenShift] = []
+            for pair in zip(compressed, compressed.dropFirst()) {
+                shifts.append(
+                    RemoteDiagnostics.TokenShift(
+                        when: pair.1.when,
+                        fromToken: pair.0.token,
+                        toToken: pair.1.token,
+                        bundleIdentifier: pair.1.bundle
+                    )
+                )
+            }
+            result.bouncingTokens = .init(
+                distinctCount: distinctTokens.count,
+                recordsScanned: history.count,
+                shifts: shifts
+            )
+        }
+
+        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
+    }
+
+    private func profileTimestamp(_ profile: NSProfile) -> Date {
+        if let s = profile.startDate, let d = NightscoutUtils.parseDate(s) { return d }
+        if let s = profile.createdAt, let d = NightscoutUtils.parseDate(s) { return d }
+        return .distantPast
+    }
 }

+ 13 - 2
LoopFollow/Stats/StatsDataFetcher.swift

@@ -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]