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/CGMBLEKitUI/Localizable.xcstrings b/CGMBLEKitUI/Localizable.xcstrings index 22668a2..b34e64b 100644 --- a/CGMBLEKitUI/Localizable.xcstrings +++ b/CGMBLEKitUI/Localizable.xcstrings @@ -1803,7 +1803,7 @@ } }, "Sensor Expired" : { - "comment" : "Title describing past sensor sensor expiration", + "comment" : "Sensor expired status\nTitle describing past sensor sensor expiration", "localizations" : { "ar" : { "stringUnit" : { 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 +}