@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
@@ -132,6 +132,13 @@ class DeviceManager {
132
132
  get() = DeviceStore.store.get("bluetooth", "power_saving_mode") as? Boolean ?: false
133
133
  set(value) = DeviceStore.apply("bluetooth", "power_saving_mode", value)
134
134
 
135
+ // Phone-side VAD gating switch. Default is OFF (VAD runs) so the
136
+ // coordinator can drive per-utterance offline/online STT switching from
137
+ // `vad_status` events. Set to `true` only as an emergency kill-switch.
138
+ private var bypassVad: Boolean
139
+ get() = DeviceStore.store.get("bluetooth", "bypass_vad") as? Boolean ?: false
140
+ set(value) = DeviceStore.apply("bluetooth", "bypass_vad", value)
141
+
135
142
  private var offlineCaptionsRunning: Boolean
136
143
  get() = DeviceStore.store.get("bluetooth", "offline_captions_running") as? Boolean ?: false
137
144
  set(value) = DeviceStore.apply("bluetooth", "offline_captions_running", value)
@@ -192,6 +199,7 @@ class DeviceManager {
192
199
  // Guard against duplicate ready callbacks firing back-to-back.
193
200
  private var lastReadyHandledAtMs: Long = 0L
194
201
  private var lastReadyHandledKey: String = ""
202
+ private var lastSystemTimeSyncConnectionKey: String = ""
195
203
 
196
204
  private var systemMicUnavailable: Boolean
197
205
  get() = DeviceStore.store.get("bluetooth", "systemMicUnavailable") as? Boolean ?: false
@@ -237,6 +245,11 @@ class DeviceManager {
237
245
  private var lastLc3Event: Long? = null
238
246
  private var micReinitRunnable: Runnable? = null
239
247
 
248
+ // VAD
249
+ private val vadBuffer = mutableListOf<ByteArray>()
250
+ private var isSpeaking = false
251
+ private var vadPolicy: com.mentra.bluetoothsdk.stt.VadGateSpeechPolicy? = null
252
+
240
253
  // STT
241
254
  private var transcriber: SherpaOnnxTranscriber? = null
242
255
 
@@ -272,6 +285,9 @@ class DeviceManager {
272
285
  } catch (e: Exception) {
273
286
  Bridge.log("Failed to initialize SherpaOnnxTranscriber: ${e.message}")
274
287
  transcriber = null
288
+ } catch (e: LinkageError) {
289
+ Bridge.log("Failed to initialize SherpaOnnxTranscriber: ${e.message}")
290
+ transcriber = null
275
291
  }
276
292
 
277
293
  // Initialize LC3 encoder/decoder for unified audio encoding
@@ -286,6 +302,30 @@ class DeviceManager {
286
302
  lc3DecoderPtr = 0
287
303
  }
288
304
 
305
+ // Initialize phone-side Silero VAD. Used by handlePcm to gate audio
306
+ // fan-out and to drive per-utterance offline/online STT switching
307
+ // (see LocalSttFallbackCoordinator on the JS side). If a previous
308
+ // policy somehow exists (re-init on hot reload), stop it first to
309
+ // release the ONNX session.
310
+ try {
311
+ vadPolicy?.stop()
312
+ val ctx = Bridge.getContext()
313
+ val policy = com.mentra.bluetoothsdk.stt.VadGateSpeechPolicy(ctx)
314
+ policy.init(blockSizeSamples = 512)
315
+ policy.onSpeechStateChanged = { speaking ->
316
+ isSpeaking = speaking
317
+ Bridge.sendVoiceActivityDetectionStatus(speaking)
318
+ }
319
+ vadPolicy = policy
320
+ Bridge.log("VadGateSpeechPolicy initialized")
321
+ } catch (e: Exception) {
322
+ Bridge.log("Failed to initialize VadGateSpeechPolicy: ${e.message}")
323
+ vadPolicy = null
324
+ } catch (e: LinkageError) {
325
+ Bridge.log("Failed to initialize VadGateSpeechPolicy: ${e.message}")
326
+ vadPolicy = null
327
+ }
328
+
289
329
  // Mic reinit check every 10 seconds
290
330
  val micReinitR =
291
331
  object : Runnable {
@@ -590,7 +630,12 @@ class DeviceManager {
590
630
  var layoutType: String,
591
631
  var text: String,
592
632
  var data: String?,
593
- var animationData: Map<String, Any>?
633
+ var animationData: Map<String, Any>?,
634
+ // Optional bitmap_view container position/size (used by G2; ignored by others)
635
+ var bmpX: Int? = null,
636
+ var bmpY: Int? = null,
637
+ var bmpWidth: Int? = null,
638
+ var bmpHeight: Int? = null
594
639
  )
595
640
  // MARK: - End Unique
596
641
 
@@ -652,9 +697,17 @@ class DeviceManager {
652
697
  }
653
698
 
654
699
  fun handlePcm(pcmData: ByteArray) {
700
+ // Audio always flows. The previous phone-side Silero VAD gate was a
701
+ // bandwidth-saver that ate transcripts when the mic delivered frames
702
+ // not aligned to 512 samples (the case for Android AudioRecord on the
703
+ // phone internal mic) and was never wired up correctly anyway —
704
+ // `bypass_vad_for_debugging` was dead, cloud-side `bypass_vad` was
705
+ // the only knob, and the policy double-VAD'd what the cloud already
706
+ // VADs server-side. VadGateSpeechPolicy is kept around because
707
+ // hardware-side VAD events from the glasses route through the same
708
+ // class to fire `vad_status` (a separate signal the cloud SDK
709
+ // surfaces as `session.audio.isSpeaking`).
655
710
  handleSendingPcm(pcmData)
656
-
657
- // Send PCM to local transcriber (always needs raw PCM)
658
711
  if (shouldSendTranscript || offlineCaptionsRunning || localSttFallbackActive) {
659
712
  transcriber?.acceptAudio(pcmData)
660
713
  }
@@ -831,7 +884,15 @@ class DeviceManager {
831
884
  sgc?.sendTextWall("${currentViewState.title}\n\n${currentViewState.text}")
832
885
  }
833
886
  "bitmap_view" -> {
834
- currentViewState.data?.let { data -> sgc?.displayBitmap(data) }
887
+ currentViewState.data?.let { data ->
888
+ sgc?.displayBitmap(
889
+ data,
890
+ currentViewState.bmpX,
891
+ currentViewState.bmpY,
892
+ currentViewState.bmpWidth,
893
+ currentViewState.bmpHeight
894
+ )
895
+ }
835
896
  }
836
897
  "clear_view" -> sgc?.clearDisplay()
837
898
  else -> Bridge.log("MAN: UNHANDLED LAYOUT_TYPE ${currentViewState.layoutType}")
@@ -974,6 +1035,7 @@ class DeviceManager {
974
1035
  Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
975
1036
  sgc?.cleanup()
976
1037
  sgc = null
1038
+ lastSystemTimeSyncConnectionKey = ""
977
1039
  }
978
1040
 
979
1041
  if (sgc != null) {
@@ -992,10 +1054,9 @@ class DeviceManager {
992
1054
  } else if (wearable.contains(DeviceTypes.NEX)) {
993
1055
  sgc = MentraNex()
994
1056
  } else if (wearable.contains(DeviceTypes.MACH1)) {
995
- sgc = Mach1()
1057
+ sgc = createOptionalMach1Sgc(DeviceTypes.MACH1)
996
1058
  } 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
1059
+ sgc = createOptionalMach1Sgc(DeviceTypes.Z100)
999
1060
  } else if (wearable.contains(DeviceTypes.FRAME)) {
1000
1061
  // sgc = FrameManager()
1001
1062
  }
@@ -1003,6 +1064,19 @@ class DeviceManager {
1003
1064
  DeviceStore.apply("glasses", "deviceModel", sgc?.type ?: "")
1004
1065
  }
1005
1066
 
1067
+ private fun createOptionalMach1Sgc(deviceType: String): SGCManager? {
1068
+ return try {
1069
+ Mach1().also { sgc ->
1070
+ if (deviceType == DeviceTypes.Z100) {
1071
+ sgc.type = DeviceTypes.Z100
1072
+ }
1073
+ }
1074
+ } catch (e: LinkageError) {
1075
+ Bridge.log("Failed to initialize $deviceType support: ${e.message}")
1076
+ null
1077
+ }
1078
+ }
1079
+
1006
1080
  fun initController(controllerType: String) {
1007
1081
  Bridge.log("MAN: Initializing controller: $controllerType")
1008
1082
  if (controller != null && controller?.type != controllerType) {
@@ -1048,6 +1122,8 @@ class DeviceManager {
1048
1122
  defaultWearable = sgc?.type ?: ""
1049
1123
  searching = false
1050
1124
 
1125
+ syncSystemTimeOnceForConnection(readyKey)
1126
+
1051
1127
  // Apply dashboard position before any boot text so content doesn't jump.
1052
1128
  sgc?.setDashboardPosition(dashboardHeight, dashboardDepth)
1053
1129
 
@@ -1084,6 +1160,21 @@ class DeviceManager {
1084
1160
  Bridge.saveSetting("device_address", deviceAddress)
1085
1161
  }
1086
1162
 
1163
+ private fun syncSystemTimeOnceForConnection(connectionKey: String) {
1164
+ val activeSgc = sgc ?: return
1165
+ if (activeSgc.type.contains(DeviceTypes.SIMULATED)) {
1166
+ return
1167
+ }
1168
+ if (connectionKey == lastSystemTimeSyncConnectionKey) {
1169
+ return
1170
+ }
1171
+
1172
+ lastSystemTimeSyncConnectionKey = connectionKey
1173
+ val timestampMs = System.currentTimeMillis()
1174
+ Bridge.log("MAN: Syncing glasses system time once for connection: $timestampMs")
1175
+ activeSgc.sendSetSystemTime(timestampMs)
1176
+ }
1177
+
1087
1178
  private fun handleG1Ready() {
1088
1179
  // G1-specific setup (if any needed in the future)
1089
1180
  // Note: G1-specific settings like silent mode, battery status,
@@ -1096,8 +1187,9 @@ class DeviceManager {
1096
1187
 
1097
1188
  fun handleDeviceDisconnected() {
1098
1189
  Bridge.log("MAN: Device disconnected")
1190
+ lastSystemTimeSyncConnectionKey = ""
1099
1191
  DeviceStore.apply("glasses", "headUp", false)
1100
- DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", true)
1192
+ DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
1101
1193
  }
1102
1194
 
1103
1195
  fun handleControllerReady() {
@@ -1154,7 +1246,26 @@ class DeviceManager {
1154
1246
  val title = parsePlaceholders(layout.getString("title", " "))
1155
1247
  val data = layout["data"] as? String
1156
1248
 
1157
- var newViewState = ViewState(topText, bottomText, title, layoutType ?: "", text, data, null)
1249
+ // Optional bitmap_view container position/size (forwarded to the SGC; used by G2).
1250
+ val bmpX = (layout["x"] as? Number)?.toInt()
1251
+ val bmpY = (layout["y"] as? Number)?.toInt()
1252
+ val bmpWidth = (layout["width"] as? Number)?.toInt()
1253
+ val bmpHeight = (layout["height"] as? Number)?.toInt()
1254
+
1255
+ var newViewState =
1256
+ ViewState(
1257
+ topText,
1258
+ bottomText,
1259
+ title,
1260
+ layoutType ?: "",
1261
+ text,
1262
+ data,
1263
+ null,
1264
+ bmpX,
1265
+ bmpY,
1266
+ bmpWidth,
1267
+ bmpHeight
1268
+ )
1158
1269
 
1159
1270
  val currentState = viewStates[stateIndex]
1160
1271
 
@@ -1176,6 +1287,10 @@ class DeviceManager {
1176
1287
  sgc?.showDashboard()
1177
1288
  }
1178
1289
 
1290
+ fun showNotificationsPanel() {
1291
+ sgc?.showNotificationsPanel()
1292
+ }
1293
+
1179
1294
  fun ping() {
1180
1295
  sgc?.ping()
1181
1296
  }
@@ -1232,6 +1347,11 @@ class DeviceManager {
1232
1347
  sgc?.sendHotspotState(enabled)
1233
1348
  }
1234
1349
 
1350
+ fun setSystemTime(timestampMs: Long) {
1351
+ Bridge.log("MAN: Setting glasses system time: $timestampMs")
1352
+ sgc?.sendSetSystemTime(timestampMs)
1353
+ }
1354
+
1235
1355
  fun queryGalleryStatus() {
1236
1356
  Bridge.log("MAN: Querying gallery status from glasses")
1237
1357
  sgc?.queryGalleryStatus()
@@ -1251,6 +1371,41 @@ class DeviceManager {
1251
1371
  (sgc as? MentraLive)?.sendOtaQueryStatus()
1252
1372
  }
1253
1373
 
1374
+ fun sendGalleryMode(requestId: String, enabled: Boolean) {
1375
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1376
+ live.sendGalleryMode(requestId, enabled)
1377
+ }
1378
+
1379
+ fun sendButtonPhotoSettings(requestId: String, size: String) {
1380
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1381
+ live.sendButtonPhotoSettings(requestId, size)
1382
+ }
1383
+
1384
+ fun sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) {
1385
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1386
+ live.sendButtonVideoRecordingSettings(requestId, width, height, fps)
1387
+ }
1388
+
1389
+ fun sendButtonCameraLedSetting(requestId: String, enabled: Boolean) {
1390
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1391
+ live.sendButtonCameraLedSetting(requestId, enabled)
1392
+ }
1393
+
1394
+ fun sendButtonMaxRecordingTime(requestId: String, minutes: Int) {
1395
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1396
+ live.sendButtonMaxRecordingTime(requestId, minutes)
1397
+ }
1398
+
1399
+ fun sendCameraFovSetting(requestId: String, fov: Int, roiPosition: Int) {
1400
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1401
+ live.sendCameraFovSetting(requestId, fov, roiPosition)
1402
+ }
1403
+
1404
+ fun retryOtaVersionCheck() {
1405
+ Bridge.log("MAN: ⏰ Retrying glasses OTA version check after clock sync")
1406
+ (sgc as? MentraLive)?.sendOtaRetryVersionCheck()
1407
+ }
1408
+
1254
1409
  /**
1255
1410
  * Read glasses media step volume (0–15) via K900 on Mentra Live only. Blocks until response,
1256
1411
  * error, or timeout (used from JS AsyncFunction on a worker thread).
@@ -1324,11 +1479,19 @@ class DeviceManager {
1324
1479
  sgc?.sendReboot()
1325
1480
  }
1326
1481
 
1327
- fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean) {
1482
+ fun startVideoRecording(
1483
+ requestId: String,
1484
+ save: Boolean,
1485
+ sound: Boolean,
1486
+ width: Int = 0,
1487
+ height: Int = 0,
1488
+ fps: Int = 0,
1489
+ ) {
1328
1490
  Bridge.log(
1329
- "MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound"
1491
+ "MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound, " +
1492
+ "resolution=${width}x${height}@${fps}fps"
1330
1493
  )
1331
- sgc?.startVideoRecording(requestId, save, true, sound)
1494
+ sgc?.startVideoRecording(requestId, save, true, sound, width, height, fps)
1332
1495
  }
1333
1496
 
1334
1497
  fun stopVideoRecording(requestId: String) {
@@ -1339,7 +1502,15 @@ class DeviceManager {
1339
1502
  fun setMicState() {
1340
1503
  val willSendPcm = shouldSendPcm || shouldSendLc3
1341
1504
  val willSendTranscript = shouldSendTranscript || offlineCaptionsRunning || localSttFallbackActive
1342
- micEnabled = willSendPcm || willSendTranscript
1505
+ val nextEnabled = willSendPcm || willSendTranscript
1506
+ // Tell VAD when the mic is shutting down so it doesn't get stuck in
1507
+ // a stale "speaking" state and keep emitting vad_status=true after
1508
+ // audio stops flowing.
1509
+ if (micEnabled && !nextEnabled) {
1510
+ vadPolicy?.microphoneStateChanged(false)
1511
+ }
1512
+ micEnabled = nextEnabled
1513
+ vadBuffer.clear()
1343
1514
  updateMicState()
1344
1515
  }
1345
1516
 
@@ -1351,6 +1522,7 @@ class DeviceManager {
1351
1522
  authToken: String?,
1352
1523
  compress: String,
1353
1524
  flash: Boolean,
1525
+ save: Boolean,
1354
1526
  sound: Boolean,
1355
1527
  exposureTimeNs: Double? = null,
1356
1528
  iso: Int? = null,
@@ -1364,7 +1536,7 @@ class DeviceManager {
1364
1536
  }
1365
1537
  val manualIso = if (exposureNs != null) iso?.takeIf { it > 0 } else null
1366
1538
  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"}"
1539
+ "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
1540
  )
1369
1541
  val activeSgc = sgc
1370
1542
  if (activeSgc == null) {
@@ -1373,7 +1545,7 @@ class DeviceManager {
1373
1545
  )
1374
1546
  return
1375
1547
  }
1376
- activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, sound, exposureNs, manualIso)
1548
+ activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, save, sound, exposureNs, manualIso)
1377
1549
  }
1378
1550
 
1379
1551
  fun rgbLedControl(
@@ -1495,6 +1667,7 @@ class DeviceManager {
1495
1667
  sgc?.clearDisplay()
1496
1668
  sgc?.disconnect()
1497
1669
  sgc = null // Clear the SGC reference after disconnect
1670
+ lastSystemTimeSyncConnectionKey = ""
1498
1671
  searching = false
1499
1672
  micEnabled = false
1500
1673
  updateMicState()
@@ -1503,7 +1676,7 @@ class DeviceManager {
1503
1676
  DeviceStore.apply("glasses", "deviceModel", "")
1504
1677
  DeviceStore.apply("glasses", "fullyBooted", false)
1505
1678
  DeviceStore.apply("glasses", "connected", false)
1506
- DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", true)
1679
+ DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
1507
1680
  // disconnect the controller as well:
1508
1681
  searchingController = false
1509
1682
  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)
@@ -123,7 +123,7 @@ object DeviceStore {
123
123
  store.set("bluetooth", "head_up_angle", 30)
124
124
  store.set("bluetooth", "contextual_dashboard", true)
125
125
  store.set("bluetooth", "gallery_mode", true)
126
- store.set("bluetooth", "voice_activity_detection_enabled", true)
126
+ store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
127
127
  store.set("bluetooth", "screen_disabled", false)
128
128
  store.set("bluetooth", "button_photo_size", "medium")
129
129
  store.set("bluetooth", "button_camera_led", true)
@@ -140,6 +140,9 @@ object DeviceStore {
140
140
  store.set("bluetooth", "should_send_lc3", false)
141
141
  store.set("bluetooth", "should_send_transcript", false)
142
142
  store.set("bluetooth", "use_native_dashboard", false)
143
+ // Mentra Nex feature flags (off by default; toggled from Nex Developer Settings):
144
+ store.set("bluetooth", "nex_chinese_captions", false)
145
+ store.set("bluetooth", "nex_audio_playback", false)
143
146
  }
144
147
 
145
148
  fun get(category: String, key: String): Any? {
@@ -241,12 +244,29 @@ object DeviceStore {
241
244
  DeviceManager.getInstance().sgc?.setDashboardMenu(items)
242
245
  }
243
246
  }
247
+ // `core` category is normalized to `bluetooth` before reaching this switch.
248
+ "bluetooth" to "calendar_events" -> {
249
+ @Suppress("UNCHECKED_CAST")
250
+ (value as? List<Map<String, Any>>)?.let { events ->
251
+ DeviceManager.getInstance().sgc?.sendCalendarEvents(events)
252
+ }
253
+ }
254
+ "bluetooth" to "metric_system",
255
+ "bluetooth" to "twelve_hour_time" -> {
256
+ DeviceManager.getInstance().sgc?.sendDashboardDisplaySettings()
257
+ }
244
258
  "bluetooth" to "gallery_mode" -> {
245
259
  DeviceManager.getInstance().sgc?.sendGalleryMode()
246
260
  }
247
261
  "bluetooth" to "voice_activity_detection_enabled" -> {
248
262
  DeviceManager.getInstance().sgc?.sendVoiceActivityDetectionSetting()
249
263
  }
264
+ "bluetooth" to "nex_audio_playback" -> {
265
+ (value as? Boolean)?.let { enabled ->
266
+ Bridge.log("DeviceStore: nex_audio_playback changed to $enabled")
267
+ DeviceManager.getInstance().sgc?.applyNexAudioPlaybackSetting()
268
+ }
269
+ }
250
270
  "bluetooth" to "screen_disabled" -> {
251
271
  (value as? Boolean)?.let { disabled ->
252
272
  if (disabled) {