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
4 changes: 4 additions & 0 deletions CGMBLEKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
438621CE2292074A00741DFE /* ShareClientUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4325E9EE210EAF3F00969CE5 /* ShareClientUI.framework */; };
43880F981D9E19FC009061A8 /* TransmitterVersionRxMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43880F971D9E19FC009061A8 /* TransmitterVersionRxMessage.swift */; };
43880F9A1D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */; };
CA05000000000000000010C2 /* SensorIssueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA05000000000000000010C1 /* SensorIssueTests.swift */; };
CA04000000000000000010C2 /* AnubisDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA04000000000000000010C1 /* AnubisDetectionTests.swift */; };
43A8EC4A210D09BE00A81379 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43A8EC49210D09BE00A81379 /* LoopKitUI.framework */; };
43A8EC4C210D09DA00A81379 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43A8EC49210D09BE00A81379 /* LoopKitUI.framework */; };
Expand Down Expand Up @@ -387,6 +388,7 @@
43846AC71D8F89BE00799272 /* CalibrationDataRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationDataRxMessageTests.swift; sourceTree = "<group>"; };
43880F971D9E19FC009061A8 /* TransmitterVersionRxMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterVersionRxMessage.swift; sourceTree = "<group>"; };
43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterVersionRxMessageTests.swift; sourceTree = "<group>"; };
CA05000000000000000010C1 /* SensorIssueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorIssueTests.swift; sourceTree = "<group>"; };
CA04000000000000000010C1 /* AnubisDetectionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnubisDetectionTests.swift; sourceTree = "<group>"; };
43A8EC49210D09BE00A81379 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
43A8EC52210D0A7400A81379 /* CGMBLEKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CGMBLEKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -727,6 +729,7 @@
43F82BCB1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift */,
43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */,
CA04000000000000000010C1 /* AnubisDetectionTests.swift */,
CA05000000000000000010C1 /* SensorIssueTests.swift */,
);
path = CGMBLEKitTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -1275,6 +1278,7 @@
43E397911D5692080028E321 /* GlucoseTests.swift in Sources */,
43880F9A1D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift in Sources */,
CA04000000000000000010C2 /* AnubisDetectionTests.swift in Sources */,
CA05000000000000000010C2 /* SensorIssueTests.swift in Sources */,
43DC87C21C8B520F005BC30D /* GlucoseRxMessageTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
23 changes: 23 additions & 0 deletions CGMBLEKit/TransmitterManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ public class TransmitterManager: TransmitterDelegate {
if state.sensorStartOffset != sensorStartOffset {
mutateState { state in
state.sensorStartOffset = sensorStartOffset
state.reportedSensorIssue = nil
}
if let sessionStartDate = glucose.sessionStartDate {
events.append(PersistedCgmEvent(
Expand All @@ -358,6 +359,19 @@ public class TransmitterManager: TransmitterDelegate {
}
}

var sensorIssue: String?
mutateState { state in
sensorIssue = state.newSensorIssue(for: glucose.state)
}
if let sensorIssue {
events.append(PersistedCgmEvent(
date: glucose.readDate,
type: .sensorIssue,
deviceIdentifier: transmitter.ID,
error: CgmEventError(code: sensorIssue, isFatal: glucose.state.sensorFailed)
))
}

// Filter out future-dated events
// Stopgap measure for the issue described in https://github.com/LoopKit/Loop/issues/2087
events = events.filter { event in
Expand Down Expand Up @@ -610,6 +624,15 @@ extension CalibrationError: LocalizedError {
}

extension CalibrationState {
var sensorFailed: Bool {
switch self {
case .known(.sensorFailure11), .known(.sensorFailure12), .known(.sessionFailure15), .known(.sessionFailure16), .known(.sessionFailure17):
return true
default:
return false
}
}

public var localizedDescription: String {
switch self {
case .known(let state):
Expand Down
17 changes: 17 additions & 0 deletions CGMBLEKit/TransmitterManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public struct TransmitterManagerState: RawRepresentable, Equatable {
/// User-configured session length; only honored for Anubis (see `sensorLife`).
public var sensorLifeDays: Int

/// The state last recorded as a `sensorIssue` this session; nil once glucose is reliable.
public var reportedSensorIssue: String?

public init(
transmitterID: String,
shouldSyncToRemoteService: Bool = true,
Expand Down Expand Up @@ -81,6 +84,7 @@ public struct TransmitterManagerState: RawRepresentable, Equatable {
transmitterExpiryInDays: transmitterExpiryInDays,
sensorLifeDays: sensorLifeDays
)
self.reportedSensorIssue = rawValue["reportedSensorIssue"] as? String
}

public var rawValue: RawValue {
Expand All @@ -93,10 +97,23 @@ public struct TransmitterManagerState: RawRepresentable, Equatable {
rval["sensorStartOffset"] = sensorStartOffset
rval["transmitterExpiryInDays"] = transmitterExpiryInDays.map { Int($0) }
rval["sensorLifeDays"] = sensorLifeDays
rval["reportedSensorIssue"] = reportedSensorIssue

return rval
}

/// The state to record as a `sensorIssue`, when `calibrationState` lacks reliable glucose and
/// differs from the last one recorded. A reliable reading clears it, so a state that returns
/// after recovery is recorded again.
mutating func newSensorIssue(for calibrationState: CalibrationState) -> String? {
let issue = calibrationState.hasReliableGlucose ? nil : calibrationState.description
guard issue != reportedSensorIssue else {
return nil
}
reportedSensorIssue = issue
return issue
}

/// `true` once the transmitter has reported the Anubis 180-day lifetime.
public var isAnubis: Bool {
return transmitterExpiryInDays == 180
Expand Down
38 changes: 38 additions & 0 deletions CGMBLEKitTests/SensorIssueTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// SensorIssueTests.swift
// CGMBLEKitTests
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import XCTest
@testable import CGMBLEKit

class SensorIssueTests: XCTestCase {

func testEachStateIsReportedOnceUntilGlucoseIsReliable() {
var state = TransmitterManagerState(transmitterID: "8G1234")
let readings: [CalibrationState.State] = [.warmup, .warmup, .ok, .questionMarks, .questionMarks, .needCalibration7, .warmup]

let issues = readings.compactMap { state.newSensorIssue(for: .known($0)) }

XCTAssertEqual(issues, ["warmup", "questionMarks", "warmup"])
XCTAssertEqual(state.reportedSensorIssue, "warmup")
}

func testReportedIssueSurvivesARelaunch() {
var state = TransmitterManagerState(transmitterID: "8G1234")
XCTAssertEqual(state.newSensorIssue(for: .known(.sensorFailure11)), "sensorFailure11")

var restored = TransmitterManagerState(rawValue: state.rawValue)!

XCTAssertEqual(restored.reportedSensorIssue, "sensorFailure11")
XCTAssertNil(restored.newSensorIssue(for: .known(.sensorFailure11)))
}

func testFailureStates() {
XCTAssertTrue(CalibrationState.known(.sessionFailure16).sensorFailed)
XCTAssertFalse(CalibrationState.known(.warmup).sensorFailed)
XCTAssertFalse(CalibrationState.unknown(99).sensorFailed)
}
}