Skip to content

Commit f36b8a9

Browse files
bjorkertmarionbarker
authored andcommitted
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".
1 parent 828fcde commit f36b8a9

8 files changed

Lines changed: 323 additions & 5 deletions

File tree

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
110110
DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
111111
DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; };
112+
AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; };
112113
DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */; };
113114
DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */; };
114115
DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878122C7B750D0048F05C /* TempTargetView.swift */; };
@@ -564,6 +565,7 @@
564565
DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
565566
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
566567
DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = "<group>"; };
568+
AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = "<group>"; };
567569
DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlViewModel.swift; sourceTree = "<group>"; };
568570
DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlView.swift; sourceTree = "<group>"; };
569571
DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = "<group>"; };
@@ -1090,6 +1092,7 @@
10901092
656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */,
10911093
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */,
10921094
DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */,
1095+
AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */,
10931096
);
10941097
path = Settings;
10951098
sourceTree = "<group>";
@@ -2223,6 +2226,7 @@
22232226
DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
22242227
DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */,
22252228
DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */,
2229+
AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */,
22262230
FCFEECA02488157B00402A7F /* Chart.swift in Sources */,
22272231
DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */,
22282232
DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */,

LoopFollow/Controllers/Nightscout/NSProfile.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ struct NSProfile: Decodable {
4848
let deviceToken: String?
4949
let teamID: String?
5050
let expirationDate: String?
51+
let startDate: String?
52+
let createdAt: String?
5153

5254
struct TrioOverrideEntry: Decodable {
5355
let name: String
@@ -97,5 +99,7 @@ struct NSProfile: Decodable {
9799
case loopSettings
98100
case teamID
99101
case expirationDate
102+
case startDate
103+
case createdAt = "created_at"
100104
}
101105
}

LoopFollow/Controllers/Nightscout/Profile.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,19 @@ import Foundation
66
extension MainViewController {
77
// NS Profile Web Call
88
func webLoadNSProfile() {
9-
NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result<NSProfile, Error>) in
9+
let formatter = ISO8601DateFormatter()
10+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
11+
let parameters: [String: String] = [
12+
"count": "1",
13+
"find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60)),
14+
]
15+
NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in
1016
switch result {
11-
case let .success(profileData):
17+
case let .success(profiles):
18+
guard let profileData = profiles.first else {
19+
LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, no profile records returned")
20+
return
21+
}
1222
self.updateProfile(profileData: profileData)
1323
case let .failure(error):
1424
LogManager.shared.log(category: .nightscout, message: "webLoadNSProfile, error fetching profile data: \(error.localizedDescription)")

LoopFollow/Helpers/NightscoutUtils.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class NightscoutUtils {
5252
case .sgv:
5353
return "/api/v1/entries.json"
5454
case .profile:
55-
return "/api/v1/profile/current.json"
55+
return "/api/v1/profiles.json"
5656
case .deviceStatus:
5757
return "/api/v1/devicestatus.json"
5858
case .temporaryOverride, .temporaryOverrideCancel:
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// LoopFollow
2+
// RemoteDiagnostics.swift
3+
4+
import Foundation
5+
6+
struct RemoteDiagnostics {
7+
enum Status: Equatable {
8+
case unknown
9+
case running
10+
case ok
11+
case failed(String)
12+
}
13+
14+
var status: Status = .unknown
15+
var bundleMismatch: BundleMismatch?
16+
var bouncingTokens: BouncingTokens?
17+
var futureStartDate: FutureStartDate?
18+
19+
var hasAnyWarning: Bool {
20+
bundleMismatch != nil || bouncingTokens != nil || futureStartDate != nil
21+
}
22+
23+
struct BundleMismatch: Equatable {
24+
let expectedDevice: String
25+
let observedBundleId: String
26+
}
27+
28+
struct BouncingTokens: Equatable {
29+
let distinctCount: Int
30+
let recordsScanned: Int
31+
let shifts: [TokenShift]
32+
}
33+
34+
struct TokenShift: Equatable {
35+
let when: Date
36+
let fromToken: String
37+
let toToken: String
38+
let bundleIdentifier: String?
39+
}
40+
41+
struct FutureStartDate: Equatable {
42+
let startDate: Date
43+
}
44+
}

LoopFollow/Remote/Settings/RemoteSettingsView.swift

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,20 @@ struct RemoteSettingsView: View {
2929
self.viewModel = viewModel
3030
}
3131

32+
private let diagnosticsAnchorID = "remoteDiagnostics"
33+
3234
var body: some View {
35+
ScrollViewReader { proxy in
36+
formContent
37+
.onChange(of: viewModel.diagnostics.status) { _ in
38+
withAnimation {
39+
proxy.scrollTo(diagnosticsAnchorID, anchor: .top)
40+
}
41+
}
42+
}
43+
}
44+
45+
private var formContent: some View {
3346
Form {
3447
// MARK: - Remote Type Section (Custom Rows)
3548

@@ -175,6 +188,8 @@ struct RemoteSettingsView: View {
175188
if Storage.shared.bolusIncrementDetected.value {
176189
Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
177190
}
191+
diagnosticsRows
192+
.id(diagnosticsAnchorID)
178193
}
179194
}
180195

@@ -277,6 +292,8 @@ struct RemoteSettingsView: View {
277292
if Storage.shared.bolusIncrementDetected.value {
278293
Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
279294
}
295+
diagnosticsRows
296+
.id(diagnosticsAnchorID)
280297
}
281298
}
282299
}
@@ -465,4 +482,119 @@ struct RemoteSettingsView: View {
465482
}
466483
}
467484
}
485+
486+
// MARK: - Diagnostics
487+
488+
@ViewBuilder
489+
private var diagnosticsRows: some View {
490+
switch viewModel.diagnostics.status {
491+
case .running:
492+
HStack {
493+
ProgressView()
494+
Text("Checking Nightscout profile history…")
495+
.foregroundColor(.secondary)
496+
}
497+
case .unknown:
498+
Button(action: { viewModel.runDiagnostics() }) {
499+
HStack {
500+
Image(systemName: "stethoscope")
501+
Text("Run diagnostics")
502+
}
503+
}
504+
case let .failed(message):
505+
Button(action: { viewModel.runDiagnostics() }) {
506+
HStack {
507+
Image(systemName: "stethoscope")
508+
Text("Run diagnostics again")
509+
}
510+
}
511+
Text("Diagnostics unavailable: \(message)")
512+
.font(.footnote)
513+
.foregroundColor(.secondary)
514+
case .ok:
515+
Button(action: { viewModel.runDiagnostics() }) {
516+
HStack {
517+
Image(systemName: "stethoscope")
518+
Text("Run diagnostics again")
519+
}
520+
}
521+
if let mismatch = viewModel.diagnostics.bundleMismatch {
522+
diagnosticWarning(
523+
title: "Profile uploaded by a different app",
524+
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."
525+
)
526+
}
527+
if let bouncing = viewModel.diagnostics.bouncingTokens {
528+
bouncingTokensWarning(bouncing)
529+
}
530+
if let future = viewModel.diagnostics.futureStartDate {
531+
diagnosticWarning(
532+
title: "Future-dated profile record found",
533+
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."
534+
)
535+
}
536+
if !viewModel.diagnostics.hasAnyWarning {
537+
HStack {
538+
Image(systemName: "checkmark.seal")
539+
.foregroundColor(.green)
540+
Text("No issues detected")
541+
.foregroundColor(.secondary)
542+
}
543+
}
544+
}
545+
}
546+
547+
private func diagnosticWarning(title: String, detail: String) -> some View {
548+
VStack(alignment: .leading, spacing: 4) {
549+
HStack {
550+
Image(systemName: "exclamationmark.triangle")
551+
.foregroundColor(.orange)
552+
Text(title)
553+
.fontWeight(.semibold)
554+
.foregroundColor(.orange)
555+
}
556+
Text(detail)
557+
.font(.footnote)
558+
.foregroundColor(.secondary)
559+
}
560+
.padding(.vertical, 4)
561+
}
562+
563+
@ViewBuilder
564+
private func bouncingTokensWarning(_ bouncing: RemoteDiagnostics.BouncingTokens) -> some View {
565+
VStack(alignment: .leading, spacing: 4) {
566+
HStack {
567+
Image(systemName: "exclamationmark.triangle")
568+
.foregroundColor(.orange)
569+
Text("Multiple devices uploading profiles")
570+
.fontWeight(.semibold)
571+
.foregroundColor(.orange)
572+
}
573+
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.")
574+
.font(.footnote)
575+
.foregroundColor(.secondary)
576+
if !bouncing.shifts.isEmpty {
577+
VStack(alignment: .leading, spacing: 2) {
578+
ForEach(Array(bouncing.shifts.enumerated()), id: \.offset) { _, shift in
579+
Text("\(shiftTimestampFormatter.string(from: shift.when)) \(abbreviateToken(shift.fromToken))\(abbreviateToken(shift.toToken))")
580+
.font(.system(.caption2, design: .monospaced))
581+
.foregroundColor(.secondary)
582+
}
583+
}
584+
.padding(.top, 2)
585+
}
586+
}
587+
.padding(.vertical, 4)
588+
}
589+
590+
private var shiftTimestampFormatter: DateFormatter {
591+
let f = DateFormatter()
592+
f.dateFormat = "yyyy-MM-dd HH:mm"
593+
return f
594+
}
595+
596+
private func abbreviateToken(_ token: String) -> String {
597+
guard token.count > 16 else { return token }
598+
return "\(token.prefix(7))\(token.suffix(6))"
599+
}
468600
}

0 commit comments

Comments
 (0)