@glassly/bluetooth-sdk 0.1.0-dev.99 → 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.
- 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,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
|
+
}
|