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
16 changes: 12 additions & 4 deletions CGMBLEKit/Messages/TransmitterVersionRxMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
}
6 changes: 4 additions & 2 deletions CGMBLEKit/Messages/TransmitterVersionTxMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
33 changes: 33 additions & 0 deletions CGMBLEKit/Transmitter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions CGMBLEKit/TransmitterManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}


Expand Down
20 changes: 18 additions & 2 deletions CGMBLEKit/TransmitterManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
)
}

Expand All @@ -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
}
}
2 changes: 1 addition & 1 deletion CGMBLEKitUI/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
170 changes: 163 additions & 7 deletions CGMBLEKitUI/TransmitterManager+UI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,18 @@ extension G5CGMManager: CGMManagerUI {
return nil
}

// TODO Placeholder.
public var cgmStatusHighlight: DeviceStatusHighlight? {
return nil
return TransmitterSessionStatus.highlight(for: latestReading)
}

// TODO Placeholder.
public var cgmStatusBadge: DeviceStatusBadge? {
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)
}
}

Expand All @@ -71,18 +70,175 @@ 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.
public var cgmStatusBadge: DeviceStatusBadge? {
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
}