@mentra/bluetooth-sdk 0.1.9 → 0.1.11
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 +116 -18
- package/android/build.gradle +289 -16
- package/android/gradle.properties.example +9 -0
- package/android/lc3Lib/build.gradle +95 -6
- package/android/settings.gradle +3 -0
- package/android/silero/LICENSE.md +17 -0
- package/android/silero/build.gradle +42 -0
- package/android/silero/proguard-rules.pro +24 -0
- package/android/silero/src/androidTest/assets/hello.wav +0 -0
- package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
- package/android/silero/src/main/assets/LICENSE +21 -0
- package/android/silero/src/main/assets/silero_vad.onnx +0 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
- package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
- package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +195 -34
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +103 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +210 -18
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
- package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +125 -11
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1293 -758
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +197 -149
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +64 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
- package/android/src/test/resources/avif_with_exif.avif +0 -0
- package/build/BluetoothSdk.types.d.ts +253 -44
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +11 -10
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +37 -23
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +43 -52
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts +4 -0
- package/build/_private/photoRequestPayload.d.ts.map +1 -0
- package/build/_private/photoRequestPayload.js +25 -0
- package/build/_private/photoRequestPayload.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +25 -0
- package/build/index.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +2 -2
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +3 -3
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +177 -70
- package/ios/LocalPhotoUploadServer.swift +370 -105
- package/ios/MentraBluetoothSDK.podspec +3 -1
- package/ios/MentraPhotoReceiverModule.swift +62 -10
- package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
- package/ios/Source/BluetoothSdkDefaults.swift +6 -0
- package/ios/Source/Bridge.swift +68 -19
- package/ios/Source/DeviceManager.swift +167 -32
- package/ios/Source/DeviceStore.swift +24 -8
- package/ios/Source/MentraBluetoothSDK.swift +803 -47
- package/ios/Source/ObservableStore.swift +6 -0
- package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
- package/ios/Source/{Camera → camera}/CameraModels.swift +226 -16
- package/ios/Source/controllers/ControllerManager.swift +4 -3
- package/ios/Source/controllers/R1.swift +3 -3
- package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
- package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
- package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
- package/ios/Source/services/PhoneMic.swift +1 -0
- package/ios/Source/sgcs/G1.swift +5 -5
- package/ios/Source/sgcs/G2.swift +1494 -870
- package/ios/Source/sgcs/Mach1.swift +4 -4
- package/ios/Source/sgcs/MentraLive.swift +814 -321
- package/ios/Source/sgcs/MentraNex.swift +604 -244
- package/ios/Source/sgcs/SGCManager.swift +68 -5
- package/ios/Source/sgcs/Simulated.swift +9 -5
- package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
- package/ios/Source/status/DeviceStatus.swift +974 -0
- package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
- package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
- package/ios/Source/stt/STTTools.swift +27 -12
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
- package/ios/Source/tts/TTSTools.swift +164 -0
- package/ios/Source/utils/JSCExperiment.swift +7 -7
- package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
- package/ios/Source/utils/MessageChunker.swift +78 -21
- package/ios/Source/utils/TarBz2Extractor.swift +53 -2
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +68 -4
- package/src/BluetoothSdk.types.ts +344 -60
- package/src/_private/BluetoothSdkModule.ts +132 -102
- package/src/_private/photoRequestPayload.ts +28 -0
- package/src/index.ts +61 -15
- package/src/react/useMentraBluetooth.ts +6 -4
- package/android/.project +0 -28
- package/android/lc3Lib/.project +0 -28
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
- package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
- package/ios/Source/Status/DeviceStatus.swift +0 -471
- /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
- /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
- /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
- /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
- /package/ios/Source/{Types → types}/DeviceModels.swift +0 -0
|
@@ -18,11 +18,11 @@ extension Data {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/// Nex firmware expects tier 1–
|
|
21
|
+
/// Nex firmware expects tier 1–4 in protobuf `DisplayDistanceConfig.distance_cm` (name is legacy, not cm).
|
|
22
22
|
/// Keep in sync with `NexProtobufUtils.dashboardDepthToDistanceCm` (Android `NexSGCUtils.kt`).
|
|
23
23
|
enum NexDashboardDisplayWire {
|
|
24
24
|
static let depthMin = 1
|
|
25
|
-
static let depthMax =
|
|
25
|
+
static let depthMax = 4
|
|
26
26
|
|
|
27
27
|
static func depthToWireTier(_ depth: Int) -> UInt32 {
|
|
28
28
|
UInt32(min(max(depth, depthMin), depthMax))
|
|
@@ -42,11 +42,18 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
42
42
|
|
|
43
43
|
func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
|
|
44
44
|
|
|
45
|
-
func setMicEnabled(_: Bool) {
|
|
45
|
+
func setMicEnabled(_ enabled: Bool) {
|
|
46
|
+
shouldUseGlassesMic = enabled
|
|
47
|
+
if enabled {
|
|
48
|
+
startMicBeat()
|
|
49
|
+
} else {
|
|
50
|
+
stopMicBeat()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
46
53
|
|
|
47
54
|
func requestPhoto(
|
|
48
55
|
_: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
|
|
49
|
-
compress _: String?, flash _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
|
|
56
|
+
compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
|
|
50
57
|
) {}
|
|
51
58
|
|
|
52
59
|
func startStream(_: [String: Any]) {}
|
|
@@ -69,15 +76,35 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
69
76
|
|
|
70
77
|
func sendCameraFovSetting() {}
|
|
71
78
|
|
|
72
|
-
func setBrightness(_: Int, autoMode
|
|
79
|
+
func setBrightness(_ level: Int, autoMode: Bool) {
|
|
80
|
+
updateGlassesBrightness(level)
|
|
81
|
+
updateGlassesAutoBrightness(autoMode)
|
|
82
|
+
}
|
|
73
83
|
|
|
74
|
-
func sendDoubleTextWall(_: String, _: String) {
|
|
84
|
+
func sendDoubleTextWall(_ top: String, _ bottom: String) async {
|
|
85
|
+
await sendTextWall("\(top)\n\(bottom)")
|
|
86
|
+
}
|
|
75
87
|
|
|
76
|
-
func displayBitmap(base64ImageData _:
|
|
77
|
-
|
|
88
|
+
func displayBitmap(base64ImageData: String, x _: Int32? = nil, y _: Int32? = nil, width _: Int32? = nil, height _: Int32? = nil) async -> Bool {
|
|
89
|
+
guard let imageData = Data(base64Encoded: base64ImageData),
|
|
90
|
+
let image = UIImage(data: imageData)
|
|
91
|
+
else {
|
|
92
|
+
Bridge.log("NEX: Failed to decode base64 image payload")
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
guard let bmpData = convertUIImageToBmpData(image) else {
|
|
96
|
+
Bridge.log("NEX: Failed to convert UIImage to bitmap bytes")
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
let pixelWidth = image.cgImage?.width ?? Int(image.size.width * image.scale)
|
|
100
|
+
let pixelHeight = image.cgImage?.height ?? Int(image.size.height * image.scale)
|
|
101
|
+
displayBitmapData(bmpData, width: pixelWidth, height: pixelHeight)
|
|
102
|
+
return true
|
|
78
103
|
}
|
|
79
104
|
|
|
80
|
-
func showDashboard() {
|
|
105
|
+
func showDashboard() {
|
|
106
|
+
exit()
|
|
107
|
+
}
|
|
81
108
|
|
|
82
109
|
func setDashboardPosition(_ height: Int, _ depth: Int) {
|
|
83
110
|
// Same order as Android MentraNex: display_height then display_distance.
|
|
@@ -93,13 +120,19 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
93
120
|
updateGlassesDisplayDistance(depth: depth)
|
|
94
121
|
}
|
|
95
122
|
|
|
96
|
-
func setHeadUpAngle(_: Int) {
|
|
123
|
+
func setHeadUpAngle(_ angle: Int) {
|
|
124
|
+
updateGlassesHeadUpAngle(angle)
|
|
125
|
+
}
|
|
97
126
|
|
|
98
|
-
func getBatteryStatus() {
|
|
127
|
+
func getBatteryStatus() {
|
|
128
|
+
queryBatteryStatus()
|
|
129
|
+
}
|
|
99
130
|
|
|
100
131
|
func setSilentMode(_: Bool) {}
|
|
101
132
|
|
|
102
|
-
func exit() {
|
|
133
|
+
func exit() {
|
|
134
|
+
queueChunks([[0x18]], waitTimeMs: 100)
|
|
135
|
+
}
|
|
103
136
|
|
|
104
137
|
func sendShutdown() {}
|
|
105
138
|
|
|
@@ -110,17 +143,27 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
110
143
|
offDurationMs _: Int, count _: Int
|
|
111
144
|
) {}
|
|
112
145
|
|
|
113
|
-
func forget() {
|
|
146
|
+
func forget() {
|
|
147
|
+
destroy()
|
|
148
|
+
}
|
|
114
149
|
|
|
115
|
-
func connectById(_: String) {
|
|
150
|
+
func connectById(_ id: String) {
|
|
151
|
+
savePreferredDeviceId(id)
|
|
152
|
+
connect(name: id)
|
|
153
|
+
}
|
|
116
154
|
|
|
117
155
|
func getConnectedBluetoothName() -> String? {
|
|
118
|
-
|
|
156
|
+
peripheral?.name
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func cleanup() {
|
|
160
|
+
destroy()
|
|
119
161
|
}
|
|
120
162
|
|
|
121
|
-
func
|
|
163
|
+
func ping() {
|
|
164
|
+
Bridge.log("NEX: ping() is host-side no-op for this transport")
|
|
165
|
+
}
|
|
122
166
|
|
|
123
|
-
func ping() {}
|
|
124
167
|
func connectController() {}
|
|
125
168
|
func disconnectController() {}
|
|
126
169
|
|
|
@@ -165,10 +208,16 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
165
208
|
private var isDisconnecting = false
|
|
166
209
|
private var reconnectionTimer: Timer?
|
|
167
210
|
private var reconnectionAttempts = 0
|
|
168
|
-
// TODO: change this
|
|
169
211
|
private let maxReconnectionAttempts = -1 // -1 for unlimited
|
|
170
|
-
private let reconnectionInterval: TimeInterval =
|
|
212
|
+
private let reconnectionInterval: TimeInterval = 2.0
|
|
171
213
|
private var peripheralToConnectName: String?
|
|
214
|
+
/// True while the current scan is a user-initiated discovery scan (the "scan for devices"
|
|
215
|
+
/// list). Discovery must NOT short-circuit into reconnecting the last paired device by
|
|
216
|
+
/// stored UUID / saved name — otherwise, once a device has been connected, every later
|
|
217
|
+
/// discovery scan silently reconnects the old glasses instead of listing nearby devices.
|
|
218
|
+
private var isDiscoveryScan = false
|
|
219
|
+
private let INITIAL_CONNECTION_DELAY_MS: UInt64 = 350
|
|
220
|
+
private let DELAY_BETWEEN_CHUNKS_SEND_MS: UInt64 = 10
|
|
172
221
|
|
|
173
222
|
// Heartbeat tracking (like Java implementation)
|
|
174
223
|
private var heartbeatCount = 0
|
|
@@ -180,6 +229,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
180
229
|
private var micBeatCount = 0
|
|
181
230
|
private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes like Java
|
|
182
231
|
private var shouldUseGlassesMic = true
|
|
232
|
+
private var isMicrophoneEnabled = true
|
|
183
233
|
private var microphoneStateBeforeDisconnection = false
|
|
184
234
|
|
|
185
235
|
// Whitelist system (like Java implementation)
|
|
@@ -191,6 +241,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
191
241
|
|
|
192
242
|
/// Device discovery cache (like MentraLive)
|
|
193
243
|
private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
|
|
244
|
+
private var lastConnectionTimestamp: TimeInterval = 0
|
|
245
|
+
private var lastReceivedLc3Sequence = -1
|
|
246
|
+
private var currentImageChunks: [[UInt8]] = []
|
|
247
|
+
private var isImageSendProgressing = false
|
|
248
|
+
private var servicesReady = false
|
|
249
|
+
private var serviceReadyWaiters: [CheckedContinuation<Void, Never>] = []
|
|
250
|
+
private var pendingWriteContinuation: CheckedContinuation<Void, Never>?
|
|
194
251
|
|
|
195
252
|
// MARK: - Published Properties (G1-compatible)
|
|
196
253
|
|
|
@@ -271,9 +328,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
271
328
|
// MARK: - Singleton Access
|
|
272
329
|
|
|
273
330
|
@objc static func getInstance() -> MentraNexSGC {
|
|
274
|
-
if instance
|
|
275
|
-
|
|
331
|
+
if let existing = instance, existing.centralManager != nil {
|
|
332
|
+
return existing
|
|
276
333
|
}
|
|
334
|
+
instance = MentraNexSGC()
|
|
277
335
|
return instance!
|
|
278
336
|
}
|
|
279
337
|
|
|
@@ -295,6 +353,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
295
353
|
private var deviceMaxMTU = 23 // Device's maximum capability
|
|
296
354
|
private var maxChunkSize = 176 // Calculated optimal chunk size
|
|
297
355
|
private var bmpChunkSize = 176 // Image chunk size (iOS-optimized)
|
|
356
|
+
private var protobufSeq: UInt8 = 0 // Rolling sequence for fragmented control messages
|
|
298
357
|
|
|
299
358
|
// MARK: - Command Queue (modeled after ERG1Manager)
|
|
300
359
|
|
|
@@ -333,11 +392,33 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
333
392
|
continuations.append(continuation)
|
|
334
393
|
}
|
|
335
394
|
}
|
|
395
|
+
|
|
396
|
+
func clear() {
|
|
397
|
+
commands.removeAll()
|
|
398
|
+
}
|
|
336
399
|
}
|
|
337
400
|
|
|
338
401
|
private let commandQueue = CommandQueue()
|
|
339
402
|
private var isQueueWorkerRunning = false
|
|
340
403
|
|
|
404
|
+
// MARK: - Text wall coalescing (G2-style)
|
|
405
|
+
|
|
406
|
+
// Captions arrive per interim transcript (several/sec while speaking) but are
|
|
407
|
+
// only worth showing if they're the freshest text. A single latest-wins slot +
|
|
408
|
+
// a 100 ms drain ticker caps glasses-bound text writes at 10/sec and discards
|
|
409
|
+
// stale interim results instead of queueing them. Without this, continuous
|
|
410
|
+
// speech while the phone is locked backlogs the FIFO commandQueue (drain slows
|
|
411
|
+
// at relaxed connection intervals) and the app does unbounded background work.
|
|
412
|
+
// Mirrors G2's pendingTextMsg/drainEvenHubQueue. One repair resend covers a
|
|
413
|
+
// dropped final caption (writes are no-ack); mid-stream drops are repaired by
|
|
414
|
+
// the next update anyway.
|
|
415
|
+
private let textWallLock = NSLock()
|
|
416
|
+
private var pendingTextWall: Data?
|
|
417
|
+
private var lastTextWall: Data?
|
|
418
|
+
private var textWallResendsRemaining = 0
|
|
419
|
+
private let TEXT_WALL_RESEND_COUNT = 1
|
|
420
|
+
private var textWallDrainTask: Task<Void, Never>?
|
|
421
|
+
|
|
341
422
|
// MARK: - Initialization
|
|
342
423
|
|
|
343
424
|
override private init() {
|
|
@@ -360,6 +441,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
360
441
|
Bridge.log(
|
|
361
442
|
"NEX: 💾 Loaded saved device - Name: \(savedDeviceName ?? "None"), Address: \(savedDeviceAddress ?? "None")"
|
|
362
443
|
)
|
|
444
|
+
DeviceStore.shared.apply("glasses", "micEnabled", false)
|
|
363
445
|
}
|
|
364
446
|
|
|
365
447
|
private func setupCommandQueue() {
|
|
@@ -375,7 +457,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
375
457
|
}
|
|
376
458
|
}
|
|
377
459
|
|
|
378
|
-
private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int =
|
|
460
|
+
private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 10) {
|
|
379
461
|
let cmd = BufferedCommand(
|
|
380
462
|
chunks: chunks, waitTimeMs: waitTimeMs, chunkDelayMs: chunkDelayMs
|
|
381
463
|
)
|
|
@@ -384,59 +466,158 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
384
466
|
}
|
|
385
467
|
}
|
|
386
468
|
|
|
387
|
-
|
|
469
|
+
private func waitUntilServicesReady() async {
|
|
470
|
+
if servicesReady {
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
await withCheckedContinuation { continuation in
|
|
474
|
+
serviceReadyWaiters.append(continuation)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private func setServicesReady(_ ready: Bool) {
|
|
479
|
+
servicesReady = ready
|
|
480
|
+
guard ready else { return }
|
|
481
|
+
releaseServiceWaiters()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private func releaseServiceWaiters() {
|
|
485
|
+
let waiters = serviceReadyWaiters
|
|
486
|
+
serviceReadyWaiters.removeAll()
|
|
487
|
+
waiters.forEach { $0.resume() }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/// Suspends until CoreBluetooth signals it can accept another write-without-response
|
|
491
|
+
/// (resumed by peripheralIsReady(toSendWriteWithoutResponse:), or by the disconnect
|
|
492
|
+
/// handlers so an in-flight send can't wedge the queue across a drop).
|
|
493
|
+
private func waitUntilReadyToWrite() async {
|
|
494
|
+
await withCheckedContinuation { continuation in
|
|
495
|
+
pendingWriteContinuation = continuation
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private func resumePendingWrite() {
|
|
500
|
+
pendingWriteContinuation?.resume()
|
|
501
|
+
pendingWriteContinuation = nil
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private func emitBleCommandSent(_ packetData: Data) {
|
|
505
|
+
guard packetData.first == PACKET_TYPE_PROTOBUF else { return }
|
|
506
|
+
guard packetData.count > 1 else { return }
|
|
507
|
+
let payload = packetData.subdata(in: 1 ..< packetData.count)
|
|
508
|
+
let commandName: String
|
|
509
|
+
if let phoneToGlasses = try? Mentraos_Ble_PhoneToGlasses(serializedData: payload) {
|
|
510
|
+
commandName = String(describing: phoneToGlasses.payload)
|
|
511
|
+
} else {
|
|
512
|
+
commandName = "UNKNOWN"
|
|
513
|
+
}
|
|
514
|
+
Bridge.sendTypedMessage("send_command_to_ble", body: [
|
|
515
|
+
"command": commandName,
|
|
516
|
+
"commandText": packetData.toHexString(),
|
|
517
|
+
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
518
|
+
])
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private func emitBleCommandReceived(_ packetData: Data, payloadDescription: String) {
|
|
522
|
+
Bridge.sendTypedMessage("receive_command_from_ble", body: [
|
|
523
|
+
"command": payloadDescription,
|
|
524
|
+
"commandText": packetData.toHexString(),
|
|
525
|
+
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
526
|
+
])
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/// Splits a serialized protobuf message into BLE-sized fragments, each prefixed with a
|
|
530
|
+
/// 4-byte transport header [packetType][seq][totalChunks][chunkIndex] so the firmware can
|
|
531
|
+
/// reassemble messages larger than one MTU. Single-fragment messages set totalChunks = 1
|
|
532
|
+
/// and are decoded directly by the firmware's fast path.
|
|
388
533
|
private func queueDataWithOptimalChunking(
|
|
389
|
-
_ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0
|
|
534
|
+
_ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0, emitTelemetry: Bool = true
|
|
390
535
|
) {
|
|
391
|
-
|
|
392
|
-
|
|
536
|
+
// Telemetry: report the full logical command (type byte + protobuf) before fragmenting.
|
|
537
|
+
// Callers on high-rate paths (caption text walls, up to 10/sec) pass
|
|
538
|
+
// emitTelemetry: false — emitBleCommandSent re-parses the protobuf,
|
|
539
|
+
// hex-dumps the packet, and crosses to the JS thread per call.
|
|
540
|
+
if emitTelemetry {
|
|
541
|
+
var packetData = Data([packetType])
|
|
542
|
+
packetData.append(data)
|
|
543
|
+
emitBleCommandSent(packetData)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let headerSize = 4 // [packetType][seq][totalChunks][chunkIndex]
|
|
547
|
+
let effectiveChunkSize = max(1, maxChunkSize - headerSize)
|
|
548
|
+
let totalChunks = data.isEmpty
|
|
549
|
+
? 1 : Int(ceil(Double(data.count) / Double(effectiveChunkSize)))
|
|
550
|
+
|
|
551
|
+
guard totalChunks <= 255 else {
|
|
552
|
+
Bridge.log(
|
|
553
|
+
"NEX: ❌ Protobuf message too large to fragment (\(totalChunks) chunks) - dropping"
|
|
554
|
+
)
|
|
555
|
+
return
|
|
556
|
+
}
|
|
393
557
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
packetData.append(data)
|
|
558
|
+
let seq = protobufSeq
|
|
559
|
+
protobufSeq = protobufSeq &+ 1
|
|
397
560
|
|
|
398
|
-
|
|
561
|
+
var chunks: [[UInt8]] = []
|
|
399
562
|
var offset = 0
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
let
|
|
403
|
-
|
|
404
|
-
offset
|
|
563
|
+
var index = 0
|
|
564
|
+
while offset < data.count || (index == 0 && data.isEmpty) {
|
|
565
|
+
let end = min(offset + effectiveChunkSize, data.count)
|
|
566
|
+
var frame: [UInt8] = [packetType, seq, UInt8(totalChunks), UInt8(index)]
|
|
567
|
+
if end > offset {
|
|
568
|
+
frame.append(contentsOf: data.subdata(in: offset ..< end))
|
|
569
|
+
}
|
|
570
|
+
chunks.append(frame)
|
|
571
|
+
offset = end
|
|
572
|
+
index += 1
|
|
405
573
|
}
|
|
406
574
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
575
|
+
// No per-send log: fires for every outbound message (up to 10/sec text
|
|
576
|
+
// walls during captions).
|
|
577
|
+
// Bridge.log(
|
|
578
|
+
// "NEX: 📦 Fragmented protobuf into \(chunks.count) chunk(s) (seq=\(seq), max payload \(effectiveChunkSize) bytes)"
|
|
579
|
+
// )
|
|
410
580
|
queueChunks(chunks, waitTimeMs: waitTimeMs)
|
|
411
581
|
}
|
|
412
582
|
|
|
413
|
-
/// Helper method for queueing chunks with optional wait time
|
|
414
|
-
private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0) {
|
|
415
|
-
let cmd = BufferedCommand(chunks: chunks, waitTimeMs: waitTimeMs, chunkDelayMs: 8)
|
|
416
|
-
Task { [weak self] in
|
|
417
|
-
await self?.commandQueue.enqueue(cmd)
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
583
|
private func processCommand(_ command: BufferedCommand) async {
|
|
422
584
|
guard let peripheral, let writeCharacteristic else {
|
|
423
585
|
Bridge.log("NEX: ⚠️ processCommand: peripheral/characteristic not ready")
|
|
424
586
|
return
|
|
425
587
|
}
|
|
426
588
|
|
|
589
|
+
await waitUntilServicesReady()
|
|
590
|
+
|
|
427
591
|
// Send each chunk sequentially
|
|
428
592
|
for (index, chunk) in command.chunks.enumerated() {
|
|
593
|
+
// let timeSinceConnection = Date().timeIntervalSince1970 * 1000 - lastConnectionTimestamp
|
|
594
|
+
// if timeSinceConnection < Double(INITIAL_CONNECTION_DELAY_MS) {
|
|
595
|
+
// let remainingMs = UInt64(Double(INITIAL_CONNECTION_DELAY_MS) - timeSinceConnection)
|
|
596
|
+
// try? await Task.sleep(nanoseconds: remainingMs * 1_000_000)
|
|
597
|
+
// }
|
|
429
598
|
let data = Data(chunk)
|
|
430
|
-
Bridge.log(
|
|
431
|
-
|
|
432
|
-
)
|
|
433
|
-
Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
599
|
+
// Bridge.log(
|
|
600
|
+
// "NEX: 📦 Sending chunk \(index) of \(command.chunks.count) to \(peripheral.name ?? "Unknown")"
|
|
601
|
+
// )
|
|
602
|
+
// Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
|
|
603
|
+
// // Write WITHOUT response: a write-with-response is one outstanding request at a
|
|
604
|
+
// // time gated on a remote round trip, which puts the (screen-off-throttled) app
|
|
605
|
+
// // thread in the path of every fragment and makes captions crawl in the background.
|
|
606
|
+
// // .withoutResponse lets CoreBluetooth batch fragments into connection events.
|
|
607
|
+
// // CoreBluetooth does NOT call didWriteValueFor for .withoutResponse, so we use its
|
|
608
|
+
// // flow-control flag instead of an ack — gating prevents dropped fragments (which
|
|
609
|
+
// // would make the firmware discard the whole reassembled caption). See
|
|
610
|
+
// // docs/ble-disconnects-during-captions.md in the firmware repo.
|
|
611
|
+
// if !peripheral.canSendWriteWithoutResponse {
|
|
612
|
+
// await waitUntilReadyToWrite()
|
|
613
|
+
// }
|
|
614
|
+
peripheral.writeValue(data, for: writeCharacteristic, type: .withoutResponse)
|
|
615
|
+
|
|
616
|
+
// // Delay between chunks except maybe after the last chunk if waitTime will handle it
|
|
617
|
+
// if index < command.chunks.count - 1 {
|
|
618
|
+
// try? await Task.sleep(nanoseconds: UInt64(command.chunkDelayMs) * 1_000_000)
|
|
619
|
+
// }
|
|
620
|
+
// try? await Task.sleep(nanoseconds: DELAY_BETWEEN_CHUNKS_SEND_MS * 1_000_000)
|
|
440
621
|
}
|
|
441
622
|
|
|
442
623
|
// Optional wait after the command
|
|
@@ -493,21 +674,14 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
493
674
|
// MARK: - Enhanced Device Filtering (ported from Java)
|
|
494
675
|
|
|
495
676
|
private func isCompatibleNexDevice(_ deviceName: String) -> Bool {
|
|
496
|
-
//
|
|
677
|
+
// Keep in parity with Android MentraNex.kt scan filter.
|
|
497
678
|
let compatiblePrefixes = [
|
|
498
|
-
"
|
|
499
|
-
|
|
500
|
-
// "NEX",
|
|
501
|
-
// "Nex",
|
|
502
|
-
// "MentraNex",
|
|
503
|
-
// "MENTRA_NEX",
|
|
504
|
-
// "Xy_A", // Legacy support
|
|
505
|
-
// "XyBLE_", // Legacy support
|
|
506
|
-
// "MENTRA_LIVE", // Cross-compatibility
|
|
679
|
+
"Nex1-",
|
|
680
|
+
"MENTRA_DISPLAY_",
|
|
507
681
|
]
|
|
508
682
|
|
|
509
683
|
for prefix in compatiblePrefixes {
|
|
510
|
-
if deviceName.
|
|
684
|
+
if deviceName.contains(prefix) {
|
|
511
685
|
Bridge.log("NEX: ✅ Device '\(deviceName)' matches compatible prefix: \(prefix)")
|
|
512
686
|
return true
|
|
513
687
|
}
|
|
@@ -522,6 +696,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
522
696
|
"Mentra_([0-9A-Fa-f]+)",
|
|
523
697
|
"NEX_([0-9A-Fa-f]+)",
|
|
524
698
|
"MENTRA_NEX_([0-9A-Fa-f]+)",
|
|
699
|
+
"MENTRA_DISPLAY_([0-9A-Fa-f]+)",
|
|
525
700
|
]
|
|
526
701
|
|
|
527
702
|
for pattern in patterns {
|
|
@@ -549,6 +724,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
549
724
|
stopScan()
|
|
550
725
|
}
|
|
551
726
|
peripheralToConnectName = name
|
|
727
|
+
isDiscoveryScan = false
|
|
552
728
|
startScan()
|
|
553
729
|
}
|
|
554
730
|
|
|
@@ -568,19 +744,36 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
568
744
|
)
|
|
569
745
|
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [uuid])
|
|
570
746
|
|
|
571
|
-
|
|
747
|
+
guard let peripheralToConnect = peripherals.first else {
|
|
572
748
|
Bridge.log(
|
|
573
|
-
"NEX-CONN: 🔵
|
|
749
|
+
"NEX-CONN: 🔵 Could not find peripheral for stored UUID. Will proceed to scan."
|
|
574
750
|
)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
751
|
+
return false
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// The stored UUID is a single "last connected" identifier. When the caller is
|
|
755
|
+
// targeting a specific device by name, only take this fast-path if the cached
|
|
756
|
+
// peripheral IS that device — otherwise we'd silently reconnect the *previously*
|
|
757
|
+
// paired glasses, and since iOS's connect() never times out and startScan()
|
|
758
|
+
// returns early on success, pairing the new device would hang forever.
|
|
759
|
+
// (Mirrors Android, which reconnects to the target device's own address, never a
|
|
760
|
+
// global "last" one. peripheralToConnectName == nil means an auto-reconnect with no
|
|
761
|
+
// specific target, so the cached device is exactly what we want — keep using it.)
|
|
762
|
+
if let targetName = peripheralToConnectName,
|
|
763
|
+
!(peripheralToConnect.name?.contains(targetName) ?? false)
|
|
764
|
+
{
|
|
579
765
|
Bridge.log(
|
|
580
|
-
"NEX-CONN: 🔵
|
|
766
|
+
"NEX-CONN: 🔵 Stored UUID is '\(peripheralToConnect.name ?? "unnamed")' but target is '\(targetName)'. Skipping UUID fast-path; will scan for the target."
|
|
581
767
|
)
|
|
582
768
|
return false
|
|
583
769
|
}
|
|
770
|
+
|
|
771
|
+
Bridge.log(
|
|
772
|
+
"NEX-CONN: 🔵 Found peripheral by UUID: \(peripheralToConnect.name ?? "Unknown"). Initiating connection."
|
|
773
|
+
)
|
|
774
|
+
peripheral = peripheralToConnect
|
|
775
|
+
centralManager.connect(peripheralToConnect, options: nil)
|
|
776
|
+
return true
|
|
584
777
|
}
|
|
585
778
|
|
|
586
779
|
private func startReconnectionTimer() {
|
|
@@ -622,6 +815,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
622
815
|
|
|
623
816
|
reconnectionAttempts += 1
|
|
624
817
|
Bridge.log("NEX-CONN: 🔄 Attempting reconnection (\(reconnectionAttempts))...")
|
|
818
|
+
isDiscoveryScan = false
|
|
625
819
|
startScan()
|
|
626
820
|
}
|
|
627
821
|
|
|
@@ -644,41 +838,48 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
644
838
|
return
|
|
645
839
|
}
|
|
646
840
|
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [
|
|
655
|
-
MAIN_SERVICE_UUID,
|
|
656
|
-
])
|
|
657
|
-
if let targetName = peripheralToConnectName,
|
|
658
|
-
let existingPeripheral = connectedPeripherals.first(where: {
|
|
659
|
-
$0.name?.contains(targetName) == true
|
|
660
|
-
})
|
|
661
|
-
{
|
|
662
|
-
Bridge.log(
|
|
663
|
-
"NEX-CONN: 📱 Found already connected peripheral that matches target: \(existingPeripheral.name ?? "Unknown")"
|
|
664
|
-
)
|
|
665
|
-
if peripheral == nil {
|
|
666
|
-
peripheral = existingPeripheral
|
|
667
|
-
centralManager.connect(existingPeripheral, options: nil)
|
|
841
|
+
// Reconnect short-circuits below are for the targeted connect/reconnect paths only.
|
|
842
|
+
// A user-initiated discovery scan must fall through to scanForPeripherals so the
|
|
843
|
+
// device list populates even after we've previously paired (which persisted a UUID).
|
|
844
|
+
if !isDiscoveryScan {
|
|
845
|
+
// First, try to reconnect using stored UUID (faster and works in background)
|
|
846
|
+
if connectByUUID() {
|
|
847
|
+
Bridge.log("NEX-CONN: 🔄 Attempting connection with stored UUID. Halting scan.")
|
|
668
848
|
return
|
|
669
849
|
}
|
|
670
|
-
}
|
|
671
850
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
851
|
+
// If that fails, check for already-connected system devices
|
|
852
|
+
let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [
|
|
853
|
+
MAIN_SERVICE_UUID,
|
|
854
|
+
])
|
|
855
|
+
if let targetName = peripheralToConnectName,
|
|
856
|
+
let existingPeripheral = connectedPeripherals.first(where: {
|
|
857
|
+
$0.name?.contains(targetName) == true
|
|
858
|
+
})
|
|
859
|
+
{
|
|
860
|
+
Bridge.log(
|
|
861
|
+
"NEX-CONN: 📱 Found already connected peripheral that matches target: \(existingPeripheral.name ?? "Unknown")"
|
|
862
|
+
)
|
|
863
|
+
if peripheral == nil {
|
|
864
|
+
peripheral = existingPeripheral
|
|
865
|
+
centralManager.connect(existingPeripheral, options: nil)
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Check if we have a saved device name to reconnect to (like MentraLive)
|
|
871
|
+
if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
|
|
872
|
+
!savedDeviceName.isEmpty
|
|
873
|
+
{
|
|
874
|
+
Bridge.log("NEX-CONN: 🔄 Looking for saved device: \(savedDeviceName)")
|
|
875
|
+
// This will be handled in didDiscover when the device is found
|
|
876
|
+
}
|
|
678
877
|
}
|
|
679
878
|
|
|
680
879
|
Bridge.log("NEX-CONN: ✅ Bluetooth is powered on, starting scan...")
|
|
681
880
|
_isScanning = true
|
|
881
|
+
connectionState = ConnTypes.SCANNING
|
|
882
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
|
|
682
883
|
|
|
683
884
|
// Scan for ALL devices, not just those with specific services
|
|
684
885
|
// Use same options as G1 scanner for consistency
|
|
@@ -706,6 +907,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
706
907
|
@objc func stopScan() {
|
|
707
908
|
centralManager?.stopScan()
|
|
708
909
|
_isScanning = false
|
|
910
|
+
// The flag describes the scan that's currently running; once it stops (10s discovery
|
|
911
|
+
// timeout, manual stop, or the stop inside connect()), clear it so a later
|
|
912
|
+
// reconnect/autoconnect scan isn't wrongly treated as discovery and suppressed.
|
|
913
|
+
isDiscoveryScan = false
|
|
709
914
|
Bridge.log("NEX-CONN: 🛑 Stopped scanning.")
|
|
710
915
|
}
|
|
711
916
|
|
|
@@ -725,6 +930,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
725
930
|
return "connecting"
|
|
726
931
|
case ConnTypes.CONNECTED:
|
|
727
932
|
return "connected"
|
|
933
|
+
case ConnTypes.SCANNING:
|
|
934
|
+
return "scanning"
|
|
728
935
|
default:
|
|
729
936
|
return "disconnected"
|
|
730
937
|
}
|
|
@@ -755,11 +962,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
755
962
|
}
|
|
756
963
|
|
|
757
964
|
@objc func findCompatibleDevices() {
|
|
758
|
-
Bridge.log("NEX-DISCOVERY: Finding compatible devices.
|
|
965
|
+
Bridge.log("NEX-DISCOVERY: Finding compatible devices.")
|
|
759
966
|
|
|
760
|
-
// Clear
|
|
967
|
+
// Clear specific connect target, but keep saved pairing data like Android.
|
|
761
968
|
peripheralToConnectName = nil
|
|
762
|
-
|
|
969
|
+
// Pure discovery: don't let startScan short-circuit into reconnecting the last
|
|
970
|
+
// paired device, and don't auto-connect a saved device found mid-scan.
|
|
971
|
+
isDiscoveryScan = true
|
|
763
972
|
|
|
764
973
|
Task {
|
|
765
974
|
if centralManager == nil {
|
|
@@ -773,6 +982,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
773
982
|
|
|
774
983
|
if centralManager?.state == .poweredOn {
|
|
775
984
|
startScan()
|
|
985
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
|
|
986
|
+
self?.stopScan()
|
|
987
|
+
}
|
|
776
988
|
} else {
|
|
777
989
|
Bridge.log("NEX-DISCOVERY: Bluetooth not ready, will scan on power on.")
|
|
778
990
|
scanOnPowerOn = true
|
|
@@ -780,16 +992,28 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
780
992
|
}
|
|
781
993
|
}
|
|
782
994
|
|
|
783
|
-
func
|
|
995
|
+
private func sanitizeDisplayText(_ text: String) -> String {
|
|
996
|
+
let normalized = text.replacingOccurrences(of: "—", with: "-")
|
|
997
|
+
let pattern = #"[^A-Za-z0-9 \r\n\.,!\?;:\-\[\]\(\)\{\}'"\+=/]"#
|
|
998
|
+
return normalized.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
func sendTextWall(_ text: String) async {
|
|
784
1002
|
guard nexReady else {
|
|
785
1003
|
Bridge.log("NEX: Not ready to display text. Device not initialized.")
|
|
786
1004
|
return
|
|
787
1005
|
}
|
|
788
1006
|
|
|
789
|
-
|
|
1007
|
+
// When Chinese captions are disabled (default), strip characters the Nex font
|
|
1008
|
+
// can't render (CJK etc.). When enabled, pass the text through unmodified.
|
|
1009
|
+
let chineseCaptionsEnabled = DeviceStore.shared.get("bluetooth", "nex_chinese_captions") as? Bool ?? false
|
|
1010
|
+
let sanitizedText = chineseCaptionsEnabled ? text : sanitizeDisplayText(text)
|
|
1011
|
+
// No per-call log: this runs for every interim transcript (several/sec
|
|
1012
|
+
// during continuous speech) and each Bridge.log costs the JS thread.
|
|
1013
|
+
// Bridge.log("NEX: Displaying text wall: '\(sanitizedText)'")
|
|
790
1014
|
|
|
791
1015
|
let displayText = Mentraos_Ble_DisplayText.with {
|
|
792
|
-
$0.text =
|
|
1016
|
+
$0.text = sanitizedText
|
|
793
1017
|
$0.size = 48
|
|
794
1018
|
$0.x = 20
|
|
795
1019
|
$0.y = 260
|
|
@@ -801,26 +1025,69 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
801
1025
|
}
|
|
802
1026
|
|
|
803
1027
|
let protobufData = try! phoneToGlasses.serializedData()
|
|
804
|
-
|
|
1028
|
+
// Latest-wins: overwrite the pending slot; the 100 ms drain ticker sends it.
|
|
1029
|
+
// Do NOT enqueue on commandQueue — that's what backlogs under continuous speech.
|
|
1030
|
+
textWallLock.lock()
|
|
1031
|
+
pendingTextWall = protobufData
|
|
1032
|
+
textWallLock.unlock()
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private func startTextWallDrain() {
|
|
1036
|
+
textWallDrainTask?.cancel()
|
|
1037
|
+
textWallDrainTask = Task { [weak self] in
|
|
1038
|
+
while !Task.isCancelled {
|
|
1039
|
+
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
1040
|
+
guard !Task.isCancelled else { break }
|
|
1041
|
+
self?.drainPendingTextWall()
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private func stopTextWallDrain() {
|
|
1047
|
+
textWallDrainTask?.cancel()
|
|
1048
|
+
textWallDrainTask = nil
|
|
1049
|
+
textWallLock.lock()
|
|
1050
|
+
pendingTextWall = nil
|
|
1051
|
+
lastTextWall = nil
|
|
1052
|
+
textWallResendsRemaining = 0
|
|
1053
|
+
textWallLock.unlock()
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private func drainPendingTextWall() {
|
|
1057
|
+
textWallLock.lock()
|
|
1058
|
+
let msg = pendingTextWall
|
|
1059
|
+
pendingTextWall = nil
|
|
1060
|
+
var toSend: Data?
|
|
1061
|
+
if let msg {
|
|
1062
|
+
lastTextWall = msg
|
|
1063
|
+
textWallResendsRemaining = TEXT_WALL_RESEND_COUNT
|
|
1064
|
+
toSend = msg
|
|
1065
|
+
} else if textWallResendsRemaining > 0, let last = lastTextWall {
|
|
1066
|
+
textWallResendsRemaining -= 1
|
|
1067
|
+
toSend = last
|
|
1068
|
+
}
|
|
1069
|
+
textWallLock.unlock()
|
|
1070
|
+
guard let toSend else { return }
|
|
1071
|
+
queueDataWithOptimalChunking(toSend, packetType: PACKET_TYPE_PROTOBUF, emitTelemetry: false)
|
|
805
1072
|
}
|
|
806
1073
|
|
|
807
1074
|
@objc func displayTextLine(_ text: String) {
|
|
808
|
-
sendTextWall(text)
|
|
1075
|
+
Task { await sendTextWall(text) }
|
|
809
1076
|
}
|
|
810
1077
|
|
|
811
1078
|
@objc func displayDoubleTextWall(_ textTop: String, textBottom: String) {
|
|
812
|
-
let combinedText = "\(textTop)\n\
|
|
813
|
-
sendTextWall(combinedText)
|
|
1079
|
+
let combinedText = "\(textTop)\n\(textBottom)"
|
|
1080
|
+
Task { await sendTextWall(combinedText) }
|
|
814
1081
|
}
|
|
815
1082
|
|
|
816
1083
|
@objc func displayReferenceCardSimple(_ title: String, body: String) {
|
|
817
1084
|
let combinedText = "\(title)\n\n\(body)"
|
|
818
|
-
sendTextWall(combinedText)
|
|
1085
|
+
Task { await sendTextWall(combinedText) }
|
|
819
1086
|
}
|
|
820
1087
|
|
|
821
1088
|
@objc func displayRowsCard(_ rowStrings: [String]) {
|
|
822
1089
|
let combinedText = rowStrings.joined(separator: "\n")
|
|
823
|
-
sendTextWall(combinedText)
|
|
1090
|
+
Task { await sendTextWall(combinedText) }
|
|
824
1091
|
}
|
|
825
1092
|
|
|
826
1093
|
@objc func displayBulletList(_ title: String, bullets: [String]) {
|
|
@@ -829,7 +1096,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
829
1096
|
text += "\n"
|
|
830
1097
|
}
|
|
831
1098
|
text += bullets.map { "• \($0)" }.joined(separator: "\n")
|
|
832
|
-
sendTextWall(text)
|
|
1099
|
+
Task { await sendTextWall(text) }
|
|
833
1100
|
}
|
|
834
1101
|
|
|
835
1102
|
@objc func displayScrollingText(_ text: String) {
|
|
@@ -944,6 +1211,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
944
1211
|
}
|
|
945
1212
|
|
|
946
1213
|
Bridge.log("NEX: Sending \(chunks.count) image chunks")
|
|
1214
|
+
currentImageChunks = chunks
|
|
1215
|
+
isImageSendProgressing = true
|
|
947
1216
|
queueChunks(chunks, waitTimeMs: 50)
|
|
948
1217
|
}
|
|
949
1218
|
|
|
@@ -988,6 +1257,14 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
988
1257
|
|
|
989
1258
|
Bridge.log("NEX: Clearing display")
|
|
990
1259
|
|
|
1260
|
+
// Drop any pending/resendable text wall so a stale caption can't
|
|
1261
|
+
// repaint the display after this clear.
|
|
1262
|
+
textWallLock.lock()
|
|
1263
|
+
pendingTextWall = nil
|
|
1264
|
+
lastTextWall = nil
|
|
1265
|
+
textWallResendsRemaining = 0
|
|
1266
|
+
textWallLock.unlock()
|
|
1267
|
+
|
|
991
1268
|
let clearDisplay = Mentraos_Ble_ClearDisplay()
|
|
992
1269
|
|
|
993
1270
|
let phoneToGlasses = Mentraos_Ble_PhoneToGlasses.with {
|
|
@@ -1006,11 +1283,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1006
1283
|
@objc func showHomeScreen() {
|
|
1007
1284
|
Bridge.log("NEX: Showing home screen")
|
|
1008
1285
|
clearDisplay()
|
|
1009
|
-
|
|
1010
|
-
// Send a simple home screen text
|
|
1011
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
1012
|
-
self.sendTextWall("MentraOS Ready")
|
|
1013
|
-
}
|
|
1014
1286
|
}
|
|
1015
1287
|
|
|
1016
1288
|
@objc func exitAllFunctions() {
|
|
@@ -1034,9 +1306,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1034
1306
|
return
|
|
1035
1307
|
}
|
|
1036
1308
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1309
|
+
let validBrightness: Int
|
|
1310
|
+
if brightness != -1 {
|
|
1311
|
+
validBrightness = (max(0, min(100, brightness)) * 63) / 100
|
|
1312
|
+
} else {
|
|
1313
|
+
validBrightness = (30 * 63) / 100
|
|
1314
|
+
}
|
|
1315
|
+
Bridge.log("NEX: Setting brightness to wire value \(validBrightness)")
|
|
1040
1316
|
|
|
1041
1317
|
let brightnessConfig = Mentraos_Ble_BrightnessConfig.with {
|
|
1042
1318
|
$0.value = UInt32(validBrightness)
|
|
@@ -1135,7 +1411,33 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1135
1411
|
queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
|
|
1136
1412
|
}
|
|
1137
1413
|
|
|
1414
|
+
func sendVoiceActivityDetectionSetting() {
|
|
1415
|
+
let enabled = DeviceStore.shared.get("bluetooth", "voice_activity_detection_enabled") as? Bool
|
|
1416
|
+
?? BluetoothSdkDefaults.voiceActivityDetectionEnabled
|
|
1417
|
+
Bridge.log("NEX: 🎤 Sending Voice Activity Detection setting to glasses: \(enabled)")
|
|
1418
|
+
|
|
1419
|
+
guard nexReady else {
|
|
1420
|
+
Bridge.log("NEX: Not ready to send VAD setting. Device not initialized.")
|
|
1421
|
+
return
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
let vadConfig = Mentraos_Ble_VadEnabledConfig.with {
|
|
1425
|
+
$0.enabled = enabled
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
let phoneToGlasses = Mentraos_Ble_PhoneToGlasses.with {
|
|
1429
|
+
$0.vadEnabled = vadConfig
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
let protobufData = try! phoneToGlasses.serializedData()
|
|
1433
|
+
queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
|
|
1434
|
+
Bridge.sendVoiceActivityDetectionStatus(enabled)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1138
1437
|
@objc func setMicrophoneEnabled(_ enabled: Bool) {
|
|
1438
|
+
isMicrophoneEnabled = enabled
|
|
1439
|
+
DeviceStore.shared.apply("glasses", "micEnabled", enabled)
|
|
1440
|
+
|
|
1139
1441
|
guard nexReady else {
|
|
1140
1442
|
Bridge.log("NEX: Not ready to set microphone state. Device not initialized.")
|
|
1141
1443
|
return
|
|
@@ -1211,28 +1513,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1211
1513
|
|
|
1212
1514
|
// MARK: - Utility Methods
|
|
1213
1515
|
|
|
1214
|
-
@objc func sendPongResponse() {
|
|
1215
|
-
guard nexReady else {
|
|
1216
|
-
Bridge.log("NEX: Not ready to send pong. Device not initialized.")
|
|
1217
|
-
return
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
let timestamp = Date().timeIntervalSince1970 * 1000
|
|
1221
|
-
Bridge.log("NEX: Sending pong response (Time: \(timestamp))")
|
|
1222
|
-
|
|
1223
|
-
let pongResponse = Mentraos_Ble_PongResponse()
|
|
1224
|
-
|
|
1225
|
-
let phoneToGlasses = Mentraos_Ble_PhoneToGlasses.with {
|
|
1226
|
-
$0.pong = pongResponse
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
let protobufData = try! phoneToGlasses.serializedData()
|
|
1230
|
-
queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
|
|
1231
|
-
|
|
1232
|
-
// Notify about heartbeat sent (pong response)
|
|
1233
|
-
notifyHeartbeatSent(timestamp)
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
1516
|
@objc func isDeviceReady() -> Bool {
|
|
1237
1517
|
nexReady && connectionState == ConnTypes.CONNECTED
|
|
1238
1518
|
}
|
|
@@ -1254,7 +1534,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1254
1534
|
|
|
1255
1535
|
@objc func displayCustomContent(_ content: String) {
|
|
1256
1536
|
// For now, treat custom content as regular text
|
|
1257
|
-
sendTextWall(content)
|
|
1537
|
+
Task { await sendTextWall(content) }
|
|
1258
1538
|
}
|
|
1259
1539
|
|
|
1260
1540
|
@objc func setUpdatingScreen(_ updating: Bool) {
|
|
@@ -1321,8 +1601,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1321
1601
|
handleDeviceInfoJson(json)
|
|
1322
1602
|
case "button_event":
|
|
1323
1603
|
handleButtonEventJson(json)
|
|
1324
|
-
case "ping":
|
|
1325
|
-
handlePingJson(json)
|
|
1326
1604
|
case "vad_event":
|
|
1327
1605
|
handleVadEventJson(json)
|
|
1328
1606
|
case "imu_data":
|
|
@@ -1340,7 +1618,12 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1340
1618
|
private func processProtobufData(_ protobufData: Data) {
|
|
1341
1619
|
do {
|
|
1342
1620
|
let glassesToPhone = try Mentraos_Ble_GlassesToPhone(serializedData: protobufData)
|
|
1343
|
-
|
|
1621
|
+
// No per-message log: String(describing: payload) stringifies the whole
|
|
1622
|
+
// protobuf and every Bridge.log costs the JS thread.
|
|
1623
|
+
// Bridge.log("NEX: Processing protobuf payload case: \(glassesToPhone.payload)")
|
|
1624
|
+
|
|
1625
|
+
let fullPacket = Data([PACKET_TYPE_PROTOBUF]) + protobufData
|
|
1626
|
+
emitBleCommandReceived(fullPacket, payloadDescription: String(describing: glassesToPhone.payload))
|
|
1344
1627
|
|
|
1345
1628
|
switch glassesToPhone.payload {
|
|
1346
1629
|
case let .batteryStatus(batteryStatus):
|
|
@@ -1358,9 +1641,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1358
1641
|
case let .headUpAngleSet(headUpAngleResponse):
|
|
1359
1642
|
handleHeadUpAngleResponseProtobuf(headUpAngleResponse)
|
|
1360
1643
|
|
|
1361
|
-
case let .ping(pingRequest):
|
|
1362
|
-
handlePingProtobuf(pingRequest)
|
|
1363
|
-
|
|
1364
1644
|
case let .vadEvent(vadEvent):
|
|
1365
1645
|
handleVadEventProtobuf(vadEvent)
|
|
1366
1646
|
|
|
@@ -1391,9 +1671,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1391
1671
|
}
|
|
1392
1672
|
|
|
1393
1673
|
private func processAudioData(_ audioData: Data, sequenceNumber: UInt8) {
|
|
1394
|
-
Bridge.log
|
|
1395
|
-
|
|
1396
|
-
)
|
|
1674
|
+
// No per-packet log: fires 20x/sec while the mic streams; each Bridge.log
|
|
1675
|
+
// costs the JS thread. Keep only the sequence-mismatch log below (rare,
|
|
1676
|
+
// fires on actual packet loss).
|
|
1677
|
+
if lastReceivedLc3Sequence != -1, UInt8((lastReceivedLc3Sequence + 1) & 0xFF) != sequenceNumber {
|
|
1678
|
+
Bridge.log("NEX: LC3 packet sequence mismatch. Expected \((lastReceivedLc3Sequence + 1) & 0xFF), got \(sequenceNumber)")
|
|
1679
|
+
}
|
|
1680
|
+
lastReceivedLc3Sequence = Int(sequenceNumber)
|
|
1397
1681
|
|
|
1398
1682
|
// Update @Published property (G1-compatible approach)
|
|
1399
1683
|
// Create packet with sequence number prefix like G1 expects
|
|
@@ -1402,6 +1686,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1402
1686
|
packetData.append(audioData)
|
|
1403
1687
|
|
|
1404
1688
|
compressedVoiceData = packetData
|
|
1689
|
+
DeviceManager.shared.handleGlassesMicData(audioData, 40)
|
|
1405
1690
|
}
|
|
1406
1691
|
|
|
1407
1692
|
private func processImageData(_ imageData: Data) {
|
|
@@ -1463,33 +1748,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1463
1748
|
emitEvent("HeadUpAngleResponseEvent", body: eventBody)
|
|
1464
1749
|
}
|
|
1465
1750
|
|
|
1466
|
-
private func handlePingProtobuf(_: Mentraos_Ble_PingRequest) {
|
|
1467
|
-
let timestamp = Date().timeIntervalSince1970 * 1000
|
|
1468
|
-
|
|
1469
|
-
Bridge.log("NEX: 💓 Received PING from glasses (Time: \(timestamp))")
|
|
1470
|
-
|
|
1471
|
-
// Automatically send pong response
|
|
1472
|
-
sendPongResponse()
|
|
1473
|
-
|
|
1474
|
-
// Emit heartbeat received event
|
|
1475
|
-
let eventBody: [String: Any] = [
|
|
1476
|
-
"heartbeat_received": [
|
|
1477
|
-
"timestamp": timestamp,
|
|
1478
|
-
"device_model": "Mentra Display",
|
|
1479
|
-
],
|
|
1480
|
-
]
|
|
1481
|
-
|
|
1482
|
-
emitEvent("HeartbeatReceivedEvent", body: eventBody)
|
|
1483
|
-
|
|
1484
|
-
// Query battery status periodically (every 10 pings like Java implementation)
|
|
1485
|
-
heartbeatCount += 1
|
|
1486
|
-
if heartbeatCount % 10 == 0 {
|
|
1487
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
1488
|
-
self.queryBatteryStatus()
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
1751
|
private func handleVadEventProtobuf(_ vadEvent: Mentraos_Ble_VadEvent) {
|
|
1494
1752
|
let vadActiveState = vadEvent.state == .active
|
|
1495
1753
|
|
|
@@ -1511,11 +1769,12 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1511
1769
|
switch status {
|
|
1512
1770
|
case .ok:
|
|
1513
1771
|
Bridge.log("NEX: Image transfer completed successfully")
|
|
1514
|
-
|
|
1772
|
+
currentImageChunks.removeAll()
|
|
1773
|
+
isImageSendProgressing = false
|
|
1515
1774
|
|
|
1516
1775
|
case .incomplete:
|
|
1517
1776
|
Bridge.log("NEX: Image transfer incomplete - Missing chunks: \(missingChunks)")
|
|
1518
|
-
|
|
1777
|
+
resendImageMissingChunks(missingChunks)
|
|
1519
1778
|
|
|
1520
1779
|
default:
|
|
1521
1780
|
Bridge.log("NEX: Unknown image transfer status")
|
|
@@ -1533,6 +1792,21 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1533
1792
|
emitEvent("ImageTransferCompleteEvent", body: eventBody)
|
|
1534
1793
|
}
|
|
1535
1794
|
|
|
1795
|
+
private func resendImageMissingChunks(_ missingChunks: [UInt32]) {
|
|
1796
|
+
guard isImageSendProgressing, !currentImageChunks.isEmpty, !missingChunks.isEmpty else {
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
let retransmit = missingChunks.compactMap { index -> [UInt8]? in
|
|
1800
|
+
let i = Int(index)
|
|
1801
|
+
guard i >= 0, i < currentImageChunks.count else { return nil }
|
|
1802
|
+
return currentImageChunks[i]
|
|
1803
|
+
}
|
|
1804
|
+
if !retransmit.isEmpty {
|
|
1805
|
+
Bridge.log("NEX: Resending \(retransmit.count) missing image chunks")
|
|
1806
|
+
queueChunks(retransmit)
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1536
1810
|
private func handleImuDataProtobuf(_ imuData: Mentraos_Ble_ImuData) {
|
|
1537
1811
|
Bridge.log("NEX: 📊 IMU Data: \(imuData)")
|
|
1538
1812
|
|
|
@@ -1579,7 +1853,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1579
1853
|
let level = json["level"] as? Int ?? -1
|
|
1580
1854
|
let isCharging = json["charging"] as? Bool ?? false
|
|
1581
1855
|
|
|
1582
|
-
Bridge.log("NEX: 🔋 JSON Battery Status - Level: \(level)%, Charging: \(
|
|
1856
|
+
Bridge.log("NEX: 🔋 JSON Battery Status - Level: \(level)%, Charging: \(isCharging)")
|
|
1583
1857
|
|
|
1584
1858
|
// Update @Published properties (G1-compatible approach)
|
|
1585
1859
|
DeviceStore.shared.apply("glasses", "batteryLevel", level)
|
|
@@ -1615,25 +1889,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1615
1889
|
emitEvent("ButtonPressEvent", body: eventBody)
|
|
1616
1890
|
}
|
|
1617
1891
|
|
|
1618
|
-
private func handlePingJson(_: [String: Any]) {
|
|
1619
|
-
let timestamp = Date().timeIntervalSince1970 * 1000
|
|
1620
|
-
|
|
1621
|
-
Bridge.log("NEX: 💓 JSON PING received (Time: \(timestamp))")
|
|
1622
|
-
|
|
1623
|
-
// Send pong response
|
|
1624
|
-
sendPongResponse()
|
|
1625
|
-
|
|
1626
|
-
// Emit heartbeat received event
|
|
1627
|
-
let eventBody: [String: Any] = [
|
|
1628
|
-
"heartbeat_received": [
|
|
1629
|
-
"timestamp": timestamp,
|
|
1630
|
-
"device_model": "Mentra Display",
|
|
1631
|
-
],
|
|
1632
|
-
]
|
|
1633
|
-
|
|
1634
|
-
emitEvent("HeartbeatReceivedEvent", body: eventBody)
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
1892
|
private func handleVadEventJson(_ json: [String: Any]) {
|
|
1638
1893
|
let vadActiveState = json["vad"] as? Bool ?? false
|
|
1639
1894
|
|
|
@@ -1682,28 +1937,16 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1682
1937
|
|
|
1683
1938
|
private func notifyHeartbeatSent(_ timestamp: TimeInterval) {
|
|
1684
1939
|
lastHeartbeatSentTime = timestamp
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
"timestamp": timestamp,
|
|
1689
|
-
"device_model": "Mentra Display",
|
|
1690
|
-
],
|
|
1691
|
-
]
|
|
1692
|
-
|
|
1693
|
-
emitEvent("HeartbeatSentEvent", body: eventBody)
|
|
1940
|
+
Bridge.sendTypedMessage("heartbeat_sent", body: [
|
|
1941
|
+
"timestamp": timestamp,
|
|
1942
|
+
])
|
|
1694
1943
|
}
|
|
1695
1944
|
|
|
1696
1945
|
private func notifyHeartbeatReceived(_ timestamp: TimeInterval) {
|
|
1697
1946
|
lastHeartbeatReceivedTime = timestamp
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
"timestamp": timestamp,
|
|
1702
|
-
"device_model": "Mentra Display",
|
|
1703
|
-
],
|
|
1704
|
-
]
|
|
1705
|
-
|
|
1706
|
-
emitEvent("HeartbeatReceivedEvent", body: eventBody)
|
|
1947
|
+
Bridge.sendTypedMessage("heartbeat_received", body: [
|
|
1948
|
+
"timestamp": timestamp,
|
|
1949
|
+
])
|
|
1707
1950
|
}
|
|
1708
1951
|
|
|
1709
1952
|
@objc func getLastHeartbeatSentTime() -> TimeInterval {
|
|
@@ -1723,8 +1966,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1723
1966
|
stopMicBeat()
|
|
1724
1967
|
}
|
|
1725
1968
|
|
|
1726
|
-
|
|
1727
|
-
setMicrophoneEnabled(true)
|
|
1969
|
+
sendSetMicEnabled(true, delaySeconds: 0.01)
|
|
1728
1970
|
micBeatCount += 1
|
|
1729
1971
|
|
|
1730
1972
|
// Schedule periodic mic beat (like Java lines 1753-1762)
|
|
@@ -1732,18 +1974,24 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1732
1974
|
[weak self] _ in
|
|
1733
1975
|
guard let self else { return }
|
|
1734
1976
|
Bridge.log("NEX: SENDING MIC BEAT")
|
|
1735
|
-
self.
|
|
1977
|
+
self.sendSetMicEnabled(self.shouldUseGlassesMic, delaySeconds: 0.001)
|
|
1736
1978
|
}
|
|
1737
1979
|
}
|
|
1738
1980
|
|
|
1739
1981
|
private func stopMicBeat() {
|
|
1740
|
-
|
|
1982
|
+
sendSetMicEnabled(false, delaySeconds: 0.01)
|
|
1741
1983
|
micBeatTimer?.invalidate()
|
|
1742
1984
|
micBeatTimer = nil
|
|
1743
1985
|
micBeatCount = 0
|
|
1744
1986
|
Bridge.log("NEX: Stopped mic beat")
|
|
1745
1987
|
}
|
|
1746
1988
|
|
|
1989
|
+
private func sendSetMicEnabled(_ enabled: Bool, delaySeconds: TimeInterval) {
|
|
1990
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
|
1991
|
+
self?.setMicrophoneEnabled(enabled)
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1747
1995
|
private func sendWhiteListCommand() {
|
|
1748
1996
|
guard !whiteListedAlready else {
|
|
1749
1997
|
Bridge.log("NEX: Whitelist already sent, skipping")
|
|
@@ -1842,13 +2090,45 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1842
2090
|
|
|
1843
2091
|
/// Save microphone state before disconnection (like Java implementation)
|
|
1844
2092
|
private func saveMicrophoneStateBeforeDisconnection() {
|
|
1845
|
-
UserDefaults.standard.set(
|
|
1846
|
-
microphoneStateBeforeDisconnection =
|
|
1847
|
-
Bridge.log("NEX: Saved microphone state before disconnection: \(
|
|
2093
|
+
UserDefaults.standard.set(isMicrophoneEnabled, forKey: "microphoneStateBeforeDisconnection")
|
|
2094
|
+
microphoneStateBeforeDisconnection = isMicrophoneEnabled
|
|
2095
|
+
Bridge.log("NEX: Saved microphone state before disconnection: \(isMicrophoneEnabled)")
|
|
1848
2096
|
}
|
|
1849
2097
|
|
|
1850
2098
|
@objc func disconnect() {
|
|
1851
2099
|
Bridge.log("NEX: 🔌 User-initiated disconnect")
|
|
2100
|
+
// Light teardown: drop the link but stay able to reconnect.
|
|
2101
|
+
sendIntentionalDisconnectThen { [weak self] in self?.finalizeDisconnect() }
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/// Best-effort: tell the glasses this disconnect is intentional so they return to
|
|
2105
|
+
/// the welcome screen immediately rather than holding the last frame through the
|
|
2106
|
+
/// firmware's unexpected-disconnect grace period, then run `teardown` after a short
|
|
2107
|
+
/// window to let the write flush. Falls straight through if nothing is connected.
|
|
2108
|
+
private func sendIntentionalDisconnectThen(_ teardown: @escaping () -> Void) {
|
|
2109
|
+
isDisconnecting = true
|
|
2110
|
+
stopReconnectionTimer()
|
|
2111
|
+
guard peripheral != nil, servicesReady else {
|
|
2112
|
+
teardown()
|
|
2113
|
+
return
|
|
2114
|
+
}
|
|
2115
|
+
sendDisconnectRequest()
|
|
2116
|
+
MentraNexSGC._bluetoothQueue.asyncAfter(deadline: .now() + 0.25, execute: teardown)
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
private func sendDisconnectRequest() {
|
|
2120
|
+
let phoneToGlasses = Mentraos_Ble_PhoneToGlasses.with {
|
|
2121
|
+
$0.disconnect = Mentraos_Ble_DisconnectRequest()
|
|
2122
|
+
}
|
|
2123
|
+
guard let protobufData = try? phoneToGlasses.serializedData() else {
|
|
2124
|
+
Bridge.log("NEX: ⚠️ Failed to serialize DisconnectRequest")
|
|
2125
|
+
return
|
|
2126
|
+
}
|
|
2127
|
+
Bridge.log("NEX: 📤 Sending DisconnectRequest before teardown")
|
|
2128
|
+
queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
private func finalizeDisconnect() {
|
|
1852
2132
|
if let peripheral {
|
|
1853
2133
|
// Save microphone state before disconnection (like Java implementation)
|
|
1854
2134
|
saveMicrophoneStateBeforeDisconnection()
|
|
@@ -1856,16 +2136,26 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1856
2136
|
// Stop mic beat system
|
|
1857
2137
|
stopMicBeat()
|
|
1858
2138
|
|
|
1859
|
-
isDisconnecting = true
|
|
1860
2139
|
connectionState = ConnTypes.DISCONNECTED
|
|
1861
2140
|
centralManager?.cancelPeripheralConnection(peripheral)
|
|
1862
2141
|
}
|
|
2142
|
+
setServicesReady(false)
|
|
2143
|
+
releaseServiceWaiters()
|
|
2144
|
+
Task { await commandQueue.clear() }
|
|
2145
|
+
resumePendingWrite()
|
|
1863
2146
|
stopReconnectionTimer()
|
|
2147
|
+
stopTextWallDrain()
|
|
1864
2148
|
}
|
|
1865
2149
|
|
|
1866
2150
|
// MARK: - Lifecycle Management (ported from Java)
|
|
1867
2151
|
|
|
1868
2152
|
@objc func destroy() {
|
|
2153
|
+
// Route through the shared path so forget()/cleanup() also signal an
|
|
2154
|
+
// intentional disconnect to the glasses before the link goes down.
|
|
2155
|
+
sendIntentionalDisconnectThen { [weak self] in self?.performDestroy() }
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
private func performDestroy() {
|
|
1869
2159
|
Bridge.log("NEX: 💥 Destroying MentraNexSGC instance")
|
|
1870
2160
|
|
|
1871
2161
|
isKilled = true
|
|
@@ -1899,11 +2189,22 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1899
2189
|
|
|
1900
2190
|
// Clear discovery cache
|
|
1901
2191
|
discoveredPeripherals.removeAll()
|
|
2192
|
+
setServicesReady(false)
|
|
2193
|
+
releaseServiceWaiters()
|
|
2194
|
+
Task { await commandQueue.clear() }
|
|
2195
|
+
resumePendingWrite()
|
|
1902
2196
|
|
|
1903
2197
|
Bridge.log("NEX: ✅ MentraNexSGC destroyed successfully")
|
|
1904
2198
|
// Reset initialization flags
|
|
1905
2199
|
whiteListedAlready = false
|
|
1906
2200
|
protobufVersionPosted = false
|
|
2201
|
+
currentImageChunks.removeAll()
|
|
2202
|
+
isImageSendProgressing = false
|
|
2203
|
+
currentMTU = MTU_DEFAULT
|
|
2204
|
+
deviceMaxMTU = MTU_DEFAULT
|
|
2205
|
+
maxChunkSize = MTU_DEFAULT - 10
|
|
2206
|
+
bmpChunkSize = MTU_DEFAULT - 20
|
|
2207
|
+
updateConnectedState(isConnected: false)
|
|
1907
2208
|
}
|
|
1908
2209
|
|
|
1909
2210
|
@objc func reset() {
|
|
@@ -1933,6 +2234,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1933
2234
|
micBeatCount = 0
|
|
1934
2235
|
shouldUseGlassesMic = true
|
|
1935
2236
|
microphoneStateBeforeDisconnection = false
|
|
2237
|
+
currentImageChunks.removeAll()
|
|
2238
|
+
isImageSendProgressing = false
|
|
2239
|
+
updateConnectedState(isConnected: false)
|
|
1936
2240
|
}
|
|
1937
2241
|
|
|
1938
2242
|
// MARK: - Helper Methods (like G1)
|
|
@@ -1949,6 +2253,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1949
2253
|
Bridge.sendDiscoveredDevice(DeviceTypes.NEX, name)
|
|
1950
2254
|
}
|
|
1951
2255
|
|
|
2256
|
+
private func updateConnectedState(isConnected: Bool) {
|
|
2257
|
+
connectionState = isConnected ? ConnTypes.CONNECTED : ConnTypes.DISCONNECTED
|
|
2258
|
+
DeviceStore.shared.apply("glasses", "connected", isConnected)
|
|
2259
|
+
DeviceStore.shared.apply("glasses", "fullyBooted", isConnected)
|
|
2260
|
+
DeviceStore.shared.apply("glasses", "connectionState", isConnected ? ConnTypes.CONNECTED : ConnTypes.DISCONNECTED)
|
|
2261
|
+
}
|
|
2262
|
+
|
|
1952
2263
|
@objc func checkBluetoothState() {
|
|
1953
2264
|
Bridge.log("NEX: 🔍 Checking Bluetooth State...")
|
|
1954
2265
|
if let centralManager {
|
|
@@ -1992,7 +2303,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
1992
2303
|
switch central.state {
|
|
1993
2304
|
case .poweredOn:
|
|
1994
2305
|
Bridge.log("NEX: ✅ Bluetooth is On and ready for scanning")
|
|
1995
|
-
if scanOnPowerOn {
|
|
2306
|
+
if scanOnPowerOn || peripheralToConnectName != nil {
|
|
1996
2307
|
Bridge.log("NEX: 🚀 Triggering scan after power on.")
|
|
1997
2308
|
scanOnPowerOn = false
|
|
1998
2309
|
startScan()
|
|
@@ -2025,10 +2336,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2025
2336
|
return
|
|
2026
2337
|
}
|
|
2027
2338
|
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
// }
|
|
2339
|
+
guard isCompatibleNexDevice(deviceName) else {
|
|
2340
|
+
return
|
|
2341
|
+
}
|
|
2032
2342
|
|
|
2033
2343
|
Bridge.log("NEX-CONN: 🎯 === Compatible Nex Device Found ===")
|
|
2034
2344
|
Bridge.log("NEX-CONN: 📱 Device Name: \(deviceName)")
|
|
@@ -2049,6 +2359,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2049
2359
|
shouldConnect = true
|
|
2050
2360
|
connectionReason = "Target device name match: \(targetName)"
|
|
2051
2361
|
}
|
|
2362
|
+
// During a user-initiated discovery scan, only list devices — never auto-connect a
|
|
2363
|
+
// saved/preferred device, so the user can pick a different one.
|
|
2364
|
+
else if isDiscoveryScan {
|
|
2365
|
+
shouldConnect = false
|
|
2366
|
+
}
|
|
2052
2367
|
// Check if this matches our saved device for reconnection
|
|
2053
2368
|
else if let savedName = savedDeviceName, deviceName == savedName {
|
|
2054
2369
|
shouldConnect = true
|
|
@@ -2090,6 +2405,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2090
2405
|
self.peripheral = peripheral
|
|
2091
2406
|
isConnecting = true
|
|
2092
2407
|
connectionState = ConnTypes.CONNECTING
|
|
2408
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
|
|
2093
2409
|
|
|
2094
2410
|
// Use connection options for better reliability (from Java implementation)
|
|
2095
2411
|
let connectionOptions: [String: Any] = [
|
|
@@ -2124,6 +2440,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2124
2440
|
Bridge.log("NEX-CONN: 💾 Device information saved for reliable reconnection")
|
|
2125
2441
|
peripheral.delegate = self
|
|
2126
2442
|
Bridge.log("NEX-CONN: 🔍 Discovering services...")
|
|
2443
|
+
setServicesReady(false)
|
|
2127
2444
|
peripheral.discoverServices([MAIN_SERVICE_UUID])
|
|
2128
2445
|
|
|
2129
2446
|
// Reset any failed connection attempt counters
|
|
@@ -2139,6 +2456,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2139
2456
|
)
|
|
2140
2457
|
isConnecting = false
|
|
2141
2458
|
connectionState = ConnTypes.DISCONNECTED
|
|
2459
|
+
setServicesReady(false)
|
|
2460
|
+
releaseServiceWaiters()
|
|
2461
|
+
Task { await commandQueue.clear() }
|
|
2462
|
+
resumePendingWrite()
|
|
2142
2463
|
self.peripheral = nil // Reset peripheral on failure to allow reconnection
|
|
2143
2464
|
// Optionally, start reconnection attempts here
|
|
2144
2465
|
if !isDisconnecting, !isKilled {
|
|
@@ -2161,12 +2482,17 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2161
2482
|
// Reset connection state
|
|
2162
2483
|
// Save microphone state before disconnection (like Java implementation)
|
|
2163
2484
|
saveMicrophoneStateBeforeDisconnection()
|
|
2485
|
+
setServicesReady(false)
|
|
2486
|
+
releaseServiceWaiters()
|
|
2487
|
+
Task { await commandQueue.clear() }
|
|
2488
|
+
resumePendingWrite()
|
|
2164
2489
|
|
|
2165
2490
|
// Reset protobuf version posted flag for next connection (like Java implementation)
|
|
2166
2491
|
protobufVersionPosted = false
|
|
2167
2492
|
|
|
2168
2493
|
// Stop mic beat system (like Java implementation)
|
|
2169
2494
|
stopMicBeat()
|
|
2495
|
+
stopTextWallDrain()
|
|
2170
2496
|
|
|
2171
2497
|
nexReady = false
|
|
2172
2498
|
deviceReady = false
|
|
@@ -2191,6 +2517,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2191
2517
|
writeCharacteristic = nil
|
|
2192
2518
|
notifyCharacteristic = nil
|
|
2193
2519
|
connectionState = ConnTypes.DISCONNECTED
|
|
2520
|
+
currentMTU = MTU_DEFAULT
|
|
2521
|
+
deviceMaxMTU = MTU_DEFAULT
|
|
2522
|
+
maxChunkSize = MTU_DEFAULT - 10
|
|
2523
|
+
bmpChunkSize = MTU_DEFAULT - 20
|
|
2524
|
+
currentImageChunks.removeAll()
|
|
2525
|
+
isImageSendProgressing = false
|
|
2526
|
+
updateConnectedState(isConnected: false)
|
|
2194
2527
|
|
|
2195
2528
|
// Clear command queue if needed
|
|
2196
2529
|
if isQueueWorkerRunning {
|
|
@@ -2228,8 +2561,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2228
2561
|
// iOS MTU is automatically negotiated - we can only discover the current value
|
|
2229
2562
|
// No manual MTU request available on iOS (platform limitation)
|
|
2230
2563
|
|
|
2231
|
-
// Get current MTU capability (iOS-specific approach)
|
|
2232
|
-
|
|
2564
|
+
// Get current MTU capability (iOS-specific approach). Query for .withoutResponse
|
|
2565
|
+
// since that's the write type the caption path uses; its limit can differ from
|
|
2566
|
+
// .withResponse, and sizing chunks to it avoids oversized writes being dropped.
|
|
2567
|
+
let maxWriteLength = peripheral.maximumWriteValueLength(for: .withoutResponse)
|
|
2233
2568
|
let actualMTU = maxWriteLength + 3 // Add L2CAP header size
|
|
2234
2569
|
|
|
2235
2570
|
Bridge.log("NEX: 📊 iOS MTU Discovery Results:")
|
|
@@ -2265,7 +2600,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2265
2600
|
|
|
2266
2601
|
// Calculate optimal chunk sizes based on iOS MTU constraints
|
|
2267
2602
|
maxChunkSize = currentMTU - 10 // Reserve 10 bytes for headers
|
|
2268
|
-
bmpChunkSize = currentMTU -
|
|
2603
|
+
bmpChunkSize = currentMTU - 20
|
|
2269
2604
|
|
|
2270
2605
|
Bridge.log("NEX: 📦 Optimized Chunk Sizes:")
|
|
2271
2606
|
Bridge.log("NEX: 📄 Data Chunk Size: \(maxChunkSize) bytes")
|
|
@@ -2289,6 +2624,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2289
2624
|
Bridge.log("NEX-CONN: ✅ Device initialization complete - ready for communication")
|
|
2290
2625
|
nexReady = true
|
|
2291
2626
|
connectionState = ConnTypes.CONNECTED
|
|
2627
|
+
lastConnectionTimestamp = Date().timeIntervalSince1970 * 1000
|
|
2628
|
+
updateConnectedState(isConnected: true)
|
|
2292
2629
|
|
|
2293
2630
|
// Update @Published property for device ready state
|
|
2294
2631
|
deviceReady = true
|
|
@@ -2296,6 +2633,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2296
2633
|
// Initialize command queue worker to process queued commands
|
|
2297
2634
|
setupCommandQueue()
|
|
2298
2635
|
|
|
2636
|
+
// Start the 100 ms latest-wins drain for caption text walls
|
|
2637
|
+
startTextWallDrain()
|
|
2638
|
+
|
|
2299
2639
|
// Emit device ready event to React Native
|
|
2300
2640
|
// emitDeviceReady()
|
|
2301
2641
|
}
|
|
@@ -2342,9 +2682,14 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2342
2682
|
self.postProtobufSchemaVersionInfo()
|
|
2343
2683
|
}
|
|
2344
2684
|
|
|
2345
|
-
// 6.
|
|
2346
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
2347
|
-
|
|
2685
|
+
// 6. Version request is removed in current schema; Android sends no-op.
|
|
2686
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
2687
|
+
Bridge.log("NEX: Skipping version request; schema removed VersionRequest")
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// 7. Push current glasses-side Voice Activity Detection setting
|
|
2691
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
2692
|
+
self.sendVoiceActivityDetectionSetting()
|
|
2348
2693
|
}
|
|
2349
2694
|
|
|
2350
2695
|
Bridge.log("NEX-CONN: ✅ Java-compatible initialization sequence started")
|
|
@@ -2436,7 +2781,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2436
2781
|
Bridge.log("NEX-CONN: ⚠️ Received notification with no data.")
|
|
2437
2782
|
return
|
|
2438
2783
|
}
|
|
2439
|
-
|
|
2784
|
+
// No per-notification logging here: this fires for EVERY inbound packet
|
|
2785
|
+
// (~20/sec audio alone), and Bridge.log is a typed message the JS thread
|
|
2786
|
+
// must process — plus the toHexString() is O(n) per packet. This was a
|
|
2787
|
+
// top contributor to the background CPU kill (cpu_resource_fatal.ips).
|
|
2788
|
+
// Bridge.log("NEX-CONN: 📥 Received data (\(data.count) bytes): \(data.toHexString())")
|
|
2440
2789
|
|
|
2441
2790
|
// Process the received data based on packet type
|
|
2442
2791
|
processReceivedData(data)
|
|
@@ -2449,10 +2798,15 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2449
2798
|
Bridge.log(
|
|
2450
2799
|
"NEX-CONN: ❌ Error writing value to \(characteristic.uuid): \(error.localizedDescription)"
|
|
2451
2800
|
)
|
|
2801
|
+
resumePendingWrite()
|
|
2452
2802
|
return
|
|
2453
2803
|
}
|
|
2454
|
-
|
|
2455
|
-
|
|
2804
|
+
resumePendingWrite()
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
func peripheralIsReady(toSendWriteWithoutResponse _: CBPeripheral) {
|
|
2808
|
+
// CoreBluetooth can accept more write-without-response data; unblock the sender.
|
|
2809
|
+
resumePendingWrite()
|
|
2456
2810
|
}
|
|
2457
2811
|
|
|
2458
2812
|
func peripheral(
|
|
@@ -2470,10 +2824,16 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
|
|
|
2470
2824
|
Bridge.log(
|
|
2471
2825
|
"NEX-CONN: ✅ Successfully subscribed to notifications for characteristic \(characteristic.uuid.uuidString)."
|
|
2472
2826
|
)
|
|
2827
|
+
if characteristic.uuid == NOTIFY_CHAR_UUID {
|
|
2828
|
+
setServicesReady(true)
|
|
2829
|
+
}
|
|
2473
2830
|
} else {
|
|
2474
2831
|
Bridge.log(
|
|
2475
2832
|
"NEX-CONN: unsubscribed from notifications for characteristic \(characteristic.uuid.uuidString)."
|
|
2476
2833
|
)
|
|
2834
|
+
if characteristic.uuid == NOTIFY_CHAR_UUID {
|
|
2835
|
+
setServicesReady(false)
|
|
2836
|
+
}
|
|
2477
2837
|
}
|
|
2478
2838
|
}
|
|
2479
2839
|
}
|