@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,277 @@
1
+ package com.glassly.bluetoothsdk
2
+
3
+ import com.glassly.bluetoothsdk.sgcs.SceneElement
4
+ import com.glassly.bluetoothsdk.sgcs.SceneFrame
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class DisplayPacerTest {
13
+ private class FakeScheduler : DisplayPacer.Scheduler {
14
+ var delay: Long? = null
15
+ var block: (() -> Unit)? = null
16
+ var scheduled = 0
17
+
18
+ override fun schedule(delayMs: Long, block: () -> Unit): () -> Unit {
19
+ scheduled++
20
+ delay = delayMs
21
+ this.block = block
22
+ return {
23
+ if (this.block === block) {
24
+ this.block = null
25
+ delay = null
26
+ }
27
+ }
28
+ }
29
+
30
+ fun fire() {
31
+ val b = block ?: error("no timer armed")
32
+ block = null
33
+ delay = null
34
+ b()
35
+ }
36
+ }
37
+
38
+ private class Harness(fps: Int = 8) {
39
+ var now = 1_000L
40
+ val scheduler = FakeScheduler()
41
+ val scenes = mutableListOf<SceneFrame>()
42
+ var legacySends = 0
43
+ val pacer =
44
+ DisplayPacer(
45
+ dispatchScene = { scenes.add(it) },
46
+ sendLegacy = { legacySends++ },
47
+ clock = { now },
48
+ scheduler = scheduler,
49
+ ).also { it.setMaxFps(fps) }
50
+ }
51
+
52
+ private fun element(id: String, change: String = "created", x: Int = 0) =
53
+ SceneElement(
54
+ id = id, type = "text", x = x, y = 0, w = 10, h = 10, text = id, data = null,
55
+ border = 0, radius = 0, change = change, contentHash = "h-$id",
56
+ )
57
+
58
+ private fun frame(
59
+ vararg elements: SceneElement,
60
+ removed: List<String> = emptyList(),
61
+ appId: String = "app",
62
+ replay: Boolean = false,
63
+ epoch: Int = 1,
64
+ ) = SceneFrame(appId = appId, epoch = epoch, replay = replay, elements = elements.toList(), removed = removed)
65
+
66
+ private fun SceneFrame.change(id: String) = elements.first { it.id == id }.change
67
+
68
+ @Test
69
+ fun firstSubmitSendsImmediately() {
70
+ val h = Harness()
71
+ h.pacer.submitScene(frame(element("a")))
72
+ assertEquals(1, h.scenes.size)
73
+ assertNull(h.scheduler.block)
74
+ }
75
+
76
+ @Test
77
+ fun secondSubmitInsideWindowIsHeldUntilTheSlot() {
78
+ val h = Harness(fps = 10) // 100 ms slots
79
+ h.pacer.submitScene(frame(element("a"), epoch = 1))
80
+ h.now += 30
81
+ h.pacer.submitScene(frame(element("a", change = "updated"), epoch = 2))
82
+ assertEquals(1, h.scenes.size)
83
+ assertEquals(70L, h.scheduler.delay)
84
+ h.now += 70
85
+ h.scheduler.fire()
86
+ assertEquals(2, h.scenes.size)
87
+ assertEquals(2, h.scenes[1].epoch)
88
+ }
89
+
90
+ @Test
91
+ fun rapidFramesCoalesceIntoOneMergedSend() {
92
+ val h = Harness(fps = 10)
93
+ h.pacer.submitScene(frame(element("a"), epoch = 1))
94
+ h.now += 10
95
+ h.pacer.submitScene(frame(element("a", change = "updated"), element("b", change = "created"), epoch = 2))
96
+ h.now += 10
97
+ h.pacer.submitScene(frame(element("a", change = "unchanged"), element("b", change = "moved", x = 5), epoch = 3))
98
+ h.now += 10
99
+ h.pacer.submitScene(
100
+ frame(element("a", change = "unchanged"), element("b", change = "unchanged", x = 5), epoch = 4),
101
+ )
102
+ assertEquals(1, h.scheduler.scheduled)
103
+ h.now += 70
104
+ h.scheduler.fire()
105
+ assertEquals(2, h.scenes.size)
106
+ val sent = h.scenes[1]
107
+ assertEquals(4, sent.epoch)
108
+ assertEquals("updated", sent.change("a"))
109
+ assertEquals("created", sent.change("b"))
110
+ assertEquals(5, sent.elements.first { it.id == "b" }.x)
111
+ }
112
+
113
+ @Test
114
+ fun updatedThenUnchangedBecomesUpdated() {
115
+ val h = Harness()
116
+ h.pacer.submitScene(frame(element("a")))
117
+ h.now += 1
118
+ h.pacer.submitScene(frame(element("a", change = "moved", x = 3)))
119
+ h.now += 1
120
+ h.pacer.submitScene(frame(element("a", change = "unchanged", x = 3)))
121
+ h.now += 200
122
+ h.scheduler.fire()
123
+ assertEquals("updated", h.scenes[1].change("a"))
124
+ }
125
+
126
+ @Test
127
+ fun removedThenRecreatedIdIsNotRemoved() {
128
+ val h = Harness()
129
+ h.pacer.submitScene(frame(element("a"), element("b")))
130
+ h.now += 1
131
+ h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("b")))
132
+ h.now += 1
133
+ h.pacer.submitScene(frame(element("a", change = "unchanged"), element("b", change = "created")))
134
+ h.now += 200
135
+ h.scheduler.fire()
136
+ val sent = h.scenes[1]
137
+ assertTrue(sent.removed.isEmpty())
138
+ assertEquals("created", sent.change("b"))
139
+ }
140
+
141
+ @Test
142
+ fun pendingRemovesSurviveIntoTheMergedFrame() {
143
+ val h = Harness()
144
+ h.pacer.submitScene(frame(element("a"), element("b"), element("c")))
145
+ h.now += 1
146
+ h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("b", "c")))
147
+ h.now += 1
148
+ h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("c")))
149
+ h.now += 200
150
+ h.scheduler.fire()
151
+ assertEquals(listOf("b", "c"), h.scenes[1].removed)
152
+ }
153
+
154
+ @Test
155
+ fun appSwitchDiscardsPending() {
156
+ val h = Harness()
157
+ h.pacer.submitScene(frame(element("a"), appId = "one"))
158
+ h.now += 1
159
+ h.pacer.submitScene(frame(element("a", change = "updated"), removed = listOf("z"), appId = "one"))
160
+ h.now += 1
161
+ h.pacer.submitScene(frame(element("b", change = "created"), appId = "two", replay = true))
162
+ h.now += 200
163
+ h.scheduler.fire()
164
+ val sent = h.scenes[1]
165
+ assertEquals("two", sent.appId)
166
+ assertTrue(sent.removed.isEmpty())
167
+ assertEquals(listOf("b"), sent.elements.map { it.id })
168
+ }
169
+
170
+ @Test
171
+ fun emptyFrameFlushesImmediatelyWithPendingRemoves() {
172
+ val h = Harness()
173
+ h.pacer.submitScene(frame(element("a"), element("b")))
174
+ h.now += 1
175
+ h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("b")))
176
+ h.now += 1
177
+ h.pacer.flushScene(frame(removed = listOf("a")))
178
+ assertEquals(2, h.scenes.size)
179
+ assertEquals(setOf("a", "b"), h.scenes[1].removed.toSet())
180
+ assertTrue(h.scenes[1].elements.isEmpty())
181
+ assertNull(h.scheduler.block)
182
+ // Nothing left pending: a later timer must not resend.
183
+ h.now += 200
184
+ h.pacer.setMaxFps(8)
185
+ assertEquals(2, h.scenes.size)
186
+ }
187
+
188
+ @Test
189
+ fun clearViewFlushesImmediatelyAndDropsPendingScene() {
190
+ val h = Harness()
191
+ h.pacer.submitScene(frame(element("a")))
192
+ h.now += 1
193
+ h.pacer.submitScene(frame(element("a", change = "updated")))
194
+ assertNotNull(h.scheduler.block)
195
+ h.pacer.flushLegacy()
196
+ assertEquals(1, h.legacySends)
197
+ assertEquals(1, h.scenes.size)
198
+ assertNull(h.scheduler.block)
199
+ }
200
+
201
+ @Test
202
+ fun legacyAfterSceneDropsThePendingScene() {
203
+ val h = Harness()
204
+ h.pacer.submitScene(frame(element("a")))
205
+ h.now += 1
206
+ h.pacer.submitScene(frame(element("a", change = "updated")))
207
+ h.now += 1
208
+ h.pacer.submitLegacy()
209
+ h.now += 200
210
+ h.scheduler.fire()
211
+ assertEquals(1, h.scenes.size)
212
+ assertEquals(1, h.legacySends)
213
+ }
214
+
215
+ @Test
216
+ fun sceneAfterLegacyDropsThePendingLegacy() {
217
+ val h = Harness()
218
+ h.pacer.submitLegacy()
219
+ assertEquals(1, h.legacySends)
220
+ h.now += 1
221
+ h.pacer.submitLegacy()
222
+ h.now += 1
223
+ h.pacer.submitScene(frame(element("a")))
224
+ h.now += 200
225
+ h.scheduler.fire()
226
+ assertEquals(1, h.legacySends)
227
+ assertEquals(1, h.scenes.size)
228
+ }
229
+
230
+ @Test
231
+ fun setMaxFpsRearmsAPendingTimer() {
232
+ val h = Harness(fps = 2) // 500 ms slots
233
+ h.pacer.submitScene(frame(element("a")))
234
+ h.now += 50
235
+ h.pacer.submitScene(frame(element("a", change = "updated")))
236
+ assertEquals(450L, h.scheduler.delay)
237
+ h.pacer.setMaxFps(20) // 50 ms slots: the slot is already due
238
+ assertEquals(2, h.scenes.size)
239
+ assertNull(h.scheduler.block)
240
+
241
+ h.now += 10
242
+ h.pacer.submitScene(frame(element("a", change = "updated")))
243
+ assertEquals(40L, h.scheduler.delay)
244
+ h.pacer.setMaxFps(10)
245
+ assertEquals(90L, h.scheduler.delay)
246
+ assertEquals(2, h.scenes.size)
247
+ }
248
+
249
+ @Test
250
+ fun cancelDropsPendingWithoutSending() {
251
+ val h = Harness()
252
+ h.pacer.submitScene(frame(element("a")))
253
+ h.now += 1
254
+ h.pacer.submitScene(frame(element("a", change = "updated")))
255
+ h.pacer.cancel()
256
+ assertNull(h.scheduler.block)
257
+ h.now += 200
258
+ h.pacer.setMaxFps(8)
259
+ assertEquals(1, h.scenes.size)
260
+ assertFalse(h.scheduler.block != null)
261
+ }
262
+
263
+ @Test
264
+ fun lateSubmitAfterTheSlotSendsMergedImmediately() {
265
+ val h = Harness(fps = 10)
266
+ h.pacer.submitScene(frame(element("a")))
267
+ h.now += 10
268
+ h.pacer.submitScene(frame(element("a", change = "updated"), element("b", change = "created")))
269
+ // The timer is late; a submit past the slot sends now with the pending fold.
270
+ h.now += 200
271
+ h.pacer.submitScene(frame(element("a", change = "unchanged"), element("b", change = "unchanged")))
272
+ assertEquals(2, h.scenes.size)
273
+ assertEquals("updated", h.scenes[1].change("a"))
274
+ assertEquals("created", h.scenes[1].change("b"))
275
+ assertNull(h.scheduler.block)
276
+ }
277
+ }
@@ -0,0 +1,70 @@
1
+ package com.glassly.bluetoothsdk
2
+
3
+ import com.glassly.bluetoothsdk.sgcs.G2BleLink
4
+ import org.junit.Assert.assertArrayEquals
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertNull
7
+ import org.junit.Test
8
+
9
+ /** Pins the BLE link control wire format shared with g2flash's patches/ble_link.c. */
10
+ class G2BleLinkTest {
11
+ private fun le16(v: Int) = byteArrayOf((v and 0xFF).toByte(), (v shr 8).toByte())
12
+
13
+ @Test
14
+ fun controlPacketFramesField127OnDeviceReceiveInfo() {
15
+ assertArrayEquals(
16
+ byteArrayOf(0x08, 0x01, 0x10, 0x00, 0xFA.toByte(), 0x07, 4, 0x42, 0x4C, 1, 1),
17
+ G2BleLink.controlPacket(G2BleLink.Op.FAST),
18
+ )
19
+ assertEquals(0.toByte(), G2BleLink.controlPacket(G2BleLink.Op.STOCK).last())
20
+ assertEquals(2.toByte(), G2BleLink.controlPacket(G2BleLink.Op.QUERY).last())
21
+ }
22
+
23
+ @Test
24
+ fun decodesFullStatusWithLivePair() {
25
+ val body = byteArrayOf(0x42, 0x4C, 1, 1, 1, 0xA3.toByte(), 0xA3.toByte()) + le16(6) + le16(6) + le16(0) + le16(12) + le16(0)
26
+ val status = G2BleLink.decodeStatus(body)
27
+ assertEquals(
28
+ G2BleLink.Status(
29
+ fast = true,
30
+ side = 1,
31
+ wanted = G2BleLink.Mode.Fast,
32
+ applied = G2BleLink.Mode.Fast,
33
+ requestedMinMs = 7.5,
34
+ requestedMaxMs = 7.5,
35
+ requestedLatency = 0,
36
+ liveIntervalMs = 15.0,
37
+ liveLatency = 0,
38
+ ),
39
+ status,
40
+ )
41
+ assertEquals("FAST wanted=fast applied=fast requested 7.50-7.50 ms / latency 0 live 15.00 ms / latency 0", status?.summary)
42
+ }
43
+
44
+ @Test
45
+ fun decodesThirteenByteStatusWithoutLivePair() {
46
+ val body = byteArrayOf(0x42, 0x4C, 1, 0, 2, 0xA4.toByte(), 0) + le16(36) + le16(72) + le16(4)
47
+ val status = G2BleLink.decodeStatus(body)!!
48
+ assertEquals(false, status.fast)
49
+ assertEquals(2, status.side)
50
+ assertEquals(G2BleLink.Mode.Slow, status.wanted)
51
+ assertEquals(G2BleLink.Mode.Pending, status.applied)
52
+ assertEquals(45.0, status.requestedMinMs, 0.0)
53
+ assertEquals(90.0, status.requestedMaxMs, 0.0)
54
+ assertEquals(4, status.requestedLatency)
55
+ assertNull(status.liveIntervalMs)
56
+ assertNull(status.liveLatency)
57
+ assertEquals("stock wanted=slow applied=pending requested 45.00-90.00 ms / latency 4", status.summary)
58
+ }
59
+
60
+ @Test
61
+ fun rejectsForeignShortOrVersionedBodies() {
62
+ assertNull(G2BleLink.decodeStatus(byteArrayOf(0x41, 0x4E, 1, 1, 1, 0xA3.toByte(), 0xA3.toByte()) + le16(6) + le16(6) + le16(0)))
63
+ assertNull(G2BleLink.decodeStatus(byteArrayOf(0x42, 0x4C, 2, 1, 1, 0xA3.toByte(), 0xA3.toByte()) + le16(6) + le16(6) + le16(0)))
64
+ assertNull(G2BleLink.decodeStatus(byteArrayOf(0x42, 0x4C, 1, 1, 1, 0xA3.toByte(), 0xA3.toByte()) + le16(6) + le16(6)))
65
+ assertEquals(
66
+ G2BleLink.Mode.Other(0x99),
67
+ G2BleLink.decodeStatus(byteArrayOf(0x42, 0x4C, 1, 0, 1, 0x99.toByte(), 0) + le16(0) + le16(0) + le16(0))?.wanted,
68
+ )
69
+ }
70
+ }
@@ -2,17 +2,10 @@ package com.glassly.bluetoothsdk.sgcs
2
2
 
