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
4 changes: 4 additions & 0 deletions CGMBLEKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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, ); }; };
Expand Down Expand Up @@ -391,6 +392,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>"; };
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; };
43A8EC54210D0A7400A81379 /* CGMBLEKitUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CGMBLEKitUI.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -730,6 +732,7 @@
43460F87200B30D10030C0E3 /* TransmitterIDTests.swift */,
43F82BCB1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift */,
43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */,
CA04000000000000000010C1 /* AnubisDetectionTests.swift */,
);
path = CGMBLEKitTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
Expand Down
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
}
}
118 changes: 118 additions & 0 deletions CGMBLEKitTests/AnubisDetectionTests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
23 changes: 22 additions & 1 deletion CGMBLEKitUI/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -2981,6 +2999,9 @@
}
}
}
},
"Warming Up" : {
"comment" : "Sensor warmup status"
}
},
"version" : "1.0"
Expand Down
Loading