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