3
3
  import org.junit.Assert.assertEquals
4
4
  import org.junit.Assert.assertFalse
5
- import org.junit.Assert.assertNotNull
6
5
  import org.junit.Assert.assertNull
7
6
  import org.junit.Assert.assertTrue
8
7
  import org.junit.Test
9
8
 
10
- /**
11
- * Pins the stock-vs-CFW identity rules that gate the connect banner. A
12
- * regression here re-opens the stuck "// Glassly connected" bug: text drawn
13
- * down the stock path before classification, cleared down the CFW path after
14
- * it, never leaves the glass.
15
- */
16
9
  class G2CfwClassificationTest {
17
10
  private fun classify(
18
11
  capability: String?,
@@ -20,70 +13,66 @@ class G2CfwClassificationTest {
20
13
  current: String? = null,
21
14
  ) = G2CfwClassification.classify(capability?.toByteArray(Charsets.UTF_8), definitive, current)
22
15
 
23
- // ── CFW detection ───────────────────────────────────────────────────────
24
-
25
16
  @Test
26
- fun cfwBannerClassifiesWithFeaturesAndRevision() {
27
- val result = classify("EVENCFW/15 img640 imgz rle directfb font15")
28
- assertNotNull(result)
29
- assertTrue(result!!.isCfw)
30
- assertEquals("EVENCFW/15 img640 imgz rle directfb font15", result.capabilityString)
31
- assertEquals(listOf("img640", "imgz", "rle", "directfb", "font15"), result.features)
32
- assertEquals(15, result.protocolVersion)
17
+ fun glasslyUsesOneContractWithoutRevisionOrTokenNegotiation() {
18
+ for (banner in listOf("GLASSLYCFW/24", "GLASSLYCFW/24 shapes36 scene37 anim38", "GLASSLYCFW/25")) {
19
+ val result = classify(banner)!!
20
+ assertEquals(G2CfwClassification.Family.GLASSLY, result.family)
21
+ assertTrue(result.isCfw)
22
+ assertTrue(result.isGlassly)
23
+ assertEquals(if (banner == "GLASSLYCFW/25") "Glassly CFW v25" else "Glassly CFW v24", result.displayName)
24
+ assertEquals(banner, result.capabilityString)
25
+ }
33
26
  }
34
27
 
35
28
  @Test
36
- fun cfwDetectionDoesNotWaitForADefinitiveResponse() {
37
- // The banner may ride any settings packet; requiring `definitive` would
38
- // delay classification past the connect banner and re-split the paths.
39
- assertTrue(classify("EVENCFW/12 img640", definitive = false)!!.isCfw)
29
+ fun revisionIsRetainedIndependentlyOfTheFirmwareFamily() {
30
+ assertEquals(24, classify("GLASSLYCFW/24 shapes36 scene37 anim38")!!.revision)
31
+ assertEquals(25, classify("GLASSLYCFW/25")!!.revision)
32
+ assertEquals(22, classify("EVENCFW/22 img640")!!.revision)
33
+ assertEquals(24, classify("GLASSLYCFW/24\u0000\u0000")!!.revision)
34
+ assertNull(G2CfwClassification.STOCK.revision)
35
+ for (banner in listOf("GLASSLYCFW/", "GLASSLYCFW/beta", "GLASSLYCFW/0", "GLASSLYCFW/-1")) {
36
+ val result = classify(banner)!!
37
+ assertNull(result.revision)
38
+ assertTrue(result.isGlassly)
39
+ }
40
40
  }
41
41
 
42
42
  @Test
43
- fun cfwBannerTrimsNulTermination() {
44
- val bytes = "EVENCFW/12 img640".toByteArray(Charsets.UTF_8) + byteArrayOf(0, 0)
45
- val result = G2CfwClassification.classify(bytes, definitive = false, currentCapability = null)
46
- assertEquals("EVENCFW/12 img640", result!!.capabilityString)
47
- assertEquals(listOf("img640"), result.features)
43
+ fun evenCfwIsUpstreamAndNeverGlassly() {
44
+ for (banner in listOf("EVENCFW/22 img640 imgz rle font15 ringbat17", "EVENCFW/24 shapes36 scene37 anim38")) {
45
+ val result = classify(banner)!!
46
+ assertEquals(G2CfwClassification.Family.UPSTREAM, result.family)
47
+ assertTrue(result.isCfw)
48
+ assertFalse(result.isGlassly)
49
+ assertEquals("g2flash CFW", result.displayName)
50
+ }
48
51
  }
49
52
 
50
53
  @Test
51
- fun malformedRevisionFallsBackToZero() {
52
- val result = classify("EVENCFW/beta img640")
53
- assertTrue(result!!.isCfw)
54
- assertEquals(0, result.protocolVersion)
55
- }
56
-
57
- // ── Stock classification ────────────────────────────────────────────────
58
-
59
- @Test
60
- fun definitiveAbsenceClassifiesStock() {
61
- val result = classify(null, definitive = true)
62
- assertNotNull(result)
63
- assertFalse(result!!.isCfw)
64
- assertEquals(G2CfwClassification.STOCK, result)
54
+ fun bannerTrimsNulTermination() {
55
+ assertEquals("GLASSLYCFW/24", classify("GLASSLYCFW/24\u0000\u0000")!!.capabilityString)
65
56
  }
66
57
 
67
58
  @Test
68
- fun definitiveNonCfwPayloadClassifiesStock() {
69
- assertEquals(G2CfwClassification.STOCK, classify("something-else", definitive = true))
70
- }
71
-
72
- // ── Packets that prove nothing ──────────────────────────────────────────
73
-
74
- @Test
75
- fun nonDefinitiveAbsenceLeavesConnectionUnclassified() {
76
- // Individual settings packets can legitimately omit field 100 or
77
- // arrive truncated — only a deviceReceiveRequest response is proof.
78
- assertNull(classify(null, definitive = false))
79
- assertNull(classify("garbage", definitive = false))
59
+ fun definitiveAbsenceOrUnrecognizedPrefixClassifiesStock() {
60
+ for (banner in listOf(null, "something-else", "GLASSLYCFW-invalid", "EVENCFW-invalid")) {
61
+ val result = classify(banner, definitive = true)!!
62
+ assertEquals(G2CfwClassification.STOCK, result)
63
+ assertFalse(result.isCfw)
64
+ assertFalse(result.isGlassly)
65
+ assertNull(result.displayName)
66
+ }
80
67
  }
81
68
 
82
69
  @Test
83
- fun definitiveAbsenceNeverDowngradesDetectedCfw() {
84
- // Firmware identity is connection-sticky: downgrading would
85
- // reinterpret an installed mode-6 carrier as a stock BMP.
86
- assertNull(classify(null, definitive = true, current = "EVENCFW/15 img640"))
87
- assertNull(classify("garbage", definitive = true, current = "EVENCFW/15 img640"))
70
+ fun partialSettingsPacketsPreserveTheKnownRenderPath() {
71
+ assertNull(classify(null))
72
+ assertNull(classify("garbage"))
73
+ for (banner in listOf("GLASSLYCFW/24", "EVENCFW/22")) {
74
+ assertNull(classify(null, definitive = true, current = banner))
75
+ assertNull(classify("garbage", definitive = true, current = banner))
76
+ }
88
77
  }
89
78
  }
@@ -1,5 +1,6 @@
1
1
  package com.glassly.bluetoothsdk.sgcs
2
2
 
3
+ import org.junit.Assert.assertArrayEquals
3
4
  import org.junit.Assert.assertEquals
4
5
  import org.junit.Assert.assertNotNull
5
6
  import org.junit.Assert.assertNull
@@ -14,6 +15,72 @@ import org.robolectric.annotation.GraphicsMode
14
15
  @Config(sdk = [33])
15
16
  @GraphicsMode(GraphicsMode.Mode.NATIVE)
16
17
  class G2CfwRendererTest {
18
+ @Test
19
+ fun globalDepthMovesFlatScenesWithoutMiniappDepth() {
20
+ val content = frame(rect(border = 1).copy(x = 100, y = 40, w = 100, h = 50))
21
+ // Start flat, split the retained mirror, change sides, then converge again.
22
+ listOf(2, 1, 3, 2).forEachIndexed { index, setting ->
23
+ val b = boxes(4, G2CfwRenderer.depthLevelForSetting(setting))
24
+ val encoded = G2CfwRenderer.encodeVector(content, b, 900, index == 0, true)!!
25
+ if (setting == 2) {
26
+ assertEquals(listOf(G2CfwTransport.LENS_BOTH), encoded.shows.map { it.lenses })
27
+ assertEquals(132, firstRectX(encoded.shows[0].payload))
28
+ } else {
29
+ assertEquals(listOf(G2CfwTransport.LENS_LEFT, G2CfwTransport.LENS_RIGHT), encoded.shows.map { it.lenses })
30
+ assertEquals(if (setting == 1) 140 else 124, firstRectX(encoded.shows[0].payload))
31
+ assertEquals(if (setting == 1) 124 else 140, firstRectX(encoded.shows[1].payload))
32
+ }
33
+ }
34
+ }
35
+
36
+ @Test
37
+ fun elementDepthIsRelativeToEachGlobalLensOrigin() {
38
+ val content = frame(rect(border = 1).copy(x = 100, y = 40, w = 100, h = 50, depth = 5))
39
+ val b = boxes(4, G2CfwRenderer.depthLevelForSetting(3))
40
+ val encoded = G2CfwRenderer.encodeVector(content, b, 901, true, true)!!
41
+ assertEquals(listOf(G2CfwTransport.LENS_LEFT, G2CfwTransport.LENS_RIGHT), encoded.shows.map { it.lenses })
42
+ assertEquals(24 + 100 + 5, firstRectX(encoded.shows[0].payload))
43
+ assertEquals(40 + 100 - 5, firstRectX(encoded.shows[1].payload))
44
+ }
45
+
46
+ private fun firstRectX(payload: ByteArray): Int {
47
+ val bytes = payload.map { it.toInt() and 0xFF }
48
+ assertEquals(38, bytes[0]) // SHOW
49
+ assertEquals(1, bytes[7]) // one object PUT
50
+ // SHOW header (8), PUT opcode/id/version (9), type/flags/color/width (4).
51
+ return (bytes[21] or (bytes[22] shl 8)).toShort().toInt()
52
+ }
53
+
54
+ @Test
55
+ fun globalDepthSettingsMapToAllThreeFirmwareLevels() {
56
+ assertEquals(listOf(0, 1, 2), listOf(1, 2, 3).map { G2CfwRenderer.depthLevelForSetting(it) })
57
+ assertEquals(0, G2CfwRenderer.depthLevelForSetting(Int.MIN_VALUE))
58
+ assertEquals(2, G2CfwRenderer.depthLevelForSetting(Int.MAX_VALUE))
59
+ }
60
+
61
+ @Test
62
+ fun rasterFallbackBakesTextIntoStereoWindows() {
63
+ for (font in listOf("builtin", "mono")) {
64
+ val text = rect(border = 0).copy(
65
+ type = "text", x = 10, y = 10, w = 200, h = 60, text = "Depth", font = font, fontSize = 20,
66
+ )
67
+ for (setting in listOf(1, 3)) {
68
+ val b = boxes(4, G2CfwRenderer.depthLevelForSetting(setting))
69
+ val update = G2CfwRenderer.encodeUpdate(
70
+ frame(text), b, previousRaster = null, forcedRects = emptyList(),
71
+ fids = (1..G2CfwRenderer.REQUIRED_FID_COUNT).toList(),
72
+ textDraws = emptyList(), allowRectCopy = true, blankBoxes = null, omitBuiltinText = false,
73
+ textureGlyphsEnabled = true, textureImagesEnabled = true,
74
+ )!!
75
+ assertTrue("$font text must remain visible", update.raster.any { it.toInt() != 0 })
76
+ val payload = update.payload!!
77
+ assertEquals("a stereo bitmap, without lens-equal text/cache commands", 0x83, payload[0].toInt() and 0xFF)
78
+ assertEquals(b.leftX, (payload[1].toInt() and 0xFF) * 4)
79
+ assertEquals(b.rightX, (payload[5].toInt() and 0xFF) * 4)
80
+ }
81
+ }
82
+ }
83
+
17
84
  @Test
18
85
  fun placementMatchesStockWindowTablesAndClamps() {
19
86
  val defaults = boxes(height = 4, depth = 2)
@@ -52,7 +119,8 @@ class G2CfwRendererTest {
52
119
  listOf(0x83, 6, 64, 144, 144, 10, 64, 144, 144, 0x34, 0x12),
53
120
  payload!!.take(11).map { it.toInt() and 0xFF },
54
121
  )
55
- assertEquals(0x78, payload[11].toInt() and 0xFF)
122
+ // Plain RLE follows the header (the transport compresses): 576*288 black pixels.
123
+ assertEquals(listOf(0x00, 0x00, 0xFF, 0xFF), payload.copyOfRange(11, 15).map { it.toInt() and 0xFF })
56
124
  assertNull(G2CfwRenderer.stereoFrame(ByteArray(10), boxes(4, 2), 1))
57
125
  }
58
126
 
@@ -94,7 +162,11 @@ class G2CfwRendererTest {
94
162
  val payload = G2CfwRenderer.blackPayload()
95
163
  assertNotNull(payload)
96
164
  assertEquals(6, payload!![0].toInt() and 0xFF)
97
- assertEquals(0x78, payload[1].toInt() and 0xFF)
165
+ // 307,200 black pixels as RLE: four 65,535 runs and a 45,060 remainder.
166
+ assertEquals(
167
+ listOf(0, 0, 0xFF, 0xFF, 0, 0, 0xFF, 0xFF, 0, 0, 0xFF, 0xFF, 0, 0, 0xFF, 0xFF, 0, 0, 0x04, 0xB0),
168
+ payload.drop(1).map { it.toInt() and 0xFF },
169
+ )
98
170
  }
99
171
 
100
172
  @Test
@@ -212,7 +284,8 @@ class G2CfwRendererTest {
212
284
  listOf(0x83, (24 + 100) / 4, (128 + 50) / 2, 8, 8, (40 + 100) / 4, (128 + 50) / 2, 8, 8, 5, 0),
213
285
  payload!!.take(11).map { it.toInt() and 0xFF },
214
286
  )
215
- assertEquals(0x78, payload[11].toInt() and 0xFF)
287
+ // Plain RLE of the 32x16 box follows (the transport compresses).
288
+ assertArrayEquals(G2CfwRenderer.rleEncode(G2CfwRenderer.pack4Bpp(ByteArray(32 * 16))), payload.copyOfRange(11, payload.size))
216
289
  assertNull(
217
290
  G2CfwRenderer.boxDeltaFrame(raster(576, 288), 576, G2CfwRenderer.CanvasRect(3, 0, 32, 16), b, 1)
218
291
  )
@@ -272,8 +345,8 @@ class G2CfwRendererTest {
272
345
  val draws = G2CfwRenderer.builtinTextDraws(frame(element), b)
273
346
  assertEquals(
274
347
  listOf(
275
- G2CfwRenderer.TextDraw(32 + 10 + 2, 128 + 20 + 2, "one"),
276
- G2CfwRenderer.TextDraw(32 + 10 + 2, 128 + 20 + 2 + 27, "two"),
348
+ G2CfwRenderer.TextDraw(32 + 10 + 2, 128 + 20 + 2, "one", 200),
349
+ G2CfwRenderer.TextDraw(32 + 10 + 2, 128 + 20 + 2 + 27, "two", 200),
277
350
  ),
278
351
  draws,
279
352
  )