@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.
Files changed (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1420 -495
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -0,0 +1,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
+ }