diff --git a/CGMBLEKit.xcodeproj/project.pbxproj b/CGMBLEKit.xcodeproj/project.pbxproj index 51a9b6c..4582d78 100644 --- a/CGMBLEKit.xcodeproj/project.pbxproj +++ b/CGMBLEKit.xcodeproj/project.pbxproj @@ -60,6 +60,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 */; }; + 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 */; }; 43A8EC56210D0A7400A81379 /* CGMBLEKitUI.h in Headers */ = {isa = PBXBuildFile; fileRef = 43A8EC54210D0A7400A81379 /* CGMBLEKitUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -391,6 +392,7 @@ 43846AC71D8F89BE00799272 /* CalibrationDataRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationDataRxMessageTests.swift; sourceTree = ""; }; 43880F971D9E19FC009061A8 /* TransmitterVersionRxMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterVersionRxMessage.swift; sourceTree = ""; }; 43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterVersionRxMessageTests.swift; sourceTree = ""; }; + CA04000000000000000010C1 /* AnubisDetectionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnubisDetectionTests.swift; sourceTree = ""; }; 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; }; 43A8EC54210D0A7400A81379 /* CGMBLEKitUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CGMBLEKitUI.h; sourceTree = ""; }; @@ -730,6 +732,7 @@ 43460F87200B30D10030C0E3 /* TransmitterIDTests.swift */, 43F82BCB1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift */, 43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */, + CA04000000000000000010C1 /* AnubisDetectionTests.swift */, ); path = CGMBLEKitTests; sourceTree = ""; @@ -1281,6 +1284,7 @@ 43F82BD21D037040006F5DD7 /* SessionStopRxMessageTests.swift in Sources */, 43E397911D5692080028E321 /* GlucoseTests.swift in Sources */, 43880F9A1D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift in Sources */, + CA04000000000000000010C2 /* AnubisDetectionTests.swift in Sources */, 43DC87C21C8B520F005BC30D /* GlucoseRxMessageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift b/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift index 4854385..16763d2 100644 --- a/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift +++ b/CGMBLEKit/Messages/TransmitterVersionRxMessage.swift @@ -9,11 +9,13 @@ import Foundation -struct TransmitterVersionRxMessage: TransmitterRxMessage { - let status: UInt8 - let firmwareVersion: [UInt8] +public struct TransmitterVersionRxMessage: TransmitterRxMessage { + public let status: UInt8 + public let firmwareVersion: [UInt8] + /// Lifetime the transmitter reports for itself (stock G6 = 90, Anubis-modded G6 = 180). + public let transmitterExpiryInDays: UInt16 - init?(data: Data) { + public init?(data: Data) { guard data.count == 19 && data.isCRCValid else { return nil } @@ -24,6 +26,12 @@ struct TransmitterVersionRxMessage: TransmitterRxMessage { status = data[1] firmwareVersion = data[2..<6].map { $0 } + transmitterExpiryInDays = (UInt16(data[14]) << 8) + UInt16(data[13]) } + /// Heuristic borrowed from xDrip4iOS: Anubis-modded G6 transmitters + /// report a 180-day expiry in the version-rx frame; stock G6 reports 90. + public var isAnubis: Bool { + return transmitterExpiryInDays == 180 + } } diff --git a/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift b/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift index 60df2c7..8c163ea 100644 --- a/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift +++ b/CGMBLEKit/Messages/TransmitterVersionTxMessage.swift @@ -9,8 +9,10 @@ import Foundation -struct TransmitterVersionTxMessage { +struct TransmitterVersionTxMessage: RespondableMessage { typealias Response = TransmitterVersionRxMessage - let opcode: Opcode = .transmitterVersionTx + var data: Data { + return Data(for: .transmitterVersionTx).appendingCRC() + } } diff --git a/CGMBLEKit/Transmitter.swift b/CGMBLEKit/Transmitter.swift index d2d1534..da5db39 100644 --- a/CGMBLEKit/Transmitter.swift +++ b/CGMBLEKit/Transmitter.swift @@ -22,6 +22,13 @@ public protocol TransmitterDelegate: AnyObject { func transmitter(_ transmitter: Transmitter, didReadBackfill glucose: [Glucose]) func transmitter(_ transmitter: Transmitter, didReadUnknownData data: Data) + + func transmitter(_ transmitter: Transmitter, didReadTransmitterVersion message: TransmitterVersionRxMessage) +} + +public extension TransmitterDelegate { + // Default no-op so existing implementors don't need to opt in. + func transmitter(_ transmitter: Transmitter, didReadTransmitterVersion message: TransmitterVersionRxMessage) {} } /// These methods are called on a private background queue. It is the responsibility of the client to ensure thread-safety. @@ -206,6 +213,16 @@ public final class Transmitter: BluetoothManagerDelegate { self.log.debug("Reading calibration data") let calibrationMessage = try? peripheral.readCalibrationData() + // Best-effort version read — surfaces transmitter-reported + // expiry (Anubis detection). Optional: don't fail the + // connect cycle if the transmitter doesn't answer. + self.log.debug("Reading transmitter version") + if let versionMessage = try? peripheral.readTransmitterVersion() { + self.delegateQueue.async { + self.delegate?.transmitter(self, didReadTransmitterVersion: versionMessage) + } + } + let glucose = Glucose( transmitterID: self.id.id, glucoseMessage: glucoseMessage, @@ -338,6 +355,14 @@ public final class Transmitter: BluetoothManagerDelegate { } lastCalibrationMessage = calibrationDataMessage + case .transmitterVersionRx?: + guard let versionMessage = TransmitterVersionRxMessage(data: response) else { + break + } + + delegateQueue.async { + self.delegate?.transmitter(self, didReadTransmitterVersion: versionMessage) + } case .none: delegateQueue.async { self.delegate?.transmitter(self, didReadUnknownData: response) @@ -551,6 +576,14 @@ fileprivate extension PeripheralManager { } } + func readTransmitterVersion() throws -> TransmitterVersionRxMessage { + do { + return try writeMessage(TransmitterVersionTxMessage(), for: .control) + } catch let error { + throw TransmitterError.controlError("Error getting transmitter version: \(error)") + } + } + func disconnect() { do { try setNotifyValue(false, for: .control) diff --git a/CGMBLEKit/TransmitterManager.swift b/CGMBLEKit/TransmitterManager.swift index 682d9dd..ca6e10f 100644 --- a/CGMBLEKit/TransmitterManager.swift +++ b/CGMBLEKit/TransmitterManager.swift @@ -431,6 +431,19 @@ public class TransmitterManager: TransmitterDelegate { logDeviceCommunication("Unknown sensor data: \(data.hexadecimalString)", type: .error) } + + public func transmitter(_ transmitter: Transmitter, didReadTransmitterVersion message: TransmitterVersionRxMessage) { + log.default("Transmitter reports expiry of %d days (isAnubis=%@)", + message.transmitterExpiryInDays, String(describing: message.isAnubis)) + mutateState { state in + state.transmitterExpiryInDays = message.transmitterExpiryInDays + } + } + + /// `true` once the transmitter has reported the Anubis 180-day lifetime. + public var isAnubis: Bool { + return state.isAnubis + } } diff --git a/CGMBLEKit/TransmitterManagerState.swift b/CGMBLEKit/TransmitterManagerState.swift index 80f7cdb..a79107d 100644 --- a/CGMBLEKit/TransmitterManagerState.swift +++ b/CGMBLEKit/TransmitterManagerState.swift @@ -24,16 +24,22 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { public var shouldSyncToRemoteService: Bool + /// Transmitter-reported lifetime in days (90 for stock G6, 180 for + /// Anubis-modded). `nil` until the first version-rx frame comes in. + public var transmitterExpiryInDays: UInt16? + public init( transmitterID: String, shouldSyncToRemoteService: Bool = true, transmitterStartDate: Date? = nil, - sensorStartOffset: UInt32? = nil + sensorStartOffset: UInt32? = nil, + transmitterExpiryInDays: UInt16? = nil ) { self.transmitterID = transmitterID self.shouldSyncToRemoteService = shouldSyncToRemoteService self.transmitterStartDate = transmitterStartDate self.sensorStartOffset = sensorStartOffset + self.transmitterExpiryInDays = transmitterExpiryInDays } public init?(rawValue: RawValue) { @@ -48,11 +54,15 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { let sensorStartOffset = rawValue["sensorStartOffset"] as? UInt32 + let transmitterExpiryInDays = (rawValue["transmitterExpiryInDays"] as? UInt16) + ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16($0) } + self.init( transmitterID: transmitterID, shouldSyncToRemoteService: shouldSyncToRemoteService, transmitterStartDate: transmitterStartDate, - sensorStartOffset: sensorStartOffset + sensorStartOffset: sensorStartOffset, + transmitterExpiryInDays: transmitterExpiryInDays ) } @@ -64,7 +74,13 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { rval["transmitterStartDate"] = transmitterStartDate rval["sensorStartOffset"] = sensorStartOffset + rval["transmitterExpiryInDays"] = transmitterExpiryInDays.map { Int($0) } return rval } + + /// `true` once the transmitter has reported the Anubis 180-day lifetime. + public var isAnubis: Bool { + return transmitterExpiryInDays == 180 + } } diff --git a/CGMBLEKitTests/AnubisDetectionTests.swift b/CGMBLEKitTests/AnubisDetectionTests.swift new file mode 100644 index 0000000..f18eb54 --- /dev/null +++ b/CGMBLEKitTests/AnubisDetectionTests.swift @@ -0,0 +1,118 @@ +// +// AnubisDetectionTests.swift +// CGMBLEKit +// +// Covers transmitter-expiry parsing + Anubis-mod detection across the two +// layers it spans: the version-rx frame decoder and the persisted manager +// state. +// + +import XCTest +@testable import CGMBLEKit + +class AnubisDetectionTests: XCTestCase { + + // MARK: - TransmitterVersionRxMessage parsing + + /// Stock G6 reports a 90-day lifetime in the version-rx frame. + func testStockG6ExpiryParsedAndNotAnubis() { + // Same layout as TransmitterVersionRxMessageTests' fixture; expiry + // bytes (13..14, little-endian) overwritten to 0x5A 0x00 = 90. + let data = makeVersionRxPayload(expiryDays: 90) + let message = TransmitterVersionRxMessage(data: data)! + + XCTAssertEqual(90, message.transmitterExpiryInDays) + XCTAssertFalse(message.isAnubis) + } + + /// Anubis-modded G6 reports 180 days. Mirrors xDrip4iOS's heuristic. + func testAnubisG6ExpiryParsedAndIsAnubis() { + let data = makeVersionRxPayload(expiryDays: 180) + let message = TransmitterVersionRxMessage(data: data)! + + XCTAssertEqual(180, message.transmitterExpiryInDays) + XCTAssertTrue(message.isAnubis) + } + + /// Some early transmitters report neither 90 nor 180. They're stock G6 + /// firmware that just doesn't expose the lifetime field reliably; the + /// only known classification we care about is "is this a 180-day Anubis." + func testOddExpiryNotClassifiedAsAnubis() { + let data = makeVersionRxPayload(expiryDays: 112) + let message = TransmitterVersionRxMessage(data: data)! + + XCTAssertEqual(112, message.transmitterExpiryInDays) + XCTAssertFalse(message.isAnubis) + } + + func testWrongOpcodeReturnsNil() { + var data = makeVersionRxPayload(expiryDays: 90) + data[0] = 0x4c // not .transmitterVersionRx + // Re-CRC so the opcode rejection is exercised, not a CRC failure. + let resealed = data.dropLast(2).appendingCRC() + XCTAssertNil(TransmitterVersionRxMessage(data: resealed)) + } + + func testInvalidCRCReturnsNil() { + var data = makeVersionRxPayload(expiryDays: 90) + // Flip the last byte of the CRC trailer. + data[data.count - 1] ^= 0xFF + XCTAssertNil(TransmitterVersionRxMessage(data: data)) + } + + func testWrongLengthReturnsNil() { + let data = makeVersionRxPayload(expiryDays: 90).dropLast() + XCTAssertNil(TransmitterVersionRxMessage(data: Data(data))) + } + + // MARK: - TransmitterManagerState round-trip + + func testManagerStateStoresAndRestoresExpiry() { + let state = TransmitterManagerState(transmitterID: "ABCDEF", transmitterExpiryInDays: 180) + let restored = TransmitterManagerState(rawValue: state.rawValue)! + + XCTAssertEqual(180, restored.transmitterExpiryInDays) + XCTAssertTrue(restored.isAnubis) + } + + /// Older installs persisted the expiry as a plain `Int` (UserDefaults + /// rounds UInt16 → Int on archive). The decoder accepts both shapes so an + /// upgrade doesn't drop the Anubis flag. + func testManagerStateRestoresLegacyIntExpiry() { + let raw: [String: Any] = [ + "transmitterID": "ABCDEF", + "transmitterExpiryInDays": Int(180) + ] + let restored = TransmitterManagerState(rawValue: raw)! + + XCTAssertEqual(180, restored.transmitterExpiryInDays) + XCTAssertTrue(restored.isAnubis) + } + + func testManagerStateMissingExpiryIsNotAnubis() { + let state = TransmitterManagerState(transmitterID: "ABCDEF") + + XCTAssertNil(state.transmitterExpiryInDays) + XCTAssertFalse(state.isAnubis) + } + + // MARK: - Fixture helper + + /// Builds a 19-byte version-rx payload with the given expiry, opcode + + /// CRC trailer correctly applied. Patterned on the canonical fixture in + /// `TransmitterVersionRxMessageTests`. + private func makeVersionRxPayload(expiryDays: UInt16) -> Data { + // Bytes 0..12: opcode + status + firmware + filler from the canonical + // existing fixture. Bytes 13..14: expiry (little-endian). Bytes + // 15..16: trailing filler. CRC re-computed at the end. + let body = Data([ + 0x4b, 0x00, // opcode + status + 0x01, 0x00, 0x00, 0x11, // firmwareVersion + 0xdf, 0x29, 0x00, 0x00, 0x51, 0x00, 0x03, // filler (bytes 6..12) + UInt8(expiryDays & 0xff), // byte 13: low byte + UInt8((expiryDays >> 8) & 0xff), // byte 14: high byte + 0xf0, 0x00 // filler (bytes 15..16) + ]) + return body.appendingCRC() + } +} diff --git a/CGMBLEKitUI/Localizable.xcstrings b/CGMBLEKitUI/Localizable.xcstrings index 22668a2..44636fe 100644 --- a/CGMBLEKitUI/Localizable.xcstrings +++ b/CGMBLEKitUI/Localizable.xcstrings @@ -312,6 +312,12 @@ } } }, + "Calibration Error" : { + "comment" : "Sensor calibration error" + }, + "Calibration Needed" : { + "comment" : "Sensor needs calibration" + }, "Cancel" : { "comment" : "The title of the cancel action in an action sheet", "localizations" : { @@ -1803,7 +1809,7 @@ } }, "Sensor Expired" : { - "comment" : "Title describing past sensor sensor expiration", + "comment" : "Sensor expired status\nTitle describing past sensor sensor expiration", "localizations" : { "ar" : { "stringUnit" : { @@ -2088,6 +2094,12 @@ } } }, + "Sensor Failed" : { + "comment" : "Sensor hardware failure" + }, + "Sensor Stopped" : { + "comment" : "Sensor session stopped" + }, "Session Age" : { "comment" : "Title describing sensor session age", "localizations" : { @@ -2237,6 +2249,12 @@ } } }, + "Session Failed" : { + "comment" : "Sensor session failed" + }, + "Signal Problem" : { + "comment" : "Sensor signal problem" + }, "Status" : { "comment" : "Title describing CGM calibration and battery state", "localizations" : { @@ -2981,6 +2999,9 @@ } } } + }, + "Warming Up" : { + "comment" : "Sensor warmup status" } }, "version" : "1.0" diff --git a/CGMBLEKitUI/TransmitterManager+UI.swift b/CGMBLEKitUI/TransmitterManager+UI.swift index 0cabda7..b332af6 100644 --- a/CGMBLEKitUI/TransmitterManager+UI.swift +++ b/CGMBLEKitUI/TransmitterManager+UI.swift @@ -33,9 +33,8 @@ extension G5CGMManager: CGMManagerUI { return nil } - // TODO Placeholder. public var cgmStatusHighlight: DeviceStatusHighlight? { - return nil + return TransmitterSessionStatus.highlight(for: latestReading) } // TODO Placeholder. @@ -43,9 +42,9 @@ extension G5CGMManager: CGMManagerUI { return nil } - // TODO Placeholder. public var cgmLifecycleProgress: DeviceLifecycleProgress? { - return nil + // G5 has no Anubis variant — always 2 h warmup. + return TransmitterSessionStatus.lifecycle(for: latestReading, isAnubis: false) } } @@ -71,9 +70,8 @@ extension G6CGMManager: CGMManagerUI { UIImage(named: "g6", in: Bundle(for: TransmitterSetupViewController.self), compatibleWith: nil)! } - // TODO Placeholder. public var cgmStatusHighlight: DeviceStatusHighlight? { - return nil + return TransmitterSessionStatus.highlight(for: latestReading) } // TODO Placeholder. @@ -81,8 +79,166 @@ extension G6CGMManager: CGMManagerUI { return nil } - // TODO Placeholder. public var cgmLifecycleProgress: DeviceLifecycleProgress? { + return TransmitterSessionStatus.lifecycle(for: latestReading, isAnubis: isAnubis) + } +} + + +/// Shared lifecycle + status-highlight derivation for both G5 and G6. +/// Both transmitters compute `sessionStartDate` / `sessionExpDate` on every +/// reading (`Glucose.swift`), so we don't need to hardcode a sensor lifetime +/// — the transmitter already knows. Sensor state (warmup, sensor failure, +/// calibration needed, session failure) comes from `Glucose.state`. +private enum TransmitterSessionStatus { + /// Stock G5/G6 warmup window (2 h from `sessionStartDate`). + static let standardWarmupDuration: TimeInterval = 2 * 60 * 60 + + /// Anubis-modded G6 warmup window (50 min from `sessionStartDate`). + static let anubisWarmupDuration: TimeInterval = 50 * 60 + + static func lifecycle(for glucose: Glucose?, isAnubis: Bool) -> DeviceLifecycleProgress? { + guard let glucose, let start = glucose.sessionStartDate else { return nil } + + // During warmup the session expiry is ~10 days out — using it as the + // ring's denominator would render ~0% and feel broken. Switch the + // ring's denominator to the actual warmup window (50 min for Anubis, + // 2 h for stock) so the arc visibly fills as warmup completes. + if case .known(.warmup) = glucose.state { + let elapsed = Date().timeIntervalSince(start) + let warmupDuration = isAnubis ? anubisWarmupDuration : standardWarmupDuration + let fraction = max(0, min(1, elapsed / warmupDuration)) + return G5G6LifecycleProgress(percentComplete: fraction, progressState: .normalCGM) + } + + // Sensor / session failure or stopped: timing is meaningless. + if case let .known(state) = glucose.state, !state.exposesLifecycle { + return nil + } + + guard let end = glucose.sessionExpDate else { return nil } + let total = end.timeIntervalSince(start) + guard total > 0 else { return nil } + let elapsed = Date().timeIntervalSince(start) + let fraction = max(0, min(1, elapsed / total)) + let progressState: DeviceLifecycleProgressState + if fraction >= 1.0 { + progressState = .critical + } else if elapsed >= total - 24 * 60 * 60 { + progressState = .warning + } else { + progressState = .normalCGM + } + return G5G6LifecycleProgress(percentComplete: fraction, progressState: progressState) + } + + static func highlight(for glucose: Glucose?) -> DeviceStatusHighlight? { + guard let glucose else { return nil } + + // Calibration-state surface wins over time-based expiry — a warming- + // up sensor hasn't reached expiry yet, and an explicit sensor / + // session failure is the more useful signal even if expiry is also + // in the past. + if case let .known(state) = glucose.state, let highlight = state.statusHighlight { + return highlight + } + + // Time-based expiry fallback. + if let end = glucose.sessionExpDate, Date() >= end { + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Sensor Expired", comment: "Sensor expired status"), + imageName: "exclamationmark.circle.fill", + state: .critical + ) + } return nil } } + +private extension CalibrationState.State { + /// `false` for states where session timing isn't yet meaningful — + /// warmup, sensor / session failure, fully stopped sensor. + var exposesLifecycle: Bool { + switch self { + case .warmup, + .stopped, + .sensorFailure11, + .sensorFailure12, + .sessionFailure15, + .sessionFailure16, + .sessionFailure17: + return false + default: + return true + } + } + + var statusHighlight: DeviceStatusHighlight? { + switch self { + case .warmup: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Warming Up", comment: "Sensor warmup status"), + imageName: "hourglass", + state: .warning + ) + case .needFirstInitialCalibration, + .needSecondInitialCalibration, + .needCalibration7, + .needCalibration14: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Calibration Needed", comment: "Sensor needs calibration"), + imageName: "drop.fill", + state: .warning + ) + case .calibrationError8, + .calibrationError9, + .calibrationError10, + .calibrationError13: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Calibration Error", comment: "Sensor calibration error"), + imageName: "exclamationmark.circle", + state: .warning + ) + case .sensorFailure11, + .sensorFailure12: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Sensor Failed", comment: "Sensor hardware failure"), + imageName: "exclamationmark.triangle.fill", + state: .critical + ) + case .sessionFailure15, + .sessionFailure16, + .sessionFailure17: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Session Failed", comment: "Sensor session failed"), + imageName: "exclamationmark.triangle.fill", + state: .critical + ) + case .stopped: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Sensor Stopped", comment: "Sensor session stopped"), + imageName: "stop.circle", + state: .critical + ) + case .questionMarks: + return G5G6StatusHighlight( + localizedMessage: NSLocalizedString("Signal Problem", comment: "Sensor signal problem"), + imageName: "questionmark.circle", + state: .warning + ) + case .ok: + return nil + } + } +} + +private struct G5G6LifecycleProgress: DeviceLifecycleProgress { + let percentComplete: Double + let progressState: DeviceLifecycleProgressState +} + +private struct G5G6StatusHighlight: DeviceStatusHighlight { + let localizedMessage: String + let imageName: String + let state: DeviceStatusHighlightState +}