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
68 changes: 68 additions & 0 deletions LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "361FF19D20420C889BD440BB"
BuildableName = "LibreLoop.framework"
BlueprintName = "LibreLoop"
ReferencedContainer = "container:LibreLoop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DBC1BA591653686BF48EE7C1"
BuildableName = "LibreLoopTests.xctest"
BlueprintName = "LibreLoopTests"
ReferencedContainer = "container:LibreLoop.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
17 changes: 14 additions & 3 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import UIKit

public final class LibreLoopCGMManager: CGMManager {
public static let pluginIdentifier = "LibreLoopCGMManager"
public static let localizedTitle = "FreeStyle Libre 3"
public static let localizedTitle = "FreeStyle Libre 3 / 3+"
public static let healthKitStorageDelay: TimeInterval = 0

public var localizedTitle: String { Self.localizedTitle }
Expand Down Expand Up @@ -251,6 +251,12 @@ public final class LibreLoopCGMManager: CGMManager {
/// One-shot guard so the re-scan alert fires once per failure run, not every
/// failed attempt.
var hasIssuedReScanAlert = false

/// Anything left standing in Loop's AlertStore replays on every app launch,
/// so this must list every alert we can issue.
static var allAlertIdentifiers: [Alert.AlertIdentifier] {
LibreLoopExpiryAlerts.allIdentifiers + [sensorAttentionAlertID, needsReScanAlertID]
}
/// Cap on the exponential reconnect backoff (seconds) so a persistently
/// failing/marginal link doesn't hammer the radio and drain the battery.
static let maxReconnectBackoff: TimeInterval = 300
Expand Down Expand Up @@ -332,7 +338,9 @@ public final class LibreLoopCGMManager: CGMManager {
monitor = nil
isReconnecting = false
recentSamples = []
retractExpiryAlerts()
hasIssuedReScanAlert = false
lastSensorAttention = nil
retractAllAlerts()
// Emit .sensorEnd before we blank state so the event's
// deviceIdentifier still resolves to the session that's ending.
// Matches the .sensorStart we emitted at pairing time so Loop's
Expand Down Expand Up @@ -809,7 +817,10 @@ public final class LibreLoopCGMManager: CGMManager {
scanner.cancelConnection(peripheral)
}
}
completion()
// Retract while the delegate reference is still good: notifying it
// releases this manager.
retractAllAlerts()
notifyDelegateOfDeletion(completion: completion)
}
}

Expand Down
8 changes: 5 additions & 3 deletions LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ extension LibreLoopCGMManager {
return activatedAt
}

