@mentra/bluetooth-sdk 0.1.9 → 0.1.10

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 +165 -27
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +89 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +189 -16
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +22 -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 +123 -10
  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 +569 -220
  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 +176 -104
  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 +54 -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 +241 -43
  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 +36 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +30 -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 +23 -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 +174 -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 +58 -19
  98. package/ios/Source/DeviceManager.swift +149 -17
  99. package/ios/Source/DeviceStore.swift +16 -6
  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 +225 -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 +2 -2
  111. package/ios/Source/sgcs/G2.swift +645 -403
  112. package/ios/Source/sgcs/Mach1.swift +2 -2
  113. package/ios/Source/sgcs/MentraLive.swift +812 -319
  114. package/ios/Source/sgcs/MentraNex.swift +412 -182
  115. package/ios/Source/sgcs/SGCManager.swift +57 -3
  116. package/ios/Source/sgcs/Simulated.swift +7 -3
  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 +331 -59
  131. package/src/_private/BluetoothSdkModule.ts +113 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +58 -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) {
85
+ 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
119
157
  }
120
158
 
121
- func cleanup() {}
159
+ func cleanup() {
160
+ destroy()
161
+ }
162
+
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,11 @@ 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
+ private let INITIAL_CONNECTION_DELAY_MS: UInt64 = 350
215
+ private let DELAY_BETWEEN_CHUNKS_SEND_MS: UInt64 = 10
172
216
 
173
217
  // Heartbeat tracking (like Java implementation)
174
218
  private var heartbeatCount = 0
@@ -180,6 +224,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
180
224
  private var micBeatCount = 0
181
225
  private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes like Java
182
226
  private var shouldUseGlassesMic = true
227
+ private var isMicrophoneEnabled = true
183
228
  private var microphoneStateBeforeDisconnection = false
184
229
 
185
230
  // Whitelist system (like Java implementation)
@@ -191,6 +236,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
191
236
 
192
237
  /// Device discovery cache (like MentraLive)
193
238
  private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
239
+ private var lastConnectionTimestamp: TimeInterval = 0
240
+ private var lastReceivedLc3Sequence = -1
241
+ private var currentImageChunks: [[UInt8]] = []
242
+ private var isImageSendProgressing = false
243
+ private var servicesReady = false
244
+ private var serviceReadyWaiters: [CheckedContinuation<Void, Never>] = []
245
+ private var pendingWriteContinuation: CheckedContinuation<Void, Never>?
194
246
 
195
247
  // MARK: - Published Properties (G1-compatible)
196
248
 
@@ -271,9 +323,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
271
323
  // MARK: - Singleton Access
272
324
 
273
325
  @objc static func getInstance() -> MentraNexSGC {
274
- if instance == nil {
275
- instance = MentraNexSGC()
326
+ if let existing = instance, existing.centralManager != nil {
327
+ return existing
276
328
  }
329
+ instance = MentraNexSGC()
277
330
  return instance!
278
331
  }
279
332
 
@@ -295,6 +348,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
295
348
  private var deviceMaxMTU = 23 // Device's maximum capability
296
349
  private var maxChunkSize = 176 // Calculated optimal chunk size
297
350
  private var bmpChunkSize = 176 // Image chunk size (iOS-optimized)
351
+ private var protobufSeq: UInt8 = 0 // Rolling sequence for fragmented control messages
298
352
 
299
353
  // MARK: - Command Queue (modeled after ERG1Manager)
300
354
 
@@ -333,6 +387,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
333
387
  continuations.append(continuation)
334
388
  }
335
389
  }
390
+
391
+ func clear() {
392
+ commands.removeAll()
393
+ }
336
394
  }
337
395
 
338
396
  private let commandQueue = CommandQueue()
@@ -360,6 +418,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
360
418
  Bridge.log(
361
419
  "NEX: 💾 Loaded saved device - Name: \(savedDeviceName ?? "None"), Address: \(savedDeviceAddress ?? "None")"
362
420
  )
421
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
363
422
  }
364
423
 
365
424
  private func setupCommandQueue() {
@@ -375,7 +434,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
375
434
  }
376
435
  }
377
436
 
378
- private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 8) {
437
+ private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 10) {
379
438
  let cmd = BufferedCommand(
380
439
  chunks: chunks, waitTimeMs: waitTimeMs, chunkDelayMs: chunkDelayMs
381
440
  )
@@ -384,59 +443,151 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
384
443
  }
385
444
  }
386
445
 
