Skip to content
Merged
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
13 changes: 13 additions & 0 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ public final class LibreLoopCGMManager: CGMManager {
}

func recordSample(_ sample: LibreLoopGlucoseSample) {
recentSamples.removeAll { $0.lifeCount == sample.lifeCount && $0.source != .realtime }
recentSamples.insert(sample, at: 0)
if recentSamples.count > Self.recentSamplesCap {
recentSamples.removeLast(recentSamples.count - Self.recentSamplesCap)
Expand All @@ -306,6 +307,18 @@ public final class LibreLoopCGMManager: CGMManager {
setState(updated)
}

/// Adds backfilled readings to Recent Readings, in lifeCount order, without
/// touching the Last Reading card.
func recordBackfillSamples(_ samples: [LibreLoopGlucoseSample]) {
let known = Set(recentSamples.map(\.lifeCount))
let added = samples.filter { !known.contains($0.lifeCount) }
guard !added.isEmpty else { return }
recentSamples = Array((recentSamples + added).sorted { $0.lifeCount > $1.lifeCount }.prefix(Self.recentSamplesCap))
var updated = state
updated.recentSamples = Array(recentSamples.prefix(LibreLoopCGMManagerState.recentSamplesPersistenceCap))
setState(updated)
}

/// Update the most recently recorded sample with its forwarding
/// outcome (sent vs throttled vs non-actionable). Matched by
/// lifeCount, which is unique within a sensor session. No-op if the
Expand Down
28 changes: 26 additions & 2 deletions LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -674,8 +674,9 @@ extension LibreLoopCGMManager {
// (historical and clinical can overlap, and they may not even agree
// on values for the same lifeCount since they're different sensor
// pipelines).
let realtimeLifeCounts = Set(recentSamples.map { $0.lifeCount })
let realtimeLifeCounts = Set(recentSamples.filter { $0.source == .realtime }.map { $0.lifeCount })
var newSamples: [NewGlucoseSample] = []
var displaySamples: [LibreLoopGlucoseSample] = []
var droppedRealtimeDup = 0
var droppedBackfillDup = 0
for sample in page.samples {
Expand Down Expand Up @@ -706,8 +707,20 @@ extension LibreLoopCGMManager {
device: device,
isBackfill: true
))
displaySamples.append(LibreLoopGlucoseSample(
date: date,
valueMgDL: Double(mgdl),
trend: .notDetermined,
rateOfChangeMgDLPerMinute: nil,
lifeCount: sample.lifeCount,
sensorTemperatureRaw: 0,
isActionable: true,
source: .historicalBackfill,
wasForwarded: true
))
backfillForwardedLifeCounts.insert(sample.lifeCount)
}
recordBackfillSamples(displaySamples)
if !newSamples.isEmpty {
delegateQueue?.async { [weak self] in
guard let self else { return }
Expand Down Expand Up @@ -750,7 +763,7 @@ extension LibreLoopCGMManager {
return
}
let serial = state.sensorSerial ?? "unknown"
let realtimeLifeCounts = Set(recentSamples.map { $0.lifeCount })
let realtimeLifeCounts = Set(recentSamples.filter { $0.source == .realtime }.map { $0.lifeCount })

// Forward only the per-minute `currentGlucoseMgDL` at
// `record.lifeCount`. Per upstream RoundWhiteDiscKit guidance:
Expand Down Expand Up @@ -811,6 +824,17 @@ extension LibreLoopCGMManager {
isBackfill: true
)
backfillForwardedLifeCounts.insert(lifeCount)
recordBackfillSamples([LibreLoopGlucoseSample(
date: date,
valueMgDL: Double(mgdl),
trend: .notDetermined,
rateOfChangeMgDLPerMinute: nil,
lifeCount: lifeCount,
sensorTemperatureRaw: 0,
isActionable: true,
source: .clinicalBackfill,
wasForwarded: true
)])
llog("forwarded clinical \(kind) sample to Loop: \(mgdl) mg/dL lifeCount=\(lifeCount) sampleDate=\(date.timeIntervalSince1970)")
delegateQueue?.async { [weak self] in
guard let self else { return }
Expand Down
4 changes: 1 addition & 3 deletions LibreLoop/Sensor/LibreLoopGlucoseSample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ public struct LibreLoopGlucoseSample: Equatable, Sendable {
/// reading actionable (e.g. "Warming up: 18 min remaining",
/// "Sensor condition: invalid"). nil when the reading IS actionable.
public let qualityIssue: String?
/// How this sample reached LibreLoop. Currently `recentSamples` only
/// contains `.realtime` entries; the field exists so backfill samples
/// can be surfaced in the same list later without a schema bump.
/// How this sample reached LibreLoop.
public let source: Source
/// True iff this sample was actually handed off to Loop's CGMManager
/// delegate. Updated in place after the throttle/actionability gate
Expand Down
39 changes: 39 additions & 0 deletions LibreLoopTests/LibreLoopCGMManagerStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,42 @@ final class LibreLoopSensorStateEventTests: XCTestCase {
XCTAssertEqual(restored?.sensorEndRecorded, true)
}
}

final class LibreLoopRecentReadingsTests: XCTestCase {
private let activatedAt = Date(timeIntervalSince1970: 1_700_000_000)

private func sample(_ lifeCount: UInt16, _ mgdl: Double, source: LibreLoopGlucoseSample.Source = .realtime) -> LibreLoopGlucoseSample {
LibreLoopGlucoseSample(
date: activatedAt.addingTimeInterval(TimeInterval(lifeCount) * 60),
valueMgDL: mgdl,
trend: source == .realtime ? .stable : .notDetermined,
rateOfChangeMgDLPerMinute: nil,
lifeCount: lifeCount,
sensorTemperatureRaw: 0,
isActionable: true,
source: source
)
}

func testBackfillFillsTheGapInLifeCountOrder() {
let manager = LibreLoopCGMManager()
manager.recordSample(sample(100, 90))
manager.recordSample(sample(130, 92))

manager.recordBackfillSamples([sample(110, 93, source: .historicalBackfill), sample(100, 91, source: .historicalBackfill)])
manager.recordBackfillSamples([sample(120, 94, source: .clinicalBackfill)])

XCTAssertEqual(manager.recentSamples.map(\.lifeCount), [130, 120, 110, 100])
XCTAssertEqual(manager.recentSamples.map(\.source), [.realtime, .clinicalBackfill, .historicalBackfill, .realtime])
XCTAssertEqual(manager.latestSample?.lifeCount, 130)
}

func testRealtimeReplacesBackfillForTheSameMinute() {
let manager = LibreLoopCGMManager()
manager.recordBackfillSamples([sample(140, 95, source: .clinicalBackfill)])
manager.recordSample(sample(140, 96))

XCTAssertEqual(manager.recentSamples.map(\.lifeCount), [140])
XCTAssertEqual(manager.recentSamples.first?.source, .realtime)
}
}
4 changes: 4 additions & 0 deletions LibreLoopUI/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
"comment" : "BLE PIN row label",
"extractionState" : "manual"
},
"Backfilled" : {
"comment" : "Recent readings: backfilled reading marker",
"extractionState" : "manual"
},
"Bluetooth" : {
"comment" : "Bluetooth address row label\n Bluetooth connection row label",
"extractionState" : "manual"
Expand Down
10 changes: 6 additions & 4 deletions LibreLoopUI/Views/LibreLoopSampleDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ struct LibreLoopSampleDetailView: View {
.monospacedDigit()
}

Section(LocalizedString("Sensor diagnostics", comment: "Sample detail section: diagnostics")) {
LabeledContent(LocalizedString("Temperature (raw)", comment: "Sample detail: raw sensor temperature"), value: "0x\(String(sample.sensorTemperatureRaw, radix: 16, uppercase: true)) (\(sample.sensorTemperatureRaw))")
.monospaced()
.font(.footnote)
if sample.source == .realtime {
Section(LocalizedString("Sensor diagnostics", comment: "Sample detail section: diagnostics")) {
LabeledContent(LocalizedString("Temperature (raw)", comment: "Sample detail: raw sensor temperature"), value: "0x\(String(sample.sensorTemperatureRaw, radix: 16, uppercase: true)) (\(sample.sensorTemperatureRaw))")
.monospaced()
.font(.footnote)
}
}
}
.navigationTitle(LocalizedString("Sample detail", comment: "Sample detail screen title"))
Expand Down
10 changes: 8 additions & 2 deletions LibreLoopUI/Views/LibreLoopSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -606,8 +606,14 @@ struct LibreLoopReadingRow: View {
.imageScale(.small)
}
Spacer()
Image(systemName: trendSymbol)
.foregroundStyle(.secondary)
if sample.source == .realtime {
Image(systemName: trendSymbol)
.foregroundStyle(.secondary)
} else {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
.accessibilityLabel(LocalizedString("Backfilled", comment: "Recent readings: backfilled reading marker"))
}
}
.font(.subheadline)
.opacity(sample.isActionable ? 1.0 : 0.7)
Expand Down