func retractExpiryAlerts() {
/// Captures the delegate strongly so the retraction still lands if this
/// manager is released immediately afterwards.
func retractAllAlerts() {
let delegate = cgmManagerDelegate
let identifiers = LibreLoopExpiryAlerts.allIdentifiers.map {
let identifiers = Self.allAlertIdentifiers.map {
Alert.Identifier(managerIdentifier: pluginIdentifier, alertIdentifier: $0)
}
llog("expiry alerts: retracting \(identifiers.count) identifier(s)")
llog("alerts: retracting \(identifiers.count) identifier(s)")
Task {
for identifier in identifiers {
await delegate?.retractAlert(identifier: identifier)
Expand Down
43 changes: 2 additions & 41 deletions LibreLoop/Sensor/LibreLoopSensorMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,6 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable {
private var lastPatchStatusAt: Date?
/// Last time a glucose frame arrived.
private var lastGlucoseAt: Date?
/// Stuck-value detector state: the last raw current-glucose word, the
/// lifeCount it arrived at, and the count of consecutive *advancing* frames
/// that repeated it. Catches a held/frozen glucose (e.g. after a DQ error) —
/// the repeats carry no error flag, so they look valid and get forwarded.
private var lastGlucoseWord: UInt16?
private var lastGlucoseWordLifeCount: UInt16?
private var stuckGlucoseRun: Int = 0
private var readingHandler: ReadingHandler?
private var disconnectHandler: DisconnectHandler?
private var statusHandler: StatusHandler?
Expand Down Expand Up @@ -521,25 +514,10 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable {
// to warmup when applicable (and report remaining warmup minutes).
let lifecycle = SensorLifecycle(currentLifeCountMinutes: Int(reading.lifeCount))
let assessment = reading.currentGlucoseQualityAssessment(lifecycle: lifecycle)
// Log trendAndStatusByte (byte 14 of the realtime frame)
// alongside the decoded fields. LibreCRKit's live-capture
// fixture shows 0x0b for a stable+actionable reading on
// Libre 3 (trend=3 | bit3 actionable | rest=0). If we
// see byte 14 with bit 3 clear but other upper bits set,
// it would suggest a sensor variant has the flag in a
// different position than the test data assumed.
//
// Also log the full 29-byte decrypted plaintext as hex --
// dropped straight into RealtimeGlucoseReading(plaintext:)
// it reproduces the exact frame for the LibreCRKit
// developer to inspect.
// The plaintext hex replays the exact frame through
// RealtimeGlucoseReading(plaintext:) when reviewing a report.
let byte14 = String(format: "0x%02x", reading.trendAndStatusByte)
let plaintextHex = packet.plaintext.map { String(format: "%02x", $0) }.joined()
// Surface the decoded data-quality evidence on every frame: the
// raw current-glucose word (the field that froze in the stuck-53
// case), the DQ error (0x8000 family), sensor condition, and
// actionability. Previously these were only visible when they
// escalated to a blocking issue — but a held value reports clean.
let mgdlStr = reading.currentGlucoseMgDL.map(String.init) ?? "nil"
let word = String(format: "0x%04x", reading.currentWord)
let dqInfo = "word=\(word) dq=\(reading.dqError) cond=\(reading.sensorCondition) act=\(reading.actionability)"
Expand All @@ -555,24 +533,7 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable {
lock.lock()
let lcHandler = lifeCountHandler
lastGlucoseAt = event.receivedAt // feed the silence watchdog
// Stuck-value detector: count consecutive *advancing* frames that
// repeat the raw current-glucose word. A same-minute resend
// (lifeCount unchanged) doesn't count; a new lifeCount carrying
// an identical word is a held/frozen value.
if reading.lifeCount == lastGlucoseWordLifeCount {
// same-minute resend — ignore for the stuck run
} else if lastGlucoseWord == reading.currentWord {
stuckGlucoseRun += 1
} else {
stuckGlucoseRun = 0
}
lastGlucoseWord = reading.currentWord
lastGlucoseWordLifeCount = reading.lifeCount
let stuckRun = stuckGlucoseRun
lock.unlock()
if stuckRun >= 3 {
llog("STUCK: current glucose word \(String(format: "0x%04x", reading.currentWord)) unchanged across \(stuckRun + 1) advancing frames (lifeCount=\(reading.lifeCount) mgdl=\(mgdlStr) dq=\(reading.dqError))")
}
lcHandler?(reading.lifeCount)
if let sample = Self.makeSample(from: reading, assessment: assessment, receivedAt: event.receivedAt) {
lock.lock()
Expand Down
2 changes: 1 addition & 1 deletion LibreLoopPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<key>NSPrincipalClass</key>
<string>LibreLoopPlugin</string>
<key>com.loopkit.Loop.CGMManagerDisplayName</key>
<string>FreeStyle Libre 3</string>
<string>FreeStyle Libre 3 / 3+</string>
<key>com.loopkit.Loop.CGMManagerIdentifier</key>
<string>LibreLoopCGMManager</string>
</dict>
Expand Down
108 changes: 108 additions & 0 deletions LibreLoopTests/LibreLoopCGMManagerStateTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import XCTest
import LoopKit
@testable import LibreLoop

final class LibreLoopCGMManagerStateTests: XCTestCase {
Expand Down Expand Up @@ -64,3 +65,110 @@ final class LibreLoopSensorLifecycleTests: XCTestCase {
)
}
}

/// `cgmManagerDelegate` is weak, so tests must hold this strongly.
private nonisolated final class RetractionRecordingDelegate: CGMManagerDelegate {
private let lock = NSLock()
private var _retracted: [Alert.Identifier] = []
var retracted: [Alert.Identifier] {
lock.lock()
defer { lock.unlock() }
return _retracted
}

private let retractionExpectation: XCTestExpectation
private let deletionExpectation: XCTestExpectation?

init(retractionExpectation: XCTestExpectation, deletionExpectation: XCTestExpectation? = nil) {
self.retractionExpectation = retractionExpectation
self.deletionExpectation = deletionExpectation
}

@MainActor func retractAlert(identifier: Alert.Identifier) async {
lock.lock()
_retracted.append(identifier)
lock.unlock()
retractionExpectation.fulfill()
}

func cgmManagerWantsDeletion(_ manager: CGMManager) async {
deletionExpectation?.fulfill()
}

@MainActor func issueAlert(_ alert: Alert) async {}
func doesIssuedAlertExist(identifier: Alert.Identifier) async throws -> Bool { false }
func lookupAllUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] }
func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] }
func recordRetractedAlert(_ alert: Alert, at date: Date) async throws {}
func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) {}
func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) {}
func cgmManager(_ manager: CGMManager, hasNew events: [PersistedCgmEvent]) {}
func cgmManagerDidUpdateState(_ manager: CGMManager) {}
func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) {}
func startDateToFilterNewData(for manager: CGMManager) -> Date? { nil }
func credentialStoragePrefix(for manager: CGMManager) -> String { "test" }
}

/// An alert left standing in Loop's AlertStore is replayed on every app launch,
/// so both exit paths must clear them.
final class LibreLoopAlertRetractionTests: XCTestCase {
private func makeManager(delegate: CGMManagerDelegate) -> LibreLoopCGMManager {
let manager = LibreLoopCGMManager()
manager.delegateQueue = DispatchQueue(label: "LibreLoopAlertRetractionTests")
manager.cgmManagerDelegate = delegate
return manager
}

private func expectRetractions() -> XCTestExpectation {
let expectation = expectation(description: "every alert identifier retracted")
expectation.expectedFulfillmentCount = LibreLoopCGMManager.allAlertIdentifiers.count
return expectation
}

private func assertRetractedEverything(_ delegate: RetractionRecordingDelegate) {
XCTAssertEqual(Set(delegate.retracted.map(\.alertIdentifier)),
Set(LibreLoopCGMManager.allAlertIdentifiers))
XCTAssertTrue(delegate.retracted.allSatisfy {
$0.managerIdentifier == LibreLoopCGMManager.pluginIdentifier
})
}

func testDeleteRetractsEveryAlertAndNotifiesDelegate() {
let retractions = expectRetractions()
let deletion = expectation(description: "delegate notified of deletion")
let completed = expectation(description: "delete completion called")
let delegate = RetractionRecordingDelegate(retractionExpectation: retractions,
deletionExpectation: deletion)
let manager = makeManager(delegate: delegate)

manager.delete { completed.fulfill() }

wait(for: [retractions, deletion, completed], timeout: 5)
assertRetractedEverything(delegate)
}

func testDiscardSensorRetractsEveryAlert() {
let retractions = expectRetractions()
let delegate = RetractionRecordingDelegate(retractionExpectation: retractions)
let manager = makeManager(delegate: delegate)
manager.hasIssuedReScanAlert = true

manager.discardSensor()

wait(for: [retractions], timeout: 5)
assertRetractedEverything(delegate)
XCTAssertFalse(manager.hasIssuedReScanAlert)
XCTAssertNil(manager.lastSensorAttention)
}

func testAllAlertIdentifiersCoversEveryIssuableAlert() {
let identifiers = Set(LibreLoopCGMManager.allAlertIdentifiers)
for expiryIdentifier in LibreLoopExpiryAlerts.allIdentifiers {
XCTAssertTrue(identifiers.contains(expiryIdentifier), "missing \(expiryIdentifier)")
}
XCTAssertTrue(identifiers.contains(LibreLoopCGMManager.sensorAttentionAlertID))
XCTAssertTrue(identifiers.contains(LibreLoopCGMManager.needsReScanAlertID))
XCTAssertEqual(identifiers.count, LibreLoopCGMManager.allAlertIdentifiers.count,
"duplicate identifiers in allAlertIdentifiers")
}
}
4 changes: 3 additions & 1 deletion LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,10 @@ final class LibreLoopUICoordinator: UINavigationController, CGMManagerOnboarding
self.completionDelegate?.completionNotifyingDidComplete(self)
},
replaceSensor: { [weak self] in self?.startReplacementPairing() },
// `delete` runs the manager's teardown and notifies the delegate
// itself; `notifyDelegateOfDeletion` alone skips the teardown.
deleteCGM: { [weak self] in
self?.cgmManager?.notifyDelegateOfDeletion {
self?.cgmManager?.delete {
DispatchQueue.main.async {
guard let self = self else { return }
self.completionDelegate?.completionNotifyingDidComplete(self)
Expand Down