@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444

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 +1075 -257
  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 +1434 -504
  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,271 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import com.glassly.bluetoothsdk.GlassesStatus
4
+ import com.glassly.bluetoothsdk.GlassesStatusUpdate
5
+ import kotlinx.coroutines.CoroutineScope
6
+ import kotlinx.coroutines.runBlocking
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertNull
10
+ import org.junit.Assert.assertThrows
11
+ import org.junit.Assert.assertTrue
12
+ import org.junit.Test
13
+
14
+ /**
15
+ * Pins the keyboard bridge wire format and HID decoding shared with g2flash's
16
+ * patches/keyboard_core.h and demos/keyboard.test.ts (same vectors).
17
+ */
18
+ class G2KeyboardTest {
19
+ private val boot = bytes(
20
+ 0x05, 1, 0x09, 6, 0xA1, 1, 0x05, 7, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 8, 0x81, 2,
21
+ 0x75, 8, 0x95, 1, 0x81, 1, 0x95, 6, 0x75, 8, 0x15, 0, 0x25, 0x65, 0x05, 7, 0x19, 0, 0x29, 0x65, 0x81, 0, 0xC0,
22
+ )
23
+ private val numbered = boot.copyOfRange(0, 6) + bytes(0x85, 1) + boot.copyOfRange(6, boot.size - 1) + bytes(
24
+ 0x85, 2, 0x05, 0x0C, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 3, 0x09, 0xE9, 0x09, 0xEA, 0x09, 0xE2, 0x81, 2,
25
+ 0x75, 5, 0x95, 1, 0x81, 1, 0xC0,
26
+ )
27
+
28
+ private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
29
+ private fun le16(v: Int) = G2KeyboardProtocol.le16(v)
30
+ private fun le32(v: Long) = bytes((v and 0xFF).toInt(), ((v shr 8) and 0xFF).toInt(), ((v shr 16) and 0xFF).toInt(), ((v shr 24) and 0xFF).toInt())
31
+
32
+ private fun frame(
33
+ kind: Int, seq: Long, epoch: Int, request: Int, code: Int, handle: Int, data: ByteArray,
34
+ offset: Int = 0, total: Int = data.size,
35
+ ): ByteArray =
36
+ bytes(75, 66, 1, kind) + le32(seq) + le16(epoch) + le16(request) + le16(code) + le16(handle) +
37
+ le16(total) + le16(offset) + le32(1234) + data
38
+
39
+ private fun usages(keys: List<G2KeyboardHid.Usage>) = keys.map { listOf(it.page, it.usage) }
40
+ private fun events(keys: List<G2KeyboardHid.KeyEvent>) = keys.map { listOf(it.page, it.usage, if (it.down) 1 else 0) }
41
+
42
+ @Test
43
+ fun bootArrayModifiersReleaseRepeatsAndRollover() {
44
+ val r = G2KeyboardHid.parseReportMap(boot).getValue(0)
45
+ val keys = G2KeyboardHid.HeldKeys()
46
+ assertEquals(listOf(listOf(7, 0xE1, 1), listOf(7, 4, 1)), events(keys.update(12, r, bytes(2, 0, 4, 0, 0, 0, 0, 0))))
47
+ assertEquals(emptyList<List<Int>>(), events(keys.update(12, r, bytes(2, 0, 4, 0, 0, 0, 0, 0))))
48
+ val rollover = assertThrows(IllegalArgumentException::class.java) { keys.update(12, r, bytes(0, 0, 1, 1, 1, 1, 1, 1)) }
49
+ assertTrue(rollover.message!!.contains("rollover"))
50
+ assertEquals(listOf(false, false), keys.reset().map { it.down })
51
+ val length = assertThrows(IllegalArgumentException::class.java) { G2KeyboardHid.reportKeys(r, bytes(0)) }
52
+ assertTrue(length.message!!.contains("length"))
53
+ }
54
+
55
+ @Test
56
+ fun hogpReportIdsAreOutOfBandWithConsumerKeys() {
57
+ val maps = G2KeyboardHid.parseReportMap(numbered)
58
+ assertEquals(listOf(listOf(7, 5)), usages(G2KeyboardHid.reportKeys(maps.getValue(1), bytes(0, 0, 5, 0, 0, 0, 0, 0))))
59
+ assertEquals(listOf(listOf(12, 0xE9), listOf(12, 0xE2)), usages(G2KeyboardHid.reportKeys(maps.getValue(2), bytes(5))))
60
+ assertThrows(IllegalArgumentException::class.java) { G2KeyboardHid.reportKeys(maps.getValue(2), bytes(2, 5)) }
61
+ }
62
+
63
+ @Test
64
+ fun nkroBitmapWithUnalignedFieldsAndReportUnion() {
65
+ val r = G2KeyboardHid.parseReportMap(bytes(5, 7, 0x19, 4, 0x29, 19, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 16, 0x81, 2)).getValue(0)
66
+ assertEquals(listOf(listOf(7, 4), listOf(7, 11), listOf(7, 19)), usages(G2KeyboardHid.reportKeys(r, bytes(0x81, 0x80))))
67
+ val keys = G2KeyboardHid.HeldKeys()
68
+ assertEquals(1, keys.update(10, r, bytes(1, 0)).size)
69
+ assertEquals(emptyList<G2KeyboardHid.KeyEvent>(), keys.update(11, r, bytes(1, 0)))
70
+ assertEquals(emptyList<G2KeyboardHid.KeyEvent>(), keys.update(10, r, bytes(0, 0)))
71
+ assertEquals(listOf(listOf(7, 4, 0)), events(keys.update(11, r, bytes(0, 0))))
72
+ }
73
+
74
+ @Test
75
+ fun arrayValuesIndexUsagesRelativeToLogicalMinimum() {
76
+ val r = G2KeyboardHid.parseReportMap(bytes(5, 7, 0x19, 4, 0x29, 6, 0x15, 1, 0x25, 3, 0x75, 8, 0x95, 1, 0x81, 0)).getValue(0)
77
+ assertEquals(listOf(listOf(7, 5)), usages(G2KeyboardHid.reportKeys(r, bytes(2))))
78
+ assertEquals(emptyList<G2KeyboardHid.Usage>(), G2KeyboardHid.reportKeys(r, bytes(0)))
79
+ }
80
+
81
+ @Test
82
+ fun descriptorBoundsAndMalformedItems() {
83
+ val malformed = listOf(
84
+ bytes(), ByteArray(513), bytes(0xFE), bytes(0x75), bytes(0xB4), bytes(0xC0), bytes(0x85, 0),
85
+ bytes(0x75, 33, 0x95, 1, 0x81, 2), bytes(0x75, 32, 0x96, 0, 2, 0x81, 2), bytes(0xA1, 1),
86
+ )
87
+ for (data in malformed) assertThrows(IllegalArgumentException::class.java) { G2KeyboardHid.parseReportMap(data) }
88
+ // Deterministic malformed corpus; parsing must be bounded and never hang.
89
+ var seed = 1L
90
+ for (i in 0 until 1000) {
91
+ val data = ByteArray(i % 128 + 1) {
92
+ seed = (seed * 1664525L + 1013904223L) and 0xFFFF_FFFFL
93
+ (seed shr 24).toByte()
94
+ }
95
+ runCatching { G2KeyboardHid.parseReportMap(data) }
96
+ }
97
+ }
98
+
99
+ @Test
100
+ fun wireControlHasTheDedicatedFieldAndEpoch() {
101
+ val packet = G2KeyboardProtocol.controlPacket(G2KeyboardProtocol.Op.CONNECT, 0x1234, 0x5678, bytes(1, 2, 3, 4, 5, 6, 7), magic = 5)
102
+ assertEquals(
103
+ listOf(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),
104
+ packet.map { it.toInt() and 0xFF },
105
+ )
106
+ // Magic ids above 127 take two varint bytes.
107
+ val query = G2KeyboardProtocol.controlPacket(G2KeyboardProtocol.Op.QUERY, 1, 0, magic = 200)
108
+ assertEquals(listOf(8, 2, 16, 0xC8, 0x01), query.take(5).map { it.toInt() and 0xFF })
109
+ }
110
+
111
+ @Test
112
+ fun fragmentsDuplicatesGapsEpochsAndSequenceWrap() {
113
+ val stream = G2KeyboardProtocol.RecordAssembler()
114
+ val data = ByteArray(200) { it.toByte() }
115
+ assertNull(stream.push(frame(3, 0xFFFF_FFFFL, 1, 0, 13, 33, data.copyOfRange(0, 96), 0, 200)))
116
+ assertNull(stream.push(frame(3, 0xFFFF_FFFFL, 1, 0, 13, 33, data.copyOfRange(96, 192), 96, 200)))
117
+ assertTrue(data.contentEquals(stream.push(frame(3, 0xFFFF_FFFFL, 1, 0, 13, 33, data.copyOfRange(192, 200), 192, 200))!!.data))
118
+ val status = frame(1, 0, 1, 0, 0, 0, ByteArray(10))
119
+ assertEquals(0L, stream.push(status)!!.sequence)
120
+ assertNull(stream.push(status))
121
+ assertEquals(1, stream.gaps)
122
+ stream.push(frame(1, 2, 1, 0, 0, 0, ByteArray(10)))
123
+ assertEquals(2, stream.gaps)
124
+ assertNull(stream.push(status))
125
+ assertEquals(2, stream.gaps)
126
+ stream.push(frame(1, 3, 2, 0, 0, 0, ByteArray(10)))
127
+ assertEquals(3, stream.gaps)
128
+ }
129
+
130
+ @Test
131
+ fun mismatchedFragmentsNeverEmitPartialInput() {
132
+ val stream = G2KeyboardProtocol.RecordAssembler()
133
+ stream.push(frame(3, 1, 1, 0, 13, 10, ByteArray(96), 0, 100))
134
+ assertNull(stream.push(frame(3, 1, 1, 0, 13, 11, ByteArray(4), 96, 100)))
135
+ assertEquals(2, stream.gaps)
136
+ assertNull(stream.push(frame(3, 1, 1, 0, 13, 10, ByteArray(4), 96, 100)))
137
+ assertNull(stream.push(ByteArray(513)))
138
+ }
139
+
140
+ @Test
141
+ fun advertParsingNamesHidDevicesAndReversesAddresses() {
142
+ val body = bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 12, 3, 3, 0x12, 0x18, 5, 9, 0x4B, 0x65, 0x79, 0x73)
143
+ val advert = G2KeyboardProtocol.Advert.parse(body)!!
144
+ assertEquals("aa:bb:cc:dd:ee:ff", advert.addressText)
145
+ assertEquals(-60, advert.rssi)
146
+ assertTrue(advert.hid)
147
+ assertEquals("Keys", advert.name)
148
+ assertEquals(advert.address, G2KeyboardProtocol.addressFromText(advert.addressText)!!.map { it.toInt() and 0xFF })
149
+ assertNull(G2KeyboardProtocol.addressFromText("aa:bb"))
150
+ }
151
+
152
+ @Test
153
+ fun advertAppearanceMarksHidAndScanResponseMerges() {
154
+ // ADV_IND with only a keyboard appearance (0x03C1), no 0x1812 service list.
155
+ val first = G2KeyboardProtocol.Advert.parse(bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 4, 3, 0x19, 0xC1, 0x03))!!
156
+ assertTrue(first.hid)
157
+ assertEquals("", first.name)
158
+ // SCAN_RSP for the same address carries the name and a better RSSI.
159
+ val second = G2KeyboardProtocol.Advert.parse(bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xCE, 4, 6, 5, 9, 0x4B, 0x65, 0x79, 0x73))!!
160
+ assertFalse(second.hid)
161
+ val merged = first.merging(second)
162
+ assertEquals("Keys", merged.name)
163
+ assertTrue(merged.hid)
164
+ assertEquals(-50, merged.rssi)
165
+ assertEquals(first.address, merged.address)
166
+ // A non-HID appearance (generic phone) does not qualify.
167
+ assertFalse(G2KeyboardProtocol.Advert.parse(bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 4, 3, 0x19, 0x40, 0x00))!!.hid)
168
+ }
169
+
170
+ @Test
171
+ fun keyboardStatusSurvivesSnapshotAndPartialStatusBridge() {
172
+ val values = mapOf<String, Any>(
173
+ "keyboardBridge" to true, "keyboardConnected" to true, "keyboardName" to "Bluetooth Keyboard",
174
+ "keyboardSaved" to true, "keyboardState" to "connected", "keyboardError" to "",
175
+ )
176
+ val snapshot = GlassesStatus.fromMap(values).toMap()
177
+ for ((key, value) in values) assertEquals(value, snapshot[key])
178
+ assertEquals(values, GlassesStatusUpdate.fromMap(values).toMap())
179
+ assertEquals("idle", GlassesStatus.fromMap(emptyMap()).keyboardState)
180
+ }
181
+
182
+ @Test
183
+ fun fullSimulatedHogpDiscoveryTypingAndLossRecovery() = runBlocking {
184
+ class Attr(val uuid: Int, val value: ByteArray)
185
+ val attrs = LinkedHashMap<Int, Attr>()
186
+ fun add(h: Int, uuid: Int, value: ByteArray) { attrs[h] = Attr(uuid, value) }
187
+ val mockMap = numbered.copyOfRange(0, numbered.size - 1) + bytes(0x85, 3, 0x05, 6, 0x09, 0x20, 0x15, 0, 0x25, 100, 0x75, 8, 0x95, 1, 0x81, 2, 0xC0)
188
+ add(1, 0x2800, bytes(1, 0x18)); add(2, 0x2803, bytes(0x20, 3, 0, 5, 0x2A)); add(3, 0x2A05, bytes()); add(4, 0x2902, bytes(0, 0))
189
+ add(20, 0x2800, bytes(0x12, 0x18))
190
+ add(21, 0x2803, bytes(2, 22, 0, 0x4B, 0x2A)); add(22, 0x2A4B, mockMap); add(23, 0x2907, bytes(0x19, 0x2A))
191
+ add(24, 0x2803, bytes(6, 25, 0, 0x4E, 0x2A)); add(25, 0x2A4E, bytes(1))
192
+ add(26, 0x2803, bytes(0x12, 27, 0, 0x4D, 0x2A)); add(27, 0x2A4D, bytes()); add(28, 0x2902, bytes(0, 0)); add(29, 0x2908, bytes(1, 1))
193
+ add(30, 0x2803, bytes(0x12, 31, 0, 0x4D, 0x2A)); add(31, 0x2A4D, bytes()); add(32, 0x2902, bytes(0, 0)); add(33, 0x2908, bytes(2, 1))
194
+ add(40, 0x2800, bytes(0x0F, 0x18)); add(41, 0x2803, bytes(0x12, 42, 0, 0x19, 0x2A)); add(42, 0x2A19, bytes(100)); add(43, 0x2902, bytes(0, 0)); add(44, 0x2908, bytes(3, 1))
195
+
196
+ var seq = 0L
197
+ var epoch = 0
198
+ val writes = ArrayList<Int>()
199
+ var watches = emptyList<Int>()
200
+ val typed = ArrayList<List<Int>>()
201
+ lateinit var client: G2KeyboardClient
202
+ fun emit(kind: Int, request: Int, code: Int, handle: Int, data: ByteArray) {
203
+ seq += 1
204
+ client.accept(frame(kind, seq, epoch, request, code, handle, data))
205
+ }
206
+ val scope = CoroutineScope(coroutineContext)
207
+ client = G2KeyboardClient(scope, { pb, _ ->
208
+ // Field 130 (tag 0x92 0x08) follows a magic varint of one or two bytes.
209
+ val tag = (0 until pb.size - 1).first { pb[it] == 0x92.toByte() && pb[it + 1] == 0x08.toByte() }
210
+ val body = pb.copyOfRange(tag + 3, pb.size)
211
+ val op = body[3].toInt()
212
+ val req = G2KeyboardProtocol.u16(body, 4)
213
+ val h = if (body.size >= 10) G2KeyboardProtocol.u16(body, 8) else 0
214
+ when (op) {
215
+ 1 -> emit(1, req, 0, 0, bytes(1, 0, 0, 0, 0, 0, 0, 0, 0, 0))
216
+ 4 -> { epoch += 1; emit(1, req, 0, 0, bytes(1, 3, 1, 1, 0, 0, 0, 0, 0, 0)) }
217
+ 9 -> {
218
+ val batch = attrs.keys.sorted().filter { it >= h }.take(4)
219
+ val payload = if (batch.isEmpty()) bytes() else bytes(1) + batch.fold(ByteArray(0)) { acc, handle -> acc + le16(handle) + le16(attrs.getValue(handle).uuid) }
220
+ emit(2, req, if (batch.isEmpty()) (10 shl 8) or 2 else 2, h, payload)
221
+ }
222
+ 10 -> {
223
+ val data = attrs.getValue(h).value
224
+ val offset = G2KeyboardProtocol.u16(body, 10)
225
+ val status = if (offset >= data.size) (7 shl 8) or 6 else if (offset > 0) 6 else 5
226
+ emit(2, req, status, h, data.copyOfRange(minOf(offset, data.size), minOf(offset + 20, data.size)))
227
+ }
228
+ 11 -> { writes.add(h); emit(2, req, if (body[10].toInt() != 0) 10 else 9, h, bytes()) }
229
+ 12 -> {
230
+ val count = body[8].toInt()
231
+ watches = (0 until count).map { G2KeyboardProtocol.u16(body, 9 + 2 * it) }
232
+ emit(1, req, 0, 0, bytes(1, 3, 1, 1, 0, 0, 0, 0, 0, 0))
233
+ }
234
+ else -> throw AssertionError("Unexpected op $op")
235
+ }
236
+ }) { event ->
237
+ if (event is G2KeyboardClient.Event.Keys) typed.addAll(event.events.map { listOf(it.usage, if (it.down) 1 else 0) })
238
+ }
239
+
240
+ client.enable()
241
+ client.connect(1, ByteArray(6))
242
+ val config = client.configure()
243
+ assertEquals(listOf(27, 31, 42, 3), watches)
244
+ assertEquals(listOf(25, 4, 28, 32, 43), writes)
245
+ assertEquals(listOf(1, 2, 3), config.reports.map { it.id })
246
+
247
+ emit(3, 0, 13, 27, bytes(0, 0, 4, 0, 0, 0, 0, 0))
248
+ emit(3, 0, 13, 31, bytes(1))
249
+ assertEquals(listOf(listOf(4, 1), listOf(0xE9, 1)), typed)
250
+ seq += 1 // release lost in the firmware queue or phone notification path
251
+ emit(1, 0, 0, 0, bytes(1, 3, 1, 1, 0, 0, 0, 0, 0, 0))
252
+ assertEquals(listOf(0, 0), typed.takeLast(2).map { it[1] })
253
+
254
+ val restored = G2KeyboardClient(scope, { _, _ -> }) { event ->
255
+ if (event is G2KeyboardClient.Event.Keys) typed.addAll(event.events.map { listOf(it.usage, if (it.down) 1 else 0) })
256
+ }
257
+ restored.restore(config)
258
+ seq += 1
259
+ restored.accept(frame(3, seq, epoch, 0, 13, 27, bytes(0, 0, 5, 0, 0, 0, 0, 0)))
260
+ assertEquals(listOf(5, 1), typed.last())
261
+ restored.lostPhoneLink()
262
+ assertEquals(listOf(5, 0), typed.last())
263
+
264
+ emit(3, 0, 14, 3, bytes(1, 0, 255, 255)) // Service Changed invalidates the mapping
265
+ val count = typed.size
266
+ emit(3, 0, 13, 27, bytes(0, 0, 6, 0, 0, 0, 0, 0))
267
+ assertEquals(count, typed.size)
268
+ client.close()
269
+ restored.close()
270
+ }
271
+ }
@@ -0,0 +1,204 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import com.glassly.bluetoothsdk.AllowedApp
4
+ import com.glassly.bluetoothsdk.PhoneNotificationUpload
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertNotEquals
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Test
10
+
11
+ class G2NotificationFileTest {
12
+ private fun readLittleEndian(bytes: ByteArray, offset: Int): Long {
13
+ var value = 0L
14
+ for (i in 0 until 4) {
15
+ value = value or ((bytes[offset + i].toLong() and 0xFF) shl (8 * i))
16
+ }
17
+ return value
18
+ }
19
+
20
+ @Test
21
+ fun `header is 93 bytes with a command byte then file type and size`() {
22
+ val content = "hello glasses".toByteArray(Charsets.UTF_8)
23
+ val header =
24
+ G2NotificationFile.fileCheckHeader(
25
+ G2NotificationFile.FILENAME,
26
+ content,
27
+ G2NotificationFile.FILE_TYPE_NOTIFICATION
28
+ )
29
+
30
+ assertEquals(93, header.size)
31
+ assertEquals(0, header[0].toInt()) // SEND_START
32
+ assertEquals(1L, readLittleEndian(header, 1)) // ANDROID_MSG_JSON_NOTIFICATION
33
+ assertEquals(content.size.toLong(), readLittleEndian(header, 5))
34
+ // The docs' shifted view of the same bytes.
35
+ assertEquals(0x100L, readLittleEndian(header, 0))
36
+ assertEquals((content.size * 256).toLong(), readLittleEndian(header, 4))
37
+ }
38
+
39
+ @Test
40
+ fun `whitelist header carries file type zero`() {
41
+ val header =
42
+ G2NotificationFile.fileCheckHeader(
43
+ G2NotificationFile.FILENAME,
44
+ "{}".toByteArray(Charsets.UTF_8),
45
+ G2NotificationFile.FILE_TYPE_WHITELIST
46
+ )
47
+ assertEquals(0L, readLittleEndian(header, 1))
48
+ }
49
+
50
+ @Test
51
+ fun `header checksum and extra reconstruct the crc`() {
52
+ val content = "the quick brown fox".toByteArray(Charsets.UTF_8)
53
+ val header =
54
+ G2NotificationFile.fileCheckHeader(
55
+ G2NotificationFile.FILENAME,
56
+ content,
57
+ G2NotificationFile.FILE_TYPE_NOTIFICATION
58
+ )
59
+
60
+ val checksum = readLittleEndian(header, 8).toInt()
61
+ val extra = header[12].toInt() and 0xFF
62
+ val reconstructed = (extra shl 24) or (checksum ushr 8)
63
+
64
+ assertEquals(G2NotificationFile.crc32c(content), reconstructed)
65
+ }
66
+
67
+ @Test
68
+ fun `header carries a nul-padded filename`() {
69
+ val header =
70
+ G2NotificationFile.fileCheckHeader(
71
+ G2NotificationFile.FILENAME,
72
+ ByteArray(0),
73
+ G2NotificationFile.FILE_TYPE_WHITELIST
74
+ )
75
+ val nameBytes = G2NotificationFile.FILENAME.toByteArray(Charsets.UTF_8)
76
+
77
+ assertEquals(
78
+ G2NotificationFile.FILENAME,
79
+ String(header, 13, nameBytes.size, Charsets.UTF_8)
80
+ )
81
+ for (i in (13 + nameBytes.size) until 93) {
82
+ assertEquals(0, header[i].toInt())
83
+ }
84
+ }
85
+
86
+ @Test
87
+ fun `crc is deterministic and content sensitive`() {
88
+ val a = "alpha".toByteArray(Charsets.UTF_8)
89
+ val b = "alphb".toByteArray(Charsets.UTF_8)
90
+
91
+ assertEquals(G2NotificationFile.crc32c(a), G2NotificationFile.crc32c(a))
92
+ assertNotEquals(G2NotificationFile.crc32c(a), G2NotificationFile.crc32c(b))
93
+ assertEquals(0, G2NotificationFile.crc32c(ByteArray(0)))
94
+ }
95
+
96
+ @Test
97
+ fun `whitelist drops over-long ids and truncates names`() {
98
+ val longId = "c".repeat(G2NotificationFile.MAX_IDENTIFIER_BYTES + 1)
99
+ val json =
100
+ String(
101
+ G2NotificationFile.whitelistJson(
102
+ filterEnabled = true,
103
+ apps =
104
+ listOf(
105
+ AllowedApp("com.example.mail", "A Very Long Application Name"),
106
+ AllowedApp(longId, "Dropped")
107
+ )
108
+ ),
109
+ Charsets.UTF_8
110
+ )
111
+
112
+ assertTrue(json.contains("\"enable\":true"))
113
+ assertTrue(json.contains("\"id\":\"com.example.mail\""))
114
+ assertFalse(json.contains(longId))
115
+ assertTrue(json.contains("\"name\":\"A Very Long App\""))
116
+ }
117
+
118
+ @Test
119
+ fun `whitelist caps the list and is deterministic`() {
120
+ val apps = (0 until G2NotificationFile.MAX_APPS + 10).map { AllowedApp("app.$it", "App $it") }
121
+ val first = G2NotificationFile.whitelistJson(false, apps)
122
+ val second = G2NotificationFile.whitelistJson(false, apps)
123
+
124
+ assertTrue(first.contentEquals(second))
125
+ assertEquals(
126
+ G2NotificationFile.MAX_APPS,
127
+ String(first, Charsets.UTF_8).split("\"id\":").size - 1
128
+ )
129
+ }
130
+
131
+ @Test
132
+ fun `notification json carries every field under the android root`() {
133
+ val upload =
134
+ PhoneNotificationUpload(
135
+ notificationId = "n-1",
136
+ packageName = "com.example.chat",
137
+ appName = "Chat",
138
+ title = " Ada ",
139
+ subtitle = "Re: build",
140
+ body = "the lenses are fine",
141
+ timestampMs = 1_700_000_000_000L
142
+ )
143
+
144
+ val json = String(G2NotificationFile.notificationJson(upload, 4242, 0L), Charsets.UTF_8)
145
+
146
+ assertTrue(json.startsWith("{\"android_notification\":{"))
147
+ assertTrue(json.contains("\"action\":0"))
148
+ assertTrue(json.contains("\"app_identifier\":\"com.example.chat\""))
149
+ assertTrue(json.contains("\"display_name\":\"Chat\""))
150
+ assertTrue(json.contains("\"msg_id\":4242"))
151
+ assertTrue(json.contains("\"time_s\":1700000000"))
152
+ assertTrue(json.contains("\"title\":\"Ada\""))
153
+ assertTrue(json.contains("\"subtitle\":\"Re: build\""))
154
+ assertTrue(json.contains("\"message\":\"the lenses are fine\""))
155
+ assertTrue(Regex("\"date\":\"\\d{8}T\\d{6}\"").containsMatchIn(json))
156
+ }
157
+
158
+ @Test
159
+ fun `notification json caps title and message lengths`() {
160
+ val upload =
161
+ PhoneNotificationUpload(
162
+ notificationId = "n-2",
163
+ packageName = "com.example.chat",
164
+ appName = "Chat",
165
+ title = "t".repeat(400),
166
+ subtitle = "",
167
+ body = "b".repeat(2000),
168
+ timestampMs = 1_700_000_000_000L
169
+ )
170
+
171
+ val json = String(G2NotificationFile.notificationJson(upload, 1, 0L), Charsets.UTF_8)
172
+
173
+ assertTrue(json.contains("\"title\":\"" + "t".repeat(G2NotificationFile.MAX_TITLE_BYTES) + "\""))
174
+ assertTrue(json.contains("\"message\":\"" + "b".repeat(G2NotificationFile.MAX_MESSAGE_BYTES) + "\""))
175
+ }
176
+
177
+ @Test
178
+ fun `truncation never splits a code point`() {
179
+ // Each emoji is 4 UTF-8 bytes, so a 6-byte budget keeps exactly one.
180
+ val truncated = G2NotificationFile.truncateUtf8("😀😀", 6)
181
+
182
+ assertEquals("😀", truncated)
183
+ assertEquals(4, truncated.toByteArray(Charsets.UTF_8).size)
184
+ }
185
+
186
+ @Test
187
+ fun `json strings escape quotes and control characters`() {
188
+ val upload =
189
+ PhoneNotificationUpload(
190
+ notificationId = "n-3",
191
+ packageName = "com.example.chat",
192
+ appName = "Chat",
193
+ title = "say \"hi\"",
194
+ subtitle = "",
195
+ body = "line\none",
196
+ timestampMs = 1_700_000_000_000L
197
+ )
198
+
199
+ val json = String(G2NotificationFile.notificationJson(upload, 1, 0L), Charsets.UTF_8)
200
+
201
+ assertTrue(json.contains("\\\"hi\\\""))
202
+ assertTrue(json.contains("line\\none"))
203
+ }
204
+ }
@@ -0,0 +1,56 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import com.glassly.bluetoothsdk.GlassesStatus
4
+ import com.glassly.bluetoothsdk.GlassesStatusUpdate
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertNull
7
+ import org.junit.Test
8
+
9
+ class G2RingBatteryTest {
10
+ private fun decode(vararg bytes: Int) = G2RingBattery.decode(bytes.map { it.toByte() }.toByteArray())
11
+
12
+ @Test
13
+ fun connectedBatteryIncludingEmptyFullAndCharging() {
14
+ for (level in listOf(0, 62, 100)) {
15
+ assertEquals(G2RingBattery(true, level, false), decode(0x52, 0x42, 1, 3, level))
16
+ assertEquals(G2RingBattery(true, level, true), decode(0x52, 0x42, 1, 7, level))
17
+ }
18
+ }
19
+
20
+ @Test
21
+ fun disconnectedAndUnknownReportsClearCachedBattery() {
22
+ assertEquals(G2RingBattery.DISCONNECTED, decode(0x52, 0x42, 1, 0, 255))
23
+ assertEquals(G2RingBattery.DISCONNECTED, decode(0x52, 0x42, 1, 6, 62))
24
+ assertEquals(G2RingBattery(true, -1, false), decode(0x52, 0x42, 1, 1, 255))
25
+ assertEquals(G2RingBattery(true, -1, false), decode(0x52, 0x42, 1, 5, 62))
26
+ }
27
+
28
+ @Test
29
+ fun batterySurvivesSnapshotAndPartialStatusBridge() {
30
+ val battery = decode(0x52, 0x42, 1, 7, 62)!!
31
+ val values = mapOf<String, Any>(
32
+ "ringConnected" to battery.connected,
33
+ "ringBatteryLevel" to battery.level,
34
+ "ringCharging" to battery.charging,
35
+ )
36
+ val snapshot = GlassesStatus.fromMap(values).toMap()
37
+ for ((key, value) in values) assertEquals(value, snapshot[key])
38
+ assertEquals(false, snapshot["controllerConnected"])
39
+ assertEquals(-1, snapshot["controllerBatteryLevel"])
40
+ assertEquals(values, GlassesStatusUpdate.fromMap(values).toMap())
41
+ assertEquals(emptyMap<String, Any>(), GlassesStatusUpdate.fromMap(emptyMap()).toMap())
42
+ assertEquals(-1, GlassesStatus.fromMap(emptyMap()).ringBatteryLevel)
43
+ val cleared = mapOf<String, Any>("ringConnected" to false, "ringBatteryLevel" to -1, "ringCharging" to false)
44
+ assertEquals(cleared, GlassesStatusUpdate.fromMap(cleared).toMap())
45
+ }
46
+
47
+ @Test
48
+ fun malformedReportsAreIgnored() {
49
+ val malformed = listOf(
50
+ intArrayOf(), intArrayOf(0x52, 0x42, 1, 3), intArrayOf(0x52, 0x42, 1, 3, 62, 0),
51
+ intArrayOf(0x41, 0x42, 1, 3, 62), intArrayOf(0x52, 0x41, 1, 3, 62),
52
+ intArrayOf(0x52, 0x42, 2, 3, 62), intArrayOf(0x52, 0x42, 1, 3, 101), intArrayOf(0x52, 0x42, 1, 3, 255),
53
+ )
54
+ for (bytes in malformed) assertNull(decode(*bytes))
55
+ }
56
+ }
@@ -0,0 +1,56 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class G2SensorsTest {
7
+ @Test fun northMayOmitTheProtobufScalarButMalformedHeadingsAreRejected() {
8
+ assertEquals(0, G2Sensors.heading(emptyMap()))
9
+ assertEquals(0, G2Sensors.heading(mapOf(1 to 0)))
10
+ assertEquals(359, G2Sensors.heading(mapOf(1 to 359)))
11
+ assertNull(G2Sensors.heading(mapOf(1 to 360)))
12
+ assertNull(G2Sensors.heading(mapOf(1 to byteArrayOf(0))))
13
+ }
14
+
15
+ @Test fun commandsMatchUpstreamModesAndLittleEndianOptions() {
16
+ assertArrayEquals(byteArrayOf(16, 0), G2Sensors.command("light", "query", emptyMap()))
17
+ assertArrayEquals(byteArrayOf(16, 1, 1, 244.toByte(), 1, 1, 0, 136.toByte(), 19), G2Sensors.command("light", "start", emptyMap()))
18
+ assertArrayEquals(byteArrayOf(10, 2, 250.toByte(), 0, 0, 0), G2Sensors.command("compass", "start", mapOf("intervalMs" to 250.0, "minChangeDegrees" to 0.0)))
19
+ assertArrayEquals(byteArrayOf(16, 2), G2Sensors.command("light", "stop", emptyMap()))
20
+ assertArrayEquals(byteArrayOf(10, 0), G2Sensors.command("compass", "stop", emptyMap()))
21
+ assertNull(G2Sensors.command("light", "start", mapOf("intervalMs" to 99.0)))
22
+ assertNull(G2Sensors.command("compass", "start", mapOf("intervalMs" to Double.NaN)))
23
+ assertNull(G2Sensors.command("compass", "start", mapOf("minChangeDegrees" to 0.5)))
24
+ }
25
+ @Test fun lightReportPreservesRawValuesAndFreshness() {
26
+ val bytes = listOf(65,76,1,2,15,3,210,4,0,0,46,22,0,0,250,0,9,3,0,4,66,0,0,240).map { it.toByte() }.toByteArray()
27
+ val report = G2Sensors.light(bytes)!!
28
+ assertEquals("poll", report["reason"])
29
+ assertEquals(1234L, report["value"])
30
+ assertEquals(5678L, report["peak"])
31
+ assertEquals(4026531906L, report["sampleTimestampMs"])
32
+ assertEquals(true, report["readSucceeded"])
33
+ assertEquals(true, report["autoBrightness"])
34
+ assertEquals(true, report["passive"])
35
+ assertNull(G2Sensors.light(bytes.copyOf(23)))
36
+ bytes[4] = 0
37
+ assertEquals(false, G2Sensors.light(bytes)!!["readSucceeded"])
38
+ bytes[2] = 2
39
+ assertNull(G2Sensors.light(bytes))
40
+ }
41
+ @Test fun compassDiagnosticsAndUnknownMarkers() {
42
+ val bytes = listOf(67,77,1,3,1,2,143,0,66,0,0,240).map { it.toByte() }.toByteArray()
43
+ val report = G2Sensors.compass(bytes)!!
44
+ assertEquals("gmrv", report["source"])
45
+ assertEquals(3, report["accuracy"])
46
+ assertEquals(true, report["sampleMatched"])
47
+ assertEquals(true, report["magneticDataFresh"])
48
+ assertEquals(4026531906L, report["sampleTimestampMs"])
49
+ bytes[3] = 255.toByte(); bytes[4] = 255.toByte(); bytes[5] = 0; bytes[6] = 0
50
+ assertNull(G2Sensors.compass(bytes)!!["accuracy"])
51
+ assertEquals(false, G2Sensors.compass(bytes)!!["sampleMatched"])
52
+ bytes[3] = 4
53
+ assertNull(G2Sensors.compass(bytes))
54
+ assertNull(G2Sensors.compass(bytes.copyOf(11)))
55
+ }
56
+ }
@@ -0,0 +1,60 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class G2TouchInputTest {
7
+ private fun fields(event: Int, wire: Int, tick: Long = 123): Map<Int, Any> = mapOf(
8
+ 1 to event, 2 to 2, 100 to byteArrayOf(0x52, 0x49, 1, 1, wire.toByte(), 0, 0, 0,
9
+ tick.toByte(), (tick shr 8).toByte(), (tick shr 16).toByte(), (tick shr 24).toByte()),
10
+ )
11
+
12
+ @Test fun rawRingCompletionsSurviveCloselySpacedInput() {
13
+ val debounce = G2TouchDebounce()
14
+ // The old global gate accepted the second touch_down but lost its click.
15
+ for ((event, wire) in listOf(1 to 4, 14 to 10, 0 to 1)) {
16
+ val ring = G2RingInput.parse(fields(event, wire))!!
17
+ assertTrue(debounce.accepts(event, ring, 1000))
18
+ }
19
+ }
20
+
21
+ @Test fun delayedProcessingUsesReceiptTimeForLegacyInput() {
22
+ val debounce = G2TouchDebounce()
23
+ // Processed together after a delay, but received a second apart.
24
+ assertTrue(debounce.accepts(0, null, 1000))
25
+ assertTrue(debounce.accepts(0, null, 2000))
26
+ assertFalse(debounce.accepts(0, null, 2010))
27
+ }
28
+
29
+ @Test fun edgesAndUnknownReportsDoNotConsumeLegacyDebounce() {
30
+ val debounce = G2TouchDebounce()
31
+ for (event in listOf(9, 10, 14, 127)) assertTrue(debounce.accepts(event, null, 1000))
32
+ assertTrue(debounce.accepts(0, null, 1000))
33
+ assertFalse(debounce.accepts(0, null, 1010))
34
+ assertTrue(debounce.accepts(10, null, 1020))
35
+ }
36
+
37
+ @Test fun ringInputDoesNotSuppressIndependentTempleInput() {
38
+ val debounce = G2TouchDebounce()
39
+ assertTrue(debounce.accepts(0, G2RingInput.parse(fields(0, 1))!!, 1000))
40
+ assertTrue(debounce.accepts(0, null, 1000))
41
+ }
42
+
43
+ @Test fun metadataValidatesSourceFormatAndEventMapping() {
44
+ val valid = fields(10, 8, 0xf1234567L)
45
+ val ring = G2RingInput.parse(valid)!!
46
+ assertEquals(8, ring.wireType)
47
+ assertEquals(0xf1234567L, ring.tick)
48
+ for (key in listOf(1, 2, 100)) assertNull(G2RingInput.parse(valid - key))
49
+ assertNull(G2RingInput.parse(valid + (2 to 0)))
50
+ for (index in listOf(0, 1, 2, 3, 4, 7)) {
51
+ val bytes = (valid[100] as ByteArray).copyOf().also { it[index] = 255.toByte() }
52
+ assertNull(G2RingInput.parse(valid + (100 to bytes)))
53
+ }
54
+ assertNull(G2RingInput.parse(valid + (100 to byteArrayOf(0x52, 0x49))))
55
+ assertNotNull(G2RingInput.parse(fields(127, 255)))
56
+ for ((wire, event) in mapOf(0 to 9, 1 to 0, 2 to 3, 4 to 1, 5 to 2, 8 to 10, 9 to 11, 10 to 14)) {
57
+ assertNotNull(G2RingInput.parse(fields(event, wire)))
58
+ }
59
+ }
60
+ }