@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
@@ -27,6 +27,9 @@ import com.mentra.bluetoothsdk.utils.MicMap
27
27
  import com.mentra.bluetoothsdk.utils.MicTypes
28
28
  import com.mentra.lc3Lib.Lc3Cpp
29
29
  import com.mentra.bluetoothsdk.stt.SherpaOnnxTranscriber
30
+ import kotlinx.coroutines.CoroutineScope
31
+ import kotlinx.coroutines.Dispatchers
32
+ import kotlinx.coroutines.launch
30
33
  import java.text.SimpleDateFormat
31
34
  import java.util.*
32
35
  import java.util.concurrent.CountDownLatch
@@ -132,6 +135,13 @@ class DeviceManager {
132
135
  get() = DeviceStore.store.get("bluetooth", "power_saving_mode") as? Boolean ?: false
133
136
  set(value) = DeviceStore.apply("bluetooth", "power_saving_mode", value)
134
137
 
138
+ // Phone-side VAD gating switch. Default is OFF (VAD runs) so the
139
+ // coordinator can drive per-utterance offline/online STT switching from
140
+ // `vad_status` events. Set to `true` only as an emergency kill-switch.
141
+ private var bypassVad: Boolean
142
+ get() = DeviceStore.store.get("bluetooth", "bypass_vad") as? Boolean ?: false
143
+ set(value) = DeviceStore.apply("bluetooth", "bypass_vad", value)
144
+
135
145
  private var offlineCaptionsRunning: Boolean
136
146
  get() = DeviceStore.store.get("bluetooth", "offline_captions_running") as? Boolean ?: false
137
147
  set(value) = DeviceStore.apply("bluetooth", "offline_captions_running", value)
@@ -192,6 +202,7 @@ class DeviceManager {
192
202
  // Guard against duplicate ready callbacks firing back-to-back.
193
203
  private var lastReadyHandledAtMs: Long = 0L
194
204
  private var lastReadyHandledKey: String = ""
205
+ private var lastSystemTimeSyncConnectionKey: String = ""
195
206
 
196
207
  private var systemMicUnavailable: Boolean
197
208
  get() = DeviceStore.store.get("bluetooth", "systemMicUnavailable") as? Boolean ?: false
@@ -237,6 +248,11 @@ class DeviceManager {
237
248
  private var lastLc3Event: Long? = null
238
249
  private var micReinitRunnable: Runnable? = null
239
250
 
251
+ // VAD
252
+ private val vadBuffer = mutableListOf<ByteArray>()
253
+ private var isSpeaking = false
254
+ private var vadPolicy: com.mentra.bluetoothsdk.stt.VadGateSpeechPolicy? = null
255
+
240
256
  // STT
241
257
  private var transcriber: SherpaOnnxTranscriber? = null
242
258
 
@@ -272,6 +288,9 @@ class DeviceManager {
272
288
  } catch (e: Exception) {
273
289
  Bridge.log("Failed to initialize SherpaOnnxTranscriber: ${e.message}")
274
290
  transcriber = null
291
+ } catch (e: LinkageError) {
292
+ Bridge.log("Failed to initialize SherpaOnnxTranscriber: ${e.message}")
293
+ transcriber = null
275
294
  }
276
295
 
277
296
  // Initialize LC3 encoder/decoder for unified audio encoding
@@ -286,6 +305,30 @@ class DeviceManager {
286
305
  lc3DecoderPtr = 0
287
306
  }
288
307
 
308
+ // Initialize phone-side Silero VAD. Used by handlePcm to gate audio
309
+ // fan-out and to drive per-utterance offline/online STT switching
310
+ // (see LocalSttFallbackCoordinator on the JS side). If a previous
311
+ // policy somehow exists (re-init on hot reload), stop it first to
312
+ // release the ONNX session.
313
+ try {
314
+ vadPolicy?.stop()
315
+ val ctx = Bridge.getContext()
316
+ val policy = com.mentra.bluetoothsdk.stt.VadGateSpeechPolicy(ctx)
317
+ policy.init(blockSizeSamples = 512)
318
+ policy.onSpeechStateChanged = { speaking ->
319
+ isSpeaking = speaking
320
+ Bridge.sendVoiceActivityDetectionStatus(speaking)
321
+ }
322
+ vadPolicy = policy
323
+ Bridge.log("VadGateSpeechPolicy initialized")
324
+ } catch (e: Exception) {
325
+ Bridge.log("Failed to initialize VadGateSpeechPolicy: ${e.message}")
326
+ vadPolicy = null
327
+ } catch (e: LinkageError) {
328
+ Bridge.log("Failed to initialize VadGateSpeechPolicy: ${e.message}")
329
+ vadPolicy = null
330
+ }
331
+
289
332
  // Mic reinit check every 10 seconds
290
333
  val micReinitR =
291
334
  object : Runnable {
@@ -590,7 +633,12 @@ class DeviceManager {
590
633
  var layoutType: String,
591
634
  var text: String,
592
635
  var data: String?,
593
- var animationData: Map<String, Any>?
636
+ var animationData: Map<String, Any>?,
637
+ // Optional bitmap_view container position/size (used by G2; ignored by others)
638
+ var bmpX: Int? = null,
639
+ var bmpY: Int? = null,
640
+ var bmpWidth: Int? = null,
641
+ var bmpHeight: Int? = null
594
642
  )
595
643
  // MARK: - End Unique
596
644
 
@@ -652,9 +700,17 @@ class DeviceManager {
652
700
  }
653
701
 
654
702
  fun handlePcm(pcmData: ByteArray) {
703
+ // Audio always flows. The previous phone-side Silero VAD gate was a
704
+ // bandwidth-saver that ate transcripts when the mic delivered frames
705
+ // not aligned to 512 samples (the case for Android AudioRecord on the
706
+ // phone internal mic) and was never wired up correctly anyway —
707
+ // `bypass_vad_for_debugging` was dead, cloud-side `bypass_vad` was
708
+ // the only knob, and the policy double-VAD'd what the cloud already
709
+ // VADs server-side. VadGateSpeechPolicy is kept around because
710
+ // hardware-side VAD events from the glasses route through the same
711
+ // class to fire `vad_status` (a separate signal the cloud SDK
712
+ // surfaces as `session.audio.isSpeaking`).
655
713
  handleSendingPcm(pcmData)
656
-
657
- // Send PCM to local transcriber (always needs raw PCM)
658
714
  if (shouldSendTranscript || offlineCaptionsRunning || localSttFallbackActive) {
659
715
  transcriber?.acceptAudio(pcmData)
660
716
  }
@@ -831,7 +887,15 @@ class DeviceManager {
831
887
  sgc?.sendTextWall("${currentViewState.title}\n\n${currentViewState.text}")
832
888
  }
833
889
  "bitmap_view" -> {
834
- currentViewState.data?.let { data -> sgc?.displayBitmap(data) }
890
+ currentViewState.data?.let { data ->
891
+ sgc?.displayBitmap(
892
+ data,
893
+ currentViewState.bmpX,
894
+ currentViewState.bmpY,
895
+ currentViewState.bmpWidth,
896
+ currentViewState.bmpHeight
897
+ )
898
+ }
835
899
  }
836
900
  "clear_view" -> sgc?.clearDisplay()
837
901
  else -> Bridge.log("MAN: UNHANDLED LAYOUT_TYPE ${currentViewState.layoutType}")
@@ -974,6 +1038,7 @@ class DeviceManager {
974
1038
  Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
975
1039
  sgc?.cleanup()
976
1040
  sgc = null
1041
+ lastSystemTimeSyncConnectionKey = ""
977
1042
  }
978
1043
 
979
1044
  if (sgc != null) {
@@ -992,10 +1057,9 @@ class DeviceManager {
992
1057
  } else if (wearable.contains(DeviceTypes.NEX)) {
993
1058
  sgc = MentraNex()
994
1059
  } else if (wearable.contains(DeviceTypes.MACH1)) {
995
- sgc = Mach1()
1060
+ sgc = createOptionalMach1Sgc(DeviceTypes.MACH1)
996
1061
  } else if (wearable.contains(DeviceTypes.Z100)) {
997
- sgc = Mach1() // Z100 uses same hardware/SDK as Mach1
998
- sgc?.type = DeviceTypes.Z100 // Override type to Z100
1062
+ sgc = createOptionalMach1Sgc(DeviceTypes.Z100)
999
1063
  } else if (wearable.contains(DeviceTypes.FRAME)) {
1000
1064
  // sgc = FrameManager()
1001
1065
  }
@@ -1003,6 +1067,19 @@ class DeviceManager {
1003
1067
  DeviceStore.apply("glasses", "deviceModel", sgc?.type ?: "")
1004
1068
  }
1005
1069
 
1070
+ private fun createOptionalMach1Sgc(deviceType: String): SGCManager? {
1071
+ return try {
1072
+ Mach1().also { sgc ->
1073
+ if (deviceType == DeviceTypes.Z100) {
1074
+ sgc.type = DeviceTypes.Z100
1075
+ }
1076
+ }
1077
+ } catch (e: LinkageError) {
1078
+ Bridge.log("Failed to initialize $deviceType support: ${e.message}")
1079
+ null
1080
+ }
1081
+ }
1082
+
1006
1083
  fun initController(controllerType: String) {
1007
1084
  Bridge.log("MAN: Initializing controller: $controllerType")
1008
1085
  if (controller != null && controller?.type != controllerType) {
@@ -1048,8 +1125,24 @@ class DeviceManager {
1048
1125
  defaultWearable = sgc?.type ?: ""
1049
1126
  searching = false
1050
1127
 
1051
- // Apply dashboard position before any boot text so content doesn't jump.
1052
- sgc?.setDashboardPosition(dashboardHeight, dashboardDepth)
1128
+ syncSystemTimeOnceForConnection(readyKey)
1129
+
1130
+ // re-apply display height/depth after reconnection
1131
+ mainHandler.postDelayed(
1132
+ {
1133
+ val h =
1134
+ (DeviceStore.store.get("bluetooth", "dashboard_height") as? Number)
1135
+ ?.toInt()
1136
+ ?: 4
1137
+ val rawDepth =
1138
+ (DeviceStore.store.get("bluetooth", "dashboard_depth") as? Number)
1139
+ ?.toInt()
1140
+ ?: dashboardDepth // canonical default (2), not 1
1141
+ val d = rawDepth.coerceIn(1, 4)
1142
+ sgc?.setDashboardPosition(h, d)
1143
+ },
1144
+ 2000
1145
+ )
1053
1146
 
1054
1147
  // Show welcome message on first connect for all display glasses
1055
1148
  if (shouldSendBootingMessage) {
@@ -1084,6 +1177,21 @@ class DeviceManager {
1084
1177
  Bridge.saveSetting("device_address", deviceAddress)
1085
1178
  }
1086
1179
 
1180
+ private fun syncSystemTimeOnceForConnection(connectionKey: String) {
1181
+ val activeSgc = sgc ?: return
1182
+ if (activeSgc.type.contains(DeviceTypes.SIMULATED)) {
1183
+ return
1184
+ }
1185
+ if (connectionKey == lastSystemTimeSyncConnectionKey) {
1186
+ return
1187
+ }
1188
+
1189
+ lastSystemTimeSyncConnectionKey = connectionKey
1190
+ val timestampMs = System.currentTimeMillis()
1191
+ Bridge.log("MAN: Syncing glasses system time once for connection: $timestampMs")
1192
+ activeSgc.sendSetSystemTime(timestampMs)
1193
+ }
1194
+
1087
1195
  private fun handleG1Ready() {
1088
1196
  // G1-specific setup (if any needed in the future)
1089
1197
  // Note: G1-specific settings like silent mode, battery status,
@@ -1096,8 +1204,9 @@ class DeviceManager {
1096
1204
 
1097
1205
  fun handleDeviceDisconnected() {
1098
1206
  Bridge.log("MAN: Device disconnected")
1207
+ lastSystemTimeSyncConnectionKey = ""
1099
1208
  DeviceStore.apply("glasses", "headUp", false)
1100
- DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", true)
1209
+ DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
1101
1210
  }
1102
1211
 
1103
1212
  fun handleControllerReady() {
@@ -1154,7 +1263,26 @@ class DeviceManager {
1154
1263
  val title = parsePlaceholders(layout.getString("title", " "))
1155
1264
  val data = layout["data"] as? String
1156
1265
 
1157
- var newViewState = ViewState(topText, bottomText, title, layoutType ?: "", text, data, null)
1266
+ // Optional bitmap_view container position/size (forwarded to the SGC; used by G2).
1267
+ val bmpX = (layout["x"] as? Number)?.toInt()
1268
+ val bmpY = (layout["y"] as? Number)?.toInt()
1269
+ val bmpWidth = (layout["width"] as? Number)?.toInt()
1270
+ val bmpHeight = (layout["height"] as? Number)?.toInt()
1271
+
1272
+ var newViewState =
1273
+ ViewState(
1274
+ topText,
1275
+ bottomText,
1276
+ title,
1277
+ layoutType ?: "",
1278
+ text,
1279
+ data,
1280
+ null,
1281
+ bmpX,
1282
+ bmpY,
1283
+ bmpWidth,
1284
+ bmpHeight
1285
+ )
1158
1286
 
1159
1287
  val currentState = viewStates[stateIndex]
1160
1288
 
@@ -1176,6 +1304,12 @@ class DeviceManager {
1176
1304
  sgc?.showDashboard()
1177
1305
  }
1178
1306
 
1307
+ fun showNotificationsPanel() {
1308
+ CoroutineScope(Dispatchers.Main).launch {
1309
+ sgc?.showNotificationsPanel()
1310
+ }
1311
+ }
1312
+
1179
1313
  fun ping() {
1180
1314
  sgc?.ping()
1181
1315
  }
@@ -1232,6 +1366,11 @@ class DeviceManager {
1232
1366
  sgc?.sendHotspotState(enabled)
1233
1367
  }
1234
1368
 
1369
+ fun setSystemTime(timestampMs: Long) {
1370
+ Bridge.log("MAN: Setting glasses system time: $timestampMs")
1371
+ sgc?.sendSetSystemTime(timestampMs)
1372
+ }
1373
+
1235
1374
  fun queryGalleryStatus() {
1236
1375
  Bridge.log("MAN: Querying gallery status from glasses")
1237
1376
  sgc?.queryGalleryStatus()
@@ -1251,6 +1390,41 @@ class DeviceManager {
1251
1390
  (sgc as? MentraLive)?.sendOtaQueryStatus()
1252
1391
  }
1253
1392
 
1393
+ fun sendGalleryMode(requestId: String, enabled: Boolean) {
1394
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1395
+ live.sendGalleryMode(requestId, enabled)
1396
+ }
1397
+
1398
+ fun sendButtonPhotoSettings(requestId: String, size: String) {
1399
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1400
+ live.sendButtonPhotoSettings(requestId, size)
1401
+ }
1402
+
1403
+ fun sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) {
1404
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1405
+ live.sendButtonVideoRecordingSettings(requestId, width, height, fps)
1406
+ }
1407
+
1408
+ fun sendButtonCameraLedSetting(requestId: String, enabled: Boolean) {
1409
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1410
+ live.sendButtonCameraLedSetting(requestId, enabled)
1411
+ }
1412
+
1413
+ fun sendButtonMaxRecordingTime(requestId: String, minutes: Int) {
1414
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1415
+ live.sendButtonMaxRecordingTime(requestId, minutes)
1416
+ }
1417
+
1418
+ fun sendCameraFovSetting(requestId: String, fov: Int, roiPosition: Int) {
1419
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1420
+ live.sendCameraFovSetting(requestId, fov, roiPosition)
1421
+ }
1422
+
1423
+ fun retryOtaVersionCheck() {
1424
+ Bridge.log("MAN: ⏰ Retrying glasses OTA version check after clock sync")
1425
+ (sgc as? MentraLive)?.sendOtaRetryVersionCheck()
1426
+ }
1427
+
1254
1428
  /**
1255
1429
  * Read glasses media step volume (0–15) via K900 on Mentra Live only. Blocks until response,
1256
1430
  * error, or timeout (used from JS AsyncFunction on a worker thread).
@@ -1324,11 +1498,19 @@ class DeviceManager {
1324
1498
  sgc?.sendReboot()
1325
1499
  }
1326
1500
 
1327
- fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean) {
1501
+ fun startVideoRecording(
1502
+ requestId: String,
1503
+ save: Boolean,
1504
+ sound: Boolean,
1505
+ width: Int = 0,
1506
+ height: Int = 0,
1507
+ fps: Int = 0,
1508
+ ) {
1328
1509
  Bridge.log(
1329
- "MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound"
1510
+ "MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound, " +
1511
+ "resolution=${width}x${height}@${fps}fps"
1330
1512
  )
1331
- sgc?.startVideoRecording(requestId, save, true, sound)
1513
+ sgc?.startVideoRecording(requestId, save, true, sound, width, height, fps)
1332
1514
  }
1333
1515
 
1334
1516
  fun stopVideoRecording(requestId: String) {
@@ -1339,7 +1521,15 @@ class DeviceManager {
1339
1521
  fun setMicState() {
1340
1522
  val willSendPcm = shouldSendPcm || shouldSendLc3
1341
1523
  val willSendTranscript = shouldSendTranscript || offlineCaptionsRunning || localSttFallbackActive
1342
- micEnabled = willSendPcm || willSendTranscript
1524
+ val nextEnabled = willSendPcm || willSendTranscript
1525
+ // Tell VAD when the mic is shutting down so it doesn't get stuck in
1526
+ // a stale "speaking" state and keep emitting vad_status=true after
1527
+ // audio stops flowing.
1528
+ if (micEnabled && !nextEnabled) {
1529
+ vadPolicy?.microphoneStateChanged(false)
1530
+ }
1531
+ micEnabled = nextEnabled
1532
+ vadBuffer.clear()
1343
1533
  updateMicState()
1344
1534
  }
1345
1535
 
@@ -1351,6 +1541,7 @@ class DeviceManager {
1351
1541
  authToken: String?,
1352
1542
  compress: String,
1353
1543
  flash: Boolean,
1544
+ save: Boolean,
1354
1545
  sound: Boolean,
1355
1546
  exposureTimeNs: Double? = null,
1356
1547
  iso: Int? = null,
@@ -1364,7 +1555,7 @@ class DeviceManager {
1364
1555
  }
1365
1556
  val manualIso = if (exposureNs != null) iso?.takeIf { it > 0 } else null
1366
1557
  Bridge.log(
1367
- "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash sound=$sound exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1558
+ "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash save=$save sound=$sound exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1368
1559
  )
1369
1560
  val activeSgc = sgc
1370
1561
  if (activeSgc == null) {
@@ -1373,7 +1564,7 @@ class DeviceManager {
1373
1564
  )
1374
1565
  return
1375
1566
  }
1376
- activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, sound, exposureNs, manualIso)
1567
+ activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, save, sound, exposureNs, manualIso)
1377
1568
  }
1378
1569
 
1379
1570
  fun rgbLedControl(
@@ -1495,6 +1686,7 @@ class DeviceManager {
1495
1686
  sgc?.clearDisplay()
1496
1687
  sgc?.disconnect()
1497
1688
  sgc = null // Clear the SGC reference after disconnect
1689
+ lastSystemTimeSyncConnectionKey = ""
1498
1690
  searching = false
1499
1691
  micEnabled = false
1500
1692
  updateMicState()
@@ -1503,7 +1695,7 @@ class DeviceManager {
1503
1695
  DeviceStore.apply("glasses", "deviceModel", "")
1504
1696
  DeviceStore.apply("glasses", "fullyBooted", false)
1505
1697
  DeviceStore.apply("glasses", "connected", false)
1506
- DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", true)
1698
+ DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
1507
1699
  // disconnect the controller as well:
1508
1700
  searchingController = false
1509
1701
  DeviceStore.apply("glasses", "controllerConnected", false)
@@ -66,7 +66,7 @@ object DeviceStore {
66
66
  store.set("glasses", "deviceModel", "")
67
67
  store.set("glasses", "firmwareVersion", "")
68
68
  store.set("glasses", "micEnabled", false)
69
- store.set("glasses", "voiceActivityDetectionEnabled", true)
69
+ store.set("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
70
70
  store.set("glasses", "bluetoothClassicConnected", false)
71
71
  store.set("glasses", "caseRemoved", true)
72
72
  store.set("glasses", "caseOpen", true)
@@ -121,9 +121,10 @@ object DeviceStore {
121
121
  store.set("bluetooth", "dashboard_height", 4)
122
122
  store.set("bluetooth", "dashboard_depth", 2)
123
123
  store.set("bluetooth", "head_up_angle", 30)
124
+ store.set("bluetooth", "imu_enabled", false)
124
125
  store.set("bluetooth", "contextual_dashboard", true)
125
126
  store.set("bluetooth", "gallery_mode", true)
126
- store.set("bluetooth", "voice_activity_detection_enabled", true)
127
+ store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
127
128
  store.set("bluetooth", "screen_disabled", false)
128
129
  store.set("bluetooth", "button_photo_size", "medium")
129
130
  store.set("bluetooth", "button_camera_led", true)
@@ -140,6 +141,9 @@ object DeviceStore {
140
141
  store.set("bluetooth", "should_send_lc3", false)
141
142
  store.set("bluetooth", "should_send_transcript", false)
142
143
  store.set("bluetooth", "use_native_dashboard", false)
144
+ // Mentra Nex feature flags (off by default; toggled from Nex Developer Settings):
145
+ store.set("bluetooth", "nex_chinese_captions", false)
146
+ store.set("bluetooth", "nex_audio_playback", false)
143
147
  }
144
148
 
145
149
  fun get(category: String, key: String): Any? {
@@ -235,18 +239,42 @@ object DeviceStore {
235
239
  DeviceManager.getInstance().sgc?.setHeadUpAngle(angle)
236
240
  }
237
241
  }
242
+ "bluetooth" to "imu_enabled" -> {
243
+ (value as? Boolean)?.let { enabled ->
244
+ CoroutineScope(Dispatchers.Main).launch {
245
+ DeviceManager.getInstance().sgc?.setImuEnabled(enabled)
246
+ }
247
+ }
248
+ }
238
249
  "bluetooth" to "menu_apps" -> {
239
250
  @Suppress("UNCHECKED_CAST")
240
251
  (value as? List<Map<String, Any>>)?.let { items ->
241
252
  DeviceManager.getInstance().sgc?.setDashboardMenu(items)
242
253
  }
243
254
  }
255
+ // `core` category is normalized to `bluetooth` before reaching this switch.
256
+ "bluetooth" to "calendar_events" -> {
257
+ @Suppress("UNCHECKED_CAST")
258
+ (value as? List<Map<String, Any>>)?.let { events ->
259
+ DeviceManager.getInstance().sgc?.sendCalendarEvents(events)
260
+ }
261
+ }
262
+ "bluetooth" to "metric_system",
263
+ "bluetooth" to "twelve_hour_time" -> {
264
+ DeviceManager.getInstance().sgc?.sendDashboardDisplaySettings()
265
+ }
244
266
  "bluetooth" to "gallery_mode" -> {
245
267
  DeviceManager.getInstance().sgc?.sendGalleryMode()
246
268
  }
247
269
  "bluetooth" to "voice_activity_detection_enabled" -> {
248
270
  DeviceManager.getInstance().sgc?.sendVoiceActivityDetectionSetting()
249
271
  }
272
+ "bluetooth" to "nex_audio_playback" -> {
273
+ (value as? Boolean)?.let { enabled ->
274
+ Bridge.log("DeviceStore: nex_audio_playback changed to $enabled")
275
+ DeviceManager.getInstance().sgc?.applyNexAudioPlaybackSetting()
276
+ }
277
+ }
250
278
  "bluetooth" to "screen_disabled" -> {
251
279
  (value as? Boolean)?.let { disabled ->
252
280
  if (disabled) {