387
- /// Enhanced method that uses MTU-optimized chunking
446
+ private func waitUntilServicesReady() async {
447
+ if servicesReady {
448
+ return
449
+ }
450
+ await withCheckedContinuation { continuation in
451
+ serviceReadyWaiters.append(continuation)
452
+ }
453
+ }
454
+
455
+ private func setServicesReady(_ ready: Bool) {
456
+ servicesReady = ready
457
+ guard ready else { return }
458
+ releaseServiceWaiters()
459
+ }
460
+
461
+ private func releaseServiceWaiters() {
462
+ let waiters = serviceReadyWaiters
463
+ serviceReadyWaiters.removeAll()
464
+ waiters.forEach { $0.resume() }
465
+ }
466
+
467
+ /// Suspends until CoreBluetooth signals it can accept another write-without-response
468
+ /// (resumed by peripheralIsReady(toSendWriteWithoutResponse:), or by the disconnect
469
+ /// handlers so an in-flight send can't wedge the queue across a drop).
470
+ private func waitUntilReadyToWrite() async {
471
+ await withCheckedContinuation { continuation in
472
+ pendingWriteContinuation = continuation
473
+ }
474
+ }
475
+
476
+ private func resumePendingWrite() {
477
+ pendingWriteContinuation?.resume()
478
+ pendingWriteContinuation = nil
479
+ }
480
+
481
+ private func emitBleCommandSent(_ packetData: Data) {
482
+ guard packetData.first == PACKET_TYPE_PROTOBUF else { return }
483
+ guard packetData.count > 1 else { return }
484
+ let payload = packetData.subdata(in: 1 ..< packetData.count)
485
+ let commandName: String
486
+ if let phoneToGlasses = try? Mentraos_Ble_PhoneToGlasses(serializedData: payload) {
487
+ commandName = String(describing: phoneToGlasses.payload)
488
+ } else {
489
+ commandName = "UNKNOWN"
490
+ }
491
+ Bridge.sendTypedMessage("send_command_to_ble", body: [
492
+ "command": commandName,
493
+ "commandText": packetData.toHexString(),
494
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
495
+ ])
496
+ }
497
+
498
+ private func emitBleCommandReceived(_ packetData: Data, payloadDescription: String) {
499
+ Bridge.sendTypedMessage("receive_command_from_ble", body: [
500
+ "command": payloadDescription,
501
+ "commandText": packetData.toHexString(),
502
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
503
+ ])
504
+ }
505
+
506
+ /// Splits a serialized protobuf message into BLE-sized fragments, each prefixed with a
507
+ /// 4-byte transport header [packetType][seq][totalChunks][chunkIndex] so the firmware can
508
+ /// reassemble messages larger than one MTU. Single-fragment messages set totalChunks = 1
509
+ /// and are decoded directly by the firmware's fast path.
388
510
  private func queueDataWithOptimalChunking(
389
511
  _ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0
390
512
  ) {
391
- var chunks: [[UInt8]] = []
392
- let effectiveChunkSize = maxChunkSize - 1 // Reserve 1 byte for packet type
393
-
394
- // Add packet type as first byte
513
+ // Telemetry: report the full logical command (type byte + protobuf) before fragmenting.
395
514
  var packetData = Data([packetType])
396
515
  packetData.append(data)
516
+ emitBleCommandSent(packetData)
517
+
518
+ let headerSize = 4 // [packetType][seq][totalChunks][chunkIndex]
519
+ let effectiveChunkSize = max(1, maxChunkSize - headerSize)
520
+ let totalChunks = data.isEmpty
521
+ ? 1 : Int(ceil(Double(data.count) / Double(effectiveChunkSize)))
522
+
523
+ guard totalChunks <= 255 else {
524
+ Bridge.log(
525
+ "NEX: ❌ Protobuf message too large to fragment (\(totalChunks) chunks) - dropping"
526
+ )
527
+ return
528
+ }
397
529
 
398
- // Split into MTU-optimized chunks
530
+ let seq = protobufSeq
531
+ protobufSeq = protobufSeq &+ 1
532
+
533
+ var chunks: [[UInt8]] = []
399
534
  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
535
+ var index = 0
536
+ while offset < data.count || (index == 0 && data.isEmpty) {
537
+ let end = min(offset + effectiveChunkSize, data.count)
538
+ var frame: [UInt8] = [packetType, seq, UInt8(totalChunks), UInt8(index)]
539
+ if end > offset {
540
+ frame.append(contentsOf: data.subdata(in: offset ..< end))
541
+ }
542
+ chunks.append(frame)
543
+ offset = end
544
+ index += 1
405
545
  }
406
546
 
407
547
  Bridge.log(
408
- "NEX: 📦 Created \(chunks.count) MTU-optimized chunks (max size: \(effectiveChunkSize) bytes)"
548
+ "NEX: 📦 Fragmented protobuf into \(chunks.count) chunk(s) (seq=\(seq), max payload \(effectiveChunkSize) bytes)"
409
549
  )
410
550
  queueChunks(chunks, waitTimeMs: waitTimeMs)
411
551
  }
