@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.
Files changed (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +195 -34
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +103 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +210 -18
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +125 -11
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1293 -758
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +197 -149
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +64 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +253 -44
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +37 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +43 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +25 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +177 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +68 -19
  98. package/ios/Source/DeviceManager.swift +167 -32
  99. package/ios/Source/DeviceStore.swift +24 -8
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +6 -0
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +226 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +5 -5
  111. package/ios/Source/sgcs/G2.swift +1494 -870
  112. package/ios/Source/sgcs/Mach1.swift +4 -4
  113. package/ios/Source/sgcs/MentraLive.swift +814 -321
  114. package/ios/Source/sgcs/MentraNex.swift +604 -244
  115. package/ios/Source/sgcs/SGCManager.swift +68 -5
  116. package/ios/Source/sgcs/Simulated.swift +9 -5
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +344 -60
  131. package/src/_private/BluetoothSdkModule.ts +132 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +61 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /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–3 in protobuf `DisplayDistanceConfig.distance_cm` (name is legacy, not cm).
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 = 3
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 _: Bool) {}
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 _: String) async -> Bool {
77
- return false
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
- return nil
156
+ peripheral?.name
157
+ }
158
+
159
+ func cleanup() {
160
+ destroy()
119
161
  }
120
162
 
121
- func cleanup() {}
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 = 5.0 // 5 seconds
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 == nil {
275
- instance = MentraNexSGC()
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 = 8) {
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
- /// Enhanced method that uses MTU-optimized chunking
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
- var chunks: [[UInt8]] = []
392
- let effectiveChunkSize = maxChunkSize - 1 // Reserve 1 byte for packet type
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
- // Add packet type as first byte
395
- var packetData = Data([packetType])
396
- packetData.append(data)
558
+ let seq = protobufSeq
559
+ protobufSeq = protobufSeq &+ 1
397
560
 
398
- // Split into MTU-optimized chunks
561
+ var chunks: [[UInt8]] = []
399
562
  var offset = 0
400
- while offset < packetData.count {
401
- let chunkSize = min(effectiveChunkSize, packetData.count - offset)
402
- let chunkData = packetData.subdata(in: offset ..< (offset + chunkSize))
403
- chunks.append(Array(chunkData))
404
- offset += chunkSize
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
- Bridge.log(
408
- "NEX: 📦 Created \(chunks.count) MTU-optimized chunks (max size: \(effectiveChunkSize) bytes)"
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
- "NEX: 📦 Sending chunk \(index) of \(command.chunks.count) to \(peripheral.name ?? "Unknown")"
432
- )
433
- Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
434
- peripheral.writeValue(data, for: writeCharacteristic, type: .withResponse)
435
-
436
- // Delay between chunks except maybe after the last chunk if waitTime will handle it
437
- if index < command.chunks.count - 1 {
438
- try? await Task.sleep(nanoseconds: UInt64(command.chunkDelayMs) * 1_000_000)
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
- // Enhanced filtering logic from Java implementation
677
+ // Keep in parity with Android MentraNex.kt scan filter.
497
678
  let compatiblePrefixes = [
498
- "NexSim",
499
- // "MENTRA",
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.hasPrefix(prefix) || deviceName.contains(prefix) {
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
- if let peripheralToConnect = peripherals.first {
747
+ guard let peripheralToConnect = peripherals.first else {
572
748
  Bridge.log(
573
- "NEX-CONN: 🔵 Found peripheral by UUID: \(peripheralToConnect.name ?? "Unknown"). Initiating connection."
749
+ "NEX-CONN: 🔵 Could not find peripheral for stored UUID. Will proceed to scan."
574
750
  )
575
- peripheral = peripheralToConnect
576
- centralManager.connect(peripheralToConnect, options: nil)
577
- return true
578
- } else {
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: 🔵 Could not find peripheral for stored UUID. Will proceed to scan."
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
- // First, try to reconnect using stored UUID (faster and works in background)
648
- if connectByUUID() {
649
- Bridge.log("NEX-CONN: 🔄 Attempting connection with stored UUID. Halting scan.")
650
- return
651
- }
652
-
653
- // If that fails, check for already-connected system devices
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
- // Check if we have a saved device name to reconnect to (like MentraLive)
673
- if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
674
- !savedDeviceName.isEmpty
675
- {
676
- Bridge.log("NEX-CONN: 🔄 Looking for saved device: \(savedDeviceName)")
677
- // This will be handled in didDiscover when the device is found
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. Clearing connection targets.")
965
+ Bridge.log("NEX-DISCOVERY: Finding compatible devices.")
759
966
 
760
- // Clear any specific device targets to ensure we are only discovering
967
+ // Clear specific connect target, but keep saved pairing data like Android.
761
968
  peripheralToConnectName = nil
762
- clearSavedDeviceInfo() // This clears UserDefaults and our in-memory cache of saved/preferred devices.
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 sendTextWall(_ text: String) {
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
- Bridge.log("NEX: Displaying text wall: '\(text)'")
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 = 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
- queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
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\n\(textBottom)"
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
- // Validate brightness range (0-100)
1038
- let validBrightness = max(0, min(100, brightness))
1039
- Bridge.log("NEX: Setting brightness to \(validBrightness)%")
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
- Bridge.log("NEX: Processing protobuf payload case: \(glassesToPhone.payload)")
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
- "NEX: Received audio data - sequence: \(sequenceNumber), size: \(audioData.count) bytes"
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
- // Clear any pending image chunks
1772
+ currentImageChunks.removeAll()
1773
+ isImageSendProgressing = false
1515
1774
 
1516
1775
  case .incomplete:
1517
1776
  Bridge.log("NEX: Image transfer incomplete - Missing chunks: \(missingChunks)")
1518
- // Could implement chunk retransmission here
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: \(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
- let eventBody: [String: Any] = [
1687
- "heartbeat_sent": [
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
- let eventBody: [String: Any] = [
1700
- "heartbeat_received": [
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
- // Set mic enabled first (like Java line 1751)
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.setMicrophoneEnabled(self.shouldUseGlassesMic)
1977
+ self.sendSetMicEnabled(self.shouldUseGlassesMic, delaySeconds: 0.001)
1736
1978
  }
1737
1979
  }
1738
1980
 
1739
1981
  private func stopMicBeat() {
1740
- setMicrophoneEnabled(false)
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(shouldUseGlassesMic, forKey: "microphoneStateBeforeDisconnection")
1846
- microphoneStateBeforeDisconnection = shouldUseGlassesMic
1847
- Bridge.log("NEX: Saved microphone state before disconnection: \(shouldUseGlassesMic)")
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
- // guard isCompatibleNexDevice(deviceName) else {
2029
- // // Bridge.log("NEX-CONN: 🚫 Ignoring incompatible device: \(deviceName)")
2030
- // return
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
- let maxWriteLength = peripheral.maximumWriteValueLength(for: .withResponse)
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 - 6 // Reserve 6 bytes for image headers
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. Query glasses protobuf version from firmware (Java line 690)
2346
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { // 150ms delay
2347
- self.queryGlassesInfo()
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
- Bridge.log("NEX-CONN: 📥 Received data (\(data.count) bytes): \(data.toHexString())")
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
- // This log can be very noisy, so it's commented out.
2455
- // Bridge.log("NEX-CONN: 📤 Successfully wrote value to \(characteristic.uuid).")
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
  }