@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,689 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import kotlinx.coroutines.CompletableDeferred
4
+ import kotlinx.coroutines.CoroutineScope
5
+ import kotlinx.coroutines.delay
6
+ import kotlinx.coroutines.launch
7
+ import kotlinx.coroutines.withTimeoutOrNull
8
+
9
+ /**
10
+ * Glassly CFW BLE keyboard bridge (contract revision 39). The right lens acts
11
+ * as a BLE central for one HID-over-GATT keyboard; the phone drives the
12
+ * connection over private sid-0x09 fields and receives raw input reports
13
+ * back. The wire format is pinned by `patches/keyboard_core.h` and documented
14
+ * in `docs/keyboard.md` in g2flash.
15
+ *
16
+ * Phone -> glasses, field 130 on a cmd=2 settings request with a magic id:
17
+ * `['K','B',1,op, request LE16, epoch LE16, body…]` (at most 80 bytes).
18
+ * Glasses -> phone, field 131 on cmd=3 EVENT notifications:
19
+ * `['K','B',1,kind, sequence LE32, epoch LE16, request LE16, code LE16,
20
+ * handle LE16, total LE16, offset LE16, timestamp LE32, fragment…]`.
21
+ */
22
+ internal object G2KeyboardProtocol {
23
+ const val PROTOCOL_VERSION = 1
24
+
25
+ enum class Op(val value: Int) {
26
+ QUERY(0), ENABLE(1), DISABLE(2), SCAN(3), CONNECT(4), DISCONNECT(5),
27
+ PAIR(6), AUTH(7), COMPARE(8), FIND(9), READ(10), WRITE(11), WATCH(12),
28
+ }
29
+
30
+ const val KIND_STATUS = 1
31
+ const val KIND_ATT = 2
32
+ const val KIND_INPUT = 3
33
+ const val KIND_DM = 4
34
+ const val KIND_ADVERT = 5
35
+
36
+ /** Firmware control error codes carried by STATUS records. */
37
+ fun errorName(code: Int): String = when (code) {
38
+ 0 -> "ok"
39
+ 1 -> "bad request"
40
+ 2 -> "keyboard bridge disabled"
41
+ 3 -> "busy"
42
+ 4 -> "stale epoch"
43
+ 5 -> "no connection or bond space"
44
+ 6 -> "not connected"
45
+ 7 -> "not encrypted"
46
+ 8 -> "timeout"
47
+ 9 -> "allocation failure"
48
+ else -> "error $code"
49
+ }
50
+
51
+ data class Record(
52
+ val kind: Int,
53
+ val sequence: Long,
54
+ val epoch: Int,
55
+ val request: Int,
56
+ val code: Int,
57
+ val handle: Int,
58
+ val timestamp: Long,
59
+ val data: ByteArray,
60
+ )
61
+
62
+ /**
63
+ * G2SettingPackage{commandId=2, magic, field 4 {1:1}, field 130}: the
64
+ * keyboard control rides an ordinary settings request so the stock parser
65
+ * still runs; the firmware only applies it on the right lens.
66
+ */
67
+ fun controlPacket(op: Op, request: Int, epoch: Int, body: ByteArray = ByteArray(0), magic: Int = 1): ByteArray {
68
+ require(body.size <= 72) { "keyboard control bounds" }
69
+ val data = byteArrayOf(0x4B, 0x42, PROTOCOL_VERSION.toByte(), op.value.toByte()) + le16(request) + le16(epoch) + body
70
+ return byteArrayOf(0x08, 0x02, 0x10) + varint(magic) +
71
+ byteArrayOf(0x22, 0x02, 0x08, 0x01, 0x92.toByte(), 0x08, data.size.toByte()) + data
72
+ }
73
+
74
+ fun le16(v: Int): ByteArray = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte())
75
+
76
+ fun u16(b: ByteArray, i: Int): Int = (b[i].toInt() and 0xFF) or ((b[i + 1].toInt() and 0xFF) shl 8)
77
+
78
+ fun u32(b: ByteArray, i: Int): Long =
79
+ (b[i].toLong() and 0xFF) or ((b[i + 1].toLong() and 0xFF) shl 8) or
80
+ ((b[i + 2].toLong() and 0xFF) shl 16) or ((b[i + 3].toLong() and 0xFF) shl 24)
81
+
82
+ private fun varint(v: Int): ByteArray {
83
+ val out = ArrayList<Byte>()
84
+ var value = v
85
+ while (value >= 0x80) {
86
+ out.add(((value and 0x7F) or 0x80).toByte())
87
+ value = value ushr 7
88
+ }
89
+ out.add(value.toByte())
90
+ return out.toByteArray()
91
+ }
92
+
93
+ /**
94
+ * Bounded, ordered reassembly of field-131 fragments. A gap (lost or
95
+ * out-of-order event, epoch change, malformed fragment) bumps [gaps] so the
96
+ * caller releases every held key and cancels setup transactions.
97
+ */
98
+ class RecordAssembler {
99
+ private var epoch: Int? = null
100
+ private var last: Long? = null
101
+ private var fragment: Fragment? = null
102
+ var gaps = 0
103
+ private set
104
+
105
+ private class Fragment(val record: Record, val total: Int, var used: Int)
106
+
107
+ fun reset() {
108
+ epoch = null
109
+ last = null
110
+ fragment = null
111
+ gaps++
112
+ }
113
+
114
+ /** Feed one field-131 payload; returns a complete record or null. */
115
+ fun push(p: ByteArray): Record? {
116
+ fun invalid(): Record? {
117
+ fragment = null
118
+ gaps++
119
+ return null
120
+ }
121
+ if (p.size < 24 || p.size > 120 || p[0] != 0x4B.toByte() || p[1] != 0x42.toByte() || p[2] != PROTOCOL_VERSION.toByte()) {
122
+ return invalid()
123
+ }
124
+ val kind = p[3].toInt() and 0xFF
125
+ val sequence = u32(p, 4)
126
+ val r = Record(kind, sequence, u16(p, 8), u16(p, 10), u16(p, 12), u16(p, 14), u32(p, 20), ByteArray(0))
127
+ val total = u16(p, 16)
128
+ val offset = u16(p, 18)
129
+ val n = p.size - 24
130
+ if (kind < 1 || kind > 5 || total > 512 || offset + n > total || (total > 0 && n == 0)) return invalid()
131
+ if (epoch != r.epoch) {
132
+ gaps++
133
+ fragment = null
134
+ last = null
135
+ epoch = r.epoch
136
+ }
137
+ if (offset == 0) {
138
+ val previous = last
139
+ if (previous != null) {
140
+ val distance = (r.sequence - previous) and 0xFFFF_FFFFL
141
+ if (distance == 0L || distance >= 0x8000_0000L) return null // duplicate/old delivery
142
+ }
143
+ if (fragment != null || (previous != null && r.sequence != ((previous + 1) and 0xFFFF_FFFFL))) gaps++
144
+ last = r.sequence
145
+ fragment = Fragment(r.copy(data = ByteArray(total)), total, 0)
146
+ }
147
+ val f = fragment
148
+ if (f == null || f.used != offset || f.total != total || f.record.sequence != r.sequence ||
149
+ f.record.kind != r.kind || f.record.request != r.request || f.record.code != r.code ||
150
+ f.record.handle != r.handle || f.record.timestamp != r.timestamp
151
+ ) {
152
+ return invalid()
153
+ }
154
+ System.arraycopy(p, 24, f.record.data, offset, n)
155
+ f.used += n
156
+ if (f.used != total) return null
157
+ fragment = null
158
+ return f.record
159
+ }
160
+ }
161
+
162
+ /** Parsed ADVERT record body: addrType, addr[6] (LSB first), RSSI, event type, AD data. */
163
+ data class Advert(
164
+ val addressType: Int,
165
+ /** Little-endian as the controller reports it; [addressText] is the usual MSB-first form. */
166
+ val address: List<Int>,
167
+ val rssi: Int,
168
+ val name: String,
169
+ val hid: Boolean,
170
+ ) {
171
+ val addressText: String
172
+ get() = address.reversed().joinToString(":") { "%02x".format(it) }
173
+
174
+ /**
175
+ * Fold a later record for the same advertiser (typically its scan
176
+ * response, which is where the name usually rides) into this one: keep
177
+ * a name once seen, HID once seen, and the best signal.
178
+ */
179
+ fun merging(other: Advert) =
180
+ copy(rssi = maxOf(rssi, other.rssi), name = name.ifEmpty { other.name }, hid = hid || other.hid)
181
+
182
+ companion object {
183
+ fun parse(body: ByteArray): Advert? {
184
+ if (body.size < 10) return null
185
+ var name = ""
186
+ var hid = false
187
+ var i = 10
188
+ while (i < body.size) {
189
+ val n = body[i++].toInt() and 0xFF
190
+ if (n == 0 || i + n > body.size) break
191
+ val type = body[i].toInt() and 0xFF
192
+ if (type == 8 || type == 9) name = String(body, i + 1, n - 1, Charsets.UTF_8)
193
+ if (type == 2 || type == 3) {
194
+ var j = i + 1
195
+ while (j + 1 < i + n) {
196
+ if (body[j] == 0x12.toByte() && body[j + 1] == 0x18.toByte()) hid = true
197
+ j += 2
198
+ }
199
+ }
200
+ // Appearance 0x03C0..0x03FF is the HID category (0x03C1 keyboard);
201
+ // some keyboards advertise that without the 0x1812 service list.
202
+ if (type == 0x19 && n >= 3 && (body[i + 2].toInt() and 0xFF) == 0x03 && (body[i + 1].toInt() and 0xFF) >= 0xC0) {
203
+ hid = true
204
+ }
205
+ i += n
206
+ }
207
+ return Advert(
208
+ body[0].toInt() and 0xFF,
209
+ (1..6).map { body[it].toInt() and 0xFF },
210
+ body[7].toInt(),
211
+ name,
212
+ hid,
213
+ )
214
+ }
215
+ }
216
+ }
217
+
218
+ /** Parse "aa:bb:cc:dd:ee:ff" into the controller's LSB-first byte order. */
219
+ fun addressFromText(text: String): ByteArray? {
220
+ val parts = text.split(":")
221
+ if (parts.size != 6) return null
222
+ val bytes = ByteArray(6)
223
+ for ((index, part) in parts.withIndex()) {
224
+ if (part.length != 2) return null
225
+ val value = part.toIntOrNull(16) ?: return null
226
+ bytes[5 - index] = value.toByte()
227
+ }
228
+ return bytes
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Transport-independent keyboard client: enable/scan/connect/pair, HOGP
234
+ * discovery, subscription, and raw input decoding into key events. Ported
235
+ * from g2flash `demos/keyboard-client.ts`. Everything runs on [scope] (the G2
236
+ * driver's main-thread scope): [accept] must be called there too.
237
+ */
238
+ internal class G2KeyboardClient(
239
+ private val scope: CoroutineScope,
240
+ private val send: (ByteArray, Int) -> Unit,
241
+ private val onEvent: (Event) -> Unit,
242
+ ) {
243
+ data class Status(
244
+ val enabled: Boolean = false,
245
+ val conn: Int = 0,
246
+ val connected: Boolean = false,
247
+ val encrypted: Boolean = false,
248
+ val scanning: Boolean = false,
249
+ )
250
+
251
+ data class Report(
252
+ val service: Int,
253
+ val handle: Int,
254
+ val id: Int,
255
+ val type: Int,
256
+ val ccc: Int?,
257
+ val properties: Int,
258
+ val map: ByteArray,
259
+ )
260
+
261
+ data class Configuration(val epoch: Int, val reports: List<Report>, val serviceChanged: Int?) {
262
+ val inputCount: Int get() = reports.count { it.type == 1 }
263
+ }
264
+
265
+ sealed class Event {
266
+ data class Keys(val events: List<G2KeyboardHid.KeyEvent>) : Event()
267
+ data class Advertisement(val advert: G2KeyboardProtocol.Advert) : Event()
268
+ data class StatusChanged(val status: Status) : Event()
269
+ /** Held keys were released because of a stream gap or link change. */
270
+ object Gap : Event()
271
+ /** Device-manager event from the controller (0x27 open, 0x28 close, 0x2a paired, …). */
272
+ data class Dm(val event: Int, val status: Int) : Event()
273
+ /** The keyboard shows a six-digit passkey the user must type into the phone. */
274
+ object PasskeyRequest : Event()
275
+ /** The phone chose a passkey the user must type on the keyboard (then Enter). */
276
+ data class PasskeyDisplay(val pin: Int) : Event()
277
+ /** Numeric comparison: the user confirms the keyboard shows this value. */
278
+ data class CompareRequest(val value: Long) : Event()
279
+ object ServicesChanged : Event()
280
+ data class Error(val message: String) : Event()
281
+ }
282
+
283
+ class ClientException(message: String) : Exception(message)
284
+
285
+ private class Pending(val op: G2KeyboardProtocol.Op, val result: CompletableDeferred<G2KeyboardProtocol.Record>)
286
+
287
+ private class Attribute(val handle: Int, val uuid: Any)
288
+
289
+ private class Input(val report: G2KeyboardHid.Report, val id: Int, val service: Int)
290
+
291
+ var epoch = 0
292
+ private set
293
+ var status = Status()
294
+ private set
295
+ var configuration: Configuration? = null
296
+ private set
297
+ private var serial = 0
298
+ private var magic = 30
299
+ private val pending = LinkedHashMap<Int, Pending>()
300
+ private val held = G2KeyboardHid.HeldKeys()
301
+ private var inputs = LinkedHashMap<Int, Input>()
302
+ private val records = G2KeyboardProtocol.RecordAssembler()
303
+ private var seenGaps = 0
304
+ private var closed = false
305
+
306
+ // ---------- Inbound ----------
307
+
308
+ /** Feed one field-131 payload from a right-lens settings notification. */
309
+ fun accept(payload: ByteArray) {
310
+ val complete = records.push(payload)
311
+ if (records.gaps != seenGaps) {
312
+ seenGaps = records.gaps
313
+ gap()
314
+ }
315
+ val r = complete ?: return
316
+ epoch = r.epoch
317
+ if (r.kind == G2KeyboardProtocol.KIND_STATUS && r.data.size == 10) {
318
+ status = Status(
319
+ enabled = r.data[0].toInt() != 0, conn = r.data[1].toInt() and 0xFF,
320
+ connected = r.data[2].toInt() != 0, encrypted = r.data[3].toInt() != 0, scanning = r.data[4].toInt() != 0,
321
+ )
322
+ if (!status.enabled || !status.connected || !status.encrypted) release()
323
+ onEvent(Event.StatusChanged(status))
324
+ }
325
+ val p = pending[r.request]
326
+ if (p != null && (r.kind == 1 || r.kind == 2)) {
327
+ pending.remove(r.request)
328
+ if (r.kind == 1 && r.code != 0) {
329
+ p.result.completeExceptionally(
330
+ ClientException("Keyboard command ${p.op}: firmware ${G2KeyboardProtocol.errorName(r.code)}"),
331
+ )
332
+ } else {
333
+ p.result.complete(r)
334
+ }
335
+ }
336
+ when (r.kind) {
337
+ G2KeyboardProtocol.KIND_DM -> {
338
+ val event = r.code and 0xFF
339
+ val dmStatus = r.code shr 8
340
+ if (event == 0x28 || event == 0x2B || event == 0x2D) {
341
+ gap()
342
+ inputs.clear()
343
+ configuration = null
344
+ }
345
+ if (event == 0x2E && r.data.size == 2) {
346
+ when {
347
+ r.data[0].toInt() != 0 -> {
348
+ scope.launch { runCatching { command(G2KeyboardProtocol.Op.DISCONNECT) } }
349
+ onEvent(Event.Error("OOB-only keyboard pairing is unsupported"))
350
+ }
351
+ r.data[1].toInt() != 0 -> {
352
+ // KeyboardDisplay on our side: choose the passkey, show it, and
353
+ // answer immediately; the user types it on the keyboard.
354
+ val pin = (0..999_999).random()
355
+ onEvent(Event.PasskeyDisplay(pin))
356
+ scope.launch {
357
+ runCatching { authenticate(pin) }.onFailure { onEvent(Event.Error(it.message ?: "$it")) }
358
+ }
359
+ }
360
+ else -> onEvent(Event.PasskeyRequest)
361
+ }
362
+ }
363
+ if (event == 0x35 && r.data.size == 4) onEvent(Event.CompareRequest(G2KeyboardProtocol.u32(r.data, 0)))
364
+ onEvent(Event.Dm(event, dmStatus))
365
+ }
366
+ G2KeyboardProtocol.KIND_INPUT -> {
367
+ val changed = configuration?.serviceChanged
368
+ if (changed != null && r.handle == changed) {
369
+ gap()
370
+ inputs.clear()
371
+ configuration = null
372
+ onEvent(Event.ServicesChanged)
373
+ return
374
+ }
375
+ val input = inputs[r.handle]
376
+ if (input == null || configuration?.epoch != r.epoch) {
377
+ gap()
378
+ return
379
+ }
380
+ try {
381
+ val events = held.update(r.handle, input.report, r.data)
382
+ if (events.isNotEmpty()) onEvent(Event.Keys(events))
383
+ } catch (e: IllegalArgumentException) {
384
+ release()
385
+ onEvent(Event.Error(e.message ?: "$e"))
386
+ }
387
+ }
388
+ G2KeyboardProtocol.KIND_ADVERT -> G2KeyboardProtocol.Advert.parse(r.data)?.let { onEvent(Event.Advertisement(it)) }
389
+ }
390
+ }
391
+
392
+ private fun release() {
393
+ val events = held.reset()
394
+ if (events.isNotEmpty()) onEvent(Event.Keys(events))
395
+ }
396
+
397
+ private fun gap() {
398
+ release()
399
+ onEvent(Event.Gap)
400
+ val iterator = pending.entries.iterator()
401
+ while (iterator.hasNext()) {
402
+ val entry = iterator.next()
403
+ if (entry.value.op.value >= G2KeyboardProtocol.Op.FIND.value) {
404
+ entry.value.result.completeExceptionally(ClientException("Keyboard stream gap during ATT transaction"))
405
+ iterator.remove()
406
+ }
407
+ }
408
+ }
409
+
410
+ /** Call on phone-link loss: clear held state and fail every command. */
411
+ fun lostPhoneLink() {
412
+ records.reset()
413
+ seenGaps = records.gaps
414
+ release()
415
+ cancel()
416
+ }
417
+
418
+ fun close() {
419
+ closed = true
420
+ lostPhoneLink()
421
+ }
422
+
423
+ private fun cancel() {
424
+ for (p in pending.values) p.result.completeExceptionally(ClientException("Keyboard client closed"))
425
+ pending.clear()
426
+ }
427
+
428
+ // ---------- Commands ----------
429
+
430
+ /**
431
+ * Send one control and await its STATUS/ATT result record. ENABLE and
432
+ * QUERY ignore the epoch; every other control must match the live one.
433
+ */
434
+ suspend fun command(op: G2KeyboardProtocol.Op, body: ByteArray = ByteArray(0)): G2KeyboardProtocol.Record {
435
+ if (closed) throw ClientException("Keyboard client closed")
436
+ if (pending.size >= 8) throw ClientException("Too many keyboard commands")
437
+ serial = serial % 65535 + 1
438
+ magic = magic % 200 + 1
439
+ val request = serial
440
+ val magicId = magic
441
+ val result = CompletableDeferred<G2KeyboardProtocol.Record>()
442
+ pending[request] = Pending(op, result)
443
+ send(G2KeyboardProtocol.controlPacket(op, request, epoch, body, magicId), magicId)
444
+ return withTimeoutOrNull(32_000) { result.await() }
445
+ ?: run {
446
+ pending.remove(request)
447
+ throw ClientException("Keyboard command $op timed out")
448
+ }
449
+ }
450
+
451
+ suspend fun enable() {
452
+ command(G2KeyboardProtocol.Op.ENABLE)
453
+ }
454
+
455
+ suspend fun disable() {
456
+ command(G2KeyboardProtocol.Op.DISABLE)
457
+ release()
458
+ }
459
+
460
+ suspend fun query(): Status {
461
+ command(G2KeyboardProtocol.Op.QUERY)
462
+ return status
463
+ }
464
+
465
+ /** Active scan for five seconds; adverts arrive as [Event.Advertisement]. */
466
+ /**
467
+ * Active scan for five seconds; adverts arrive as [Event.Advertisement].
468
+ * The firmware only clears `scanning` once the stock scanner is idle
469
+ * again, and a ring hunting for its lens can hold the radio well past
470
+ * that, so the fixed five-second window is the real end of the scan:
471
+ * results that already streamed stand even when the flag lingers.
472
+ */
473
+ suspend fun scan() {
474
+ command(G2KeyboardProtocol.Op.SCAN)
475
+ try {
476
+ until(8_000) { !status.scanning }
477
+ } catch (e: ClientException) {
478
+ if (closed) throw e
479
+ }
480
+ }
481
+
482
+ /** Connect, then pair unless the link is already encrypted. [address] is LSB first. */
483
+ suspend fun connect(addressType: Int, address: ByteArray) {
484
+ if (address.size != 6 || addressType !in 0..3) throw ClientException("Invalid BLE address")
485
+ release()
486
+ inputs.clear()
487
+ configuration = null
488
+ command(G2KeyboardProtocol.Op.CONNECT, byteArrayOf(addressType.toByte()) + address)
489
+ until(32_000) { status.connected }
490
+ if (!status.encrypted) {
491
+ // A peripheral security request can have started pairing already;
492
+ // BUSY is not success, the encrypted status below still decides.
493
+ try {
494
+ command(G2KeyboardProtocol.Op.PAIR)
495
+ } catch (e: ClientException) {
496
+ if (e.message?.endsWith("firmware busy") != true) throw e
497
+ }
498
+ until(65_000) { status.encrypted }
499
+ }
500
+ }
501
+
502
+ private suspend fun until(timeoutMs: Long, test: () -> Boolean) {
503
+ val end = System.currentTimeMillis() + timeoutMs
504
+ while (!test()) {
505
+ if (closed) throw ClientException("Keyboard client closed")
506
+ if (System.currentTimeMillis() >= end) throw ClientException("Keyboard state timeout")
507
+ delay(50)
508
+ }
509
+ }
510
+
511
+ /** Answer a pending passkey prompt (the value the keyboard displayed). */
512
+ suspend fun authenticate(pin: Int) {
513
+ if (pin !in 0..999_999) throw ClientException("Invalid pairing passkey")
514
+ command(G2KeyboardProtocol.Op.AUTH, byteArrayOf((pin and 0xFF).toByte(), ((pin shr 8) and 0xFF).toByte(), (pin shr 16).toByte()))
515
+ }
516
+
517
+ /** Answer a pending numeric comparison. */
518
+ suspend fun confirm(accept: Boolean) {
519
+ command(G2KeyboardProtocol.Op.COMPARE, byteArrayOf(if (accept) 1 else 0))
520
+ }
521
+
522
+ private suspend fun att(op: G2KeyboardProtocol.Op, body: ByteArray): G2KeyboardProtocol.Record {
523
+ val r = command(op, body)
524
+ if (r.kind != G2KeyboardProtocol.KIND_ATT) throw ClientException("Expected keyboard ATT response")
525
+ return r
526
+ }
527
+
528
+ /** Read Blob until a short/empty response or Invalid Offset; never assumes MTU 23. */
529
+ suspend fun read(handle: Int): ByteArray {
530
+ var parts = ByteArray(0)
531
+ while (true) {
532
+ val r = att(G2KeyboardProtocol.Op.READ, G2KeyboardProtocol.le16(handle) + G2KeyboardProtocol.le16(parts.size))
533
+ val status = r.code shr 8
534
+ if (parts.isNotEmpty() && (status == 7 || status == 11)) break
535
+ if (status != 0) throw ClientException("ATT read $handle: $status")
536
+ if (r.data.isEmpty()) break
537
+ parts += r.data
538
+ if (parts.size > 512) throw ClientException("Attribute exceeds 512 bytes")
539
+ if (parts.size == 512) break
540
+ }
541
+ return parts
542
+ }
543
+
544
+ private suspend fun shortRead(handle: Int): ByteArray {
545
+ val r = att(G2KeyboardProtocol.Op.READ, G2KeyboardProtocol.le16(handle) + byteArrayOf(0, 0))
546
+ if (r.code shr 8 != 0) throw ClientException("ATT read $handle: ${r.code shr 8}")
547
+ return r.data
548
+ }
549
+
550
+ suspend fun write(handle: Int, value: ByteArray, withoutResponse: Boolean = false) {
551
+ if (value.isEmpty() || value.size > 18) throw ClientException("Keyboard write supports 1..18 bytes")
552
+ val body = G2KeyboardProtocol.le16(handle) + byteArrayOf(if (withoutResponse) 1 else 0, value.size.toByte()) + value
553
+ val r = att(G2KeyboardProtocol.Op.WRITE, body)
554
+ if (r.code shr 8 != 0) throw ClientException("ATT write $handle: ${r.code shr 8}")
555
+ }
556
+
557
+ /**
558
+ * ATT UUIDs are little endian; the Bluetooth base UUID collapses to its
559
+ * 16-bit form (an Int), vendor UUIDs stay as hex strings.
560
+ */
561
+ private fun attributeUuid(bytes: ByteArray): Any {
562
+ if (bytes.size == 2) return G2KeyboardProtocol.u16(bytes, 0)
563
+ if (bytes.size != 16) throw ClientException("Invalid ATT UUID length")
564
+ val base = intArrayOf(0xFB, 0x34, 0x9B, 0x5F, 0x80, 0, 0, 0x80, 0, 0x10, 0, 0)
565
+ val isBase = base.indices.all { (bytes[it].toInt() and 0xFF) == base[it] } && bytes[14].toInt() == 0 && bytes[15].toInt() == 0
566
+ return if (isBase) G2KeyboardProtocol.u16(bytes, 12) else bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
567
+ }
568
+
569
+ private suspend fun attributes(): List<Attribute> {
570
+ val attrs = ArrayList<Attribute>()
571
+ var start = 1
572
+ while (start <= 65535) {
573
+ val r = att(G2KeyboardProtocol.Op.FIND, G2KeyboardProtocol.le16(start) + byteArrayOf(0xFF.toByte(), 0xFF.toByte()))
574
+ val status = r.code shr 8
575
+ if (status == 10) break // attribute not found: normal end
576
+ if (status != 0) throw ClientException("ATT Find Information: $status")
577
+ val format = r.data.firstOrNull()?.toInt() ?: 0
578
+ val width = when (format) { 1 -> 4; 2 -> 18; else -> 0 }
579
+ if (width == 0 || r.data.size <= 1 || (r.data.size - 1) % width != 0) throw ClientException("Malformed Find Information response")
580
+ var i = 1
581
+ while (i < r.data.size) {
582
+ val handle = G2KeyboardProtocol.u16(r.data, i)
583
+ if (handle < start || attrs.size >= 512) throw ClientException("Invalid or excessive GATT handles")
584
+ attrs.add(Attribute(handle, attributeUuid(r.data.copyOfRange(i + 2, i + width))))
585
+ start = handle + 1
586
+ i += width
587
+ }
588
+ }
589
+ return attrs
590
+ }
591
+
592
+ /**
593
+ * Discover HIDS instances, Report Maps and each Report Reference; select
594
+ * Report Protocol and subscribe input reports. No fixed keyboard handles.
595
+ */
596
+ suspend fun configure(): Configuration {
597
+ val attrs = attributes()
598
+ val reports = ArrayList<Report>()
599
+ fun descriptorsFor(handle: Int): List<Attribute> {
600
+ val end = attrs.firstOrNull { it.handle > handle && it.uuid in listOf(0x2800, 0x2801, 0x2803) }?.handle ?: 65536
601
+ return attrs.filter { it.handle > handle && it.handle < end }
602
+ }
603
+ suspend fun addReport(service: Int, a: Attribute, map: ByteArray, parsed: Map<Int, G2KeyboardHid.Report>) {
604
+ val descriptors = descriptorsFor(a.handle)
605
+ val ref = descriptors.firstOrNull { it.uuid == 0x2908 }
606
+ val decl = attrs.filter { it.handle < a.handle }.lastOrNull { it.uuid == 0x2803 }
607
+ if (ref == null || decl == null) throw ClientException("HID Report has no declaration/reference")
608
+ val reference = shortRead(ref.handle)
609
+ val declaration = shortRead(decl.handle)
610
+ if (reference.size != 2 || (declaration.size != 5 && declaration.size != 19) ||
611
+ G2KeyboardProtocol.u16(declaration, 1) != a.handle ||
612
+ attributeUuid(declaration.copyOfRange(3, declaration.size)) != a.uuid
613
+ ) {
614
+ throw ClientException("Invalid HID Report metadata")
615
+ }
616
+ val id = reference[0].toInt() and 0xFF
617
+ val type = reference[1].toInt() and 0xFF
618
+ val properties = declaration[0].toInt() and 0xFF
619
+ if (type !in 1..3) throw ClientException("Invalid Report Reference type")
620
+ val ccc = descriptors.firstOrNull { it.uuid == 0x2902 }?.handle
621
+ if (type == 1 && (!parsed.containsKey(id) || ccc == null || (properties and 0x10) == 0)) {
622
+ throw ClientException("Input Report lacks map/notify/CCC")
623
+ }
624
+ if (reports.any { it.handle == a.handle }) throw ClientException("Ambiguous report shared by multiple HID services")
625
+ reports.add(Report(service, a.handle, id, type, ccc, properties, map))
626
+ }
627
+ for ((i, service) in attrs.withIndex()) {
628
+ if (service.uuid != 0x2800) continue
629
+ if (attributeUuid(shortRead(service.handle)) != 0x1812) continue
630
+ val end = attrs.drop(i + 1).firstOrNull { it.uuid == 0x2800 || it.uuid == 0x2801 }?.handle ?: 65536
631
+ val members = attrs.filter { it.handle > service.handle && it.handle < end }
632
+ val mapAttr = members.firstOrNull { it.uuid == 0x2A4B } ?: throw ClientException("HID service has no Report Map")
633
+ val map = read(mapAttr.handle)
634
+ val parsed = G2KeyboardHid.parseReportMap(map)
635
+ for (mode in members.filter { it.uuid == 0x2A4E }) write(mode.handle, byteArrayOf(1), withoutResponse = true)
636
+ for (a in members.filter { it.uuid == 0x2A4D }) addReport(service.handle, a, map, parsed)
637
+ // HOGP can refer to input reports in other services (commonly Battery).
638
+ for (ext in descriptorsFor(mapAttr.handle).filter { it.uuid == 0x2907 }) {
639
+ val uuid = attributeUuid(shortRead(ext.handle))
640
+ val matches = attrs.filter { it.uuid == uuid && (it.handle < service.handle || it.handle >= end) }
641
+ if (matches.isEmpty()) throw ClientException("External HID report characteristic missing")
642
+ for (a in matches) addReport(service.handle, a, map, parsed)
643
+ }
644
+ }
645
+ val inputReports = reports.filter { it.type == 1 }
646
+ if (inputReports.isEmpty() || inputReports.size > 16) throw ClientException("Keyboard requires 1..16 input reports")
647
+ val changed = attrs.firstOrNull { it.uuid == 0x2A05 }
648
+ val changedCcc = changed?.let { c -> descriptorsFor(c.handle).firstOrNull { it.uuid == 0x2902 } }
649
+ val watches = inputReports.map { it.handle }.toMutableList()
650
+ if (changed != null && changedCcc != null) watches.add(changed.handle)
651
+ if (watches.size > 16) throw ClientException("More than 16 subscribed report/Service Changed handles")
652
+ val config = Configuration(epoch, reports, if (changedCcc != null) changed?.handle else null)
653
+ restore(config)
654
+ try {
655
+ command(
656
+ G2KeyboardProtocol.Op.WATCH,
657
+ byteArrayOf(watches.size.toByte()) + watches.fold(ByteArray(0)) { acc, h -> acc + G2KeyboardProtocol.le16(h) },
658
+ )
659
+ if (changedCcc != null) write(changedCcc.handle, byteArrayOf(2, 0))
660
+ for (r in inputReports) write(r.ccc!!, byteArrayOf(1, 0))
661
+ } catch (e: Exception) {
662
+ inputs.clear()
663
+ configuration = null
664
+ release()
665
+ throw e
666
+ }
667
+ return config
668
+ }
669
+
670
+ /**
671
+ * Install a discovered mapping. Valid only for the same connection epoch:
672
+ * a fresh firmware boot needs enable/connect/configure again.
673
+ */
674
+ fun restore(config: Configuration) {
675
+ if (config.reports.isEmpty() || config.reports.size > 64) throw ClientException("Invalid keyboard configuration")
676
+ val next = LinkedHashMap<Int, Input>()
677
+ for (r in config.reports) {
678
+ if (r.type != 1) continue
679
+ if (r.handle < 1 || r.handle > 65535 || next.containsKey(r.handle)) throw ClientException("Invalid restored Report handle")
680
+ if (r.map.isEmpty() || r.map.size > 512) throw ClientException("Invalid restored Report Map")
681
+ val report = G2KeyboardHid.parseReportMap(r.map)[r.id] ?: throw ClientException("Restored Report ID missing")
682
+ next[r.handle] = Input(report, r.id, r.service)
683
+ }
684
+ if (next.isEmpty() || next.size > 16) throw ClientException("Invalid restored input count")
685
+ release()
686
+ inputs = next
687
+ configuration = config
688
+ }
689
+ }