412
552
 
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
553
  private func processCommand(_ command: BufferedCommand) async {
422
554
  guard let peripheral, let writeCharacteristic else {
423
555
  Bridge.log("NEX: ⚠️ processCommand: peripheral/characteristic not ready")
424
556
  return
425
557
  }
426
558
 
559
+ await waitUntilServicesReady()
560
+
427
561
  // Send each chunk sequentially
428
562
  for (index, chunk) in command.chunks.enumerated() {
563
+ let timeSinceConnection = Date().timeIntervalSince1970 * 1000 - lastConnectionTimestamp
564
+ if timeSinceConnection < Double(INITIAL_CONNECTION_DELAY_MS) {
565
+ let remainingMs = UInt64(Double(INITIAL_CONNECTION_DELAY_MS) - timeSinceConnection)
566
+ try? await Task.sleep(nanoseconds: remainingMs * 1_000_000)
567
+ }
429
568
  let data = Data(chunk)
430
569
  Bridge.log(
431
570
  "NEX: 📦 Sending chunk \(index) of \(command.chunks.count) to \(peripheral.name ?? "Unknown")"
432
571
  )
433
572
  Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
434
- peripheral.writeValue(data, for: writeCharacteristic, type: .withResponse)
573
+ // Write WITHOUT response: a write-with-response is one outstanding request at a
574
+ // time gated on a remote round trip, which puts the (screen-off-throttled) app
575
+ // thread in the path of every fragment and makes captions crawl in the background.
576
+ // .withoutResponse lets CoreBluetooth batch fragments into connection events.
577
+ // CoreBluetooth does NOT call didWriteValueFor for .withoutResponse, so we use its
578
+ // flow-control flag instead of an ack — gating prevents dropped fragments (which
579
+ // would make the firmware discard the whole reassembled caption). See
580
+ // docs/ble-disconnects-during-captions.md in the firmware repo.
581
+ if !peripheral.canSendWriteWithoutResponse {
582
+ await waitUntilReadyToWrite()
583
+ }
584
+ peripheral.writeValue(data, for: writeCharacteristic, type: .withoutResponse)
435
585
 
436
586
  // Delay between chunks except maybe after the last chunk if waitTime will handle it
437
587
  if index < command.chunks.count - 1 {
438
588
  try? await Task.sleep(nanoseconds: UInt64(command.chunkDelayMs) * 1_000_000)
439
589
  }
590
+ try? await Task.sleep(nanoseconds: DELAY_BETWEEN_CHUNKS_SEND_MS * 1_000_000)
440
591
  }
441
592
 
442
593
  // Optional wait after the command
@@ -493,21 +644,14 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
493
644
  // MARK: - Enhanced Device Filtering (ported from Java)
494
645
 
495
646
  private func isCompatibleNexDevice(_ deviceName: String) -> Bool {
496
- // Enhanced filtering logic from Java implementation
647
+ // Keep in parity with Android MentraNex.kt scan filter.
497
648
  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
649
+ "Nex1-",
650
+ "MENTRA_DISPLAY_",
507
651
  ]
508
652
 
509
653
  for prefix in compatiblePrefixes {
510
- if deviceName.hasPrefix(prefix) || deviceName.contains(prefix) {
654
+ if deviceName.contains(prefix) {
511
655
  Bridge.log("NEX: ✅ Device '\(deviceName)' matches compatible prefix: \(prefix)")
512
656
  return true
513
657
  }
@@ -679,6 +823,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
679
823
 
680
824
  Bridge.log("NEX-CONN: ✅ Bluetooth is powered on, starting scan...")
681
825
  _isScanning = true
826
+ connectionState = ConnTypes.SCANNING
827
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
682
828
 
683
829
  // Scan for ALL devices, not just those with specific services
684
830
  // Use same options as G1 scanner for consistency
@@ -725,6 +871,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
725
871
  return "connecting"
726
872
  case ConnTypes.CONNECTED:
727
873
  return "connected"
874
+ case ConnTypes.SCANNING:
875
+ return "scanning"
728
876
  default:
729
877
  return "disconnected"
730
878
  }
@@ -755,11 +903,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
755
903
  }
756
904
 
757
905
  @objc func findCompatibleDevices() {
758
- Bridge.log("NEX-DISCOVERY: Finding compatible devices. Clearing connection targets.")
906
+ Bridge.log("NEX-DISCOVERY: Finding compatible devices.")
759
907
 
760
- // Clear any specific device targets to ensure we are only discovering
908
+ // Clear specific connect target, but keep saved pairing data like Android.
761
909
  peripheralToConnectName = nil
762
- clearSavedDeviceInfo() // This clears UserDefaults and our in-memory cache of saved/preferred devices.
763
910
 
764
911
  Task {
765
912
  if centralManager == nil {
@@ -773,6 +920,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
773
920
 
774
921
  if centralManager?.state == .poweredOn {
775
922
  startScan()
923
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
924
+ self?.stopScan()
925
+ }
776
926
  } else {
777
927
  Bridge.log("NEX-DISCOVERY: Bluetooth not ready, will scan on power on.")
778
928
  scanOnPowerOn = true
@@ -780,16 +930,26 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
780
930
  }
781
931
  }
782
932
 
933
+ private func sanitizeDisplayText(_ text: String) -> String {
934
+ let normalized = text.replacingOccurrences(of: "—", with: "-")
935
+ let pattern = #"[^A-Za-z0-9 \r\n\.,!\?;:\-\[\]\(\)\{\}'"\+=/]"#
936
+ return normalized.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
937
+ }
938
+
783
939
  func sendTextWall(_ text: String) {
784
940
  guard nexReady else {
785
941
  Bridge.log("NEX: Not ready to display text. Device not initialized.")
786
942
  return
787
943
  }
788
944
 
789
- Bridge.log("NEX: Displaying text wall: '\(text)'")
945
+ // When Chinese captions are disabled (default), strip characters the Nex font
946
+ // can't render (CJK etc.). When enabled, pass the text through unmodified.
947
+ let chineseCaptionsEnabled = DeviceStore.shared.get("bluetooth", "nex_chinese_captions") as? Bool ?? false
948
+ let sanitizedText = chineseCaptionsEnabled ? text : sanitizeDisplayText(text)
949
+ Bridge.log("NEX: Displaying text wall: '\(sanitizedText)'")
790
950
 
791
951
  let displayText = Mentraos_Ble_DisplayText.with {
792
- $0.text = text
952
+ $0.text = sanitizedText
793
953
  $0.size = 48
794
954
  $0.x = 20
795
955
  $0.y = 260
@@ -809,7 +969,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
809
969
  }
810
970
 
811
971
  @objc func displayDoubleTextWall(_ textTop: String, textBottom: String) {
812
- let combinedText = "\(textTop)\n\n\(textBottom)"
972
+ let combinedText = "\(textTop)\n\(textBottom)"
813
973
  sendTextWall(combinedText)
814
974
  }
815
975
 
@@ -944,6 +1104,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
944
1104
  }
945
1105
 
946
1106
  Bridge.log("NEX: Sending \(chunks.count) image chunks")
1107
+ currentImageChunks = chunks
1108
+ isImageSendProgressing = true
947
1109
  queueChunks(chunks, waitTimeMs: 50)
948
1110
  }
949
1111
 
@@ -1006,11 +1168,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1006
1168
  @objc func showHomeScreen() {
1007
1169
  Bridge.log("NEX: Showing home screen")
1008
1170
  clearDisplay()
1009
-
1010
- // Send a simple home screen text
1011
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
1012
- self.sendTextWall("MentraOS Ready")
1013
- }
1014
1171
  }
