@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,452 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import android.content.SharedPreferences
4
+ import com.glassly.bluetoothsdk.Bridge
5
+ import com.glassly.bluetoothsdk.DeviceStore
6
+ import kotlinx.coroutines.CancellationException
7
+ import kotlinx.coroutines.CoroutineScope
8
+ import kotlinx.coroutines.Job
9
+ import kotlinx.coroutines.delay
10
+ import kotlinx.coroutines.launch
11
+ import kotlin.math.min
12
+ import kotlin.math.pow
13
+
14
+ /**
15
+ * Owns the phone side of the CFW keyboard bridge for one G2 connection: the
16
+ * client, the remembered keyboard, the reconnect loop, and what the app sees
17
+ * (glasses-status keys plus `keyboard_*` events). The G2 driver hands it
18
+ * right-lens field-131 payloads and a sender for settings payloads; everything
19
+ * else stays in here so the driver only tracks link lifecycle.
20
+ *
21
+ * Firmware state is volatile (a lens reboot returns to stock behaviour), so
22
+ * the remembered keyboard is re-enabled and reconnected on every CFW
23
+ * detection and after every keyboard-side disconnect, with backoff. The
24
+ * discovered handle mapping is only valid for one connection epoch and is
25
+ * never persisted. All calls run on [scope] (the driver's main-thread scope).
26
+ */
27
+ internal class G2KeyboardBridge(
28
+ private val scope: CoroutineScope,
29
+ private val prefs: SharedPreferences,
30
+ private val send: (ByteArray) -> Unit,
31
+ ) {
32
+ companion object {
33
+ const val MINIMUM_REVISION = 39
34
+ private const val KEY_ADDRESS = "g2_keyboard_address"
35
+ private const val KEY_ADDRESS_TYPE = "g2_keyboard_address_type"
36
+ private const val KEY_NAME = "g2_keyboard_name"
37
+
38
+ fun advertValues(advert: G2KeyboardProtocol.Advert): Map<String, Any> =
39
+ mapOf(
40
+ "address" to advert.addressText, "addressType" to advert.addressType, "name" to advert.name,
41
+ "hid" to advert.hid, "rssi" to advert.rssi,
42
+ )
43
+
44
+ /** Drop the remembered keyboard without a live driver. */
45
+ fun forgetSaved(prefs: SharedPreferences) {
46
+ prefs.edit().remove(KEY_ADDRESS).remove(KEY_ADDRESS_TYPE).remove(KEY_NAME).apply()
47
+ DeviceStore.apply("glasses", "keyboardSaved", false)
48
+ DeviceStore.apply("glasses", "keyboardName", "")
49
+ }
50
+ }
51
+
52
+ data class Saved(val address: String, val addressType: Int, val name: String)
53
+
54
+ enum class State(val value: String) {
55
+ IDLE("idle"), SCANNING("scanning"), CONNECTING("connecting"), PAIRING("pairing"),
56
+ CONFIGURING("configuring"), CONNECTED("connected"), ERROR("error"),
57
+ }
58
+
59
+ class BridgeException(message: String) : Exception(message)
60
+
61
+ private var client: G2KeyboardClient? = null
62
+ var supported = false
63
+ private set
64
+ var ready = false
65
+ private set
66
+ var state = State.IDLE
67
+ private set
68
+ private var busy = false
69
+ /** `busy` is held by the automatic reconnect loop (not the user). */
70
+ private var autoReconnecting = false
71
+ private var userDisconnected = false
72
+ private var lastError: String? = null
73
+ private var connectedName = ""
74
+ private val scanResults = ArrayList<G2KeyboardProtocol.Advert>()
75
+ private var reconnectJob: Job? = null
76
+ private var reconnectAttempt = 0
77
+ var saved: Saved? = loadSaved()
78
+ private set(value) {
79
+ field = value
80
+ val editor = prefs.edit()
81
+ if (value == null) {
82
+ editor.remove(KEY_ADDRESS).remove(KEY_ADDRESS_TYPE).remove(KEY_NAME)
83
+ } else {
84
+ editor.putString(KEY_ADDRESS, value.address).putInt(KEY_ADDRESS_TYPE, value.addressType).putString(KEY_NAME, value.name)
85
+ }
86
+ editor.apply()
87
+ }
88
+
89
+ private fun loadSaved(): Saved? {
90
+ val address = prefs.getString(KEY_ADDRESS, null) ?: return null
91
+ return Saved(address, prefs.getInt(KEY_ADDRESS_TYPE, 1), prefs.getString(KEY_NAME, "") ?: "")
92
+ }
93
+
94
+ // ---------- Link lifecycle (driven by G2) ----------
95
+
96
+ /** CFW classification landed (or changed). Re-arms the remembered keyboard. */
97
+ fun cfwDetected(revision: Int?) {
98
+ val now = (revision ?: 0) >= MINIMUM_REVISION
99
+ if (now != supported) Bridge.log("G2: keyboard bridge ${if (now) "available" else "unavailable"} (revision ${revision ?: 0})")
100
+ supported = now
101
+ userDisconnected = false
102
+ reconnectAttempt = 0
103
+ if (!supported) teardownClient()
104
+ publish()
105
+ if (supported && saved != null) scheduleReconnect(1_000)
106
+ }
107
+
108
+ /** The glasses link is gone: drop everything but the remembered keyboard. */
109
+ fun linkLost() {
110
+ supported = false
111
+ teardownClient()
112
+ publish()
113
+ }
114
+
115
+ /** Right-lens sid-0x09 field 131. */
116
+ fun accept(payload: ByteArray) {
117
+ client?.accept(payload)
118
+ }
119
+
120
+ private fun teardownClient() {
121
+ reconnectJob?.cancel()
122
+ reconnectJob = null
123
+ client?.close()
124
+ client = null
125
+ ready = false
126
+ busy = false
127
+ state = State.IDLE
128
+ lastError = null
129
+ }
130
+
131
+ // ---------- App-facing commands ----------
132
+
133
+ /** Five-second active scan; returns every distinct advertiser seen. */
134
+ suspend fun scan(): List<G2KeyboardProtocol.Advert> {
135
+ requireSupported()
136
+ interruptReconnect()
137
+ requireIdle()
138
+ busy = true
139
+ try {
140
+ val client = ensureClient()
141
+ scanResults.clear()
142
+ lastError = null
143
+ setState(State.SCANNING)
144
+ try {
145
+ releaseLink(client)
146
+ client.enable()
147
+ client.scan()
148
+ setState(State.IDLE)
149
+ } catch (e: Exception) {
150
+ if (e is CancellationException) throw e
151
+ fail(e)
152
+ throw friendly(e)
153
+ }
154
+ return scanResults.toList()
155
+ } finally {
156
+ busy = false
157
+ }
158
+ }
159
+
160
+ /** Connect, pair and subscribe a keyboard, then remember it. */
161
+ suspend fun connect(address: String, addressType: Int, name: String) {
162
+ requireSupported()
163
+ val bytes = G2KeyboardProtocol.addressFromText(address)
164
+ if (bytes == null || addressType !in 0..3) throw BridgeException("Invalid keyboard address")
165
+ interruptReconnect()
166
+ requireIdle()
167
+ busy = true
168
+ try {
169
+ userDisconnected = false
170
+ val client = ensureClient()
171
+ lastError = null
172
+ setState(State.CONNECTING)
173
+ try {
174
+ releaseLink(client)
175
+ client.enable()
176
+ client.connect(addressType, bytes)
177
+ setState(State.CONFIGURING)
178
+ val config = client.configure()
179
+ ready = true
180
+ connectedName = name
181
+ saved = Saved(address, addressType, name)
182
+ reconnectAttempt = 0
183
+ Bridge.log("G2: keyboard ready — ${config.inputCount} input reports, epoch ${config.epoch}")
184
+ setState(State.CONNECTED)
185
+ } catch (e: Exception) {
186
+ ready = false
187
+ if (e is CancellationException) throw e
188
+ fail(e)
189
+ throw friendly(e)
190
+ }
191
+ } finally {
192
+ busy = false
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Drop the keyboard link but keep the bond and the remembered identity. No
198
+ * automatic reconnect until the next CFW detection or explicit connect.
199
+ */
200
+ suspend fun disconnect() {
201
+ userDisconnected = true
202
+ cancelReconnect()
203
+ ready = false
204
+ val client = client
205
+ if (client == null) {
206
+ setState(State.IDLE)
207
+ return
208
+ }
209
+ try {
210
+ client.disable()
211
+ setState(State.IDLE)
212
+ } catch (e: Exception) {
213
+ fail(e)
214
+ throw e
215
+ }
216
+ }
217
+
218
+ /** Forget the remembered keyboard. The firmware keeps its bond. */
219
+ suspend fun forget() {
220
+ runCatching { disconnect() }
221
+ saved = null
222
+ connectedName = ""
223
+ publish()
224
+ }
225
+
226
+ /** Reconnect the remembered keyboard now (an app-driven retry). */
227
+ suspend fun reconnect() {
228
+ val saved = saved ?: throw BridgeException("No remembered keyboard")
229
+ connect(saved.address, saved.addressType, saved.name)
230
+ }
231
+
232
+ suspend fun passkey(pin: Int) {
233
+ val client = client
234
+ if (client == null || pin !in 0..999_999) throw BridgeException("No pending keyboard pairing")
235
+ client.authenticate(pin)
236
+ }
237
+
238
+ suspend fun compare(accept: Boolean) {
239
+ val client = client ?: throw BridgeException("No pending keyboard pairing")
240
+ client.confirm(accept)
241
+ }
242
+
243
+ /** Firmware status query plus the bridge's own view. */
244
+ suspend fun status(): Map<String, Any> {
245
+ val values = statusValues().toMutableMap()
246
+ val client = client
247
+ if (client != null && supported) {
248
+ runCatching { client.query() }.getOrNull()?.let { status ->
249
+ values["firmware"] = mapOf(
250
+ "enabled" to status.enabled, "conn" to status.conn, "connected" to status.connected,
251
+ "encrypted" to status.encrypted, "scanning" to status.scanning,
252
+ )
253
+ }
254
+ }
255
+ return values
256
+ }
257
+
258
+ // ---------- Internals ----------
259
+
260
+ private fun requireSupported() {
261
+ if (!supported) throw BridgeException("Keyboard bridge requires Glassly CFW revision $MINIMUM_REVISION on connected G2 glasses")
262
+ }
263
+
264
+ private fun requireIdle() {
265
+ if (busy) throw BridgeException("Keyboard operation already in progress")
266
+ }
267
+
268
+ private fun ensureClient(): G2KeyboardClient {
269
+ client?.let { return it }
270
+ val created = G2KeyboardClient(scope, { payload, _ -> send(payload) }) { handle(it) }
271
+ client = created
272
+ return created
273
+ }
274
+
275
+ private fun setState(next: State) {
276
+ state = next
277
+ publish()
278
+ }
279
+
280
+ private fun fail(error: Throwable) {
281
+ lastError = error.message ?: error.toString()
282
+ Bridge.log("G2: keyboard error — $lastError")
283
+ setState(State.ERROR)
284
+ }
285
+
286
+ private fun cancelReconnect() {
287
+ reconnectJob?.cancel()
288
+ reconnectJob = null
289
+ }
290
+
291
+ /**
292
+ * Hand the bridge to a user-driven scan/connect: cancel a pending retry
293
+ * and, when an attempt is mid-flight (a sleeping keyboard keeps one busy
294
+ * for up to 30 s at a time), let it unwind before continuing. Without
295
+ * this the scan screen mostly saw "operation already in progress"
296
+ * whenever a keyboard was remembered.
297
+ */
298
+ private suspend fun interruptReconnect() {
299
+ val job = reconnectJob ?: return
300
+ reconnectJob = null
301
+ job.cancel()
302
+ job.join()
303
+ }
304
+
305
+ /**
306
+ * The firmware refuses SCAN/CONNECT while it still owns a keyboard slot,
307
+ * and an interrupted connect keeps initiating until DM close: drop the
308
+ * link and poll until the slot is free (close is asynchronous).
309
+ */
310
+ private suspend fun releaseLink(client: G2KeyboardClient) {
311
+ // DISABLE is epoch-checked and a fresh client has not seen the
312
+ // firmware's epoch yet (it bumps on every CONNECT); QUERY is not, and
313
+ // its STATUS record carries the epoch, so sync before disabling.
314
+ client.query()
315
+ client.disable()
316
+ ready = false
317
+ repeat(20) {
318
+ if (client.query().conn == 0) return
319
+ delay(150)
320
+ }
321
+ throw BridgeException("The glasses are still closing the previous keyboard link; try again in a moment")
322
+ }
323
+
324
+ /**
325
+ * Firmware BUSY almost always means the ring's own scan/reconnect holds
326
+ * the radio; say so instead of leaking the raw control error.
327
+ */
328
+ private fun friendly(e: Exception): Exception =
329
+ if (e.message?.endsWith("firmware busy") == true) {
330
+ BridgeException("The glasses' radio is busy (usually the ring reconnecting). Wait a few seconds and try again.")
331
+ } else {
332
+ e
333
+ }
334
+
335
+ private fun scheduleReconnect(delayMs: Long) {
336
+ cancelReconnect()
337
+ // The job stays in `reconnectJob` for the whole attempt so
338
+ // interruptReconnect() can join it.
339
+ reconnectJob = scope.launch {
340
+ delay(delayMs)
341
+ attemptReconnect()
342
+ }
343
+ }
344
+
345
+ private suspend fun attemptReconnect() {
346
+ val saved = saved
347
+ if (!supported || saved == null || userDisconnected || ready || busy) return
348
+ autoReconnecting = true
349
+ try {
350
+ connect(saved.address, saved.addressType, saved.name)
351
+ } catch (e: CancellationException) {
352
+ throw e // a user scan/connect took over
353
+ } catch (e: Exception) {
354
+ reconnectAttempt++
355
+ // Keyboards sleep and the ring's own scan can hold the controller
356
+ // (BUSY); back off to 30 s and keep trying while the glasses stay up.
357
+ val seconds = min(30.0, 5.0 * 2.0.pow(min(reconnectAttempt - 1, 3).toDouble()))
358
+ scheduleReconnect((seconds * 1000).toLong())
359
+ } finally {
360
+ autoReconnecting = false
361
+ }
362
+ }
363
+
364
+ private fun handle(event: G2KeyboardClient.Event) {
365
+ when (event) {
366
+ is G2KeyboardClient.Event.Keys -> {
367
+ val now = System.currentTimeMillis()
368
+ for (e in event.events) {
369
+ Bridge.sendTypedMessage(
370
+ "keyboard_key",
371
+ mapOf("page" to e.page, "usage" to e.usage, "down" to e.down, "timestamp" to now),
372
+ )
373
+ }
374
+ }
375
+ is G2KeyboardClient.Event.Advertisement -> {
376
+ // An active scan reports the advertisement and its scan response
377
+ // as separate records, and the name usually only rides in the
378
+ // latter: merge per address instead of keeping whichever came first.
379
+ val advert = event.advert
380
+ val i = scanResults.indexOfFirst { it.address == advert.address && it.addressType == advert.addressType }
381
+ val merged = if (i >= 0) {
382
+ val next = scanResults[i].merging(advert)
383
+ if (next == scanResults[i]) return
384
+ scanResults[i] = next
385
+ next
386
+ } else {
387
+ scanResults.add(advert)
388
+ advert
389
+ }
390
+ Bridge.sendTypedMessage("keyboard_advert", advertValues(merged))
391
+ }
392
+ is G2KeyboardClient.Event.StatusChanged -> {
393
+ if (ready && (!event.status.connected || !event.status.encrypted)) keyboardDropped("status")
394
+ }
395
+ G2KeyboardClient.Event.Gap -> Unit
396
+ is G2KeyboardClient.Event.Dm -> {
397
+ if (event.event == 0x28 || event.event == 0x2B || event.event == 0x2D) {
398
+ Bridge.log("G2: keyboard DM event 0x${event.event.toString(16)} status ${event.status}")
399
+ if (ready) keyboardDropped("dm")
400
+ }
401
+ }
402
+ G2KeyboardClient.Event.PasskeyRequest -> {
403
+ setState(State.PAIRING)
404
+ Bridge.sendTypedMessage("keyboard_pairing", mapOf("kind" to "passkey"))
405
+ }
406
+ is G2KeyboardClient.Event.PasskeyDisplay -> {
407
+ setState(State.PAIRING)
408
+ Bridge.sendTypedMessage("keyboard_pairing", mapOf("kind" to "display", "value" to event.pin))
409
+ }
410
+ is G2KeyboardClient.Event.CompareRequest -> {
411
+ setState(State.PAIRING)
412
+ Bridge.sendTypedMessage("keyboard_pairing", mapOf("kind" to "compare", "value" to event.value))
413
+ }
414
+ G2KeyboardClient.Event.ServicesChanged -> {
415
+ Bridge.log("G2: keyboard services changed; rediscovering")
416
+ ready = false
417
+ publish()
418
+ val saved = saved ?: return
419
+ scope.launch {
420
+ busy = false
421
+ runCatching { connect(saved.address, saved.addressType, saved.name) }
422
+ }
423
+ }
424
+ is G2KeyboardClient.Event.Error -> {
425
+ lastError = event.message
426
+ Bridge.log("G2: keyboard — ${event.message}")
427
+ publish()
428
+ }
429
+ }
430
+ }
431
+
432
+ private fun keyboardDropped(reason: String) {
433
+ Bridge.log("G2: keyboard disconnected ($reason)")
434
+ ready = false
435
+ setState(State.IDLE)
436
+ if (saved != null && !userDisconnected) scheduleReconnect(2_000)
437
+ }
438
+
439
+ private fun statusValues(): Map<String, Any> =
440
+ mapOf(
441
+ "keyboardBridge" to supported,
442
+ "keyboardConnected" to ready,
443
+ "keyboardName" to (if (ready) connectedName else saved?.name ?: ""),
444
+ "keyboardSaved" to (saved != null),
445
+ "keyboardState" to state.value,
446
+ "keyboardError" to (lastError ?: ""),
447
+ )
448
+
449
+ private fun publish() {
450
+ for ((key, value) in statusValues()) DeviceStore.apply("glasses", key, value)
451
+ }
452
+ }
@@ -0,0 +1,208 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * Bounded HID report-descriptor decoder for the G2 keyboard bridge. HOGP
5
+ * passes the Report ID out of band (Report Reference descriptor), so a
6
+ * characteristic value is decoded as-is; byte zero is never stripped the way
7
+ * USB HID would. Ported from g2flash `demos/keyboard-hid.ts`; the descriptor
8
+ * limits match the firmware's 512-byte Report Map cap.
9
+ */
10
+ internal object G2KeyboardHid {
11
+ data class Usage(val page: Int, val usage: Int)
12
+
13
+ data class KeyEvent(val page: Int, val usage: Int, val down: Boolean)
14
+
15
+ class Field(
16
+ val bit: Int,
17
+ val size: Int,
18
+ val count: Int,
19
+ val variable: Boolean,
20
+ val min: Int,
21
+ val max: Int,
22
+ val usages: List<Usage>,
23
+ val range: Pair<Usage, Usage>?,
24
+ )
25
+
26
+ class Report(val id: Int, var bits: Int, val fields: MutableList<Field>)
27
+
28
+ class DescriptorException(reason: String) : IllegalArgumentException("Unsupported or malformed HID descriptor: $reason")
29
+
30
+ class ReportException(reason: String) : IllegalArgumentException(reason)
31
+
32
+ private class Global(
33
+ var page: Int = 0,
34
+ var min: Int = 0,
35
+ var max: Int = 0,
36
+ var size: Int = 0,
37
+ var count: Int = 0,
38
+ var id: Int = 0,
39
+ ) {
40
+ fun copy() = Global(page, min, max, size, count, id)
41
+ }
42
+
43
+ /**
44
+ * Parse a Report Map into its input reports keyed by Report ID (0 when the
45
+ * map has no Report ID items). Rejects long items, usage delimiters, mixed
46
+ * numbered/unnumbered reports and oversized dimensions.
47
+ */
48
+ fun parseReportMap(bytes: ByteArray): Map<Int, Report> {
49
+ fun fail(reason: String): Nothing = throw DescriptorException(reason)
50
+ if (bytes.isEmpty() || bytes.size > 512) fail("report map must contain 1..512 bytes")
51
+ val reports = LinkedHashMap<Int, Report>()
52
+ var g = Global()
53
+ val stack = ArrayDeque<Global>()
54
+ var usages = ArrayList<Usage>()
55
+ var lo: Usage? = null
56
+ var hi: Usage? = null
57
+ var depth = 0
58
+ var p = 0
59
+ while (p < bytes.size) {
60
+ val prefix = bytes[p++].toInt() and 0xFF
61
+ if (prefix == 0xFE) fail("long items")
62
+ val n = intArrayOf(0, 1, 2, 4)[prefix and 3]
63
+ if (p + n > bytes.size) fail("truncated item")
64
+ var v = 0L
65
+ for (j in 0 until n) v += (bytes[p + j].toLong() and 0xFF) shl (8 * j)
66
+ p += n
67
+ val signed = if (n > 0 && v >= 1L shl (n * 8 - 1)) v - (1L shl (n * 8)) else v
68
+ val type = (prefix shr 2) and 3
69
+ val tag = prefix shr 4
70
+ fun usage(value: Long, width: Int): Usage =
71
+ if (width == 4) Usage((value shr 16).toInt(), (value and 0xFFFF).toInt()) else Usage(g.page, value.toInt())
72
+ when (type) {
73
+ 1 -> when (tag) {
74
+ 0 -> g.page = v.toInt()
75
+ 1 -> g.min = signed.toInt()
76
+ 2 -> g.max = if (g.min < 0) signed.toInt() else v.toInt()
77
+ 7 -> g.size = v.toInt()
78
+ 8 -> {
79
+ if (v == 0L || v > 255) fail("Report ID")
80
+ g.id = v.toInt()
81
+ }
82
+ 9 -> g.count = v.toInt()
83
+ 10 -> {
84
+ if (stack.size >= 8) fail("global stack overflow")
85
+ stack.addLast(g.copy())
86
+ }
87
+ 11 -> g = stack.removeLastOrNull() ?: fail("global stack underflow")
88
+ 3, 4, 5, 6 -> Unit // physical range, unit/exponent
89
+ else -> fail("unknown global item")
90
+ }
91
+ 2 -> when {
92
+ tag == 0 -> {
93
+ if (usages.size >= 512) fail("too many usages")
94
+ usages.add(usage(v, n))
95
+ }
96
+ tag == 1 -> lo = usage(v, n)
97
+ tag == 2 -> hi = usage(v, n)
98
+ tag == 10 -> fail("usage delimiters")
99
+ tag > 9 -> fail("unknown local item")
100
+ }
101
+ 0 -> {
102
+ when (tag) {
103
+ 10 -> if (++depth > 32) fail("collection depth")
104
+ 12 -> if (--depth < 0) fail("collection underflow")
105
+ 8 -> {
106
+ if (g.size == 0 || g.size > 32 || g.count == 0 || g.count > 512) fail("field dimensions")
107
+ val r = reports.getOrPut(g.id) {
108
+ if (reports.size >= 16) fail("too many input reports")
109
+ Report(g.id, 0, ArrayList())
110
+ }
111
+ var range: Pair<Usage, Usage>? = null
112
+ if (lo != null || hi != null) {
113
+ val l = lo
114
+ val h = hi
115
+ if (l == null || h == null || l.page != h.page || h.usage < l.usage) fail("usage range")
116
+ range = l to h
117
+ }
118
+ val field = Field(r.bits, g.size, g.count, (v.toInt() and 2) != 0, g.min, g.max, ArrayList(usages), range)
119
+ r.bits += g.size * g.count
120
+ if (r.bits > 4096) fail("report exceeds 512 bytes")
121
+ // Constant padding and relative pointer axes are not keys.
122
+ if ((v.toInt() and 1) == 0 && (v.toInt() and 4) == 0) r.fields.add(field)
123
+ }
124
+ 9, 11 -> Unit
125
+ else -> fail("unknown main item")
126
+ }
127
+ usages = ArrayList()
128
+ lo = null
129
+ hi = null
130
+ }
131
+ else -> fail("reserved item type")
132
+ }
133
+ }
134
+ if (depth != 0 || stack.isNotEmpty()) fail("unbalanced collection/global stack")
135
+ if (reports.containsKey(0) && reports.size > 1) fail("mixed numbered and unnumbered input reports")
136
+ if (reports.isEmpty()) fail("no input reports")
137
+ return reports
138
+ }
139
+
140
+ private fun usageAt(f: Field, index: Int): Usage? {
141
+ if (index < 0) return null
142
+ if (index < f.usages.size) return f.usages[index]
143
+ val rangeIndex = index - f.usages.size
144
+ val range = f.range
145
+ if (range != null && rangeIndex <= range.second.usage - range.first.usage) {
146
+ return Usage(range.first.page, range.first.usage + rangeIndex)
147
+ }
148
+ // Variable items reuse their last usage when Report Count exceeds usages.
149
+ if (f.variable) return range?.second ?: f.usages.lastOrNull()
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * Keyboard (page 7) and consumer (page 12) usages currently asserted in one
155
+ * input report value. Throws on a length mismatch or a rollover report.
156
+ */
157
+ fun reportKeys(report: Report, data: ByteArray): List<Usage> {
158
+ if (data.size != (report.bits + 7) / 8) throw ReportException("HID input length does not match Report Map")
159
+ val keys = LinkedHashSet<Usage>()
160
+ for (f in report.fields) {
161
+ for (i in 0 until f.count) {
162
+ var value = 0
163
+ for (j in 0 until f.size) {
164
+ val bit = f.bit + i * f.size + j
165
+ if ((data[bit shr 3].toInt() and (1 shl (bit and 7))) != 0) value += 1 shl j
166
+ }
167
+ if (f.min < 0 && value >= 1 shl (f.size - 1)) value -= 1 shl f.size
168
+ if (value < f.min || value > f.max) continue // null state
169
+ val u = usageAt(f, if (f.variable) i else value - f.min) ?: continue
170
+ if (f.variable && value == 0) continue
171
+ if (u.usage == 0) continue
172
+ if (u.page == 7 && u.usage <= 3) throw ReportException("HID keyboard rollover/error report")
173
+ if (u.page == 7 || u.page == 12) keys.add(u)
174
+ }
175
+ }
176
+ return keys.toList()
177
+ }
178
+
179
+ /**
180
+ * Key ownership is per characteristic, then unioned: releasing one report
181
+ * cannot release a modifier still held by another report.
182
+ */
183
+ class HeldKeys {
184
+ private val reports = LinkedHashMap<Int, List<Usage>>()
185
+ private var held: List<Usage> = emptyList()
186
+
187
+ fun update(handle: Int, report: Report, data: ByteArray): List<KeyEvent> {
188
+ reports[handle] = reportKeys(report, data)
189
+ return diff()
190
+ }
191
+
192
+ fun reset(): List<KeyEvent> {
193
+ reports.clear()
194
+ return diff()
195
+ }
196
+
197
+ private fun diff(): List<KeyEvent> {
198
+ val next = LinkedHashSet<Usage>()
199
+ for (handle in reports.keys.sorted()) next.addAll(reports.getValue(handle))
200
+ val heldSet = held.toSet()
201
+ val events = ArrayList<KeyEvent>()
202
+ for (u in held) if (u !in next) events.add(KeyEvent(u.page, u.usage, false))
203
+ for (u in next) if (u !in heldSet) events.add(KeyEvent(u.page, u.usage, true))
204
+ held = next.toList()
205
+ return events
206
+ }
207
+ }
208
+ }