@glassly/bluetooth-sdk 0.1.0-dev.99 → 0.2.8-dev.445

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1420 -495
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -0,0 +1,104 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class G2ImageAckTests: XCTestCase {
6
+ func testOmittedZeroSessionAndFragmentDecodeAsDefaults() throws {
7
+ let ack = try XCTUnwrap(ImgAckBox.decodeResult(Data([0x40, 0x04])))
8
+ XCTAssertEqual(ack.session, 0)
9
+ XCTAssertEqual(ack.fragment, 0)
10
+ XCTAssertTrue(ack.success)
11
+ let explicit = try XCTUnwrap(ImgAckBox.decodeResult(Data([0x18, 0, 0x30, 0, 0x40, 4])))
12
+ XCTAssertEqual(explicit.session, ack.session)
13
+ XCTAssertEqual(explicit.fragment, ack.fragment)
14
+ }
15
+
16
+ func testNonzeroSessionAndNackRemainCorrelated() throws {
17
+ let ack = try XCTUnwrap(ImgAckBox.decodeResult(Data([0x18, 0xFF, 1, 0x30, 2, 0x40, 5])))
18
+ XCTAssertEqual(ack.session, 255)
19
+ XCTAssertEqual(ack.fragment, 2)
20
+ XCTAssertFalse(ack.success)
21
+ XCTAssertNil(ImgAckBox.decodeResult(Data()))
22
+ XCTAssertNil(ImgAckBox.decodeResult(Data([0x18, 1])))
23
+ XCTAssertNil(ImgAckBox.decodeResult(Data([0x1A, 0, 0x40, 4])))
24
+ }
25
+
26
+ @MainActor
27
+ func testWireAckAtSessionWrapResumesWithoutTimingOut() async throws {
28
+ let box = ImgAckBox()
29
+ for session in [254, 255, 0, 1] {
30
+ // Protobuf encoders can omit default-valued scalar fields.
31
+ var wire: [UInt8] = [0x40, 4]
32
+ if session > 0 {
33
+ wire += session < 128 ? [0x18, UInt8(session)] : [0x18, UInt8(session & 0x7F) | 0x80, 1]
34
+ }
35
+ let ack = try XCTUnwrap(ImgAckBox.decodeResult(Data(wire)))
36
+ let ok = await box.wait(session: session, fragment: 0, timeoutNs: 100_000_000) {
37
+ XCTAssertTrue(box.resolve(session: ack.session, fragment: ack.fragment, success: ack.success))
38
+ }
39
+ XCTAssertTrue(ok)
40
+ XCTAssertEqual(box.outcome()?.reason, "ack")
41
+ }
42
+ }
43
+
44
+ @MainActor
45
+ func testImmediateAckAndDuplicateDuringSustainedTransfers() async {
46
+ let box = ImgAckBox()
47
+ // Cross the session wrap repeatedly. No timer from an earlier arming
48
+ // may complete a newer transfer, including one with the same wire key.
49
+ for index in 0 ..< 1024 {
50
+ let session = index % 256
51
+ let ok = await box.wait(session: session, fragment: 0, timeoutNs: 1_000_000_000) {
52
+ XCTAssertFalse(box.resolve(session: session, fragment: 1, success: true))
53
+ XCTAssertTrue(box.resolve(session: session, fragment: 0, success: true))
54
+ XCTAssertFalse(box.resolve(session: session, fragment: 0, success: true))
55
+ }
56
+ XCTAssertTrue(ok)
57
+ }
58
+ }
59
+
60
+ @MainActor
61
+ func testCancelledBeforeArmingDoesNotSend() async {
62
+ let box = ImgAckBox()
63
+ let task = Task { @MainActor in
64
+ await box.wait(session: 1, fragment: 0, timeoutNs: 1_000_000_000) {
65
+ XCTFail("cancelled transfer sent a packet")
66
+ }
67
+ }
68
+ task.cancel()
69
+ let ok = await task.value
70
+ XCTAssertFalse(ok)
71
+ }
72
+
73
+ @MainActor
74
+ func testCancellationReleasesAnArmedWaiter() async {
75
+ let box = ImgAckBox()
76
+ let sent = expectation(description: "sent")
77
+ let task = Task { @MainActor in
78
+ await box.wait(session: 1, fragment: 0, timeoutNs: 60_000_000_000) {
79
+ sent.fulfill()
80
+ }
81
+ }
82
+ await fulfillment(of: [sent], timeout: 1)
83
+ task.cancel()
84
+ let ok = await task.value
85
+ XCTAssertFalse(ok)
86
+ XCTAssertFalse(box.resolve(session: 1, fragment: 0, success: true))
87
+ }
88
+
89
+ func testOldTimeoutCannotResolveReusedSession() async {
90
+ let box = ImgAckBox()
91
+ var oldToken: UInt64 = 0
92
+ let first = await withCheckedContinuation { cont in
93
+ oldToken = box.arm(session: 1, fragment: 0, cont: cont)
94
+ box.resolve(session: 1, fragment: 0, success: true)
95
+ }
96
+ XCTAssertTrue(first)
97
+ let second = await withCheckedContinuation { cont in
98
+ _ = box.arm(session: 1, fragment: 0, cont: cont)
99
+ box.timeout(token: oldToken)
100
+ XCTAssertTrue(box.resolve(session: 1, fragment: 0, success: true))
101
+ }
102
+ XCTAssertTrue(second)
103
+ }
104
+ }
@@ -0,0 +1,274 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ /// Pins the keyboard bridge wire format and HID decoding shared with g2flash's
6
+ /// patches/keyboard_core.h and demos/keyboard.test.ts (same vectors).
7
+ final class G2KeyboardTests: XCTestCase {
8
+ private static let boot: [UInt8] = [
9
+ 0x05, 1, 0x09, 6, 0xA1, 1, 0x05, 7, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 8, 0x81, 2,
10
+ 0x75, 8, 0x95, 1, 0x81, 1, 0x95, 6, 0x75, 8, 0x15, 0, 0x25, 0x65, 0x05, 7, 0x19, 0, 0x29, 0x65, 0x81, 0, 0xC0,
11
+ ]
12
+
13
+ private static let numbered: [UInt8] = Array(boot[0 ..< 6]) + [0x85, 1] + Array(boot[6 ..< (boot.count - 1)]) + [
14
+ 0x85, 2, 0x05, 0x0C, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 3, 0x09, 0xE9, 0x09, 0xEA, 0x09, 0xE2, 0x81, 2,
15
+ 0x75, 5, 0x95, 1, 0x81, 1, 0xC0,
16
+ ]
17
+
18
+ private func le16(_ v: Int) -> [UInt8] {
19
+ [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF)]
20
+ }
21
+
22
+ private func le32(_ v: UInt32) -> [UInt8] {
23
+ [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF), UInt8((v >> 16) & 0xFF), UInt8(v >> 24)]
24
+ }
25
+
26
+ private func frame(
27
+ _ kind: UInt8, _ seq: UInt32, _ epoch: Int, _ request: Int, _ code: Int, _ handle: Int,
28
+ _ data: [UInt8], offset: Int = 0, total: Int? = nil
29
+ ) -> Data {
30
+ var p: [UInt8] = [75, 66, 1, kind] + le32(seq) + le16(epoch) + le16(request) + le16(code) + le16(handle)
31
+ p += le16(total ?? data.count) + le16(offset) + le32(1234) + data
32
+ return Data(p)
33
+ }
34
+
35
+ private func usages(_ keys: [G2KeyboardHid.Usage]) -> [[Int]] {
36
+ keys.map { [$0.page, $0.usage] }
37
+ }
38
+
39
+ private func events(_ keys: [G2KeyboardHid.KeyEvent]) -> [[Int]] {
40
+ keys.map { [$0.page, $0.usage, $0.down ? 1 : 0] }
41
+ }
42
+
43
+ // MARK: - HID decoding
44
+
45
+ func testBootArrayModifiersReleaseRepeatsAndRollover() throws {
46
+ let r = try XCTUnwrap(G2KeyboardHid.parseReportMap(Self.boot)[0])
47
+ var keys = G2KeyboardHid.HeldKeys()
48
+ XCTAssertEqual(try events(keys.update(handle: 12, report: r, data: [2, 0, 4, 0, 0, 0, 0, 0])), [[7, 0xE1, 1], [7, 4, 1]])
49
+ XCTAssertEqual(try keys.update(handle: 12, report: r, data: [2, 0, 4, 0, 0, 0, 0, 0]), [])
50
+ XCTAssertThrowsError(try keys.update(handle: 12, report: r, data: [0, 0, 1, 1, 1, 1, 1, 1])) { error in
51
+ XCTAssertTrue("\(error)".contains("rollover"))
52
+ }
53
+ XCTAssertEqual(keys.reset().map(\.down), [false, false])
54
+ XCTAssertThrowsError(try G2KeyboardHid.reportKeys(r, [0])) { XCTAssertTrue("\($0)".contains("length")) }
55
+ }
56
+
57
+ func testHogpReportIdsAreOutOfBandWithConsumerKeys() throws {
58
+ let maps = try G2KeyboardHid.parseReportMap(Self.numbered)
59
+ XCTAssertEqual(try usages(G2KeyboardHid.reportKeys(XCTUnwrap(maps[1]), [0, 0, 5, 0, 0, 0, 0, 0])), [[7, 5]])
60
+ XCTAssertEqual(try usages(G2KeyboardHid.reportKeys(XCTUnwrap(maps[2]), [5])), [[12, 0xE9], [12, 0xE2]])
61
+ XCTAssertThrowsError(try G2KeyboardHid.reportKeys(XCTUnwrap(maps[2]), [2, 5]))
62
+ }
63
+
64
+ func testNkroBitmapWithUnalignedFieldsAndReportUnion() throws {
65
+ let map: [UInt8] = [5, 7, 0x19, 4, 0x29, 19, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 16, 0x81, 2]
66
+ let r = try XCTUnwrap(G2KeyboardHid.parseReportMap(map)[0])
67
+ XCTAssertEqual(try usages(G2KeyboardHid.reportKeys(r, [0x81, 0x80])), [[7, 4], [7, 11], [7, 19]])
68
+ var keys = G2KeyboardHid.HeldKeys()
69
+ XCTAssertEqual(try keys.update(handle: 10, report: r, data: [1, 0]).count, 1)
70
+ XCTAssertEqual(try keys.update(handle: 11, report: r, data: [1, 0]), [])
71
+ XCTAssertEqual(try keys.update(handle: 10, report: r, data: [0, 0]), [])
72
+ XCTAssertEqual(try events(keys.update(handle: 11, report: r, data: [0, 0])), [[7, 4, 0]])
73
+ }
74
+
75
+ func testArrayValuesIndexUsagesRelativeToLogicalMinimum() throws {
76
+ let r = try XCTUnwrap(G2KeyboardHid.parseReportMap([5, 7, 0x19, 4, 0x29, 6, 0x15, 1, 0x25, 3, 0x75, 8, 0x95, 1, 0x81, 0])[0])
77
+ XCTAssertEqual(try usages(G2KeyboardHid.reportKeys(r, [2])), [[7, 5]])
78
+ XCTAssertEqual(try G2KeyboardHid.reportKeys(r, [0]), [])
79
+ }
80
+
81
+ func testDescriptorBoundsAndMalformedItems() {
82
+ let malformed: [[UInt8]] = [
83
+ [], [UInt8](repeating: 0, count: 513), [0xFE], [0x75], [0xB4], [0xC0], [0x85, 0],
84
+ [0x75, 33, 0x95, 1, 0x81, 2], [0x75, 32, 0x96, 0, 2, 0x81, 2], [0xA1, 1],
85
+ ]
86
+ for data in malformed {
87
+ XCTAssertThrowsError(try G2KeyboardHid.parseReportMap(data))
88
+ }
89
+ // Deterministic malformed corpus; parsing must be bounded and never hang.
90
+ var seed: UInt32 = 1
91
+ for i in 0 ..< 1000 {
92
+ let data = (0 ..< (i % 128 + 1)).map { _ -> UInt8 in
93
+ seed = seed &* 1_664_525 &+ 1_013_904_223
94
+ return UInt8(seed >> 24)
95
+ }
96
+ _ = try? G2KeyboardHid.parseReportMap(data)
97
+ }
98
+ }
99
+
100
+ // MARK: - Relay protocol
101
+
102
+ func testShortUuidsEncodedWithTheBluetoothBase() throws {
103
+ let uuid = try G2KeyboardClient.AttributeUuid([0xFB, 0x34, 0x9B, 0x5F, 0x80, 0, 0, 0x80, 0, 0x10, 0, 0, 0x12, 0x18, 0, 0])
104
+ XCTAssertEqual(uuid, .short(0x1812))
105
+ }
106
+
107
+ func testWireControlHasTheDedicatedFieldAndEpoch() {
108
+ let packet = [UInt8](G2KeyboardProtocol.controlPacket(op: .connect, request: 0x1234, epoch: 0x5678, body: [1, 2, 3, 4, 5, 6, 7], magic: 5))
109
+ XCTAssertEqual(packet, [8, 2, 16, 5, 0x22, 2, 8, 1, 0x92, 8, 15, 75, 66, 1, 4, 0x34, 0x12, 0x78, 0x56, 1, 2, 3, 4, 5, 6, 7])
110
+ // Magic ids above 127 take two varint bytes.
111
+ XCTAssertEqual(Array([UInt8](G2KeyboardProtocol.controlPacket(op: .query, request: 1, epoch: 0, magic: 200))[0 ..< 5]), [8, 2, 16, 0xC8, 0x01])
112
+ }
113
+
114
+ func testFragmentsDuplicatesGapsEpochsAndSequenceWrap() {
115
+ var stream = G2KeyboardProtocol.RecordAssembler()
116
+ let data = (0 ..< 200).map { UInt8($0) }
117
+ XCTAssertNil(stream.push(frame(3, 0xFFFF_FFFF, 1, 0, 13, 33, Array(data[0 ..< 96]), offset: 0, total: 200)))
118
+ XCTAssertNil(stream.push(frame(3, 0xFFFF_FFFF, 1, 0, 13, 33, Array(data[96 ..< 192]), offset: 96, total: 200)))
119
+ XCTAssertEqual(stream.push(frame(3, 0xFFFF_FFFF, 1, 0, 13, 33, Array(data[192...]), offset: 192, total: 200))?.data, data)
120
+ let status = frame(1, 0, 1, 0, 0, 0, [UInt8](repeating: 0, count: 10))
121
+ XCTAssertEqual(stream.push(status)?.sequence, 0)
122
+ XCTAssertNil(stream.push(status))
123
+ XCTAssertEqual(stream.gaps, 1)
124
+ _ = stream.push(frame(1, 2, 1, 0, 0, 0, [UInt8](repeating: 0, count: 10)))
125
+ XCTAssertEqual(stream.gaps, 2)
126
+ XCTAssertNil(stream.push(status))
127
+ XCTAssertEqual(stream.gaps, 2)
128
+ _ = stream.push(frame(1, 3, 2, 0, 0, 0, [UInt8](repeating: 0, count: 10)))
129
+ XCTAssertEqual(stream.gaps, 3)
130
+ }
131
+
132
+ func testMismatchedFragmentsNeverEmitPartialInput() {
133
+ var stream = G2KeyboardProtocol.RecordAssembler()
134
+ _ = stream.push(frame(3, 1, 1, 0, 13, 10, [UInt8](repeating: 0, count: 96), offset: 0, total: 100))
135
+ XCTAssertNil(stream.push(frame(3, 1, 1, 0, 13, 11, [UInt8](repeating: 0, count: 4), offset: 96, total: 100)))
136
+ XCTAssertEqual(stream.gaps, 2)
137
+ XCTAssertNil(stream.push(frame(3, 1, 1, 0, 13, 10, [UInt8](repeating: 0, count: 4), offset: 96, total: 100)))
138
+ XCTAssertNil(stream.push(Data(repeating: 0, count: 513)))
139
+ }
140
+
141
+ func testAdvertParsingNamesHidDevicesAndReversesAddresses() throws {
142
+ let body: [UInt8] = [1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 12, 3, 3, 0x12, 0x18, 5, 9, 0x4B, 0x65, 0x79, 0x73]
143
+ let advert = try XCTUnwrap(G2KeyboardProtocol.Advert.parse(body))
144
+ XCTAssertEqual(advert.addressText, "aa:bb:cc:dd:ee:ff")
145
+ XCTAssertEqual(advert.rssi, -60)
146
+ XCTAssertTrue(advert.hid)
147
+ XCTAssertEqual(advert.name, "Keys")
148
+ XCTAssertEqual(G2KeyboardProtocol.address(fromText: advert.addressText), advert.address)
149
+ XCTAssertNil(G2KeyboardProtocol.address(fromText: "aa:bb"))
150
+ }
151
+
152
+ func testAdvertAppearanceMarksHidAndScanResponseMerges() throws {
153
+ // ADV_IND with only a keyboard appearance (0x03C1), no 0x1812 service list.
154
+ let adv: [UInt8] = [1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 4, 3, 0x19, 0xC1, 0x03]
155
+ let first = try XCTUnwrap(G2KeyboardProtocol.Advert.parse(adv))
156
+ XCTAssertTrue(first.hid)
157
+ XCTAssertEqual(first.name, "")
158
+ // SCAN_RSP for the same address carries the name and a better RSSI.
159
+ let rsp: [UInt8] = [1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xCE, 4, 6, 5, 9, 0x4B, 0x65, 0x79, 0x73]
160
+ let second = try XCTUnwrap(G2KeyboardProtocol.Advert.parse(rsp))
161
+ XCTAssertFalse(second.hid)
162
+ let merged = first.merging(second)
163
+ XCTAssertEqual(merged.name, "Keys")
164
+ XCTAssertTrue(merged.hid)
165
+ XCTAssertEqual(merged.rssi, -50)
166
+ XCTAssertEqual(merged.address, first.address)
167
+ // A non-HID appearance (generic phone) does not qualify.
168
+ let phone: [UInt8] = [1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 4, 3, 0x19, 0x40, 0x00]
169
+ XCTAssertFalse(try XCTUnwrap(G2KeyboardProtocol.Advert.parse(phone)).hid)
170
+ }
171
+
172
+ // MARK: - Simulated HOGP keyboard
173
+
174
+ @MainActor
175
+ func testFullSimulatedHogpDiscoveryTypingAndLossRecovery() async throws {
176
+ struct Attr { let uuid: Int; let value: [UInt8] }
177
+ var attrs: [Int: Attr] = [:]
178
+ func add(_ h: Int, _ uuid: Int, _ bytes: [UInt8]) {
179
+ attrs[h] = Attr(uuid: uuid, value: bytes)
180
+ }
181
+ let mockMap = Array(Self.numbered[0 ..< (Self.numbered.count - 1)]) + [0x85, 3, 0x05, 6, 0x09, 0x20, 0x15, 0, 0x25, 100, 0x75, 8, 0x95, 1, 0x81, 2, 0xC0]
182
+ add(1, 0x2800, [1, 0x18]); add(2, 0x2803, [0x20, 3, 0, 5, 0x2A]); add(3, 0x2A05, []); add(4, 0x2902, [0, 0])
183
+ add(20, 0x2800, [0x12, 0x18])
184
+ add(21, 0x2803, [2, 22, 0, 0x4B, 0x2A]); add(22, 0x2A4B, mockMap); add(23, 0x2907, [0x19, 0x2A])
185
+ add(24, 0x2803, [6, 25, 0, 0x4E, 0x2A]); add(25, 0x2A4E, [1])
186
+ add(26, 0x2803, [0x12, 27, 0, 0x4D, 0x2A]); add(27, 0x2A4D, []); add(28, 0x2902, [0, 0]); add(29, 0x2908, [1, 1])
187
+ add(30, 0x2803, [0x12, 31, 0, 0x4D, 0x2A]); add(31, 0x2A4D, []); add(32, 0x2902, [0, 0]); add(33, 0x2908, [2, 1])
188
+ add(40, 0x2800, [0x0F, 0x18]); add(41, 0x2803, [0x12, 42, 0, 0x19, 0x2A]); add(42, 0x2A19, [100]); add(43, 0x2902, [0, 0]); add(44, 0x2908, [3, 1])
189
+
190
+ var seq: UInt32 = 0
191
+ var epoch = 0
192
+ var writes: [Int] = []
193
+ var watches: [Int] = []
194
+ var typed: [[Int]] = []
195
+ var client: G2KeyboardClient!
196
+ func emit(_ kind: UInt8, _ request: Int, _ code: Int, _ handle: Int, _ data: [UInt8]) {
197
+ seq += 1
198
+ client.accept(frame(kind, seq, epoch, request, code, handle, data))
199
+ }
200
+ let sorted = { attrs.keys.sorted() }
201
+ client = G2KeyboardClient(send: { pb, _ in
202
+ let p = [UInt8](pb)
203
+ // Field 130 (tag 0x92 0x08) follows a magic varint of one or two bytes.
204
+ let tag = p.indices.first { p[$0] == 0x92 && p[$0 + 1] == 0x08 }!
205
+ let body = Array(p[(tag + 3)...])
206
+ let op = Int(body[3]), req = Int(G2KeyboardProtocol.u16(body, 4))
207
+ let h = body.count >= 10 ? Int(G2KeyboardProtocol.u16(body, 8)) : 0
208
+ switch op {
209
+ case 1: emit(1, req, 0, 0, [1, 0, 0, 0, 0, 0, 0, 0, 0, 0])
210
+ case 4:
211
+ epoch += 1
212
+ emit(1, req, 0, 0, [1, 3, 1, 1, 0, 0, 0, 0, 0, 0])
213
+ case 9:
214
+ let batch = sorted().filter { $0 >= h }.prefix(4)
215
+ var payload: [UInt8] = []
216
+ if !batch.isEmpty {
217
+ payload = [1] + batch.flatMap { handle in self.le16(handle) + self.le16(attrs[handle]!.uuid) }
218
+ }
219
+ emit(2, req, batch.isEmpty ? (10 << 8) | 2 : 2, h, payload)
220
+ case 10:
221
+ let data = attrs[h]!.value
222
+ let offset = Int(G2KeyboardProtocol.u16(body, 10))
223
+ let status = offset >= data.count ? (7 << 8) | 6 : (offset > 0 ? 6 : 5)
224
+ emit(2, req, status, h, Array(data[min(offset, data.count) ..< min(offset + 20, data.count)]))
225
+ case 11:
226
+ writes.append(h)
227
+ emit(2, req, body[10] != 0 ? 10 : 9, h, [])
228
+ case 12:
229
+ let count = Int(body[8])
230
+ watches = (0 ..< count).map { Int(G2KeyboardProtocol.u16(body, 9 + 2 * $0)) }
231
+ emit(1, req, 0, 0, [1, 3, 1, 1, 0, 0, 0, 0, 0, 0])
232
+ default:
233
+ XCTFail("Unexpected op \(op)")
234
+ }
235
+ }, onEvent: { event in
236
+ if case let .keys(events) = event {
237
+ typed += events.map { [$0.usage, $0.down ? 1 : 0] }
238
+ }
239
+ })
240
+
241
+ try await client.enable()
242
+ try await client.connect(addressType: 1, address: [0, 0, 0, 0, 0, 0])
243
+ let config = try await client.configure()
244
+ XCTAssertEqual(watches, [27, 31, 42, 3])
245
+ XCTAssertEqual(writes, [25, 4, 28, 32, 43])
246
+ XCTAssertEqual(config.reports.map(\.id), [1, 2, 3])
247
+
248
+ emit(3, 0, 13, 27, [0, 0, 4, 0, 0, 0, 0, 0])
249
+ emit(3, 0, 13, 31, [1])
250
+ XCTAssertEqual(typed, [[4, 1], [0xE9, 1]])
251
+ seq += 1 // release lost in the firmware queue or phone notification path
252
+ emit(1, 0, 0, 0, [1, 3, 1, 1, 0, 0, 0, 0, 0, 0])
253
+ XCTAssertEqual(typed.suffix(2).map { $0[1] }, [0, 0])
254
+
255
+ let restored = G2KeyboardClient(send: { _, _ in }, onEvent: { event in
256
+ if case let .keys(events) = event {
257
+ typed += events.map { [$0.usage, $0.down ? 1 : 0] }
258
+ }
259
+ })
260
+ try restored.restore(config)
261
+ seq += 1
262
+ restored.accept(frame(3, seq, epoch, 0, 13, 27, [0, 0, 5, 0, 0, 0, 0, 0]))
263
+ XCTAssertEqual(typed.last, [5, 1])
264
+ restored.lostPhoneLink()
265
+ XCTAssertEqual(typed.last, [5, 0])
266
+
267
+ emit(3, 0, 14, 3, [1, 0, 255, 255]) // Service Changed invalidates the mapping
268
+ let count = typed.count
269
+ emit(3, 0, 13, 27, [0, 0, 6, 0, 0, 0, 0, 0])
270
+ XCTAssertEqual(typed.count, count)
271
+ client.close()
272
+ restored.close()
273
+ }
274
+ }
@@ -22,7 +22,7 @@ final class G2NotifyFileTests: XCTestCase {
22
22
  XCTAssertEqual(G2NotifyFile.crc32c(content), 0xE96D_1DA8)
23
23
 
24
24
  let header = G2NotifyFile.fileCheckHeader(
25
- filename: G2NotifyFile.whitelistFilename, content: content
25
+ filename: G2NotifyFile.whitelistFilename, content: content, fileType: .whitelist
26
26
  )
27
27
  XCTAssertEqual(header.count, 93)
28
28
 
@@ -33,10 +33,18 @@ final class G2NotifyFileTests: XCTestCase {
33
33
  }
34
34
  return value
35
35
  }
