@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1075 -257
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1434 -504
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -0,0 +1,438 @@
1
+ import Foundation
2
+
3
+ /// Owns the phone side of the CFW keyboard bridge for one G2 connection:
4
+ /// the client, the remembered keyboard, the reconnect loop, and what the
5
+ /// app sees (glasses-status keys plus `keyboard_*` events). The G2 driver
6
+ /// hands it right-lens field-131 payloads and a sender for settings payloads;
7
+ /// everything else stays in here so the driver only tracks link lifecycle.
8
+ ///
9
+ /// Firmware state is volatile (a lens reboot returns to stock behaviour), so
10
+ /// the remembered keyboard is re-enabled and reconnected on every CFW
11
+ /// detection and after every keyboard-side disconnect, with backoff. The
12
+ /// discovered handle mapping is only valid for one connection epoch and is
13
+ /// never persisted.
14
+ @MainActor
15
+ final class G2KeyboardBridge {
16
+ static let minimumRevision = 39
17
+ private static let defaultsKey = "g2_keyboard"
18
+
19
+ struct Saved: Equatable {
20
+ let address: String
21
+ let addressType: Int
22
+ let name: String
23
+
24
+ var values: [String: Any] {
25
+ ["address": address, "addressType": addressType, "name": name]
26
+ }
27
+
28
+ init(address: String, addressType: Int, name: String) {
29
+ self.address = address
30
+ self.addressType = addressType
31
+ self.name = name
32
+ }
33
+
34
+ init?(values: [String: Any]?) {
35
+ guard let values, let address = values["address"] as? String,
36
+ let addressType = values["addressType"] as? Int
37
+ else { return nil }
38
+ self.init(address: address, addressType: addressType, name: values["name"] as? String ?? "")
39
+ }
40
+ }
41
+
42
+ enum State: String {
43
+ case idle, scanning, connecting, pairing, configuring, connected, error
44
+ }
45
+
46
+ struct BridgeError: Error, LocalizedError, CustomStringConvertible {
47
+ let description: String
48
+ var errorDescription: String? { description }
49
+ }
50
+
51
+ private let send: (Data) -> Void
52
+ private var client: G2KeyboardClient?
53
+ private(set) var supported = false
54
+ private(set) var ready = false
55
+ private(set) var state = State.idle
56
+ private var busy = false
57
+ /// `busy` is held by the automatic reconnect loop (not the user).
58
+ private var autoReconnecting = false
59
+ private var userDisconnected = false
60
+ private var lastError: String?
61
+ private var connectedName = ""
62
+ private var scanResults: [G2KeyboardProtocol.Advert] = []
63
+ private var reconnectTask: Task<Void, Never>?
64
+ private var reconnectAttempt = 0
65
+ private(set) var saved: Saved? {
66
+ didSet { UserDefaults.standard.set(saved?.values, forKey: Self.defaultsKey) }
67
+ }
68
+
69
+ /// `send` delivers a complete G2SettingPackage payload to the right lens.
70
+ init(send: @escaping (Data) -> Void) {
71
+ self.send = send
72
+ saved = Saved(values: UserDefaults.standard.dictionary(forKey: Self.defaultsKey))
73
+ }
74
+
75
+ // MARK: - Link lifecycle (driven by G2)
76
+
77
+ /// CFW classification landed (or changed). Re-arms the remembered keyboard.
78
+ func cfwDetected(revision: Int?) {
79
+ let now = (revision ?? 0) >= Self.minimumRevision
80
+ if now != supported {
81
+ Bridge.log("G2: keyboard bridge \(now ? "available" : "unavailable") (revision \(revision ?? 0))")
82
+ }
83
+ supported = now
84
+ userDisconnected = false
85
+ reconnectAttempt = 0
86
+ if !supported {
87
+ teardownClient()
88
+ }
89
+ publish()
90
+ if supported, saved != nil {
91
+ scheduleReconnect(after: 1)
92
+ }
93
+ }
94
+
95
+ /// The glasses link is gone: drop everything but the remembered keyboard.
96
+ func linkLost() {
97
+ supported = false
98
+ teardownClient()
99
+ publish()
100
+ }
101
+
102
+ /// Right-lens sid-0x09 field 131.
103
+ func accept(_ payload: Data) {
104
+ client?.accept(payload)
105
+ }
106
+
107
+ private func teardownClient() {
108
+ reconnectTask?.cancel()
109
+ reconnectTask = nil
110
+ client?.close()
111
+ client = nil
112
+ ready = false
113
+ busy = false
114
+ state = .idle
115
+ lastError = nil
116
+ }
117
+
118
+ // MARK: - App-facing commands
119
+
120
+ /// Five-second active scan; returns every distinct advertiser seen.
121
+ func scan() async throws -> [G2KeyboardProtocol.Advert] {
122
+ try requireSupported()
123
+ await interruptReconnect()
124
+ try requireIdle()
125
+ busy = true
126
+ defer { busy = false }
127
+ let client = ensureClient()
128
+ scanResults = []
129
+ lastError = nil
130
+ setState(.scanning)
131
+ do {
132
+ try await releaseLink(client)
133
+ try await client.enable()
134
+ try await client.scan()
135
+ setState(.idle)
136
+ } catch {
137
+ fail(error)
138
+ throw friendly(error)
139
+ }
140
+ return scanResults
141
+ }
142
+
143
+ /// Connect, pair and subscribe a keyboard, then remember it.
144
+ func connect(address: String, addressType: Int, name: String) async throws {
145
+ try requireSupported()
146
+ guard let bytes = G2KeyboardProtocol.address(fromText: address), (0 ... 3).contains(addressType) else {
147
+ throw BridgeError(description: "Invalid keyboard address")
148
+ }
149
+ await interruptReconnect()
150
+ try requireIdle()
151
+ busy = true
152
+ defer { busy = false }
153
+ userDisconnected = false
154
+ let client = ensureClient()
155
+ lastError = nil
156
+ setState(.connecting)
157
+ do {
158
+ try await releaseLink(client)
159
+ try await client.enable()
160
+ try await client.connect(addressType: UInt8(addressType), address: bytes)
161
+ setState(.configuring)
162
+ let config = try await client.configure()
163
+ ready = true
164
+ connectedName = name
165
+ saved = Saved(address: address, addressType: addressType, name: name)
166
+ reconnectAttempt = 0
167
+ Bridge.log("G2: keyboard ready — \(config.inputCount) input reports, epoch \(config.epoch)")
168
+ setState(.connected)
169
+ } catch {
170
+ ready = false
171
+ fail(error)
172
+ throw friendly(error)
173
+ }
174
+ }
175
+
176
+ /// Drop the keyboard link but keep the bond and the remembered identity.
177
+ /// No automatic reconnect until the next CFW detection or explicit connect.
178
+ func disconnect() async throws {
179
+ userDisconnected = true
180
+ cancelReconnect()
181
+ ready = false
182
+ guard let client else {
183
+ setState(.idle)
184
+ return
185
+ }
186
+ do {
187
+ try await client.disable()
188
+ setState(.idle)
189
+ } catch {
190
+ fail(error)
191
+ throw error
192
+ }
193
+ }
194
+
195
+ /// Forget the remembered keyboard. The firmware keeps its bond; a stale
196
+ /// bond is cleared with the glasses' own device tools.
197
+ func forget() async {
198
+ try? await disconnect()
199
+ saved = nil
200
+ connectedName = ""
201
+ publish()
202
+ }
203
+
204
+ /// Reconnect the remembered keyboard now (an app-driven retry).
205
+ func reconnect() async throws {
206
+ guard let saved else { throw BridgeError(description: "No remembered keyboard") }
207
+ try await connect(address: saved.address, addressType: saved.addressType, name: saved.name)
208
+ }
209
+
210
+ func passkey(_ pin: Int) async throws {
211
+ guard let client, (0 ... 999_999).contains(pin) else { throw BridgeError(description: "No pending keyboard pairing") }
212
+ try await client.authenticate(UInt32(pin))
213
+ }
214
+
215
+ func compare(_ accept: Bool) async throws {
216
+ guard let client else { throw BridgeError(description: "No pending keyboard pairing") }
217
+ try await client.confirm(accept)
218
+ }
219
+
220
+ /// Firmware status query plus the bridge's own view.
221
+ func status() async -> [String: Any] {
222
+ var values = statusValues()
223
+ if let client, supported, let status = try? await client.query() {
224
+ values["firmware"] = [
225
+ "enabled": status.enabled, "conn": Int(status.conn), "connected": status.connected,
226
+ "encrypted": status.encrypted, "scanning": status.scanning,
227
+ ]
228
+ }
229
+ return values
230
+ }
231
+
232
+ // MARK: - Internals
233
+
234
+ private func requireSupported() throws {
235
+ guard supported else {
236
+ throw BridgeError(description: "Keyboard bridge requires Glassly CFW revision \(Self.minimumRevision) on connected G2 glasses")
237
+ }
238
+ }
239
+
240
+ private func requireIdle() throws {
241
+ guard !busy else { throw BridgeError(description: "Keyboard operation already in progress") }
242
+ }
243
+
244
+ private func ensureClient() -> G2KeyboardClient {
245
+ if let client {
246
+ return client
247
+ }
248
+ let client = G2KeyboardClient(send: { [weak self] payload, _ in self?.send(payload) }) { [weak self] event in
249
+ self?.handle(event)
250
+ }
251
+ self.client = client
252
+ return client
253
+ }
254
+
255
+ private func setState(_ next: State) {
256
+ state = next
257
+ publish()
258
+ }
259
+
260
+ private func fail(_ error: Error) {
261
+ lastError = "\(error)"
262
+ Bridge.log("G2: keyboard error — \(lastError ?? "")")
263
+ setState(.error)
264
+ }
265
+
266
+ private func cancelReconnect() {
267
+ reconnectTask?.cancel()
268
+ reconnectTask = nil
269
+ }
270
+
271
+ /// Hand the bridge to a user-driven scan/connect: cancel a pending retry
272
+ /// and, when an attempt is mid-flight (a sleeping keyboard keeps one busy
273
+ /// for up to 30 s at a time), close its client so its waits throw, then
274
+ /// let it unwind. Without this the scan screen mostly saw "operation
275
+ /// already in progress" whenever a keyboard was remembered.
276
+ private func interruptReconnect() async {
277
+ let task = reconnectTask
278
+ reconnectTask = nil
279
+ task?.cancel()
280
+ if autoReconnecting {
281
+ client?.close()
282
+ client = nil
283
+ }
284
+ if let task {
285
+ await task.value
286
+ }
287
+ }
288
+
289
+ /// The firmware refuses SCAN/CONNECT while it still owns a keyboard slot,
290
+ /// and an interrupted connect keeps initiating until DM close: drop the
291
+ /// link and poll until the slot is free (close is asynchronous).
292
+ private func releaseLink(_ client: G2KeyboardClient) async throws {
293
+ // DISABLE is epoch-checked and a fresh client has not seen the
294
+ // firmware's epoch yet (it bumps on every CONNECT); QUERY is not, and
295
+ // its STATUS record carries the epoch, so sync before disabling.
296
+ _ = try await client.query()
297
+ try await client.disable()
298
+ ready = false
299
+ for _ in 0 ..< 20 {
300
+ let status = try await client.query()
301
+ if status.conn == 0 { return }
302
+ try? await Task.sleep(nanoseconds: 150_000_000)
303
+ }
304
+ throw BridgeError(description: "The glasses are still closing the previous keyboard link; try again in a moment")
305
+ }
306
+
307
+ /// Firmware BUSY almost always means the ring's own scan/reconnect holds
308
+ /// the radio; say so instead of leaking the raw control error.
309
+ private func friendly(_ error: Error) -> Error {
310
+ if "\(error)".hasSuffix("firmware busy") {
311
+ return BridgeError(
312
+ description: "The glasses' radio is busy (usually the ring reconnecting). Wait a few seconds and try again."
313
+ )
314
+ }
315
+ return error
316
+ }
317
+
318
+ private func scheduleReconnect(after seconds: Double) {
319
+ cancelReconnect()
320
+ // The task stays in `reconnectTask` for the whole attempt so
321
+ // interruptReconnect() can await it unwinding.
322
+ reconnectTask = Task { @MainActor [weak self] in
323
+ try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
324
+ guard let self, !Task.isCancelled else { return }
325
+ await self.attemptReconnect()
326
+ }
327
+ }
328
+
329
+ private func attemptReconnect() async {
330
+ guard supported, let saved, !userDisconnected, !ready, !busy else { return }
331
+ autoReconnecting = true
332
+ defer { autoReconnecting = false }
333
+ do {
334
+ try await connect(address: saved.address, addressType: saved.addressType, name: saved.name)
335
+ } catch {
336
+ if Task.isCancelled { return } // a user scan/connect took over
337
+ reconnectAttempt += 1
338
+ // Keyboards sleep and the ring's own scan can hold the controller
339
+ // (BUSY); back off to 30 s and keep trying while the glasses stay up.
340
+ let delay = min(30.0, 5.0 * pow(2.0, Double(min(reconnectAttempt - 1, 3))))
341
+ scheduleReconnect(after: delay)
342
+ }
343
+ }
344
+
345
+ private func handle(_ event: G2KeyboardClient.Event) {
346
+ switch event {
347
+ case let .keys(events):
348
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
349
+ for e in events {
350
+ Bridge.sendTypedMessage("keyboard_key", body: [
351
+ "page": e.page, "usage": e.usage, "down": e.down, "timestamp": now,
352
+ ])
353
+ }
354
+ case let .advert(advert):
355
+ // An active scan reports the advertisement and its scan response as
356
+ // separate records, and the name usually only rides in the latter:
357
+ // merge per address instead of keeping whichever came first.
358
+ let merged: G2KeyboardProtocol.Advert
359
+ if let i = scanResults.firstIndex(where: { $0.address == advert.address && $0.addressType == advert.addressType }) {
360
+ merged = scanResults[i].merging(advert)
361
+ guard merged != scanResults[i] else { return }
362
+ scanResults[i] = merged
363
+ } else {
364
+ merged = advert
365
+ scanResults.append(advert)
366
+ }
367
+ Bridge.sendTypedMessage("keyboard_advert", body: Self.advertValues(merged))
368
+ case let .status(status):
369
+ if ready, !status.connected || !status.encrypted {
370
+ keyboardDropped("status")
371
+ }
372
+ case .gap:
373
+ break
374
+ case let .dm(event, dmStatus):
375
+ if event == 0x28 || event == 0x2B || event == 0x2D {
376
+ Bridge.log("G2: keyboard DM event 0x\(String(event, radix: 16)) status \(dmStatus)")
377
+ if ready {
378
+ keyboardDropped("dm")
379
+ }
380
+ }
381
+ case .passkeyRequest:
382
+ setState(.pairing)
383
+ Bridge.sendTypedMessage("keyboard_pairing", body: ["kind": "passkey"])
384
+ case let .passkeyDisplay(pin):
385
+ setState(.pairing)
386
+ Bridge.sendTypedMessage("keyboard_pairing", body: ["kind": "display", "value": Int(pin)])
387
+ case let .compareRequest(value):
388
+ setState(.pairing)
389
+ Bridge.sendTypedMessage("keyboard_pairing", body: ["kind": "compare", "value": Int(value)])
390
+ case .servicesChanged:
391
+ Bridge.log("G2: keyboard services changed; rediscovering")
392
+ ready = false
393
+ publish()
394
+ Task { @MainActor [weak self] in
395
+ guard let self, let saved = self.saved else { return }
396
+ self.busy = false
397
+ try? await self.connect(address: saved.address, addressType: saved.addressType, name: saved.name)
398
+ }
399
+ case let .error(message):
400
+ lastError = message
401
+ Bridge.log("G2: keyboard — \(message)")
402
+ publish()
403
+ }
404
+ }
405
+
406
+ private func keyboardDropped(_ reason: String) {
407
+ Bridge.log("G2: keyboard disconnected (\(reason))")
408
+ ready = false
409
+ setState(.idle)
410
+ if saved != nil, !userDisconnected {
411
+ scheduleReconnect(after: 2)
412
+ }
413
+ }
414
+
415
+ static func advertValues(_ advert: G2KeyboardProtocol.Advert) -> [String: Any] {
416
+ [
417
+ "address": advert.addressText, "addressType": Int(advert.addressType), "name": advert.name,
418
+ "hid": advert.hid, "rssi": Int(advert.rssi),
419
+ ]
420
+ }
421
+
422
+ private func statusValues() -> [String: Any] {
423
+ [
424
+ "keyboardBridge": supported,
425
+ "keyboardConnected": ready,
426
+ "keyboardName": ready ? connectedName : (saved?.name ?? ""),
427
+ "keyboardSaved": saved != nil,
428
+ "keyboardState": state.rawValue,
429
+ "keyboardError": lastError ?? "",
430
+ ]
431
+ }
432
+
433
+ private func publish() {
434
+ for (key, value) in statusValues() {
435
+ DeviceStore.shared.apply("glasses", key, value)
436
+ }
437
+ }
438
+ }