@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
@@ -1,5 +1,6 @@
1
1
  package com.mentra.bluetoothsdk.sgcs
2
2
 
3
+ import com.mentra.bluetoothsdk.BluetoothSdkDefaults
3
4
  import com.mentra.bluetoothsdk.DeviceManager
4
5
  import com.mentra.bluetoothsdk.DeviceStore
5
6
 
@@ -27,7 +28,6 @@ import mentraos.ble.MentraosBle.GlassesToPhone
27
28
  import mentraos.ble.MentraosBle.PhoneToGlasses
28
29
  import mentraos.ble.MentraosBle.ChargingState
29
30
  import mentraos.ble.MentraosBle.BatteryStatus
30
- import mentraos.ble.MentraosBle.VersionResponse
31
31
  import mentraos.ble.MentraosBle.HeadGesture
32
32
  import mentraos.ble.MentraosBle.ButtonEvent
33
33
  import mentraos.ble.MentraosBle.ImuData
@@ -69,9 +69,15 @@ class MentraNex : SGCManager() {
69
69
  private const val MTU_DEFAULT: Int = 23
70
70
 
71
71
  private const val MAX_CHUNK_SIZE_DEFAULT: Int = 176 // Maximum chunk size for BLE packets
72
- private const val DELAY_BETWEEN_CHUNKS_SEND: Long = 10 // Adjust this value as needed
72
+ private const val DELAY_BETWEEN_CHUNKS_SEND: Long = 5 // Adjust this value as needed
73
+ // Upper bound on waiting for onCharacteristicWrite. A no-response write's
74
+ // callback is normally near-instant; this only guards against a callback that
75
+ // never arrives, so a dropped write can't wedge the send queue forever.
76
+ private const val WRITE_CALLBACK_TIMEOUT_MS: Long = 10
73
77
 
74
78
  private const val INITIAL_CONNECTION_DELAY_MS = 350L // Adjust this value as needed
79
+ // Window to let a queued DisconnectRequest flush to the glasses before we close GATT.
80
+ private const val DISCONNECT_FLUSH_DELAY_MS = 250L
75
81
  private const val MICBEAT_INTERVAL_MS: Long = (1000 * 60) * 30; // micbeat every 30 minutes
76
82
 
77
83
  private const val MAIN_TASK_HANDLER_CODE_GATT_STATUS_CHANGED: Int = 110
@@ -98,7 +104,10 @@ class MentraNex : SGCManager() {
98
104
  private var context: Context? = null
99
105
  // private var isDebug: Boolean = true
100
106
 
101
- private var isLc3AudioEnabled: Boolean = true
107
+ // Off by default; toggled from Nex Developer Settings via the nex_audio_playback flag.
108
+ private val isLc3AudioEnabled: Boolean
109
+ // get() = DeviceStore.get("bluetooth", "nex_audio_playback") as? Boolean ?: false
110
+ get() = false
102
111
  private var lc3AudioPlayer: Lc3Player? = null
103
112
 
104
113
  private var lc3DecoderPtr: Long = 0
@@ -132,6 +141,7 @@ class MentraNex : SGCManager() {
132
141
 
133
142
  private var maxChunkSize: Int = MAX_CHUNK_SIZE_DEFAULT // Maximum chunk size for BLE packets
134
143
  private var bmpChunkSize: Int = 194 // BMP chunk size
144
+ private var protobufSeq: Int = 0 // Rolling sequence for fragmented control messages
135
145
 
136
146
  @Volatile private var isWorkerRunning = false
137
147
  // Queue to hold pending requests
@@ -149,8 +159,6 @@ class MentraNex : SGCManager() {
149
159
 
150
160
  private var protobufVersionPosted: Boolean = false
151
161
 
152
- private var hasConnectedThisSession: Boolean = false
153
-
154
162
  private var bleScanCallback: ScanCallback? = null
155
163
 
156
164
  private val modernScanCallback = object : ScanCallback() {
@@ -212,6 +220,17 @@ class MentraNex : SGCManager() {
212
220
  }
213
221
  }
214
222
 
223
+ /** Start/stop the LC3 player when the nex_audio_playback flag changes. */
224
+ override fun applyNexAudioPlaybackSetting() {
225
+ if (isLc3AudioEnabled) {
226
+ Bridge.log("Nex: LC3 audio playback enabled - starting player")
227
+ lc3AudioPlayer?.startPlay()
228
+ } else {
229
+ Bridge.log("Nex: LC3 audio playback disabled - stopping player")
230
+ lc3AudioPlayer?.stopPlay()
231
+ }
232
+ }
233
+
215
234
  private val random: Random = Random()
216
235
 
217
236
  private val mainGattCallback: BluetoothGattCallback = createGattCallback()
@@ -264,6 +283,17 @@ class MentraNex : SGCManager() {
264
283
  }
265
284
  }
266
285
 
286
+ /** Bounded wait: returns early if the flag is still set after [timeoutMs]. */
287
+ @Synchronized
288
+ fun waitWhileTrue(timeoutMs: Long) {
289
+ val deadline = System.currentTimeMillis() + timeoutMs
290
+ while (flag) {
291
+ val remaining = deadline - System.currentTimeMillis()
292
+ if (remaining <= 0) break
293
+ (this as Object).wait(remaining)
294
+ }
295
+ }
296
+
267
297
  @Synchronized
268
298
  fun setTrue() {
269
299
  flag = true
@@ -286,11 +316,27 @@ class MentraNex : SGCManager() {
286
316
  override fun queryGalleryStatus() { Bridge.log("Nex: queryGalleryStatus operation not supported") }
287
317
  override fun sendGalleryMode() { Bridge.log("Nex: sendGalleryMode operation not supported") }
288
318
 
319
+ override fun sendVoiceActivityDetectionSetting() {
320
+ val enabled =
321
+ DeviceStore.get("bluetooth", "voice_activity_detection_enabled") as? Boolean
322
+ ?: BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED
323
+ Bridge.log("Nex: 🎤 Sending Voice Activity Detection setting to glasses: $enabled")
324
+
325
+ if (connectionState != ConnTypes.CONNECTED) {
326
+ Bridge.log("Nex: Cannot send VAD setting - not connected")
327
+ return
328
+ }
329
+
330
+ val bytes = NexProtobufUtils.generateVadEnabledRequestCommandBytes(enabled)
331
+ sendDataSequentially(bytes)
332
+ Bridge.sendVoiceActivityDetectionStatus(enabled)
333
+ }
334
+
289
335
  // Version info: Not supported on Nex (uses protobuf for version info)
290
336
  override fun requestVersionInfo() { Bridge.log("Nex: requestVersionInfo operation not supported") }
291
337
 
292
338
  // Camera & Media: Not supported on Nex (No camera)
293
- override fun requestPhoto(requestId: String, appId: String, size: String, webhookUrl: String?, authToken: String?, compress: String?, flash: Boolean, sound: Boolean, exposureTimeNs: Long?, iso: Int?) { Bridge.log("Nex: requestPhoto operation not supported") }
339
+ override fun requestPhoto(requestId: String, appId: String, size: String, webhookUrl: String?, authToken: String?, compress: String?, flash: Boolean, save: Boolean, sound: Boolean, exposureTimeNs: Long?, iso: Int?) { Bridge.log("Nex: requestPhoto operation not supported") }
294
340
  override fun startStream(message: MutableMap<String, Any>) { Bridge.log("Nex: startStream operation not supported") }
295
341
  override fun stopStream() { Bridge.log("Nex: stopStream operation not supported") }
296
342
  override fun sendStreamKeepAlive(message: MutableMap<String, Any>) { Bridge.log("Nex: sendStreamKeepAlive operation not supported") }
@@ -391,12 +437,12 @@ class MentraNex : SGCManager() {
391
437
 
392
438
  override fun disconnect() {
393
439
  DeviceStore.apply("glasses", "fullyBooted", false)
394
- destroy();
440
+ destroy()
395
441
  }
396
442
 
397
443
  override fun forget() {
398
444
  DeviceStore.apply("glasses", "fullyBooted", false)
399
- destroy();
445
+ destroy()
400
446
  }
401
447
 
402
448
  override fun cleanup() {
@@ -411,11 +457,10 @@ class MentraNex : SGCManager() {
411
457
  Bridge.log("Nex: === SENDING HEAD UP ANGLE COMMAND TO GLASSES ===")
412
458
  Bridge.log("Nex: Head Up Angle: $validatedAngle degrees (validated range: 0-60)")
413
459
  val cmdBytes = NexProtobufUtils.generateHeadUpAngleConfigCommandBytes(angle)
414
- sendDataSequentially(cmdBytes, 10)
460
+ sendProtobuf(cmdBytes, 10)
415
461
  }
416
462
 
417
463
  override fun getBatteryStatus() {
418
- Bridge.log("Nex: Requesting battery status");
419
464
  queryBatteryStatus()
420
465
  }
421
466
 
@@ -444,16 +489,16 @@ class MentraNex : SGCManager() {
444
489
  // TODO: test this logic. Is it correct? Should we send both or just one?
445
490
  Bridge.log("Nex: setBrightness() - level: " + level + "%, autoMode: " + autoMode);
446
491
  val brightnessCmdBytes = NexProtobufUtils.generateBrightnessConfigCommandBytes(level)
447
- sendDataSequentially(brightnessCmdBytes, 10)
492
+ sendProtobuf(brightnessCmdBytes, 10)
448
493
 
449
494
  val autoBrightnessCmdBytes = NexProtobufUtils.generateAutoBrightnessConfigCommandBytes(autoMode)
450
- sendDataSequentially(autoBrightnessCmdBytes, 10)
495
+ sendProtobuf(autoBrightnessCmdBytes, 10)
451
496
  }
452
497
 
453
498
  override fun clearDisplay() {
454
499
  Bridge.log("Nex: clearDisplay() - sending clear display request command bytes");
455
500
  val clearDisplayPackets = NexProtobufUtils.generateClearDisplayRequestCommandBytes()
456
- sendDataSequentially(clearDisplayPackets, 10)
501
+ sendProtobuf(clearDisplayPackets, 10)
457
502
  // sendTextWall(" ")
458
503
  Bridge.log("Nex: clearDisplay() - sent clear display request command bytes");
459
504
  }
@@ -461,7 +506,7 @@ class MentraNex : SGCManager() {
461
506
  override fun sendTextWall(text: String) {
462
507
  Bridge.log("Nex: sendTextWall() - text: " + text);
463
508
  val textChunks: ByteArray = createTextWallChunksForNex(text)
464
- sendDataSequentially(textChunks);
509
+ sendProtobuf(textChunks)
465
510
  }
466
511
 
467
512
  override fun sendDoubleTextWall(top: String, bottom: String) {
@@ -472,10 +517,16 @@ class MentraNex : SGCManager() {
472
517
  bottom?.let { append(it) }
473
518
  }
474
519
  val textChunks = createTextWallChunksForNex(finalText)
475
- sendDataSequentially(textChunks)
520
+ sendProtobuf(textChunks)
476
521
  }
477
522
 
478
- override fun displayBitmap(base64ImageData: String): Boolean {
523
+ override fun displayBitmap(
524
+ base64ImageData: String,
525
+ x: Int?,
526
+ y: Int?,
527
+ width: Int?,
528
+ height: Int?
529
+ ): Boolean {
479
530
  try {
480
531
  val bmpData: ByteArray? = android.util.Base64.decode(base64ImageData, android.util.Base64.DEFAULT)
481
532
  if (bmpData == null || bmpData.isEmpty()) {
@@ -501,23 +552,23 @@ class MentraNex : SGCManager() {
501
552
  Bridge.log("Nex: setDashboardPosition() - height: " + height + ", depth: " + depth)
502
553
  // Send display_height then display_distance; adjust order if Nex firmware requires otherwise.
503
554
  val heightBytes = NexProtobufUtils.generateDisplayHeightCommandBytes(height)
504
- sendDataSequentially(heightBytes, 10)
555
+ sendProtobuf(heightBytes, 10)
505
556
  val distanceCm = NexProtobufUtils.dashboardDepthToDistanceCm(depth)
506
557
  val distanceBytes = NexProtobufUtils.generateDisplayDistanceCommandBytes(distanceCm)
507
- sendDataSequentially(distanceBytes, 10)
558
+ sendProtobuf(distanceBytes, 10)
508
559
  }
509
560
 
510
561
  override fun setDashboardHeightOnly(height: Int) {
511
562
  Bridge.log("Nex: setDashboardHeightOnly() - height: $height")
512
563
  val heightBytes = NexProtobufUtils.generateDisplayHeightCommandBytes(height)
513
- sendDataSequentially(heightBytes, 10)
564
+ sendProtobuf(heightBytes, 10)
514
565
  }
515
566
 
516
567
  override fun setDashboardDepthOnly(depth: Int) {
517
568
  Bridge.log("Nex: setDashboardDepthOnly() - depth: $depth")
518
569
  val distanceCm = NexProtobufUtils.dashboardDepthToDistanceCm(depth)
519
570
  val distanceBytes = NexProtobufUtils.generateDisplayDistanceCommandBytes(distanceCm)
520
- sendDataSequentially(distanceBytes, 10)
571
+ sendProtobuf(distanceBytes, 10)
521
572
  }
522
573
 
523
574
  override fun ping() {
@@ -696,29 +747,6 @@ class MentraNex : SGCManager() {
696
747
  characteristic: BluetoothGattCharacteristic,
697
748
  status: Int
698
749
  ) {
699
- Bridge.log("onCharacteristicWrite callback - ")
700
- val values = characteristic.value ?: byteArrayOf()
701
-
702
- if (status == BluetoothGatt.GATT_SUCCESS) {
703
- Bridge.log("[BLE] onCharacteristicWrite: SUCCESS len=${values.size}")
704
- val packetHex = values.joinToString("") { "%02X".format(it) }
705
- Bridge.log("onCharacteristicWrite Values - $packetHex")
706
-
707
- if (values.isNotEmpty()) {
708
- val packetType = values[0]
709
- val protobufData = values.copyOfRange(1, values.size)
710
-
711
- if (packetType == NexBluetoothPacketTypes.PACKET_TYPE_PROTOBUF) {
712
- // just for test
713
- decodeProtobufsByWrite(protobufData, packetHex)
714
- }
715
- }
716
- } else {
717
- Log.e(TAG, "[BLE] onCharacteristicWrite: FAILED status=$status${if (status == 133) " (GATT_ERROR 133 - likely disconnected mid-write)" else ""}")
718
- }
719
-
720
- // clear the waiter
721
- Bridge.log("[BLE] onCharacteristicWrite: releasing mainWaiter")
722
750
  mainWaiter.setFalse()
723
751
  }
724
752
 
@@ -787,9 +815,12 @@ class MentraNex : SGCManager() {
787
815
 
788
816
  private fun handleMainTaskMessage(msg: Message): Boolean {
789
817
  val msgCode = msg.what
790
- Bridge.log("Nex: handleMessage msgCode: $msgCode")
791
- Bridge.log("Nex: handleMessage obj: ${msg.obj}")
792
-
818
+ // NOTE: do not log here. Every incoming BLE notification (including
819
+ // ~50 audio packets/sec) is dispatched through this handler on the
820
+ // main looper, and Bridge.log marshals an event across the RN bridge
821
+ // to JS. Per-message logging here floods the main thread and backs up
822
+ // the audio queue when the CPU downclocks at screen-off. (Matches G1,
823
+ // which does not log per audio packet.)
793
824
  when (msgCode) {
794
825
  MAIN_TASK_HANDLER_CODE_GATT_STATUS_CHANGED -> {}
795
826
  MAIN_TASK_HANDLER_CODE_DISCOVER_SERVICES -> {
@@ -817,7 +848,6 @@ class MentraNex : SGCManager() {
817
848
  MAIN_TASK_HANDLER_CODE_HEART_BEAT -> {
818
849
  // Note: Heartbeat is now handled by receiving ping from glasses
819
850
  // This case is kept for backward compatibility but no longer used
820
- Bridge.log("Nex: Heartbeat handler called - no longer sending periodic pings")
821
851
  }
822
852
  else -> { }
823
853
  }
@@ -838,9 +868,19 @@ class MentraNex : SGCManager() {
838
868
  val writeChar = uartService.getCharacteristic(NexBluetoothConstants.WRITE_CHAR_UUID)
839
869
  val notifyChar = uartService.getCharacteristic(NexBluetoothConstants.NOTIFY_CHAR_UUID)
840
870
 
841
- writeChar?.let {
871
+ writeChar?.let {
842
872
  mainWriteChar = it
843
- Bridge.log("Nex: glass TX characteristic found")
873
+ // Write-without-response on the characteristic we actually write
874
+ // to (the firmware RX char advertises WRITE_WITHOUT_RESP). This
875
+ // is what unblocks TX from the ~1-packet-per-connection-interval
876
+ // ATT round-trip — critical when the phone sleeps and the
877
+ // interval is relaxed. Android defaults a freshly-discovered
878
+ // characteristic to WRITE_TYPE_DEFAULT (with response), and the
879
+ // reference is reassigned on every service rediscovery, so set
880
+ // it here. (Previously this was set on the notify char in
881
+ // enableNotification(), where it had no effect on writes.)
882
+ it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
883
+ Bridge.log("Nex: glass TX characteristic found (write type NO_RESPONSE)")
844
884
  }
845
885
 
846
886
  notifyChar?.let {
@@ -871,14 +911,6 @@ class MentraNex : SGCManager() {
871
911
  showHomeScreen() // Turn on the NexGlasses display
872
912
  updateConnectionState()
873
913
 
874
- if (hasConnectedThisSession) {
875
- Bridge.log("Nex: BLE reconnect detected — showing reconnected banner")
876
- sendTextWall("// MentraOS Reconnected")
877
- mainTaskHandler.postDelayed({ clearDisplay() }, 3000)
878
- } else {
879
- hasConnectedThisSession = true
880
- }
881
-
882
914
  // Post protobuf schema version information (only once)
883
915
  if (!protobufVersionPosted) {
884
916
  postProtobufSchemaVersionInfo()
@@ -888,8 +920,15 @@ class MentraNex : SGCManager() {
888
920
  // Query glasses protobuf version from firmware
889
921
  Bridge.log("=== SENDING GLASSES PROTOBUF VERSION REQUEST ===")
890
922
  val versionQueryPacket = NexProtobufUtils.generateVersionRequestCommandBytes()
891
- sendDataSequentially(versionQueryPacket, 100)
892
- Bridge.log("Sent glasses protobuf version request")
923
+ if (versionQueryPacket.isNotEmpty()) {
924
+ sendProtobuf(versionQueryPacket, 100)
925
+ Bridge.log("Sent glasses protobuf version request")
926
+ } else {
927
+ Bridge.log("Skipping version request: schema removed VersionRequest")
928
+ }
929
+
930
+ // Push current glasses-side Voice Activity Detection setting
931
+ sendVoiceActivityDetectionSetting()
893
932
  }
894
933
  } else {
895
934
  Log.e(TAG, " glass UART service not found")
@@ -903,9 +942,10 @@ class MentraNex : SGCManager() {
903
942
  val result = gatt.setCharacteristicNotification(characteristic, true)
904
943
  Bridge.log("Nex: PROC_QUEUE - setCharacteristicNotification result: $result")
905
944
 
906
- // Set write type for the characteristic
907
- characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
908
- Bridge.log("Nex: PROC_QUEUE - write type set")
945
+ // NOTE: write type is NOT set here. This receives the *notify*
946
+ // characteristic, which the phone never writes to — setting writeType
947
+ // on it had no effect. The write characteristic (mainWriteChar) is
948
+ // configured for WRITE_TYPE_NO_RESPONSE at its assignment instead.
909
949
 
910
950
  // Add delay
911
951
  Bridge.log("Nex: PROC_QUEUE - waiting to enable notification...")
@@ -928,15 +968,16 @@ class MentraNex : SGCManager() {
928
968
  if (characteristic.uuid == NexBluetoothConstants.NOTIFY_CHAR_UUID) {
929
969
  val data = characteristic.value
930
970
  val deviceName = mainGlassGatt?.device?.name ?: return
931
- val packetHex = data.joinToString("") { "%02X".format(it) }
932
- Bridge.log("onCharacteristicChangedHandler len: ${data.size}")
933
- Bridge.log("onCharacteristicChangedHandler: $packetHex")
934
-
971
+ // NOTE: this runs on the main looper for every notification, audio
972
+ // included (~50/sec). Do not log or stringify the whole packet to
973
+ // hex on this hot path — Bridge.log crosses the RN bridge to JS and
974
+ // packetHex is O(n) per packet. Both back up the audio queue at
975
+ // screen-off. packetHex is only needed by the protobuf branch, so
976
+ // compute it lazily there.
935
977
  if (data.isEmpty()) return
936
-
978
+
937
979
  val packetType = data[0]
938
- Bridge.log("onCharacteristicChangedHandler packetType: ${String.format("%02X ", packetType)}")
939
-
980
+
940
981
  when (packetType) {
941
982
  NexBluetoothPacketTypes.PACKET_TYPE_JSON -> {
942
983
  val jsonData = data.copyOfRange(1, data.size)
@@ -944,6 +985,7 @@ class MentraNex : SGCManager() {
944
985
  }
945
986
  NexBluetoothPacketTypes.PACKET_TYPE_PROTOBUF -> {
946
987
  val protobufData = data.copyOfRange(1, data.size)
988
+ val packetHex = data.joinToString("") { "%02X".format(it) }
947
989
  decodeProtobufs(protobufData, packetHex)
948
990
  }
949
991
  NexBluetoothPacketTypes.PACKET_TYPE_AUDIO -> {
@@ -960,17 +1002,17 @@ class MentraNex : SGCManager() {
960
1002
 
961
1003
  val lc3Data = data.copyOfRange(2, data.size)
962
1004
 
963
- Bridge.log("Received LC3 audio packet seq=$sequenceNumber, size=${lc3Data.size}")
1005
+ // No per-packet log here: this fires ~50x/sec and each
1006
+ // Bridge.log crosses the RN bridge to JS. (Matches G1.)
964
1007
 
965
- // Play LC3 audio directly through LC3 player
1008
+ // Play LC3 audio directly through LC3 player.
1009
+ // No logging in any branch: this runs per audio packet
1010
+ // (~50/sec) and Bridge.log crosses the RN bridge to JS.
1011
+ // With isLc3AudioEnabled hardcoded off, the disabled
1012
+ // branch would otherwise log on every single packet.
966
1013
  if (lc3AudioPlayer != null && isLc3AudioEnabled) {
967
1014
  // Use the original packet format that LC3 player expects
968
1015
  lc3AudioPlayer?.write(data, 0, data.size)
969
- Bridge.log("Playing LC3 audio directly through LC3 player: ${data.size} bytes")
970
- } else if (!isLc3AudioEnabled) {
971
- Bridge.log("LC3 audio disabled - skipping LC3 audio output")
972
- } else {
973
- Bridge.log("LC3 player not available - skipping LC3 audio output")
974
1016
  }
975
1017
 
976
1018
  DeviceManager.getInstance().handleGlassesMicData(lc3Data);
@@ -1085,6 +1127,21 @@ class MentraNex : SGCManager() {
1085
1127
  }
1086
1128
 
1087
1129
  private fun destroy() {
1130
+ // Tell the glasses this teardown is intentional so they return to the welcome
1131
+ // screen immediately instead of holding the last frame through the firmware's
1132
+ // unexpected-disconnect grace period. Best-effort: if the write can't be sent
1133
+ // or doesn't flush in time, the firmware grace period is the safety net.
1134
+ val canSend = mainGlassGatt != null && mainWriteChar != null && isMainConnected
1135
+ if (!canSend) {
1136
+ performDestroy()
1137
+ return
1138
+ }
1139
+ Bridge.log("Nex: Sending DisconnectRequest before teardown")
1140
+ sendProtobuf(NexProtobufUtils.generateDisconnectRequestCommandBytes(), 100)
1141
+ mainTaskHandler.postDelayed({ performDestroy() }, DISCONNECT_FLUSH_DELAY_MS)
1142
+ }
1143
+
1144
+ private fun performDestroy() {
1088
1145
  Bridge.log("Nex: MentraNexSGC ONDESTROY")
1089
1146
  showHomeScreen()
1090
1147
  isKilled = true
@@ -1136,7 +1193,6 @@ class MentraNex : SGCManager() {
1136
1193
  sendQueue.offer(emptyArray()) // is this needed?
1137
1194
  isWorkerRunning = false
1138
1195
  isMainConnected = false
1139
- hasConnectedThisSession = false
1140
1196
  Bridge.log("Nex: MentraNexSGC cleanup complete")
1141
1197
  }
1142
1198
 
@@ -1200,7 +1256,7 @@ class MentraNex : SGCManager() {
1200
1256
  val streamId = "%04X".format(random.nextInt(0x10000)) // 4-digit hex format
1201
1257
 
1202
1258
  val startImageSendingBytes = NexProtobufUtils.generateDisplayImageCommandBytes(streamId, totalChunks, width, height)
1203
- sendDataSequentially(startImageSendingBytes)
1259
+ sendProtobuf(startImageSendingBytes)
1204
1260
 
1205
1261
  // Send all chunks with proper stream ID parsing
1206
1262
  val chunks = NexProtobufUtils.createBmpChunksForNexGlasses(streamId, bmpData, totalChunks, bmpChunkSize)
@@ -1231,7 +1287,6 @@ class MentraNex : SGCManager() {
1231
1287
  }
1232
1288
  sendSetMicEnabled(true, 10)
1233
1289
  micBeatRunnable = Runnable {
1234
- Bridge.log("Nex: SENDING MIC BEAT")
1235
1290
  sendSetMicEnabled(shouldUseGlassesMic, 1)
1236
1291
  micBeatHandler.postDelayed(micBeatRunnable!!, MICBEAT_INTERVAL_MS)
1237
1292
  }
@@ -1362,7 +1417,6 @@ class MentraNex : SGCManager() {
1362
1417
  val batteryStatus: BatteryStatus = glassesToPhone.batteryStatus
1363
1418
  DeviceStore.apply("glasses", "batteryLevel", batteryStatus.level)
1364
1419
  // EventBus.getDefault().post(BatteryLevelEvent(batteryStatus.level, batteryStatus.charging))
1365
- Bridge.log("batteryStatus: $batteryStatus")
1366
1420
  }
1367
1421
  GlassesToPhone.PayloadCase.CHARGING_STATE -> {
1368
1422
  val chargingState: ChargingState = glassesToPhone.chargingState
@@ -1382,11 +1436,6 @@ class MentraNex : SGCManager() {
1382
1436
  val headUpAngleResponse: HeadUpAngleResponse = glassesToPhone.headUpAngleSet
1383
1437
  Bridge.log("headUpAngleResponse: $headUpAngleResponse")
1384
1438
  }
1385
- GlassesToPhone.PayloadCase.PING -> {
1386
- lastHeartbeatReceivedTime = System.currentTimeMillis()
1387
- Bridge.log("=== RECEIVED PING FROM GLASSES === (Time: $lastHeartbeatReceivedTime)")
1388
- sendPongResponse()
1389
- }
1390
1439
  GlassesToPhone.PayloadCase.VAD_EVENT -> {
1391
1440
  // val vadEvent = glassesToPhone.vadEvent
1392
1441
  // EventBus.getDefault().post(VadEvent(vadEvent.vad))
@@ -1429,28 +1478,6 @@ class MentraNex : SGCManager() {
1429
1478
  // EventBus.getDefault().post(GlassesHeadDownEvent())
1430
1479
  // EventBus.getDefault().post(GlassesTapOutputEvent(2, isRight, System.currentTimeMillis()))
1431
1480
  }
1432
- GlassesToPhone.PayloadCase.VERSION_RESPONSE -> {
1433
- val versionResponse: VersionResponse = glassesToPhone.versionResponse
1434
- Bridge.log("=== RECEIVED GLASSES PROTOBUF VERSION RESPONSE ===")
1435
- Bridge.log("Glasses Protobuf Version: ${versionResponse.version}")
1436
- Bridge.log("Message ID: ${versionResponse.msgId}")
1437
- DeviceStore.apply("glasses", "protobufVersion", versionResponse.version.toString())
1438
-
1439
- if (versionResponse.commit.isNotEmpty()) {
1440
- Bridge.log("Commit: ${versionResponse.commit}")
1441
- }
1442
- if (versionResponse.buildDate.isNotEmpty()) {
1443
- Bridge.log("Build Date: ${versionResponse.buildDate}")
1444
- }
1445
-
1446
- // Post glasses protobuf version event to update UI
1447
- // EventBus.getDefault().post(ProtocolVersionResponseEvent(
1448
- // versionResponse.version,
1449
- // versionResponse.commit,
1450
- // versionResponse.buildDate,
1451
- // versionResponse.msgId
1452
- // ))
1453
- }
1454
1481
  GlassesToPhone.PayloadCase.PAYLOAD_NOT_SET,
1455
1482
  null -> {
1456
1483
  // Do nothing
@@ -1490,38 +1517,6 @@ class MentraNex : SGCManager() {
1490
1517
  sendDataSequentially(missingChunks)
1491
1518
  }
1492
1519
 
1493
- private fun sendPongResponse() {
1494
- // Respond to ping from glasses with pong
1495
- lastHeartbeatReceivedTime = System.currentTimeMillis()
1496
- Bridge.log("=== SENDING PONG RESPONSE TO GLASSES === (Time: $lastHeartbeatReceivedTime)")
1497
-
1498
- val pongPacket = NexProtobufUtils.constructPongResponse()
1499
-
1500
- // Send the pong response
1501
- if (pongPacket != null) {
1502
- sendDataSequentially(pongPacket, 100)
1503
- Bridge.log("Pong response sent successfully")
1504
-
1505
- // Notify mobile app about pong sent
1506
- lastHeartbeatSentTime = System.currentTimeMillis()
1507
- NexEventUtils.sendHeartbeatSentEvent(lastHeartbeatSentTime)
1508
- // EventBus.getDefault().post(HeartbeatSentEvent(timestamp))
1509
- } else {
1510
- Log.e(TAG,"Failed to construct pong response packet")
1511
- }
1512
-
1513
- // Still query battery periodically (every 10 pings received)
1514
- if (batteryLevel == -1 || heartbeatCount % 10 == 0) {
1515
- mainTaskHandler.sendEmptyMessageDelayed(MAIN_TASK_HANDLER_CODE_BATTERY_QUERY, 500)
1516
- }
1517
-
1518
- heartbeatCount++
1519
-
1520
- // Notify mobile app about heartbeat received
1521
- NexEventUtils.sendHeartbeatReceivedEvent(lastHeartbeatReceivedTime)
1522
- // EventBus.getDefault().post(HeartbeatReceivedEvent(lastHeartbeatReceivedTime))
1523
- }
1524
-
1525
1520
  private fun createTextWallChunksForNex(text: String): ByteArray {
1526
1521
  // Create the PhoneToGlasses using its builder and set the DisplayText
1527
1522
  return NexProtobufUtils.generateDisplayTextCommandBytes(text)
@@ -1540,14 +1535,14 @@ class MentraNex : SGCManager() {
1540
1535
  }
1541
1536
  Bridge.log("Nex: === SENDING MICROPHONE STATE COMMAND TO GLASSES ===")
1542
1537
  val micConfigBytes = NexProtobufUtils.generateMicStateConfigCommandBytes(enable)
1543
- sendDataSequentially(micConfigBytes, 10) // wait some time to setup the mic
1538
+ sendProtobuf(micConfigBytes, 10) // wait some time to setup the mic
1544
1539
  Bridge.log("Nex: Sent MIC command: ${micConfigBytes.joinToString("") { "%02x".format(it) }}")
1545
1540
  }, delay)
1546
1541
  }
1547
1542
 
1548
1543
  private fun queryBatteryStatus() {
1549
1544
  val batteryQueryPacket = NexProtobufUtils.generateBatteryStateRequestCommandBytes()
1550
- sendDataSequentially(batteryQueryPacket, 250)
1545
+ sendProtobuf(batteryQueryPacket, 250)
1551
1546
  }
1552
1547
 
1553
1548
  ///// PROCESSING THREAD /////////////////
@@ -1581,6 +1576,51 @@ class MentraNex : SGCManager() {
1581
1576
  startWorkerIfNeeded()
1582
1577
  }
1583
1578
 
1579
+ /**
1580
+ * Frames a serialized protobuf message onto the 0x02 control channel and sends it.
1581
+ *
1582
+ * The message is split into fragments that each fit the negotiated MTU, prefixed with a
1583
+ * 4-byte transport header: [0x02][seq][totalChunks][chunkIndex]. The firmware reassembles
1584
+ * fragments by seq/index before decoding. Single-fragment messages (totalChunks == 1) are
1585
+ * decoded directly by the firmware fast path.
1586
+ *
1587
+ * @param protobufBytes raw serialized PhoneToGlasses bytes (no framing)
1588
+ * @param waitTime optional post-message delay in ms applied after the last fragment (-1 = none)
1589
+ */
1590
+ private fun sendProtobuf(protobufBytes: ByteArray, waitTime: Int = -1) {
1591
+ val headerSize = 4 // [0x02][seq][totalChunks][chunkIndex]
1592
+ val maxFragment = (maxChunkSize - headerSize).coerceAtLeast(1)
1593
+ val totalChunks =
1594
+ if (protobufBytes.isEmpty()) 1 else (protobufBytes.size + maxFragment - 1) / maxFragment
1595
+
1596
+ if (totalChunks > 255) {
1597
+ Log.e(TAG, "Nex: Protobuf message too large to fragment ($totalChunks chunks) - dropping")
1598
+ return
1599
+ }
1600
+
1601
+ val seq = protobufSeq
1602
+ protobufSeq = (protobufSeq + 1) and 0xFF
1603
+
1604
+ val frames = Array(totalChunks) { index ->
1605
+ val start = index * maxFragment
1606
+ val end = minOf(start + maxFragment, protobufBytes.size)
1607
+ val fragLen = end - start
1608
+ val frame = ByteArray(headerSize + fragLen)
1609
+ frame[0] = NexBluetoothPacketTypes.PACKET_TYPE_PROTOBUF
1610
+ frame[1] = seq.toByte()
1611
+ frame[2] = totalChunks.toByte()
1612
+ frame[3] = index.toByte()
1613
+ if (fragLen > 0) {
1614
+ System.arraycopy(protobufBytes, start, frame, headerSize, fragLen)
1615
+ }
1616
+ // Carry the post-message delay on the final fragment only.
1617
+ SendRequest(frame, if (index == totalChunks - 1) waitTime else -1)
1618
+ }
1619
+
1620
+ sendQueue.offer(frames)
1621
+ startWorkerIfNeeded()
1622
+ }
1623
+
1584
1624
  private fun processQueue() {
1585
1625
  // First wait until the services are ready to receive data
1586
1626
  Bridge.log("[BLE] PROC_QUEUE started — waiting for descriptor write (mainServicesWaiter)")
@@ -1597,9 +1637,7 @@ class MentraNex : SGCManager() {
1597
1637
  mainServicesWaiter.waitWhileTrue()
1598
1638
 
1599
1639
  // This will block until data is available
1600
- Bridge.log("[BLE] PROC_QUEUE waiting for data in sendQueue (size=${sendQueue.size})")
1601
1640
  val requests = sendQueue.take()
1602
- Bridge.log("[BLE] PROC_QUEUE dequeued ${requests.size} request(s)")
1603
1641
 
1604
1642
  for (request in requests) {
1605
1643
  if (isKilled) {
@@ -1616,20 +1654,30 @@ class MentraNex : SGCManager() {
1616
1654
 
1617
1655
  // Send to main glass
1618
1656
  val canSend = mainGlassGatt != null && mainWriteChar != null && isMainConnected
1619
- Bridge.log("[BLE] PROC_QUEUE send check: gatt=${mainGlassGatt != null} writeChar=${mainWriteChar != null} connected=$isMainConnected → canSend=$canSend len=${request.data.size}")
1657
+ var writeStarted = false
1620
1658
  if (canSend) {
1621
1659
  mainWaiter.setTrue()
1622
1660
  mainWriteChar?.value = request.data
1623
- mainGlassGatt?.writeCharacteristic(mainWriteChar)
1624
- lastSendTimestamp = System.currentTimeMillis()
1625
- Bridge.log("[BLE] PROC_QUEUE writeCharacteristic issued, waiting for onCharacteristicWrite callback")
1661
+ // Honor the return value: writeCharacteristic returns false when the
1662
+ // stack is busy / out of buffers, and in that case no callback fires.
1663
+ // Only wait for the callback if the write actually started, otherwise
1664
+ // the waiter would block until the next disconnect.
1665
+ val issued = mainGlassGatt?.writeCharacteristic(mainWriteChar) ?: false
1666
+ if (issued) {
1667
+ writeStarted = true
1668
+ lastSendTimestamp = System.currentTimeMillis()
1669
+ } else {
1670
+ mainWaiter.setFalse()
1671
+ Log.e(TAG, "[BLE] PROC_QUEUE writeCharacteristic returned false — stack busy, dropping fragment")
1672
+ }
1626
1673
  } else {
1627
1674
  Bridge.log("[BLE] PROC_QUEUE skipping write — not ready, packet dropped")
1628
1675
  }
1629
1676
 
1630
- mainWaiter.waitWhileTrue()
1677
+ if (writeStarted) {
1678
+ mainWaiter.waitWhileTrue(WRITE_CALLBACK_TIMEOUT_MS)
1679
+ }
1631
1680
 
1632
- Thread.sleep(DELAY_BETWEEN_CHUNKS_SEND)
1633
1681
 
1634
1682
  // If the packet asked us to do a delay, then do it
1635
1683
  if (request.waitTime != -1) {