1015
1172
 
1016
1173
  @objc func exitAllFunctions() {
@@ -1034,9 +1191,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1034
1191
  return
1035
1192
  }
1036
1193
 
1037
- // Validate brightness range (0-100)
1038
- let validBrightness = max(0, min(100, brightness))
1039
- Bridge.log("NEX: Setting brightness to \(validBrightness)%")
1194
+ let validBrightness: Int
1195
+ if brightness != -1 {
1196
+ validBrightness = (max(0, min(100, brightness)) * 63) / 100
1197
+ } else {
1198
+ validBrightness = (30 * 63) / 100
1199
+ }
1200
+ Bridge.log("NEX: Setting brightness to wire value \(validBrightness)")
1040
1201
 
1041
1202
  let brightnessConfig = Mentraos_Ble_BrightnessConfig.with {
1042
1203
  $0.value = UInt32(validBrightness)
@@ -1135,7 +1296,33 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1135
1296
  queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1136
1297
  }
1137
1298
 
1299
+ func sendVoiceActivityDetectionSetting() {
1300
+ let enabled = DeviceStore.shared.get("bluetooth", "voice_activity_detection_enabled") as? Bool
1301
+ ?? BluetoothSdkDefaults.voiceActivityDetectionEnabled
1302
+ Bridge.log("NEX: 🎤 Sending Voice Activity Detection setting to glasses: \(enabled)")
1303
+
1304
+ guard nexReady else {
1305
+ Bridge.log("NEX: Not ready to send VAD setting. Device not initialized.")
1306
+ return
1307
+ }
1308
+
1309
+ let vadConfig = Mentraos_Ble_VadEnabledConfig.with {
1310
+ $0.enabled = enabled
1311
+ }
1312
+
1313
+ let phoneToGlasses = Mentraos_Ble_PhoneToGlasses.with {
1314
+ $0.vadEnabled = vadConfig
1315
+ }
1316
+
1317
+ let protobufData = try! phoneToGlasses.serializedData()
1318
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1319
+ Bridge.sendVoiceActivityDetectionStatus(enabled)
1320
+ }
1321
+
1138
1322
  @objc func setMicrophoneEnabled(_ enabled: Bool) {
1323
+ isMicrophoneEnabled = enabled
1324
+ DeviceStore.shared.apply("glasses", "micEnabled", enabled)
1325
+
1139
1326
  guard nexReady else {
1140
1327
  Bridge.log("NEX: Not ready to set microphone state. Device not initialized.")
1141
1328
  return
@@ -1211,28 +1398,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1211
1398
 
1212
1399
  // MARK: - Utility Methods
1213
1400
 
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
1401
  @objc func isDeviceReady() -> Bool {
1237
1402
  nexReady && connectionState == ConnTypes.CONNECTED
1238
1403
  }
@@ -1321,8 +1486,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1321
1486
  handleDeviceInfoJson(json)
1322
1487
  case "button_event":
1323
1488
  handleButtonEventJson(json)
1324
- case "ping":
1325
- handlePingJson(json)
1326
1489
  case "vad_event":
1327
1490
  handleVadEventJson(json)
1328
1491
  case "imu_data":
@@ -1342,6 +1505,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1342
1505
  let glassesToPhone = try Mentraos_Ble_GlassesToPhone(serializedData: protobufData)
1343
1506
  Bridge.log("NEX: Processing protobuf payload case: \(glassesToPhone.payload)")
1344
1507
 
1508
+ let fullPacket = Data([PACKET_TYPE_PROTOBUF]) + protobufData
1509
+ emitBleCommandReceived(fullPacket, payloadDescription: String(describing: glassesToPhone.payload))
1510
+
1345
1511
  switch glassesToPhone.payload {
1346
1512
  case let .batteryStatus(batteryStatus):
1347
1513
  handleBatteryStatusProtobuf(batteryStatus)
@@ -1358,9 +1524,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1358
1524
  case let .headUpAngleSet(headUpAngleResponse):
1359
1525
  handleHeadUpAngleResponseProtobuf(headUpAngleResponse)
1360
1526
 
1361
- case let .ping(pingRequest):
1362
- handlePingProtobuf(pingRequest)
1363
-
1364
1527
  case let .vadEvent(vadEvent):
1365
1528
  handleVadEventProtobuf(vadEvent)
1366
1529
 
@@ -1395,6 +1558,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1395
1558
  "NEX: Received audio data - sequence: \(sequenceNumber), size: \(audioData.count) bytes"
1396
1559
  )
1397
1560
 
1561
+ if lastReceivedLc3Sequence != -1, UInt8((lastReceivedLc3Sequence + 1) & 0xFF) != sequenceNumber {
1562
+ Bridge.log("NEX: LC3 packet sequence mismatch. Expected \((lastReceivedLc3Sequence + 1) & 0xFF), got \(sequenceNumber)")
1563
+ }
1564
+ lastReceivedLc3Sequence = Int(sequenceNumber)
1565
+
1398
1566
  // Update @Published property (G1-compatible approach)
1399
1567
  // Create packet with sequence number prefix like G1 expects
1400
1568
  var packetData = Data()
@@ -1402,6 +1570,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1402
1570
  packetData.append(audioData)
1403
1571
 
1404
1572
  compressedVoiceData = packetData
1573
+ DeviceManager.shared.handleGlassesMicData(audioData, 40)
1405
1574
  }
1406
1575
 
1407
1576
  private func processImageData(_ imageData: Data) {
@@ -1463,33 +1632,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1463
1632
  emitEvent("HeadUpAngleResponseEvent", body: eventBody)
1464
1633
  }
1465
1634
 
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
1635
  private func handleVadEventProtobuf(_ vadEvent: Mentraos_Ble_VadEvent) {
1494
1636
  let vadActiveState = vadEvent.state == .active
1495
1637
 
@@ -1511,11 +1653,12 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1511
1653
  switch status {
1512
1654
  case .ok:
1513
1655
  Bridge.log("NEX: Image transfer completed successfully")
1514
- // Clear any pending image chunks
1656
+ currentImageChunks.removeAll()
1657
+ isImageSendProgressing = false
1515
1658
 
1516
1659
  case .incomplete:
1517
1660
  Bridge.log("NEX: Image transfer incomplete - Missing chunks: \(missingChunks)")
1518
- // Could implement chunk retransmission here
1661
+ resendImageMissingChunks(missingChunks)
1519
1662
 
1520
1663
  default:
1521
1664
  Bridge.log("NEX: Unknown image transfer status")
@@ -1533,6 +1676,21 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1533
1676
  emitEvent("ImageTransferCompleteEvent", body: eventBody)
1534
1677
  }
1535
1678
 
1679
+ private func resendImageMissingChunks(_ missingChunks: [UInt32]) {
1680
+ guard isImageSendProgressing, !currentImageChunks.isEmpty, !missingChunks.isEmpty else {
1681
+ return
1682
+ }
1683
+ let retransmit = missingChunks.compactMap { index -> [UInt8]? in
1684
+ let i = Int(index)
1685
+ guard i >= 0, i < currentImageChunks.count else { return nil }
1686
+ return currentImageChunks[i]
1687
+ }
1688
+ if !retransmit.isEmpty {
1689
+ Bridge.log("NEX: Resending \(retransmit.count) missing image chunks")
1690
+ queueChunks(retransmit)
1691
+ }
1692
+ }
1693
+
1536
1694
  private func handleImuDataProtobuf(_ imuData: Mentraos_Ble_ImuData) {
1537
1695
  Bridge.log("NEX: 📊 IMU Data: \(imuData)")
1538
1696
 
@@ -1579,7 +1737,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1579
1737
  let level = json["level"] as? Int ?? -1
1580
1738
  let isCharging = json["charging"] as? Bool ?? false
1581
1739
 
1582
- Bridge.log("NEX: 🔋 JSON Battery Status - Level: \(level)%, Charging: \(charging)")
1740
+ Bridge.log("NEX: 🔋 JSON Battery Status - Level: \(level)%, Charging: \(isCharging)")
1583
1741
 
1584
1742
  // Update @Published properties (G1-compatible approach)
1585
1743
  DeviceStore.shared.apply("glasses", "batteryLevel", level)
@@ -1615,25 +1773,6 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1615
1773
  emitEvent("ButtonPressEvent", body: eventBody)
1616
1774
  }
1617
1775
 
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
1776
  private func handleVadEventJson(_ json: [String: Any]) {
1638
1777
  let vadActiveState = json["vad"] as? Bool ?? false
1639
1778
 
@@ -1682,28 +1821,16 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1682
1821
 
1683
1822
  private func notifyHeartbeatSent(_ timestamp: TimeInterval) {
1684
1823
  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)
1824
+ Bridge.sendTypedMessage("heartbeat_sent", body: [
1825
+ "timestamp": timestamp,
1826
+ ])
1694
1827
  }
1695
1828
 
1696
1829
  private func notifyHeartbeatReceived(_ timestamp: TimeInterval) {
1697
1830
  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)
1831
+ Bridge.sendTypedMessage("heartbeat_received", body: [
1832
+ "timestamp": timestamp,
1833
+ ])
1707
1834
  }
1708
1835
 
1709
1836
  @objc func getLastHeartbeatSentTime() -> TimeInterval {
@@ -1723,8 +1850,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1723
1850
  stopMicBeat()
1724
1851
  }
1725
1852
 
1726
- // Set mic enabled first (like Java line 1751)
1727
- setMicrophoneEnabled(true)
1853
+ sendSetMicEnabled(true, delaySeconds: 0.01)
1728
1854
  micBeatCount += 1
1729
1855
 
1730
1856
  // Schedule periodic mic beat (like Java lines 1753-1762)
@@ -1732,18 +1858,24 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1732
1858
  [weak self] _ in
1733
1859
  guard let self else { return }
1734
1860
  Bridge.log("NEX: SENDING MIC BEAT")
1735
- self.setMicrophoneEnabled(self.shouldUseGlassesMic)
1861
+ self.sendSetMicEnabled(self.shouldUseGlassesMic, delaySeconds: 0.001)
1736
1862
  }
