@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.
- 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 +1075 -257
- 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 +1434 -504
- 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,780 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Glassly CFW BLE keyboard bridge (contract revision 39). The right lens
|
|
4
|
+
/// acts as a BLE central for one HID-over-GATT keyboard; the phone drives
|
|
5
|
+
/// the connection over private sid-0x09 fields and receives raw input reports
|
|
6
|
+
/// back. The wire format is pinned by `patches/keyboard_core.h` and documented
|
|
7
|
+
/// in `docs/keyboard.md` in g2flash.
|
|
8
|
+
///
|
|
9
|
+
/// Phone -> glasses, field 130 on a cmd=2 settings request with a magic id:
|
|
10
|
+
/// `['K','B',1,op, request LE16, epoch LE16, body…]` (at most 80 bytes).
|
|
11
|
+
/// Glasses -> phone, field 131 on cmd=3 EVENT notifications:
|
|
12
|
+
/// `['K','B',1,kind, sequence LE32, epoch LE16, request LE16, code LE16,
|
|
13
|
+
/// handle LE16, total LE16, offset LE16, timestamp LE32, fragment…]`.
|
|
14
|
+
enum G2KeyboardProtocol {
|
|
15
|
+
static let protocolVersion: UInt8 = 1
|
|
16
|
+
|
|
17
|
+
enum Op: UInt8 {
|
|
18
|
+
case query = 0, enable = 1, disable = 2, scan = 3, connect = 4, disconnect = 5,
|
|
19
|
+
pair = 6, auth = 7, compare = 8, find = 9, read = 10, write = 11, watch = 12
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
enum Kind: UInt8 {
|
|
23
|
+
case status = 1, att = 2, input = 3, dm = 4, advert = 5
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Firmware control error codes carried by STATUS records.
|
|
27
|
+
static func errorName(_ code: UInt16) -> String {
|
|
28
|
+
switch code {
|
|
29
|
+
case 0: "ok"
|
|
30
|
+
case 1: "bad request"
|
|
31
|
+
case 2: "keyboard bridge disabled"
|
|
32
|
+
case 3: "busy"
|
|
33
|
+
case 4: "stale epoch"
|
|
34
|
+
case 5: "no connection or bond space"
|
|
35
|
+
case 6: "not connected"
|
|
36
|
+
case 7: "not encrypted"
|
|
37
|
+
case 8: "timeout"
|
|
38
|
+
case 9: "allocation failure"
|
|
39
|
+
default: "error \(code)"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
struct Record: Equatable {
|
|
44
|
+
let kind: UInt8
|
|
45
|
+
let sequence: UInt32
|
|
46
|
+
let epoch: UInt16
|
|
47
|
+
let request: UInt16
|
|
48
|
+
let code: UInt16
|
|
49
|
+
let handle: UInt16
|
|
50
|
+
let timestamp: UInt32
|
|
51
|
+
var data: [UInt8]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// G2SettingPackage{commandId=2, magic, field 4 {1:1}, field 130}: the
|
|
55
|
+
/// keyboard control rides an ordinary settings request so the stock parser
|
|
56
|
+
/// still runs; the firmware only applies it on the right lens.
|
|
57
|
+
static func controlPacket(op: Op, request: UInt16, epoch: UInt16, body: [UInt8] = [], magic: UInt8 = 1) -> Data {
|
|
58
|
+
precondition(body.count <= 72, "keyboard control bounds")
|
|
59
|
+
let data: [UInt8] = [0x4B, 0x42, protocolVersion, op.rawValue] + le16(request) + le16(epoch) + body
|
|
60
|
+
var packet: [UInt8] = [0x08, 0x02, 0x10] + varint(UInt32(magic))
|
|
61
|
+
packet += [0x22, 0x02, 0x08, 0x01, 0x92, 0x08, UInt8(data.count)] + data
|
|
62
|
+
return Data(packet)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static func le16(_ v: UInt16) -> [UInt8] {
|
|
66
|
+
[UInt8(v & 0xFF), UInt8(v >> 8)]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static func u16(_ b: [UInt8], _ i: Int) -> UInt16 {
|
|
70
|
+
UInt16(b[i]) | (UInt16(b[i + 1]) << 8)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static func u32(_ b: [UInt8], _ i: Int) -> UInt32 {
|
|
74
|
+
UInt32(b[i]) | (UInt32(b[i + 1]) << 8) | (UInt32(b[i + 2]) << 16) | (UInt32(b[i + 3]) << 24)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private static func varint(_ v: UInt32) -> [UInt8] {
|
|
78
|
+
var out: [UInt8] = []
|
|
79
|
+
var value = v
|
|
80
|
+
while value >= 0x80 {
|
|
81
|
+
out.append(UInt8(value & 0x7F) | 0x80)
|
|
82
|
+
value >>= 7
|
|
83
|
+
}
|
|
84
|
+
out.append(UInt8(value))
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Bounded, ordered reassembly of field-131 fragments. A gap (lost or
|
|
89
|
+
/// out-of-order event, epoch change, malformed fragment) is reported so the
|
|
90
|
+
/// caller releases every held key and cancels setup transactions; heartbeats
|
|
91
|
+
/// make a lost final release visible.
|
|
92
|
+
struct RecordAssembler {
|
|
93
|
+
private var epoch: UInt16?
|
|
94
|
+
private var last: UInt32?
|
|
95
|
+
private var fragment: (record: Record, total: Int, used: Int)?
|
|
96
|
+
private(set) var gaps = 0
|
|
97
|
+
|
|
98
|
+
mutating func reset() {
|
|
99
|
+
epoch = nil
|
|
100
|
+
last = nil
|
|
101
|
+
fragment = nil
|
|
102
|
+
gaps += 1
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Feed one field-131 payload. Returns a complete record, or nil while a
|
|
106
|
+
/// fragmented record is still arriving. `gaps` increments on any break.
|
|
107
|
+
mutating func push(_ payload: Data) -> Record? {
|
|
108
|
+
let p = [UInt8](payload)
|
|
109
|
+
func invalid() -> Record? {
|
|
110
|
+
fragment = nil
|
|
111
|
+
gaps += 1
|
|
112
|
+
return nil
|
|
113
|
+
}
|
|
114
|
+
guard p.count >= 24, p.count <= 120, p[0] == 0x4B, p[1] == 0x42, p[2] == G2KeyboardProtocol.protocolVersion
|
|
115
|
+
else { return invalid() }
|
|
116
|
+
let r = Record(
|
|
117
|
+
kind: p[3], sequence: u32(p, 4), epoch: u16(p, 8), request: u16(p, 10), code: u16(p, 12),
|
|
118
|
+
handle: u16(p, 14), timestamp: u32(p, 20), data: []
|
|
119
|
+
)
|
|
120
|
+
let total = Int(u16(p, 16))
|
|
121
|
+
let offset = Int(u16(p, 18))
|
|
122
|
+
let n = p.count - 24
|
|
123
|
+
if r.kind < 1 || r.kind > 5 || total > 512 || offset + n > total || (total > 0 && n == 0) {
|
|
124
|
+
return invalid()
|
|
125
|
+
}
|
|
126
|
+
if epoch != r.epoch {
|
|
127
|
+
gaps += 1
|
|
128
|
+
fragment = nil
|
|
129
|
+
last = nil
|
|
130
|
+
epoch = r.epoch
|
|
131
|
+
}
|
|
132
|
+
if offset == 0 {
|
|
133
|
+
if let last {
|
|
134
|
+
let distance = r.sequence &- last
|
|
135
|
+
if distance == 0 || distance >= 0x8000_0000 {
|
|
136
|
+
return nil
|
|
137
|
+
} // duplicate/old delivery
|
|
138
|
+
}
|
|
139
|
+
if fragment != nil || (last != nil && r.sequence != last! &+ 1) {
|
|
140
|
+
gaps += 1
|
|
141
|
+
}
|
|
142
|
+
last = r.sequence
|
|
143
|
+
var started = r
|
|
144
|
+
started.data = [UInt8](repeating: 0, count: total)
|
|
145
|
+
fragment = (started, total, 0)
|
|
146
|
+
}
|
|
147
|
+
guard var f = fragment, f.used == offset, f.total == total, f.record.sequence == r.sequence,
|
|
148
|
+
f.record.kind == r.kind, f.record.request == r.request, f.record.code == r.code,
|
|
149
|
+
f.record.handle == r.handle, f.record.timestamp == r.timestamp
|
|
150
|
+
else { return invalid() }
|
|
151
|
+
f.record.data.replaceSubrange(offset ..< offset + n, with: p[24...])
|
|
152
|
+
f.used += n
|
|
153
|
+
if f.used != total {
|
|
154
|
+
fragment = f
|
|
155
|
+
return nil
|
|
156
|
+
}
|
|
157
|
+
fragment = nil
|
|
158
|
+
return f.record
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Parsed ADVERT record body: addrType, addr[6] (LSB first), RSSI, event type, AD data.
|
|
163
|
+
struct Advert: Equatable {
|
|
164
|
+
let addressType: UInt8
|
|
165
|
+
/// Little-endian as the controller reports it; `addressText` is the usual MSB-first form.
|
|
166
|
+
let address: [UInt8]
|
|
167
|
+
let rssi: Int8
|
|
168
|
+
let name: String
|
|
169
|
+
let hid: Bool
|
|
170
|
+
|
|
171
|
+
var addressText: String {
|
|
172
|
+
address.reversed().map { String(format: "%02x", $0) }.joined(separator: ":")
|
|
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:
|
|
177
|
+
/// keep a name once seen, HID once seen, and the best signal.
|
|
178
|
+
func merging(_ other: Advert) -> Advert {
|
|
179
|
+
Advert(
|
|
180
|
+
addressType: addressType, address: address, rssi: max(rssi, other.rssi),
|
|
181
|
+
name: name.isEmpty ? other.name : name, hid: hid || other.hid
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
static func parse(_ body: [UInt8]) -> Advert? {
|
|
186
|
+
guard body.count >= 10 else { return nil }
|
|
187
|
+
var name = ""
|
|
188
|
+
var hid = false
|
|
189
|
+
var i = 10
|
|
190
|
+
while i < body.count {
|
|
191
|
+
let n = Int(body[i])
|
|
192
|
+
i += 1
|
|
193
|
+
if n == 0 || i + n > body.count {
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
let type = body[i]
|
|
197
|
+
if type == 8 || type == 9 {
|
|
198
|
+
name = String(decoding: body[(i + 1) ..< (i + n)], as: UTF8.self)
|
|
199
|
+
}
|
|
200
|
+
if type == 2 || type == 3 {
|
|
201
|
+
var j = i + 1
|
|
202
|
+
while j + 1 < i + n {
|
|
203
|
+
if body[j] == 0x12, body[j + 1] == 0x18 {
|
|
204
|
+
hid = true
|
|
205
|
+
}
|
|
206
|
+
j += 2
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Appearance 0x03C0..0x03FF is the HID category (0x03C1 keyboard);
|
|
210
|
+
// some keyboards advertise that without the 0x1812 service list.
|
|
211
|
+
if type == 0x19, n >= 3, body[i + 2] == 0x03, body[i + 1] >= 0xC0 {
|
|
212
|
+
hid = true
|
|
213
|
+
}
|
|
214
|
+
i += n
|
|
215
|
+
}
|
|
216
|
+
return Advert(
|
|
217
|
+
addressType: body[0], address: Array(body[1 ..< 7]), rssi: Int8(bitPattern: body[7]),
|
|
218
|
+
name: name, hid: hid
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// Parse "aa:bb:cc:dd:ee:ff" into the controller's LSB-first byte order.
|
|
224
|
+
static func address(fromText text: String) -> [UInt8]? {
|
|
225
|
+
let parts = text.split(separator: ":")
|
|
226
|
+
guard parts.count == 6 else { return nil }
|
|
227
|
+
var bytes: [UInt8] = []
|
|
228
|
+
for part in parts {
|
|
229
|
+
guard part.count == 2, let value = UInt8(part, radix: 16) else { return nil }
|
|
230
|
+
bytes.append(value)
|
|
231
|
+
}
|
|
232
|
+
return bytes.reversed()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// Transport-independent keyboard client: enable/scan/connect/pair, HOGP
|
|
237
|
+
/// discovery, subscription, and raw input decoding into key events. Ported
|
|
238
|
+
/// from g2flash `demos/keyboard-client.ts`; the async command path replaces
|
|
239
|
+
/// its promise map with continuations. Callers own the G2 link: `send` is
|
|
240
|
+
/// handed complete settings payloads for the right lens and `accept` receives
|
|
241
|
+
/// every field-131 payload. Everything runs on the main actor with the rest
|
|
242
|
+
/// of the G2 driver.
|
|
243
|
+
@MainActor
|
|
244
|
+
final class G2KeyboardClient {
|
|
245
|
+
typealias Op = G2KeyboardProtocol.Op
|
|
246
|
+
typealias Record = G2KeyboardProtocol.Record
|
|
247
|
+
|
|
248
|
+
struct Status: Equatable {
|
|
249
|
+
var enabled = false
|
|
250
|
+
var conn: UInt8 = 0
|
|
251
|
+
var connected = false
|
|
252
|
+
var encrypted = false
|
|
253
|
+
var scanning = false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
struct Report {
|
|
257
|
+
let service: UInt16
|
|
258
|
+
let handle: UInt16
|
|
259
|
+
let id: Int
|
|
260
|
+
let type: UInt8
|
|
261
|
+
let ccc: UInt16?
|
|
262
|
+
let properties: UInt8
|
|
263
|
+
let map: [UInt8]
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
struct Configuration {
|
|
267
|
+
let epoch: UInt16
|
|
268
|
+
let reports: [Report]
|
|
269
|
+
let serviceChanged: UInt16?
|
|
270
|
+
var inputCount: Int {
|
|
271
|
+
reports.filter { $0.type == 1 }.count
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
enum Event {
|
|
276
|
+
case keys([G2KeyboardHid.KeyEvent])
|
|
277
|
+
case advert(G2KeyboardProtocol.Advert)
|
|
278
|
+
case status(Status)
|
|
279
|
+
/// Held keys were released because of a stream gap or link change.
|
|
280
|
+
case gap
|
|
281
|
+
/// Device-manager event from the controller (0x27 open, 0x28 close, 0x2a paired, …).
|
|
282
|
+
case dm(event: UInt8, status: UInt8)
|
|
283
|
+
/// The keyboard shows a six-digit passkey the user must type into the phone.
|
|
284
|
+
case passkeyRequest
|
|
285
|
+
/// The phone chose a passkey the user must type on the keyboard (then Enter).
|
|
286
|
+
case passkeyDisplay(UInt32)
|
|
287
|
+
/// Numeric comparison: the user confirms the keyboard shows this value.
|
|
288
|
+
case compareRequest(UInt32)
|
|
289
|
+
case servicesChanged
|
|
290
|
+
case error(String)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
struct ClientError: Error, LocalizedError, CustomStringConvertible {
|
|
294
|
+
let description: String
|
|
295
|
+
/// Expo surfaces `localizedDescription`; without this the app only sees
|
|
296
|
+
/// "The operation couldn't be completed. (… ClientError error 1.)".
|
|
297
|
+
var errorDescription: String? { description }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private struct Pending {
|
|
301
|
+
let op: Op
|
|
302
|
+
let continuation: CheckedContinuation<Record, Error>
|
|
303
|
+
let timeout: Task<Void, Never>
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private struct Attribute {
|
|
307
|
+
let handle: UInt16
|
|
308
|
+
let uuid: AttributeUuid
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
enum AttributeUuid: Equatable {
|
|
312
|
+
case short(UInt16)
|
|
313
|
+
case long(String)
|
|
314
|
+
|
|
315
|
+
/// ATT UUIDs are little endian; the Bluetooth base UUID collapses to
|
|
316
|
+
/// its 16-bit form, vendor UUIDs stay as hex strings.
|
|
317
|
+
init(_ bytes: [UInt8]) throws {
|
|
318
|
+
if bytes.count == 2 {
|
|
319
|
+
self = .short(G2KeyboardProtocol.u16(bytes, 0))
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
guard bytes.count == 16 else { throw ClientError(description: "Invalid ATT UUID length") }
|
|
323
|
+
let base: [UInt8] = [0xFB, 0x34, 0x9B, 0x5F, 0x80, 0, 0, 0x80, 0, 0x10, 0, 0]
|
|
324
|
+
if Array(bytes[0 ..< 12]) == base, bytes[14] == 0, bytes[15] == 0 {
|
|
325
|
+
self = .short(G2KeyboardProtocol.u16(bytes, 12))
|
|
326
|
+
} else {
|
|
327
|
+
self = .long(bytes.map { String(format: "%02x", $0) }.joined())
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private(set) var epoch: UInt16 = 0
|
|
333
|
+
private(set) var status = Status()
|
|
334
|
+
private(set) var configuration: Configuration?
|
|
335
|
+
private var serial: UInt16 = 0
|
|
336
|
+
private var magic: UInt8 = 30
|
|
337
|
+
private var pending: [UInt16: Pending] = [:]
|
|
338
|
+
private var held = G2KeyboardHid.HeldKeys()
|
|
339
|
+
private var inputs: [UInt16: (report: G2KeyboardHid.Report, id: Int, service: UInt16)] = [:]
|
|
340
|
+
private var records = G2KeyboardProtocol.RecordAssembler()
|
|
341
|
+
private var seenGaps = 0
|
|
342
|
+
private var closed = false
|
|
343
|
+
|
|
344
|
+
private let send: (Data, UInt8) -> Void
|
|
345
|
+
private let onEvent: (Event) -> Void
|
|
346
|
+
|
|
347
|
+
/// `send` receives the settings payload and its magic id for the right lens.
|
|
348
|
+
init(send: @escaping (Data, UInt8) -> Void, onEvent: @escaping (Event) -> Void) {
|
|
349
|
+
self.send = send
|
|
350
|
+
self.onEvent = onEvent
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// MARK: - Inbound
|
|
354
|
+
|
|
355
|
+
/// Feed one field-131 payload from a right-lens settings notification.
|
|
356
|
+
func accept(_ payload: Data) {
|
|
357
|
+
let complete = records.push(payload)
|
|
358
|
+
if records.gaps != seenGaps {
|
|
359
|
+
seenGaps = records.gaps
|
|
360
|
+
gap()
|
|
361
|
+
}
|
|
362
|
+
guard let r = complete else { return }
|
|
363
|
+
epoch = r.epoch
|
|
364
|
+
if r.kind == G2KeyboardProtocol.Kind.status.rawValue, r.data.count == 10 {
|
|
365
|
+
status = Status(
|
|
366
|
+
enabled: r.data[0] != 0, conn: r.data[1], connected: r.data[2] != 0,
|
|
367
|
+
encrypted: r.data[3] != 0, scanning: r.data[4] != 0
|
|
368
|
+
)
|
|
369
|
+
if !status.enabled || !status.connected || !status.encrypted {
|
|
370
|
+
release()
|
|
371
|
+
}
|
|
372
|
+
onEvent(.status(status))
|
|
373
|
+
}
|
|
374
|
+
if let p = pending[r.request], r.kind == 1 || r.kind == 2 {
|
|
375
|
+
pending[r.request] = nil
|
|
376
|
+
p.timeout.cancel()
|
|
377
|
+
if r.kind == 1, r.code != 0 {
|
|
378
|
+
p.continuation.resume(throwing: ClientError(
|
|
379
|
+
description: "Keyboard command \(p.op): firmware \(G2KeyboardProtocol.errorName(r.code))"
|
|
380
|
+
))
|
|
381
|
+
} else {
|
|
382
|
+
p.continuation.resume(returning: r)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
switch r.kind {
|
|
386
|
+
case G2KeyboardProtocol.Kind.dm.rawValue:
|
|
387
|
+
let event = UInt8(r.code & 0xFF)
|
|
388
|
+
let dmStatus = UInt8(r.code >> 8)
|
|
389
|
+
if event == 0x28 || event == 0x2B || event == 0x2D {
|
|
390
|
+
gap()
|
|
391
|
+
inputs.removeAll()
|
|
392
|
+
configuration = nil
|
|
393
|
+
}
|
|
394
|
+
if event == 0x2E, r.data.count == 2 {
|
|
395
|
+
if r.data[0] != 0 {
|
|
396
|
+
Task { [weak self] in
|
|
397
|
+
guard let self else { return }
|
|
398
|
+
_ = try? await self.command(.disconnect)
|
|
399
|
+
}
|
|
400
|
+
onEvent(.error("OOB-only keyboard pairing is unsupported"))
|
|
401
|
+
} else if r.data[1] != 0 {
|
|
402
|
+
// KeyboardDisplay on our side: choose the passkey, show it, and
|
|
403
|
+
// answer immediately; the user types it on the keyboard.
|
|
404
|
+
let pin = UInt32.random(in: 0 ... 999_999)
|
|
405
|
+
onEvent(.passkeyDisplay(pin))
|
|
406
|
+
Task { [weak self] in
|
|
407
|
+
guard let self else { return }
|
|
408
|
+
do { try await self.authenticate(pin) } catch { self.onEvent(.error("\(error)")) }
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
onEvent(.passkeyRequest)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if event == 0x35, r.data.count == 4 {
|
|
415
|
+
onEvent(.compareRequest(G2KeyboardProtocol.u32(r.data, 0)))
|
|
416
|
+
}
|
|
417
|
+
onEvent(.dm(event: event, status: dmStatus))
|
|
418
|
+
case G2KeyboardProtocol.Kind.input.rawValue:
|
|
419
|
+
if let changed = configuration?.serviceChanged, r.handle == changed {
|
|
420
|
+
gap()
|
|
421
|
+
inputs.removeAll()
|
|
422
|
+
configuration = nil
|
|
423
|
+
onEvent(.servicesChanged)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
guard let input = inputs[r.handle], configuration?.epoch == r.epoch else {
|
|
427
|
+
gap()
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
do {
|
|
431
|
+
let events = try held.update(handle: r.handle, report: input.report, data: r.data)
|
|
432
|
+
if !events.isEmpty {
|
|
433
|
+
onEvent(.keys(events))
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
release()
|
|
437
|
+
onEvent(.error("\(error)"))
|
|
438
|
+
}
|
|
439
|
+
case G2KeyboardProtocol.Kind.advert.rawValue:
|
|
440
|
+
if let advert = G2KeyboardProtocol.Advert.parse(r.data) {
|
|
441
|
+
onEvent(.advert(advert))
|
|
442
|
+
}
|
|
443
|
+
default:
|
|
444
|
+
break
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private func release() {
|
|
449
|
+
let events = held.reset()
|
|
450
|
+
if !events.isEmpty {
|
|
451
|
+
onEvent(.keys(events))
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private func gap() {
|
|
456
|
+
release()
|
|
457
|
+
onEvent(.gap)
|
|
458
|
+
for (id, p) in pending where p.op.rawValue >= Op.find.rawValue {
|
|
459
|
+
p.timeout.cancel()
|
|
460
|
+
p.continuation.resume(throwing: ClientError(description: "Keyboard stream gap during ATT transaction"))
|
|
461
|
+
pending[id] = nil
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/// Call on phone-link loss: clear held state and fail every command.
|
|
466
|
+
func lostPhoneLink() {
|
|
467
|
+
records.reset()
|
|
468
|
+
seenGaps = records.gaps
|
|
469
|
+
release()
|
|
470
|
+
cancel()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
func close() {
|
|
474
|
+
closed = true
|
|
475
|
+
lostPhoneLink()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private func cancel() {
|
|
479
|
+
for p in pending.values {
|
|
480
|
+
p.timeout.cancel()
|
|
481
|
+
p.continuation.resume(throwing: ClientError(description: "Keyboard client closed"))
|
|
482
|
+
}
|
|
483
|
+
pending.removeAll()
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// MARK: - Commands
|
|
487
|
+
|
|
488
|
+
/// Send one control and await its STATUS/ATT result record. ENABLE and
|
|
489
|
+
/// QUERY ignore the epoch; every other control must match the live one.
|
|
490
|
+
@discardableResult
|
|
491
|
+
func command(_ op: Op, body: [UInt8] = []) async throws -> Record {
|
|
492
|
+
if closed {
|
|
493
|
+
throw ClientError(description: "Keyboard client closed")
|
|
494
|
+
}
|
|
495
|
+
if pending.count >= 8 {
|
|
496
|
+
throw ClientError(description: "Too many keyboard commands")
|
|
497
|
+
}
|
|
498
|
+
serial = serial % 65535 + 1
|
|
499
|
+
magic = magic % 200 + 1
|
|
500
|
+
let request = serial
|
|
501
|
+
let magicId = magic
|
|
502
|
+
let packet = G2KeyboardProtocol.controlPacket(op: op, request: request, epoch: epoch, body: body, magic: magicId)
|
|
503
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
504
|
+
let timeout = Task { @MainActor [weak self] in
|
|
505
|
+
try? await Task.sleep(nanoseconds: 32_000_000_000)
|
|
506
|
+
guard let self, !Task.isCancelled, let p = self.pending.removeValue(forKey: request) else { return }
|
|
507
|
+
p.continuation.resume(throwing: ClientError(description: "Keyboard command \(op) timed out"))
|
|
508
|
+
}
|
|
509
|
+
pending[request] = Pending(op: op, continuation: continuation, timeout: timeout)
|
|
510
|
+
send(packet, magicId)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
func enable() async throws {
|
|
515
|
+
try await command(.enable)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
func disable() async throws {
|
|
519
|
+
try await command(.disable)
|
|
520
|
+
release()
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
func query() async throws -> Status {
|
|
524
|
+
try await command(.query)
|
|
525
|
+
return status
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/// Active scan for five seconds; adverts arrive as `.advert` events. The
|
|
529
|
+
/// firmware only clears `scanning` once the stock scanner is idle again,
|
|
530
|
+
/// and a ring hunting for its lens can hold the radio well past that, so
|
|
531
|
+
/// the fixed five-second window is the real end of the scan: results that
|
|
532
|
+
/// already streamed stand even when the flag lingers.
|
|
533
|
+
func scan() async throws {
|
|
534
|
+
try await command(.scan)
|
|
535
|
+
do {
|
|
536
|
+
try await until({ !self.status.scanning }, timeout: 8)
|
|
537
|
+
} catch {
|
|
538
|
+
if closed { throw error }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/// Connect, then pair unless the link is already encrypted. `address` is
|
|
543
|
+
/// LSB first as the controller reports it.
|
|
544
|
+
func connect(addressType: UInt8, address: [UInt8]) async throws {
|
|
545
|
+
guard address.count == 6, addressType <= 3 else { throw ClientError(description: "Invalid BLE address") }
|
|
546
|
+
release()
|
|
547
|
+
inputs.removeAll()
|
|
548
|
+
configuration = nil
|
|
549
|
+
try await command(.connect, body: [addressType] + address)
|
|
550
|
+
try await until({ self.status.connected }, timeout: 32)
|
|
551
|
+
if !status.encrypted {
|
|
552
|
+
// A peripheral security request can have started pairing already;
|
|
553
|
+
// BUSY is not success, the encrypted status below still decides.
|
|
554
|
+
do {
|
|
555
|
+
try await command(.pair)
|
|
556
|
+
} catch let error as ClientError where error.description.hasSuffix("firmware busy") {
|
|
557
|
+
// fall through to the encryption wait
|
|
558
|
+
}
|
|
559
|
+
try await until({ self.status.encrypted }, timeout: 65)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private func until(_ test: @escaping () -> Bool, timeout seconds: Double) async throws {
|
|
564
|
+
let end = Date().addingTimeInterval(seconds)
|
|
565
|
+
while !test() {
|
|
566
|
+
if closed {
|
|
567
|
+
throw ClientError(description: "Keyboard client closed")
|
|
568
|
+
}
|
|
569
|
+
if Date() >= end {
|
|
570
|
+
throw ClientError(description: "Keyboard state timeout")
|
|
571
|
+
}
|
|
572
|
+
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/// Answer a pending passkey prompt (the value the keyboard displayed).
|
|
577
|
+
func authenticate(_ pin: UInt32) async throws {
|
|
578
|
+
guard pin <= 999_999 else { throw ClientError(description: "Invalid pairing passkey") }
|
|
579
|
+
try await command(.auth, body: [UInt8(pin & 0xFF), UInt8((pin >> 8) & 0xFF), UInt8(pin >> 16)])
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/// Answer a pending numeric comparison.
|
|
583
|
+
func confirm(_ accept: Bool) async throws {
|
|
584
|
+
try await command(.compare, body: [accept ? 1 : 0])
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private func att(_ op: Op, _ body: [UInt8]) async throws -> Record {
|
|
588
|
+
let r = try await command(op, body: body)
|
|
589
|
+
guard r.kind == G2KeyboardProtocol.Kind.att.rawValue else {
|
|
590
|
+
throw ClientError(description: "Expected keyboard ATT response")
|
|
591
|
+
}
|
|
592
|
+
return r
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// Read Blob until a short/empty response or Invalid Offset; never assumes MTU 23.
|
|
596
|
+
func read(_ handle: UInt16) async throws -> [UInt8] {
|
|
597
|
+
var parts: [UInt8] = []
|
|
598
|
+
while true {
|
|
599
|
+
let r = try await att(.read, G2KeyboardProtocol.le16(handle) + G2KeyboardProtocol.le16(UInt16(parts.count)))
|
|
600
|
+
let status = r.code >> 8
|
|
601
|
+
if !parts.isEmpty, status == 7 || status == 11 {
|
|
602
|
+
break
|
|
603
|
+
}
|
|
604
|
+
if status != 0 {
|
|
605
|
+
throw ClientError(description: "ATT read \(handle): \(status)")
|
|
606
|
+
}
|
|
607
|
+
if r.data.isEmpty {
|
|
608
|
+
break
|
|
609
|
+
}
|
|
610
|
+
parts += r.data
|
|
611
|
+
if parts.count > 512 {
|
|
612
|
+
throw ClientError(description: "Attribute exceeds 512 bytes")
|
|
613
|
+
}
|
|
614
|
+
if parts.count == 512 {
|
|
615
|
+
break
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return parts
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/// Read a short attribute once (Report Reference, declaration, service UUID).
|
|
622
|
+
private func shortRead(_ handle: UInt16) async throws -> [UInt8] {
|
|
623
|
+
let r = try await att(.read, G2KeyboardProtocol.le16(handle) + [0, 0])
|
|
624
|
+
if r.code >> 8 != 0 {
|
|
625
|
+
throw ClientError(description: "ATT read \(handle): \(r.code >> 8)")
|
|
626
|
+
}
|
|
627
|
+
return r.data
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
func write(_ handle: UInt16, _ value: [UInt8], withoutResponse: Bool = false) async throws {
|
|
631
|
+
guard !value.isEmpty, value.count <= 18 else { throw ClientError(description: "Keyboard write supports 1..18 bytes") }
|
|
632
|
+
let r = try await att(.write, G2KeyboardProtocol.le16(handle) + [withoutResponse ? 1 : 0, UInt8(value.count)] + value)
|
|
633
|
+
if r.code >> 8 != 0 {
|
|
634
|
+
throw ClientError(description: "ATT write \(handle): \(r.code >> 8)")
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private func attributes() async throws -> [Attribute] {
|
|
639
|
+
var attrs: [Attribute] = []
|
|
640
|
+
var start = 1
|
|
641
|
+
while start <= 65535 {
|
|
642
|
+
let r = try await att(.find, G2KeyboardProtocol.le16(UInt16(start)) + [0xFF, 0xFF])
|
|
643
|
+
let status = r.code >> 8
|
|
644
|
+
if status == 10 {
|
|
645
|
+
break
|
|
646
|
+
} // attribute not found: normal end
|
|
647
|
+
if status != 0 {
|
|
648
|
+
throw ClientError(description: "ATT Find Information: \(status)")
|
|
649
|
+
}
|
|
650
|
+
let width = r.data.first == 1 ? 4 : r.data.first == 2 ? 18 : 0
|
|
651
|
+
guard width > 0, r.data.count > 1, (r.data.count - 1) % width == 0 else {
|
|
652
|
+
throw ClientError(description: "Malformed Find Information response")
|
|
653
|
+
}
|
|
654
|
+
var i = 1
|
|
655
|
+
while i < r.data.count {
|
|
656
|
+
let handle = Int(G2KeyboardProtocol.u16(r.data, i))
|
|
657
|
+
if handle < start || attrs.count >= 512 {
|
|
658
|
+
throw ClientError(description: "Invalid or excessive GATT handles")
|
|
659
|
+
}
|
|
660
|
+
try attrs.append(Attribute(handle: UInt16(handle), uuid: AttributeUuid(Array(r.data[(i + 2) ..< (i + width)]))))
|
|
661
|
+
start = handle + 1
|
|
662
|
+
i += width
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return attrs
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/// Discover HIDS instances, Report Maps and each Report Reference; select
|
|
669
|
+
/// Report Protocol and subscribe input reports. No fixed keyboard handles.
|
|
670
|
+
@discardableResult
|
|
671
|
+
func configure() async throws -> Configuration {
|
|
672
|
+
let attrs = try await attributes()
|
|
673
|
+
var reports: [Report] = []
|
|
674
|
+
func descriptorsFor(_ handle: UInt16) -> [Attribute] {
|
|
675
|
+
let end = attrs.first { a in
|
|
676
|
+
a.handle > handle && [AttributeUuid.short(0x2800), .short(0x2801), .short(0x2803)].contains(a.uuid)
|
|
677
|
+
}.map { Int($0.handle) } ?? 65536
|
|
678
|
+
return attrs.filter { $0.handle > handle && Int($0.handle) < end }
|
|
679
|
+
}
|
|
680
|
+
func addReport(service: UInt16, _ a: Attribute, map: [UInt8], parsed: [Int: G2KeyboardHid.Report]) async throws {
|
|
681
|
+
let descriptors = descriptorsFor(a.handle)
|
|
682
|
+
guard let ref = descriptors.first(where: { $0.uuid == .short(0x2908) }),
|
|
683
|
+
let decl = attrs.filter({ $0.handle < a.handle }).last(where: { $0.uuid == .short(0x2803) })
|
|
684
|
+
else { throw ClientError(description: "HID Report has no declaration/reference") }
|
|
685
|
+
let reference = try await shortRead(ref.handle)
|
|
686
|
+
let declaration = try await shortRead(decl.handle)
|
|
687
|
+
guard reference.count == 2, declaration.count == 5 || declaration.count == 19,
|
|
688
|
+
G2KeyboardProtocol.u16(declaration, 1) == a.handle,
|
|
689
|
+
try AttributeUuid(Array(declaration[3...])) == a.uuid
|
|
690
|
+
else { throw ClientError(description: "Invalid HID Report metadata") }
|
|
691
|
+
let id = Int(reference[0])
|
|
692
|
+
let type = reference[1]
|
|
693
|
+
let properties = declaration[0]
|
|
694
|
+
guard type >= 1, type <= 3 else { throw ClientError(description: "Invalid Report Reference type") }
|
|
695
|
+
let ccc = descriptors.first { $0.uuid == .short(0x2902) }?.handle
|
|
696
|
+
if type == 1, parsed[id] == nil || ccc == nil || properties & 0x10 == 0 {
|
|
697
|
+
throw ClientError(description: "Input Report lacks map/notify/CCC")
|
|
698
|
+
}
|
|
699
|
+
if reports.contains(where: { $0.handle == a.handle }) {
|
|
700
|
+
throw ClientError(description: "Ambiguous report shared by multiple HID services")
|
|
701
|
+
}
|
|
702
|
+
reports.append(Report(service: service, handle: a.handle, id: id, type: type, ccc: ccc, properties: properties, map: map))
|
|
703
|
+
}
|
|
704
|
+
for (i, service) in attrs.enumerated() {
|
|
705
|
+
guard service.uuid == .short(0x2800) else { continue }
|
|
706
|
+
guard try AttributeUuid(await shortRead(service.handle)) == .short(0x1812) else { continue }
|
|
707
|
+
let end = attrs[(i + 1)...].first { $0.uuid == .short(0x2800) || $0.uuid == .short(0x2801) }?.handle
|
|
708
|
+
let members = attrs.filter { $0.handle > service.handle && (end == nil || $0.handle < end!) }
|
|
709
|
+
guard let mapAttr = members.first(where: { $0.uuid == .short(0x2A4B) }) else {
|
|
710
|
+
throw ClientError(description: "HID service has no Report Map")
|
|
711
|
+
}
|
|
712
|
+
let map = try await read(mapAttr.handle)
|
|
713
|
+
let parsed = try G2KeyboardHid.parseReportMap(map)
|
|
714
|
+
for mode in members.filter({ $0.uuid == .short(0x2A4E) }) {
|
|
715
|
+
try await write(mode.handle, [1], withoutResponse: true)
|
|
716
|
+
}
|
|
717
|
+
for a in members.filter({ $0.uuid == .short(0x2A4D) }) {
|
|
718
|
+
try await addReport(service: service.handle, a, map: map, parsed: parsed)
|
|
719
|
+
}
|
|
720
|
+
// HOGP can refer to input reports in other services (commonly Battery).
|
|
721
|
+
for ext in descriptorsFor(mapAttr.handle).filter({ $0.uuid == .short(0x2907) }) {
|
|
722
|
+
let uuid = try AttributeUuid(await shortRead(ext.handle))
|
|
723
|
+
let matches = attrs.filter { $0.uuid == uuid && ($0.handle < service.handle || (end != nil && $0.handle >= end!)) }
|
|
724
|
+
if matches.isEmpty {
|
|
725
|
+
throw ClientError(description: "External HID report characteristic missing")
|
|
726
|
+
}
|
|
727
|
+
for a in matches {
|
|
728
|
+
try await addReport(service: service.handle, a, map: map, parsed: parsed)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
let inputReports = reports.filter { $0.type == 1 }
|
|
733
|
+
guard !inputReports.isEmpty, inputReports.count <= 16 else {
|
|
734
|
+
throw ClientError(description: "Keyboard requires 1..16 input reports")
|
|
735
|
+
}
|
|
736
|
+
let changed = attrs.first { $0.uuid == .short(0x2A05) }
|
|
737
|
+
let changedCcc = changed.flatMap { c in descriptorsFor(c.handle).first { $0.uuid == .short(0x2902) } }
|
|
738
|
+
var watches = inputReports.map(\.handle)
|
|
739
|
+
if let changed, changedCcc != nil {
|
|
740
|
+
watches.append(changed.handle)
|
|
741
|
+
}
|
|
742
|
+
guard watches.count <= 16 else { throw ClientError(description: "More than 16 subscribed report/Service Changed handles") }
|
|
743
|
+
let config = Configuration(epoch: epoch, reports: reports, serviceChanged: changedCcc != nil ? changed?.handle : nil)
|
|
744
|
+
try restore(config)
|
|
745
|
+
do {
|
|
746
|
+
try await command(.watch, body: [UInt8(watches.count)] + watches.flatMap(G2KeyboardProtocol.le16))
|
|
747
|
+
if let changedCcc {
|
|
748
|
+
try await write(changedCcc.handle, [2, 0])
|
|
749
|
+
}
|
|
750
|
+
for r in inputReports {
|
|
751
|
+
try await write(r.ccc!, [1, 0])
|
|
752
|
+
}
|
|
753
|
+
} catch {
|
|
754
|
+
inputs.removeAll()
|
|
755
|
+
configuration = nil
|
|
756
|
+
release()
|
|
757
|
+
throw error
|
|
758
|
+
}
|
|
759
|
+
return config
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/// Install a discovered mapping. Valid only for the same connection epoch:
|
|
763
|
+
/// a fresh firmware boot needs enable/connect/configure again.
|
|
764
|
+
func restore(_ config: Configuration) throws {
|
|
765
|
+
guard !config.reports.isEmpty, config.reports.count <= 64 else { throw ClientError(description: "Invalid keyboard configuration") }
|
|
766
|
+
var next: [UInt16: (report: G2KeyboardHid.Report, id: Int, service: UInt16)] = [:]
|
|
767
|
+
for r in config.reports where r.type == 1 {
|
|
768
|
+
guard r.handle >= 1, next[r.handle] == nil else { throw ClientError(description: "Invalid restored Report handle") }
|
|
769
|
+
guard !r.map.isEmpty, r.map.count <= 512 else { throw ClientError(description: "Invalid restored Report Map") }
|
|
770
|
+
guard let report = try G2KeyboardHid.parseReportMap(r.map)[r.id] else {
|
|
771
|
+
throw ClientError(description: "Restored Report ID missing")
|
|
772
|
+
}
|
|
773
|
+
next[r.handle] = (report, r.id, r.service)
|
|
774
|
+
}
|
|
775
|
+
guard !next.isEmpty, next.count <= 16 else { throw ClientError(description: "Invalid restored input count") }
|
|
776
|
+
release()
|
|
777
|
+
inputs = next
|
|
778
|
+
configuration = config
|
|
779
|
+
}
|
|
780
|
+
}
|