36
- XCTAssertEqual(u32(0), 0x100) // mode
37
- XCTAssertEqual(u32(4), 41472) // size = len * 256
38
- XCTAssertEqual(u32(8), 0x6D1D_A800) // checksum = crc << 8
39
- XCTAssertEqual(header[12], 0xE9) // extra = crc >> 24
36
+ XCTAssertEqual(header[0], 0x00) // SEND_START
37
+ XCTAssertEqual(u32(1), 0) // NOTIFICATION_JSON_WHITELIST
38
+ XCTAssertEqual(u32(5), 162) // size
39
+ XCTAssertEqual(u32(9), 0xE96D_1DA8) // crc32c
40
+
41
+ // The docs' shifted view of the same bytes: "mode" 0x100 would have been
42
+ // the Android-notification type, which is what put blank cards in the
43
+ // stock viewer.
44
+ XCTAssertEqual(u32(0), 0x000) // mode == fileType << 8
45
+ XCTAssertEqual(u32(4), 41472) // size * 256
46
+ XCTAssertEqual(u32(8), 0x6D1D_A800) // crc << 8
47
+ XCTAssertEqual(header[12], 0xE9) // crc >> 24
40
48
 
41
49
  let nameBytes = header.subdata(in: 13 ..< 93)
42
50
  let expectedName = Array(G2NotifyFile.whitelistFilename.utf8)