1737
1863
  }
1738
1864
 
1739
1865
  private func stopMicBeat() {
1740
- setMicrophoneEnabled(false)
1866
+ sendSetMicEnabled(false, delaySeconds: 0.01)
1741
1867
  micBeatTimer?.invalidate()
1742
1868
  micBeatTimer = nil
1743
1869
  micBeatCount = 0
1744
1870
  Bridge.log("NEX: Stopped mic beat")
1745
1871
  }
1746
1872
 
1873
+ private func sendSetMicEnabled(_ enabled: Bool, delaySeconds: TimeInterval) {
1874
+ DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
1875
+ self?.setMicrophoneEnabled(enabled)
1876
+ }
1877
+ }
1878
+
1747
1879
  private func sendWhiteListCommand() {
1748
1880
  guard !whiteListedAlready else {
1749
1881
  Bridge.log("NEX: Whitelist already sent, skipping")
@@ -1842,13 +1974,45 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1842
1974
 
1843
1975
  /// Save microphone state before disconnection (like Java implementation)
1844
1976
  private func saveMicrophoneStateBeforeDisconnection() {
1845
- UserDefaults.standard.set(shouldUseGlassesMic, forKey: "microphoneStateBeforeDisconnection")
1846
- microphoneStateBeforeDisconnection = shouldUseGlassesMic
1847
- Bridge.log("NEX: Saved microphone state before disconnection: \(shouldUseGlassesMic)")
1977
+ UserDefaults.standard.set(isMicrophoneEnabled, forKey: "microphoneStateBeforeDisconnection")
1978
+ microphoneStateBeforeDisconnection = isMicrophoneEnabled
1979
+ Bridge.log("NEX: Saved microphone state before disconnection: \(isMicrophoneEnabled)")
1848
1980
  }
1849
1981
 
1850
1982
  @objc func disconnect() {
1851
1983
  Bridge.log("NEX: 🔌 User-initiated disconnect")
1984
+ // Light teardown: drop the link but stay able to reconnect.
1985
+ sendIntentionalDisconnectThen { [weak self] in self?.finalizeDisconnect() }
1986
+ }
1987
+
1988
+ /// Best-effort: tell the glasses this disconnect is intentional so they return to
1989
+ /// the welcome screen immediately rather than holding the last frame through the
1990
+ /// firmware's unexpected-disconnect grace period, then run `teardown` after a short
1991
+ /// window to let the write flush. Falls straight through if nothing is connected.
1992
+ private func sendIntentionalDisconnectThen(_ teardown: @escaping () -> Void) {
1993
+ isDisconnecting = true
1994
+ stopReconnectionTimer()
1995
+ guard peripheral != nil, servicesReady else {
1996
+ teardown()
1997
+ return
1998
+ }
1999
+ sendDisconnectRequest()
2000
+ MentraNexSGC._bluetoothQueue.asyncAfter(deadline: .now() + 0.25, execute: teardown)
2001
+ }
2002
+
2003
+ private func sendDisconnectRequest() {
2004
+ let phoneToGlasses = Mentraos_Ble_PhoneToGlasses.with {
2005
+ $0.disconnect = Mentraos_Ble_DisconnectRequest()
2006
+ }
2007
+ guard let protobufData = try? phoneToGlasses.serializedData() else {
2008
+ Bridge.log("NEX: ⚠️ Failed to serialize DisconnectRequest")
2009
+ return
2010
+ }
2011
+ Bridge.log("NEX: 📤 Sending DisconnectRequest before teardown")
2012
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
2013
+ }
2014
+
2015
+ private func finalizeDisconnect() {
1852
2016
  if let peripheral {
1853
2017
  // Save microphone state before disconnection (like Java implementation)
1854
2018
  saveMicrophoneStateBeforeDisconnection()
@@ -1856,16 +2020,25 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1856
2020
  // Stop mic beat system
1857
2021
  stopMicBeat()
1858
2022
 
1859
- isDisconnecting = true
1860
2023
  connectionState = ConnTypes.DISCONNECTED
1861
2024
  centralManager?.cancelPeripheralConnection(peripheral)
1862
2025
  }
2026
+ setServicesReady(false)
2027
+ releaseServiceWaiters()
2028
+ Task { await commandQueue.clear() }
2029
+ resumePendingWrite()
1863
2030
  stopReconnectionTimer()
1864
2031
  }
1865
2032
 
1866
2033
  // MARK: - Lifecycle Management (ported from Java)
1867
2034
 
1868
2035
  @objc func destroy() {
2036
+ // Route through the shared path so forget()/cleanup() also signal an
2037
+ // intentional disconnect to the glasses before the link goes down.
2038
+ sendIntentionalDisconnectThen { [weak self] in self?.performDestroy() }
2039
+ }
2040
+
2041
+ private func performDestroy() {
1869
2042
  Bridge.log("NEX: 💥 Destroying MentraNexSGC instance")
1870
2043
 
1871
2044
  isKilled = true
@@ -1899,11 +2072,22 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1899
2072
 
1900
2073
  // Clear discovery cache
1901
2074
  discoveredPeripherals.removeAll()
2075
+ setServicesReady(false)
2076
+ releaseServiceWaiters()
2077
+ Task { await commandQueue.clear() }
2078
+ resumePendingWrite()
1902
2079
 
1903
2080
  Bridge.log("NEX: ✅ MentraNexSGC destroyed successfully")
1904
2081
  // Reset initialization flags
1905
2082
  whiteListedAlready = false
1906
2083
  protobufVersionPosted = false
2084
+ currentImageChunks.removeAll()
2085
+ isImageSendProgressing = false
2086
+ currentMTU = MTU_DEFAULT
2087
+ deviceMaxMTU = MTU_DEFAULT
2088
+ maxChunkSize = MTU_DEFAULT - 10
2089
+ bmpChunkSize = MTU_DEFAULT - 20
2090
+ updateConnectedState(isConnected: false)
1907
2091
  }
1908
2092
 
1909
2093
  @objc func reset() {
@@ -1933,6 +2117,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1933
2117
  micBeatCount = 0
1934
2118
  shouldUseGlassesMic = true
1935
2119
  microphoneStateBeforeDisconnection = false
2120
+ currentImageChunks.removeAll()
2121
+ isImageSendProgressing = false
2122
+ updateConnectedState(isConnected: false)
1936
2123
  }
1937
2124
 
1938
2125
  // MARK: - Helper Methods (like G1)
@@ -1949,6 +2136,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1949
2136
  Bridge.sendDiscoveredDevice(DeviceTypes.NEX, name)
1950
2137
  }
1951
2138
 
2139
+ private func updateConnectedState(isConnected: Bool) {
2140
+ connectionState = isConnected ? ConnTypes.CONNECTED : ConnTypes.DISCONNECTED
2141
+ DeviceStore.shared.apply("glasses", "connected", isConnected)
2142
+ DeviceStore.shared.apply("glasses", "fullyBooted", isConnected)
2143
+ DeviceStore.shared.apply("glasses", "connectionState", isConnected ? ConnTypes.CONNECTED : ConnTypes.DISCONNECTED)
2144
+ }
2145
+
1952
2146
  @objc func checkBluetoothState() {
1953
2147
  Bridge.log("NEX: 🔍 Checking Bluetooth State...")
1954
2148
  if let centralManager {
@@ -2025,10 +2219,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2025
2219
  return
2026
2220
  }
2027
2221
 
2028
- // guard isCompatibleNexDevice(deviceName) else {
2029
- // // Bridge.log("NEX-CONN: 🚫 Ignoring incompatible device: \(deviceName)")
2030
- // return
2031
- // }
2222
+ guard isCompatibleNexDevice(deviceName) else {
2223
+ return
2224
+ }
2032
2225
 
2033
2226
  Bridge.log("NEX-CONN: 🎯 === Compatible Nex Device Found ===")
2034
2227
  Bridge.log("NEX-CONN: 📱 Device Name: \(deviceName)")
@@ -2090,6 +2283,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2090
2283
  self.peripheral = peripheral
2091
2284
  isConnecting = true
2092
2285
  connectionState = ConnTypes.CONNECTING
2286
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
2093
2287
 
2094
2288
  // Use connection options for better reliability (from Java implementation)
2095
2289
  let connectionOptions: [String: Any] = [
@@ -2124,6 +2318,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2124
2318
  Bridge.log("NEX-CONN: 💾 Device information saved for reliable reconnection")
2125
2319
  peripheral.delegate = self
2126
2320
  Bridge.log("NEX-CONN: 🔍 Discovering services...")
2321
+ setServicesReady(false)
2127
2322
  peripheral.discoverServices([MAIN_SERVICE_UUID])
2128
2323
 
2129
2324
  // Reset any failed connection attempt counters
@@ -2139,6 +2334,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2139
2334
  )
2140
2335
  isConnecting = false
2141
2336
  connectionState = ConnTypes.DISCONNECTED
2337
+ setServicesReady(false)
2338
+ releaseServiceWaiters()
2339
+ Task { await commandQueue.clear() }
2340
+ resumePendingWrite()
2142
2341
  self.peripheral = nil // Reset peripheral on failure to allow reconnection
2143
2342
  // Optionally, start reconnection attempts here
2144
2343
  if !isDisconnecting, !isKilled {
@@ -2161,6 +2360,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2161
2360
  // Reset connection state
2162
2361
  // Save microphone state before disconnection (like Java implementation)
2163
2362
  saveMicrophoneStateBeforeDisconnection()
2363
+ setServicesReady(false)
2364
+ releaseServiceWaiters()
2365
+ Task { await commandQueue.clear() }
2366
+ resumePendingWrite()
2164
2367
 
2165
2368
  // Reset protobuf version posted flag for next connection (like Java implementation)
2166
2369
  protobufVersionPosted = false
@@ -2191,6 +2394,13 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2191
2394
  writeCharacteristic = nil
2192
2395
  notifyCharacteristic = nil
2193
2396
  connectionState = ConnTypes.DISCONNECTED
2397
+ currentMTU = MTU_DEFAULT
2398
+ deviceMaxMTU = MTU_DEFAULT
2399
+ maxChunkSize = MTU_DEFAULT - 10
2400
+ bmpChunkSize = MTU_DEFAULT - 20
2401
+ currentImageChunks.removeAll()
2402
+ isImageSendProgressing = false
2403
+ updateConnectedState(isConnected: false)
2194
2404
 
2195
2405
  // Clear command queue if needed
2196
2406
  if isQueueWorkerRunning {
@@ -2228,8 +2438,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2228
2438
  // iOS MTU is automatically negotiated - we can only discover the current value
2229
2439
  // No manual MTU request available on iOS (platform limitation)
2230
2440
 
2231
- // Get current MTU capability (iOS-specific approach)
2232
- let maxWriteLength = peripheral.maximumWriteValueLength(for: .withResponse)
2441
+ // Get current MTU capability (iOS-specific approach). Query for .withoutResponse
2442
+ // since that's the write type the caption path uses; its limit can differ from
2443
+ // .withResponse, and sizing chunks to it avoids oversized writes being dropped.
2444
+ let maxWriteLength = peripheral.maximumWriteValueLength(for: .withoutResponse)
2233
2445
  let actualMTU = maxWriteLength + 3 // Add L2CAP header size
2234
2446
 
2235
2447
  Bridge.log("NEX: 📊 iOS MTU Discovery Results:")
@@ -2265,7 +2477,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2265
2477
 
2266
2478
  // Calculate optimal chunk sizes based on iOS MTU constraints
2267
2479
  maxChunkSize = currentMTU - 10 // Reserve 10 bytes for headers
2268
- bmpChunkSize = currentMTU - 6 // Reserve 6 bytes for image headers
2480
+ bmpChunkSize = currentMTU - 20
2269
2481
 
2270
2482
  Bridge.log("NEX: 📦 Optimized Chunk Sizes:")
2271
2483
  Bridge.log("NEX: 📄 Data Chunk Size: \(maxChunkSize) bytes")
@@ -2289,6 +2501,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2289
2501
  Bridge.log("NEX-CONN: ✅ Device initialization complete - ready for communication")
2290
2502
  nexReady = true
2291
2503
  connectionState = ConnTypes.CONNECTED
2504
+ lastConnectionTimestamp = Date().timeIntervalSince1970 * 1000
2505
+ updateConnectedState(isConnected: true)
2292
2506
 
2293
2507
  // Update @Published property for device ready state
2294
2508
  deviceReady = true
@@ -2342,9 +2556,14 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2342
2556
  self.postProtobufSchemaVersionInfo()
2343
2557
  }
2344
2558
 
2345
- // 6. Query glasses protobuf version from firmware (Java line 690)
2346
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { // 150ms delay
2347
- self.queryGlassesInfo()
2559
+ // 6. Version request is removed in current schema; Android sends no-op.
2560
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
2561
+ Bridge.log("NEX: Skipping version request; schema removed VersionRequest")
2562
+ }
2563
+
2564
+ // 7. Push current glasses-side Voice Activity Detection setting
2565
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
2566
+ self.sendVoiceActivityDetectionSetting()
2348
2567
  }
2349
2568
 
2350
2569
  Bridge.log("NEX-CONN: ✅ Java-compatible initialization sequence started")
@@ -2449,10 +2668,15 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2449
2668
  Bridge.log(
2450
2669
  "NEX-CONN: ❌ Error writing value to \(characteristic.uuid): \(error.localizedDescription)"
2451
2670
  )
2671
+ resumePendingWrite()
2452
2672
  return
2453
2673
  }
2454
- // This log can be very noisy, so it's commented out.
2455
- // Bridge.log("NEX-CONN: 📤 Successfully wrote value to \(characteristic.uuid).")
2674
+ resumePendingWrite()
2675
+ }
2676
+
2677
+ func peripheralIsReady(toSendWriteWithoutResponse _: CBPeripheral) {
2678
+ // CoreBluetooth can accept more write-without-response data; unblock the sender.
2679
+ resumePendingWrite()
2456
2680
  }
2457
2681
 
2458
2682
  func peripheral(
@@ -2470,10 +2694,16 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2470
2694
  Bridge.log(
2471
2695
  "NEX-CONN: ✅ Successfully subscribed to notifications for characteristic \(characteristic.uuid.uuidString)."
2472
2696
  )
2697
+ if characteristic.uuid == NOTIFY_CHAR_UUID {
2698
+ setServicesReady(true)
2699
+ }
2473
2700
  } else {
2474
2701
  Bridge.log(
2475
2702
  "NEX-CONN: unsubscribed from notifications for characteristic \(characteristic.uuid.uuidString)."
2476
2703
  )
2704
+ if characteristic.uuid == NOTIFY_CHAR_UUID {
2705
+ setServicesReady(false)
2706
+ }
2477
2707
  }
2478
2708
  }
2479
2709
  }