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/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 b34e64b..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" : { @@ -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"