@@ -44,6 +52,14 @@ final class G2NotifyFileTests: XCTestCase {
44
52
  XCTAssertTrue(nameBytes.dropFirst(expectedName.count).allSatisfy { $0 == 0 })
45
53
  }
46
54
 
55
+ func testFileCheckHeaderTypesTheAndroidNotification() {
56
+ let header = G2NotifyFile.fileCheckHeader(
57
+ filename: G2NotifyFile.whitelistFilename, content: Data("{}".utf8), fileType: .androidNotification
58
+ )
59
+ XCTAssertEqual(header[0], 0x00)
60
+ XCTAssertEqual(Array(header[1 ..< 5]), [0x01, 0x00, 0x00, 0x00])
61
+ }
62
+
47
63
  func testWhitelistJsonShape() throws {
48
64
  let json = G2NotifyFile.whitelistJson(
49
65
  filterEnabled: true,
@@ -0,0 +1,56 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class G2RingBatteryTests: XCTestCase {
6
+ func testConnectedBatteryIncludingEmptyFullAndCharging() {
7
+ for level: UInt8 in [0, 62, 100] {
8
+ XCTAssertEqual(G2RingBattery.decode(Data([0x52, 0x42, 1, 3, level])),
9
+ G2RingBattery(connected: true, level: Int(level), charging: false))
10
+ XCTAssertEqual(G2RingBattery.decode(Data([0x52, 0x42, 1, 7, level])),
11
+ G2RingBattery(connected: true, level: Int(level), charging: true))
12
+ }
13
+ }
14
+
15
+ func testDisconnectedAndUnknownReportsClearCachedBattery() {
16
+ XCTAssertEqual(G2RingBattery.decode(Data([0x52, 0x42, 1, 0, 255])), .disconnected)
17
+ XCTAssertEqual(G2RingBattery.decode(Data([0x52, 0x42, 1, 6, 62])), .disconnected)
18
+ XCTAssertEqual(G2RingBattery.decode(Data([0x52, 0x42, 1, 1, 255])),
19
+ G2RingBattery(connected: true, level: -1, charging: false))
20
+ XCTAssertEqual(G2RingBattery.decode(Data([0x52, 0x42, 1, 5, 62])),
21
+ G2RingBattery(connected: true, level: -1, charging: false))
22
+ }
23
+
24
+ func testBatterySurvivesSnapshotAndPartialStatusBridge() throws {
25
+ let battery = try XCTUnwrap(G2RingBattery.decode(Data([0x52, 0x42, 1, 7, 62])))
26
+ let values: [String: Any] = [
27
+ "ringConnected": battery.connected, "ringBatteryLevel": battery.level, "ringCharging": battery.charging,
28
+ ]
29
+ let status = GlassesStatus(values: values)
30
+ XCTAssertEqual(status.dictionary["ringBatteryLevel"] as? Int, 62)
31
+ XCTAssertTrue(status.ringConnected)
32
+ XCTAssertTrue(status.ringCharging)
33
+ XCTAssertFalse(status.controllerConnected)
34
+ XCTAssertEqual(status.controllerBatteryLevel, -1)
35
+ let update = GlassesStatusUpdate(values: values)
36
+ XCTAssertEqual(update.dictionary as NSDictionary, values as NSDictionary)
37
+ XCTAssertEqual(update.ringBatteryLevel, 62)
38
+ XCTAssertNil(GlassesStatusUpdate(values: [:]).ringBatteryLevel)
39
+ XCTAssertEqual(GlassesStatus(values: [:]).ringBatteryLevel, -1)
40
+ let disconnected = status.disconnected()
41
+ XCTAssertFalse(disconnected.ringConnected)
42
+ XCTAssertEqual(disconnected.ringBatteryLevel, -1)
43
+ XCTAssertFalse(disconnected.ringCharging)
44
+ }
45
+
46
+ func testMalformedReportsAreIgnored() {
47
+ let malformed: [[UInt8]] = [
48
+ [], [0x52, 0x42, 1, 3], [0x52, 0x42, 1, 3, 62, 0],
49
+ [0x41, 0x42, 1, 3, 62], [0x52, 0x41, 1, 3, 62],
50
+ [0x52, 0x42, 2, 3, 62], [0x52, 0x42, 1, 3, 101], [0x52, 0x42, 1, 3, 255],
51
+ ]
52
+ for bytes in malformed {
53
+ XCTAssertNil(G2RingBattery.decode(Data(bytes)), "Invalid report: \(bytes)")
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,56 @@
1
+ @testable import BluetoothSDK
2
+ import XCTest
3
+
4
+ final class G2SensorsTests: XCTestCase {
5
+ func testNorthMayOmitTheProtobufScalarButMalformedHeadingsAreRejected() {
6
+ XCTAssertEqual(G2Sensors.heading([:]), 0)
7
+ XCTAssertEqual(G2Sensors.heading([1: Int32(0)]), 0)
8
+ XCTAssertEqual(G2Sensors.heading([1: Int32(359)]), 359)
9
+ XCTAssertNil(G2Sensors.heading([1: Int32(360)]))
10
+ XCTAssertNil(G2Sensors.heading([1: Data([0])]))
11
+ }
12
+
13
+ func testControlsMatchUpstreamModesAndLittleEndianOptions() {
14
+ XCTAssertEqual(G2Sensors.command(sensor: "light", operation: "query", options: [:]), Data([16, 0]))
15
+ XCTAssertEqual(G2Sensors.command(sensor: "light", operation: "start", options: [:]), Data([16, 1, 1, 244, 1, 1, 0, 136, 19]))
16
+ XCTAssertEqual(G2Sensors.command(sensor: "compass", operation: "start", options: ["intervalMs": 250, "minChangeDegrees": 0]), Data([10, 2, 250, 0, 0, 0]))
17
+ XCTAssertEqual(G2Sensors.command(sensor: "light", operation: "stop", options: [:]), Data([16, 2]))
18
+ XCTAssertEqual(G2Sensors.command(sensor: "compass", operation: "stop", options: [:]), Data([10, 0]))
19
+ XCTAssertNil(G2Sensors.command(sensor: "light", operation: "start", options: ["intervalMs": 99]))
20
+ XCTAssertNil(G2Sensors.command(sensor: "compass", operation: "start", options: ["intervalMs": .nan]))
21
+ XCTAssertNil(G2Sensors.command(sensor: "compass", operation: "start", options: ["minChangeDegrees": 0.5]))
22
+ }
23
+
24
+ func testLightReportPreservesRawValuesAndFreshness() throws {
25
+ let bytes: [UInt8] = [65, 76, 1, 2, 15, 3, 210, 4, 0, 0, 46, 22, 0, 0, 250, 0, 9, 3, 0, 4, 66, 0, 0, 240]
26
+ let report = try XCTUnwrap(G2Sensors.light(Data(bytes)))
27
+ XCTAssertEqual(report["reason"] as? String, "poll")
28
+ XCTAssertEqual(report["value"] as? Int64, 1234)
29
+ XCTAssertEqual(report["peak"] as? Int64, 5678)
30
+ XCTAssertEqual(report["sampleTimestampMs"] as? Int64, 4_026_531_906)
31
+ XCTAssertEqual(report["readSucceeded"] as? Bool, true)
32
+ XCTAssertEqual(report["autoBrightness"] as? Bool, true)
33
+ XCTAssertEqual(report["passive"] as? Bool, true)
34
+ XCTAssertNil(G2Sensors.light(Data(bytes.dropLast())))
35
+ var stale = bytes; stale[4] = 0
36
+ XCTAssertEqual(G2Sensors.light(Data(stale))?["readSucceeded"] as? Bool, false)
37
+ stale[2] = 2
38
+ XCTAssertNil(G2Sensors.light(Data(stale)))
39
+ }
40
+
41
+ func testCompassDiagnosticsAndUnknownMarkers() throws {
42
+ var bytes: [UInt8] = [67, 77, 1, 3, 1, 2, 143, 0, 66, 0, 0, 240]
43
+ let report = try XCTUnwrap(G2Sensors.compass(Data(bytes)))
44
+ XCTAssertEqual(report["source"] as? String, "gmrv")
45
+ XCTAssertEqual(report["accuracy"] as? Int, 3)
46
+ XCTAssertEqual(report["sampleMatched"] as? Bool, true)
47
+ XCTAssertEqual(report["magneticDataFresh"] as? Bool, true)
48
+ XCTAssertEqual(report["sampleTimestampMs"] as? Int64, 4_026_531_906)
49
+ bytes[3] = 255; bytes[4] = 255; bytes[5] = 0; bytes[6] = 0
50
+ XCTAssertTrue(G2Sensors.compass(Data(bytes))?["accuracy"] is NSNull)
51
+ XCTAssertEqual(G2Sensors.compass(Data(bytes))?["sampleMatched"] as? Bool, false)
52
+ bytes[3] = 4
53
+ XCTAssertNil(G2Sensors.compass(Data(bytes)))
54
+ XCTAssertNil(G2Sensors.compass(Data(bytes.dropLast())))
55
+ }
56
+ }
@@ -0,0 +1,71 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class G2TouchInputTests: XCTestCase {
6
+ private func fields(event: Int32, wire: UInt8, tick: UInt32 = 123) -> [Int: Any] {
7
+ [1: event, 2: Int32(2), 100: Data([
8
+ 0x52, 0x49, 1, 1, wire, 0, 0, 0,
9
+ UInt8(truncatingIfNeeded: tick), UInt8(truncatingIfNeeded: tick >> 8),
10
+ UInt8(truncatingIfNeeded: tick >> 16), UInt8(truncatingIfNeeded: tick >> 24),
11
+ ])]
12
+ }
13
+
14
+ func testRawRingCompletionsSurviveCloselySpacedInput() throws {
15
+ var debounce = G2TouchDebounce()
16
+ // swipe, next touch_down, click: the old global 100 ms gate lost the
17
+ // click and left GestureDotsService holding the second touch_down.
18
+ for (event, wire) in [(Int32(1), UInt8(4)), (14, 10), (0, 1)] {
19
+ let ring = try XCTUnwrap(G2RingInput(fields: fields(event: event, wire: wire)))
20
+ XCTAssertTrue(debounce.accepts(event: event, ring: ring, receivedAt: 1))
21
+ }
22
+ }
23
+
24
+ func testDelayedProcessingUsesReceiptTimeForLegacyInput() {
25
+ var debounce = G2TouchDebounce()
26
+ // These calls run together, but the BLE callbacks arrived a second apart.
27
+ XCTAssertTrue(debounce.accepts(event: 0, ring: nil, receivedAt: 1))
28
+ XCTAssertTrue(debounce.accepts(event: 0, ring: nil, receivedAt: 2))
29
+ XCTAssertFalse(debounce.accepts(event: 0, ring: nil, receivedAt: 2.01))
30
+ }
31
+
32
+ func testEdgesAndUnknownReportsDoNotConsumeLegacyDebounce() {
33
+ var debounce = G2TouchDebounce()
34
+ for event: Int32 in [9, 10, 14, 127] {
35
+ XCTAssertTrue(debounce.accepts(event: event, ring: nil, receivedAt: 1))
36
+ }
37
+ XCTAssertTrue(debounce.accepts(event: 0, ring: nil, receivedAt: 1))
38
+ XCTAssertFalse(debounce.accepts(event: 0, ring: nil, receivedAt: 1.01))
39
+ XCTAssertTrue(debounce.accepts(event: 10, ring: nil, receivedAt: 1.02))
40
+ }
41
+
42
+ func testRingInputDoesNotSuppressIndependentTempleInput() throws {
43
+ var debounce = G2TouchDebounce()
44
+ let ring = try XCTUnwrap(G2RingInput(fields: fields(event: 0, wire: 1)))
45
+ XCTAssertTrue(debounce.accepts(event: 0, ring: ring, receivedAt: 1))
46
+ XCTAssertTrue(debounce.accepts(event: 0, ring: nil, receivedAt: 1))
47
+ }
48
+
49
+ func testRingMetadataValidatesSourceFormatAndEventMapping() throws {
50
+ let valid = fields(event: 10, wire: 8, tick: 0xF123_4567)
51
+ let ring = try XCTUnwrap(G2RingInput(fields: valid))
52
+ XCTAssertEqual(ring.wireType, 8)
53
+ XCTAssertEqual(ring.tick, 0xF123_4567)
54
+ for key in [1, 2, 100] {
55
+ var malformed = valid
56
+ malformed.removeValue(forKey: key)
57
+ XCTAssertNil(G2RingInput(fields: malformed))
58
+ }
59
+ for index in [0, 1, 2, 3, 4, 7] {
60
+ var malformed = valid
61
+ var bytes = try XCTUnwrap(valid[100] as? Data)
62
+ bytes[index] = 255
63
+ malformed[100] = bytes
64
+ XCTAssertNil(G2RingInput(fields: malformed))
65
+ }
66
+ var truncated = valid
67
+ truncated[100] = Data([0x52, 0x49])
68
+ XCTAssertNil(G2RingInput(fields: truncated))
69
+ XCTAssertNotNil(G2RingInput(fields: fields(event: 127, wire: 255)))
70
+ }
71
+ }
@@ -0,0 +1,35 @@
1
+ @testable import BluetoothSDK
2
+ import UIKit
3
+ import XCTest
4
+
5
+ final class G2WritePacingTests: XCTestCase {
6
+ func testBackgroundingUnblocksAWriterWithNoCreditsAndForegroundingRestoresTheGate() {
7
+ // Model a false readiness flag across lifecycle transitions. The
8
+ // drainer must reselect its policy while a packet waits to be sent.
9
+ var submitted = 0
10
+ let transitions: [(UIApplication.State, Int)] = [(.active, 0), (.inactive, 1), (.background, 2), (.active, 2)]
11
+ for (state, expectedSubmissions) in transitions {
12
+ let pacing = G2WritePacing(cfwEnabled: true, applicationState: state)
13
+ if pacing.canWrite(peripheralReady: false) {
14
+ submitted += 1
15
+ XCTAssertEqual(pacing, .timed)
16
+ XCTAssertEqual(G2WritePacing.packetDelayNs, 6_000_000)
17
+ }
18
+ XCTAssertEqual(submitted, expectedSubmissions)
19
+ }
20
+ XCTAssertEqual(submitted, 2)
21
+ let resumed = G2WritePacing(cfwEnabled: true, applicationState: .active)
22
+ XCTAssertFalse(resumed.canWrite(peripheralReady: false))
23
+ XCTAssertTrue(resumed.canWrite(peripheralReady: true))
24
+ XCTAssertEqual(resumed, .creditBurst)
25
+ }
26
+
27
+ func testStockKeepsTimedPacingRegardlessOfReadinessOrApplicationState() {
28
+ for state in [UIApplication.State.active, .inactive, .background] {
29
+ let pacing = G2WritePacing(cfwEnabled: false, applicationState: state)
30
+ XCTAssertEqual(pacing, .timed)
31
+ XCTAssertTrue(pacing.canWrite(peripheralReady: false))
32
+ XCTAssertTrue(pacing.canWrite(peripheralReady: true))
33
+ }
34
+ }
35
+ }