@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.
- package/README.md +47 -6
- package/android/build.gradle +7 -7
- package/android/lc3Lib/build.gradle +2 -2
- package/android/src/main/assets/G2Firmware/README.md +27 -2
- 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
- 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
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
- package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
- package/build/BluetoothSdk.types.d.ts +209 -20
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -1
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +30 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +28 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +37 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +102 -13
- package/ios/GlasslyBluetoothSDK.podspec +1 -1
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
- package/ios/Source/BluetoothSDK.swift +53 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +13 -0
- package/ios/Source/DeviceManager.swift +151 -33
- package/ios/Source/DeviceStore.swift +28 -14
- package/ios/Source/DisplayPacer.swift +180 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
- package/ios/Source/GlassesNotifications.swift +181 -0
- package/ios/Source/ObservableStore.swift +14 -2
- package/ios/Source/Resources/G2Firmware/README.md +27 -2
- package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/controllers/R1.swift +0 -1
- package/ios/Source/internal/BluetoothAvailability.swift +31 -0
- package/ios/Source/sgcs/G2.swift +1420 -495
- package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
- package/ios/Source/sgcs/G2BleLink.swift +102 -0
- package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
- package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
- package/ios/Source/sgcs/G2CfwScene.swift +779 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
- package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
- package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
- package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
- package/ios/Source/sgcs/G2Flasher.swift +9 -9
- package/ios/Source/sgcs/G2Keyboard.swift +780 -0
- package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
- package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
- package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
- package/ios/Source/sgcs/G2RingBattery.swift +22 -0
- package/ios/Source/sgcs/G2Sensors.swift +75 -0
- package/ios/Source/sgcs/G2TouchInput.swift +51 -0
- package/ios/Source/sgcs/G2WritePacing.swift +18 -0
- package/ios/Source/sgcs/MentraNex.swift +44 -44
- package/ios/Source/sgcs/SGCManager.swift +120 -13
- package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
- package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
- package/ios/Source/status/DeviceStatus.swift +27 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
- package/ios/Source/utils/Enums.swift +0 -1
- package/ios/Source/utils/G1Text.swift +0 -1
- package/ios/Source/utils/G2NotifyFile.swift +20 -10
- package/ios/Source/utils/JSCExperiment.swift +6 -6
- package/ios/Source/utils/Models.swift +0 -1
- package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
- package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
- package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
- package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
- package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
- package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
- package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
- package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
- package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
- package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
- package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
- package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +2 -2
- package/plugin/build/withIos.js +1 -1
- package/scripts/gen-nex-proto.sh +12 -0
- package/scripts/inject-ios-sdk-version.mjs +1 -1
- package/src/BluetoothSdk.types.ts +214 -19
- package/src/_internal.ts +1 -1
- package/src/_private/BluetoothSdkModule.ts +40 -0
- package/src/generated/changelogCatalog.ts +37 -1
- package/src/generated/releaseMetadata.ts +4 -4
- 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
|
+
}
|