@mentra/bluetooth-sdk 0.1.5 → 0.1.7

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 (92) hide show
  1. package/README.md +45 -27
  2. package/android/build.gradle +2 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +53 -36
  4. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +22 -9
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +25 -36
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +9 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +68 -56
  8. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +125 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +209 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +78 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +4 -3
  12. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +2 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +72 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +132 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +39 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +2 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1 -1
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +168 -10
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +607 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/status/RuntimeState.kt +201 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +173 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +348 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -3
  28. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +75 -0
  29. package/build/BluetoothSdk.types.d.ts +46 -20
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js +1 -0
  32. package/build/BluetoothSdk.types.js.map +1 -1
  33. package/build/_private/BluetoothSdkModule.d.ts +6 -5
  34. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  35. package/build/_private/BluetoothSdkModule.js +40 -5
  36. package/build/_private/BluetoothSdkModule.js.map +1 -1
  37. package/build/index.d.ts +1 -1
  38. package/build/index.d.ts.map +1 -1
  39. package/build/index.js +4 -2
  40. package/build/index.js.map +1 -1
  41. package/build/react/useMentraBluetooth.d.ts +5 -3
  42. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  43. package/build/react/useMentraBluetooth.js +15 -4
  44. package/build/react/useMentraBluetooth.js.map +1 -1
  45. package/ios/BluetoothSdkModule.swift +45 -28
  46. package/ios/MentraBluetoothSDK.podspec +0 -3
  47. package/ios/Source/Audio/AudioModels.swift +159 -0
  48. package/ios/Source/Bridge.swift +22 -11
  49. package/ios/Source/Camera/CameraModels.swift +248 -0
  50. package/ios/Source/Connection/ScanSession.swift +27 -0
  51. package/ios/Source/DeviceManager.swift +20 -97
  52. package/ios/Source/DeviceStore.swift +13 -4
  53. package/ios/Source/Errors/BluetoothError.swift +19 -0
  54. package/ios/Source/Events/BluetoothEvents.swift +149 -0
  55. package/ios/Source/Internal/BluetoothAvailability.swift +58 -0
  56. package/ios/Source/Internal/ValueParsing.swift +90 -0
  57. package/ios/Source/MentraBluetoothSDK.swift +50 -2150
  58. package/ios/Source/Requests/DisplayRequests.swift +58 -0
  59. package/ios/Source/Status/DeviceStatus.swift +471 -0
  60. package/ios/Source/Status/RuntimeState.swift +359 -0
  61. package/ios/Source/Status/WifiHotspotStatus.swift +326 -0
  62. package/ios/Source/Streaming/StreamModels.swift +436 -0
  63. package/ios/Source/Types/DeviceModels.swift +134 -0
  64. package/ios/Source/controllers/ControllerManager.swift +3 -3
  65. package/ios/Source/controllers/R1.swift +1 -1
  66. package/ios/Source/sgcs/G1.swift +1 -1
  67. package/ios/Source/sgcs/G2.swift +99 -39
  68. package/ios/Source/sgcs/Mach1.swift +1 -1
  69. package/ios/Source/sgcs/MentraLive.swift +109 -12
  70. package/ios/Source/sgcs/MentraNex.swift +1 -1
  71. package/ios/Source/sgcs/SGCManager.swift +11 -3
  72. package/ios/Source/sgcs/Simulated.swift +2 -2
  73. package/ios/Source/stt/SherpaOnnxTranscriber.swift +1 -4
  74. package/package.json +1 -1
  75. package/src/BluetoothSdk.types.ts +48 -28
  76. package/src/_private/BluetoothSdkModule.ts +47 -21
  77. package/src/index.ts +6 -4
  78. package/src/react/useMentraBluetooth.ts +19 -7
  79. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothModels.kt +0 -1810
  80. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +0 -221
  81. package/ios/Packages/VAD/Common/VAD/VADQuality.swift +0 -41
  82. package/ios/Packages/VAD/Common/VAD/VADState.swift +0 -26
  83. package/ios/Packages/VAD/Common/VAD/VADStrategy.swift +0 -29
  84. package/ios/Packages/VAD/Common/VAD/VADType.swift +0 -14
  85. package/ios/Packages/VAD/Data/Configuration.swift +0 -68
  86. package/ios/Packages/VAD/Data/FrameSize.swift +0 -39
  87. package/ios/Packages/VAD/Data/Record.swift +0 -13
  88. package/ios/Packages/VAD/Data/Result.swift +0 -22
  89. package/ios/Packages/VAD/Data/SampleRate.swift +0 -48
  90. package/ios/Packages/VAD/Silero/Model/silero_vad.onnx +0 -0
  91. package/ios/Packages/VAD/Silero/SileroVAD.swift +0 -284
  92. package/ios/Packages/VAD/Silero/SileroVADStrategy.swift +0 -64
package/README.md CHANGED
@@ -123,15 +123,21 @@ const devices = await BluetoothSdk.scan(DeviceModels.MentraLive, {
123
123
  },
124
124
  })
125
125
 
126
- const device = devices[0]
126
+ const device = await chooseDevice(devices)
127
127
  if (!device) {
128
- throw new Error('No Mentra Live glasses found')
128
+ throw new Error('No Mentra Live glasses selected')
129
129
  }
130
130
 
131
131
  await BluetoothSdk.connect(device)
132
- await BluetoothSdk.displayText('Hello from Mentra', 0, 0, 24)
132
+ await BluetoothSdk.requestVersionInfo()
133
133
  ```
134
134
 
135
+ In multi-device environments, present an explicit picker instead of
136
+ auto-connecting to the first nearby device.
137
+
138
+ Mentra Live does not have a display. Use display commands only after gating by
139
+ model capability.
140
+
135
141
  Use `Device.id` as the stable app-facing key for scan rows, selected devices,
136
142
  and persisted default devices. Do not parse it for model, name, or address
137
143
  information; use the typed `model`, `name`, `address`, and `rssi` fields
@@ -139,6 +145,10 @@ instead. Android commonly uses a Bluetooth address when available, iOS commonly
139
145
  uses a CoreBluetooth identifier when available, and the SDK falls back to
140
146
  `model:name` when no platform identifier is available.
141
147
 
148
+ `Device.rssi` is optional. A device can appear in scan results before the
149
+ platform reports RSSI, so picker UI should handle `undefined` and avoid
150
+ reordering rows just because RSSI metadata arrives later.
151
+
142
152
  ## React Hooks
143
153
 
144
154
  React Native apps can import optional lifecycle helpers from the `react`
@@ -225,8 +235,8 @@ await BluetoothSdk.sendWifiCredentials('Office WiFi', 'secret')
225
235
  await BluetoothSdk.forgetWifiNetwork('Office WiFi')
226
236
  await BluetoothSdk.setHotspotState(true)
227
237
 
228
- await BluetoothSdk.setGalleryMode('auto')
229
- await BluetoothSdk.setGalleryMode('manual')
238
+ await BluetoothSdk.setGalleryModeEnabled(true)
239
+ await BluetoothSdk.setGalleryModeEnabled(false)
230
240
 
231
241
  await BluetoothSdk.setPreferredMic('auto')
232
242
  await BluetoothSdk.setMicState(true)
@@ -243,20 +253,20 @@ await BluetoothSdk.rgbLedControl(
243
253
  )
244
254
  ```
245
255
 
246
- `setMicState(true)` defaults to continuous microphone PCM from the glasses. VAD means Voice Activity Detection; pass `false` as the third argument only when your app intentionally wants VAD-gated microphone events.
256
+ `setMicState(true)` defaults to continuous microphone PCM from the glasses. The SDK does not apply phone-side Voice Activity Detection gating to microphone audio events. Use `setVoiceActivityDetectionEnabled(false)` when you want glasses-side Voice Activity Detection disabled for continuous external STT, recording, or playback. `voice_activity_detection_status` reports whether glasses-side Voice Activity Detection is enabled, and `speaking_status` reports speaking/not-speaking when supported. Microphone events include the latest `voiceActivityDetectionEnabled` value.
247
257
 
248
258
  ## Photo Upload
249
259
 
250
260
  ```ts
251
- await BluetoothSdk.requestPhoto(
252
- `photo-${Date.now()}`,
253
- 'com.example.app',
254
- 'medium',
255
- 'https://api.example.com/mentra/photo',
256
- 'optional-token',
257
- 'medium',
258
- true,
259
- )
261
+ await BluetoothSdk.requestPhoto({
262
+ requestId: `photo-${Date.now()}`,
263
+ appId: 'com.example.app',
264
+ size: 'medium',
265
+ webhookUrl: 'https://api.example.com/mentra/photo',
266
+ authToken: 'optional-token',
267
+ compress: 'medium',
268
+ sound: true,
269
+ })
260
270
  ```
261
271
 
262
272
  The webhook should accept multipart form data with a `photo` file and `requestId`. If `authToken` is provided, the uploader adds `Authorization: Bearer <token>`. The camera light is always enabled for photo capture.
@@ -270,6 +280,7 @@ await BluetoothSdk.startStream({
270
280
  type: 'start_stream',
271
281
  streamUrl: 'http://192.168.1.42:8889/mentra-live/whip',
272
282
  streamId,
283
+ video: {fps: 15},
273
284
  })
274
285
 
275
286
  await BluetoothSdk.keepStreamAlive({
@@ -285,24 +296,31 @@ Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https:
285
296
 
286
297
  ## Events
287
298
 
288
- ```ts
289
- const subscriptions = [
290
- BluetoothSdk.addListener('button_press', (event) => console.log(event)),
291
- BluetoothSdk.addListener('touch_event', (event) => console.log(event)),
292
- BluetoothSdk.addListener('photo_response', (event) => console.log(event)),
293
- BluetoothSdk.addListener('stream_status', (event) => console.log(event)),
294
- BluetoothSdk.addListener('mic_pcm', (event) => {
299
+ React Native components should use `useBluetoothEvent()` for hardware events:
300
+
301
+ ```tsx
302
+ import {useBluetoothEvent} from '@mentra/bluetooth-sdk/react'
303
+
304
+ export function HardwareEventLogger() {
305
+ useBluetoothEvent('button_press', (event) => console.log(event))
306
+ useBluetoothEvent('touch_event', (event) => console.log(event))
307
+ useBluetoothEvent('photo_response', (event) => console.log(event))
308
+ useBluetoothEvent('stream_status', (event) => console.log(event))
309
+ useBluetoothEvent('speaking_status', (event) => console.log(event.speaking))
310
+ useBluetoothEvent('mic_pcm', (event) => {
295
311
  console.log(event.sampleRate, event.bitsPerSample, event.channels, event.encoding)
296
312
  console.log(event.pcm)
297
- }),
298
- ]
313
+ })
299
314
 
300
- subscriptions.forEach((subscription) => subscription.remove())
315
+ return null
316
+ }
301
317
  ```
302
318
 
319
+ For non-React modules, `BluetoothSdk.addListener(...)` is the low-level subscription API. Keep the returned subscription and call `remove()` when the listener is no longer needed.
320
+
303
321
  Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `hotspot_status_change`, `photo_response`, `gallery_status`, `stream_status`, `keep_alive_ack`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
304
322
 
305
- React Native event payload fields use camelCase. For example, `touch_event` includes `gestureName`, `photo_response` success includes `uploadUrl`, and `gallery_status` includes `hasContent` and `cameraBusy`. `mic_pcm` includes `sampleRate`, `bitsPerSample`, `channels`, `encoding`, and `vadGated`; `mic_lc3` includes `sampleRate`, `channels`, `encoding`, `frameDurationMs`, `frameSizeBytes`, `bitrate`, `packetizedFromGlasses`, and `vadGated`.
323
+ React Native event payload fields use camelCase. For example, `touch_event` includes `gestureName`, `photo_response` success includes `uploadUrl`, and `gallery_status` includes `hasContent` and `cameraBusy`. `mic_pcm` includes `sampleRate`, `bitsPerSample`, `channels`, and `encoding`; `mic_lc3` includes `sampleRate`, `channels`, `encoding`, `frameDurationMs`, `frameSizeBytes`, `bitrate`, and `packetizedFromGlasses`.
306
324
 
307
325
  Only documented imports are supported for app developers. Undocumented package subpaths or symbols with a leading underscore can change without notice.
308
326
 
@@ -319,4 +337,4 @@ Use `bunx expo run:android` for Android. Keep local paths in your shell or CI en
319
337
 
320
338
  ## Starter Example App
321
339
 
322
- The [Mentra Bluetooth SDK Starter Kit](https://github.com/Mentra-Community/Mentra-Bluetooth-SDK-Starter-Kit) includes starter example apps for Android, iOS, and React Native / Expo. The React Native starter demonstrates scan/connect, display, camera photo upload, RTMP/SRT/WebRTC streaming, Wi-Fi/hotspot, microphone PCM, RGB LED, gallery-button mode, and console event inspection.
340
+ The [Mentra Bluetooth SDK Starter Kit](https://github.com/Mentra-Community/Mentra-Bluetooth-SDK-Starter-Kit) includes starter example apps for Android, iOS, and React Native / Expo. The React Native starter demonstrates scan/connect, display, camera photo upload, RTMP/SRT/WebRTC streaming, Wi-Fi/hotspot, microphone PCM, RGB LED, gallery mode, and console event inspection.
@@ -89,13 +89,6 @@ dependencies {
89
89
  // Sherpa-ONNX for local transcription
90
90
  implementation 'com.github.k2-fsa:sherpa-onnx:v1.12.8'
91
91
 
92
- // Silero VAD (with onnxruntime exclusions to avoid conflicts with sherpa-onnx)
93
- implementation ('com.github.gkonovalov:android-vad:2.0.9') {
94
- exclude group: 'com.microsoft.onnxruntime', module: 'onnxruntime'
95
- exclude group: 'com.microsoft.onnxruntime', module: 'onnxruntime4j'
96
- exclude group: 'com.microsoft.onnxruntime', module: 'onnxruntime4j-jni'
97
- }
98
-
99
92
  // Vuzix Z100
100
93
  implementation 'com.vuzix:ultralite-sdk-android:1.8'
101
94
  implementation 'com.squareup.picasso:picasso:2.8'
@@ -110,11 +103,11 @@ configurations.all {
110
103
  eachDependency { DependencyResolveDetails details ->
111
104
  if (details.requested.group == 'com.microsoft.onnxruntime') {
112
105
  details.useVersion '1.17.1'
113
- details.because('Force override vad\'s transitive version')
106
+ details.because('Force override transitive onnxruntime version')
114
107
  }
115
108
  if (details.requested.name == 'onnxruntime-android') {
116
109
  details.useVersion '1.17.1'
117
- details.because('Force override vad\'s transitive version')
110
+ details.because('Force override transitive onnxruntime version')
118
111
  }
119
112
  }
120
113
  }
@@ -9,12 +9,23 @@ class BluetoothSdkModule : Module() {
9
9
  private var deviceManager: DeviceManager? = null
10
10
  private val sdkListener =
11
11
  object : MentraBluetoothSdkListener {
12
- override fun onGlassesStatusChanged(status: GlassesStatusUpdate) {
13
- sendEvent("glasses_status", status.toMap())
12
+ override fun onGlassesChanged(glasses: GlassesRuntimeState) {
13
+ sendEvent(
14
+ "glasses_status",
15
+ sdk?.getRawGlassesStatus()?.toMap()
16
+ ?: GlassesStatus.fromMap(DeviceStore.store.getCategory("glasses")).toMap()
17
+ )
14
18
  }
15
19
 
16
- override fun onBluetoothStatusChanged(status: BluetoothStatusUpdate) {
17
- sendEvent("bluetooth_status", status.toMap())
20
+ override fun onSdkStateChanged(sdkState: PhoneSdkRuntimeState) {
21
+ sendEvent(
22
+ "bluetooth_status",
23
+ sdk?.getRawBluetoothStatus()?.toMap()
24
+ ?: BluetoothStatus.fromMap(
25
+ DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY)
26
+ )
27
+ .toMap()
28
+ )
18
29
  }
19
30
 
20
31
  override fun onDeviceDiscovered(device: Device) {
@@ -31,7 +42,7 @@ class BluetoothSdkModule : Module() {
31
42
 
32
43
  override fun onScanStopped(reason: ScanStopReason) {
33
44
  if (reason == ScanStopReason.COMPLETED) {
34
- val status = sdk?.getBluetoothStatus()
45
+ val status = sdk?.getRawBluetoothStatus()
35
46
  val deviceModel =
36
47
  status?.pendingWearable?.takeIf { it.isNotBlank() }
37
48
  ?: status?.defaultWearable
@@ -65,6 +76,14 @@ class BluetoothSdkModule : Module() {
65
76
  sendEvent("head_up", mapOf("up" to headUp))
66
77
  }
67
78
 
79
+ override fun onVoiceActivityDetectionStatus(event: VoiceActivityDetectionStatusEvent) {
80
+ sendEvent("voice_activity_detection_status", event.values)
81
+ }
82
+
83
+ override fun onSpeakingStatus(event: SpeakingStatusEvent) {
84
+ sendEvent("speaking_status", event.values)
85
+ }
86
+
68
87
  override fun onBatteryStatus(event: BatteryStatusEvent) {
69
88
  sendEvent("battery_status", event.values)
70
89
  }
@@ -137,7 +156,8 @@ class BluetoothSdkModule : Module() {
137
156
  "button_press",
138
157
  "touch_event",
139
158
  "head_up",
140
- "vad_status",
159
+ "voice_activity_detection_status",
160
+ "speaking_status",
141
161
  "battery_status",
142
162
  "local_transcription",
143
163
  "wifi_status_change",
@@ -196,12 +216,14 @@ class BluetoothSdkModule : Module() {
196
216
  // MARK: - Observable Store Functions
197
217
 
198
218
  Function("getGlassesStatus") {
199
- sdk?.getGlassesStatus()?.toMap()
219
+ sdk?.getRawGlassesStatus()?.toMap()
200
220
  ?: GlassesStatus.fromMap(DeviceStore.store.getCategory("glasses")).toMap()
201
221
  }
202
222
 
203
223
  Function("getBluetoothStatus") {
204
- sdk?.getBluetoothStatus()?.toMap() ?: DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY)
224
+ sdk?.getRawBluetoothStatus()?.toMap()
225
+ ?: BluetoothStatus.fromMap(DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY))
226
+ .toMap()
205
227
  }
206
228
 
207
229
  Function("getDefaultDevice") { sdk?.getDefaultDevice()?.toMap() }
@@ -336,37 +358,34 @@ class BluetoothSdkModule : Module() {
336
358
 
337
359
  // MARK: - Gallery Commands
338
360
 
339
- AsyncFunction("setGalleryMode") { mode: String ->
340
- val galleryMode =
341
- when (mode.lowercase()) {
342
- "auto" -> GalleryMode.AUTO
343
- "manual" -> GalleryMode.MANUAL
344
- else -> throw IllegalArgumentException("setGalleryMode mode must be \"auto\" or \"manual\".")
345
- }
346
- sdk?.setGalleryMode(galleryMode)
361
+ AsyncFunction("setGalleryModeEnabled") { enabled: Boolean ->
362
+ sdk?.setGalleryModeEnabled(enabled)
363
+ }
364
+
365
+ AsyncFunction("setVoiceActivityDetectionEnabled") { enabled: Boolean ->
366
+ sdk?.setVoiceActivityDetectionEnabled(enabled)
347
367
  }
348
368
 
349
369
  AsyncFunction("queryGalleryStatus") { sdk?.queryGalleryStatus() }
350
370
 
351
- AsyncFunction("requestPhoto") {
352
- requestId: String,
353
- appId: String,
354
- size: String,
355
- webhookUrl: String,
356
- authToken: String?,
357
- compress: String,
358
- sound: Boolean ->
359
- sdk?.requestPhoto(
360
- PhotoRequest(
361
- requestId = requestId,
362
- appId = appId,
363
- size = PhotoSize.fromValue(size),
364
- webhookUrl = webhookUrl,
365
- authToken = authToken,
366
- compress = PhotoCompression.fromValue(compress),
367
- sound = sound,
368
- )
371
+ AsyncFunction("requestPhoto") { params: Map<String, Any?> ->
372
+ // JS may pass null for optional fields; Map<String, Any> rejects null values at the bridge.
373
+ val sanitized =
374
+ params.mapNotNull { (key, value) ->
375
+ if (value == null) null else key to value
376
+ }.toMap()
377
+ val req = PhotoRequest.fromMap(sanitized)
378
+ Bridge.log(
379
+ "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} appId=${req.appId} size=${req.size} compress=${req.compress} flash=${req.flash} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs}"
369
380
  )
381
+ val activeSdk = sdk
382
+ if (activeSdk == null) {
383
+ Bridge.log(
384
+ "NATIVE: PHOTO PIPELINE — sdk is null; requestPhoto dropped requestId=${req.requestId}"
385
+ )
386
+ } else {
387
+ activeSdk.requestPhoto(req)
388
+ }
370
389
  }
371
390
 
372
391
  // MARK: - OTA Commands
@@ -412,13 +431,11 @@ class BluetoothSdkModule : Module() {
412
431
  AsyncFunction("setMicState") {
413
432
  enabled: Boolean,
414
433
  useGlassesMic: Boolean?,
415
- bypassVad: Boolean?,
416
434
  sendTranscript: Boolean?,
417
435
  sendLc3Data: Boolean? ->
418
436
  sdk?.setMicState(
419
437
  enabled = enabled,
420
438
  useGlassesMic = useGlassesMic ?: true,
421
- bypassVad = bypassVad ?: true,
422
439
  sendTranscript = sendTranscript ?: false,
423
440
  sendLc3Data = sendLc3Data ?: false,
424
441
  )
@@ -150,17 +150,23 @@ public class Bridge private constructor() {
150
150
  }
151
151
 
152
152
  private fun micPcmEventBody(data: ByteArray): HashMap<String, Any> {
153
+ val voiceActivityDetectionEnabled =
154
+ DeviceStore.get("glasses", "voiceActivityDetectionEnabled") as? Boolean
155
+ ?: true
153
156
  val body = HashMap<String, Any>()
154
157
  body["pcm"] = data
155
158
  body["sampleRate"] = MIC_SAMPLE_RATE
156
159
  body["bitsPerSample"] = PCM_BITS_PER_SAMPLE
157
160
  body["channels"] = MIC_CHANNELS
158
161
  body["encoding"] = "pcm_s16le"
159
- body["vadGated"] = isVadGated()
162
+ body["voiceActivityDetectionEnabled"] = voiceActivityDetectionEnabled
160
163
  return body
161
164
  }
162
165
 
163
166
  private fun micLc3EventBody(data: ByteArray): HashMap<String, Any> {
167
+ val voiceActivityDetectionEnabled =
168
+ DeviceStore.get("glasses", "voiceActivityDetectionEnabled") as? Boolean
169
+ ?: true
164
170
  val frameSizeBytes =
165
171
  (DeviceStore.store.get("bluetooth", "lc3_frame_size") as? Number)?.toInt()
166
172
  ?: DEFAULT_LC3_FRAME_SIZE_BYTES
@@ -173,13 +179,10 @@ public class Bridge private constructor() {
173
179
  body["frameSizeBytes"] = frameSizeBytes
174
180
  body["bitrate"] = frameSizeBytes * 8 * (1000 / LC3_FRAME_DURATION_MS)
175
181
  body["packetizedFromGlasses"] = false
176
- body["vadGated"] = isVadGated()
182
+ body["voiceActivityDetectionEnabled"] = voiceActivityDetectionEnabled
177
183
  return body
178
184
  }
179
185
 
180
- private fun isVadGated(): Boolean =
181
- !((DeviceStore.store.get("bluetooth", "bypass_vad") as? Boolean) ?: true)
182
-
183
186
  /** Save a setting */
184
187
  @JvmStatic
185
188
  fun saveSetting(key: String, value: Any) {
@@ -189,12 +192,22 @@ public class Bridge private constructor() {
189
192
  sendTypedMessage("save_setting", body as Map<String, Any>)
190
193
  }
191
194
 
192
- /** Send VAD (Voice Activity Detection) status */
195
+ /** Send Voice Activity Detection status */
196
+ @JvmStatic
197
+ fun sendVoiceActivityDetectionStatus(enabled: Boolean) {
198
+ DeviceStore.set("glasses", "voiceActivityDetectionEnabled", enabled)
199
+ val body = HashMap<String, Any>()
200
+ body["voiceActivityDetectionEnabled"] = enabled
201
+ sendTypedMessage("voice_activity_detection_status", body as Map<String, Any>)
202
+ }
203
+
204
+ /** Send live speaking status reported by glasses-side Voice Activity Detection. */
193
205
  @JvmStatic
194
- fun sendVadEvent(isSpeaking: Boolean) {
206
+ fun sendSpeakingStatus(speaking: Boolean) {
195
207
  val body = HashMap<String, Any>()
196
- body["status"] = isSpeaking
197
- sendTypedMessage("vad_status", body as Map<String, Any>)
208
+ body["speaking"] = speaking
209
+ body["timestamp"] = System.currentTimeMillis()
210
+ sendTypedMessage("speaking_status", body as Map<String, Any>)
198
211
  }
199
212
 
200
213
  /** Send battery status */
@@ -132,10 +132,6 @@ 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
- private var bypassVad: Boolean
136
- get() = DeviceStore.store.get("bluetooth", "bypass_vad") as? Boolean ?: true
137
- set(value) = DeviceStore.apply("bluetooth", "bypass_vad", value)
138
-
139
135
  private var offlineCaptionsRunning: Boolean
140
136
  get() = DeviceStore.store.get("bluetooth", "offline_captions_running") as? Boolean ?: false
141
137
  set(value) = DeviceStore.apply("bluetooth", "offline_captions_running", value)
@@ -169,8 +165,8 @@ class DeviceManager {
169
165
  set(value) = DeviceStore.apply("bluetooth", "dashboard_depth", value)
170
166
 
171
167
  private var galleryMode: Boolean
172
- get() = DeviceStore.store.get("bluetooth", "galleryModeAuto") as? Boolean ?: true
173
- set(value) = DeviceStore.apply("bluetooth", "galleryModeAuto", value)
168
+ get() = DeviceStore.store.get("bluetooth", "gallery_mode") as? Boolean ?: true
169
+ set(value) = DeviceStore.apply("bluetooth", "gallery_mode", value)
174
170
 
175
171
  // state:
176
172
  private var searching: Boolean
@@ -241,10 +237,6 @@ class DeviceManager {
241
237
  private var lastLc3Event: Long? = null
242
238
  private var micReinitRunnable: Runnable? = null
243
239
 
244
- // VAD
245
- private val vadBuffer = mutableListOf<ByteArray>()
246
- private var isSpeaking = false
247
-
248
240
  // STT
249
241
  private var transcriber: SherpaOnnxTranscriber? = null
250
242
 
@@ -604,13 +596,6 @@ class DeviceManager {
604
596
 
605
597
  // MARK: - Voice Data Handling
606
598
 
607
- private fun checkSetVadStatus(speaking: Boolean) {
608
- if (speaking != isSpeaking) {
609
- isSpeaking = speaking
610
- Bridge.sendVadEvent(isSpeaking)
611
- }
612
- }
613
-
614
599
  private fun convertAndSendMicLc3(pcmData: ByteArray) {
615
600
  synchronized(lc3Lock) {
616
601
  if (lc3EncoderPtr == 0L) {
@@ -636,21 +621,6 @@ class DeviceManager {
636
621
  }
637
622
  }
638
623
 
639
- private fun emptyVadBuffer() {
640
- while (vadBuffer.isNotEmpty()) {
641
- val chunk = vadBuffer.removeAt(0)
642
- handleSendingPcm(chunk) // Uses our encoder, not Bridge directly
643
- }
644
- }
645
-
646
- private fun addToVadBuffer(chunk: ByteArray) {
647
- val MAX_BUFFER_SIZE = 20
648
- vadBuffer.add(chunk)
649
- while (vadBuffer.size > MAX_BUFFER_SIZE) {
650
- vadBuffer.removeAt(0)
651
- }
652
- }
653
-
654
624
  /**
655
625
  * Handle raw LC3 audio data from glasses. Decodes the glasses LC3, then passes to handlePcm for
656
626
  * canonical LC3 encoding. Note: frameSize here is for glasses→phone decoding, NOT for
@@ -1127,6 +1097,7 @@ class DeviceManager {
1127
1097
  fun handleDeviceDisconnected() {
1128
1098
  Bridge.log("MAN: Device disconnected")
1129
1099
  DeviceStore.apply("glasses", "headUp", false)
1100
+ DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", true)
1130
1101
  }
1131
1102
 
1132
1103
  fun handleControllerReady() {
@@ -1369,7 +1340,6 @@ class DeviceManager {
1369
1340
  val willSendPcm = shouldSendPcm || shouldSendLc3
1370
1341
  val willSendTranscript = shouldSendTranscript || offlineCaptionsRunning || localSttFallbackActive
1371
1342
  micEnabled = willSendPcm || willSendTranscript
1372
- vadBuffer.clear()
1373
1343
  updateMicState()
1374
1344
  }
1375
1345
 
@@ -1380,10 +1350,28 @@ class DeviceManager {
1380
1350
  webhookUrl: String,
1381
1351
  authToken: String?,
1382
1352
  compress: String,
1383
- sound: Boolean
1353
+ flash: Boolean,
1354
+ sound: Boolean,
1355
+ exposureTimeNs: Double? = null,
1384
1356
  ) {
1385
- Bridge.log("MAN: requestPhoto: $requestId, $appId, $size, compress=$compress, flash=true, sound=$sound")
1386
- sgc?.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, true, sound)
1357
+ val exposureNs: Long? =
1358
+ exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
1359
+ when {
1360
+ v > Long.MAX_VALUE.toDouble() -> Long.MAX_VALUE
1361
+ else -> v.toLong()
1362
+ }
1363
+ }
1364
+ Bridge.log(
1365
+ "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash sound=$sound exposureTimeNs=$exposureNs sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1366
+ )
1367
+ val activeSgc = sgc
1368
+ if (activeSgc == null) {
1369
+ Bridge.log(
1370
+ "MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=$requestId"
1371
+ )
1372
+ return
1373
+ }
1374
+ activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, sound, exposureNs)
1387
1375
  }
1388
1376
 
1389
1377
  fun rgbLedControl(
@@ -1513,6 +1501,7 @@ class DeviceManager {
1513
1501
  DeviceStore.apply("glasses", "deviceModel", "")
1514
1502
  DeviceStore.apply("glasses", "fullyBooted", false)
1515
1503
  DeviceStore.apply("glasses", "connected", false)
1504
+ DeviceStore.apply("glasses", "voiceActivityDetectionEnabled", true)
1516
1505
  // disconnect the controller as well:
1517
1506
  searchingController = false
1518
1507
  DeviceStore.apply("glasses", "controllerConnected", false)
@@ -66,6 +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
70
  store.set("glasses", "bluetoothClassicConnected", false)
70
71
  store.set("glasses", "caseRemoved", true)
71
72
  store.set("glasses", "caseOpen", true)
@@ -121,12 +122,13 @@ object DeviceStore {
121
122
  store.set("bluetooth", "dashboard_depth", 2)
122
123
  store.set("bluetooth", "head_up_angle", 30)
123
124
  store.set("bluetooth", "contextual_dashboard", true)
124
- store.set("bluetooth", "galleryModeAuto", true)
125
+ store.set("bluetooth", "gallery_mode", true)
126
+ store.set("bluetooth", "voice_activity_detection_enabled", true)
125
127
  store.set("bluetooth", "screen_disabled", false)
126
128
  store.set("bluetooth", "button_photo_size", "medium")
127
129
  store.set("bluetooth", "button_camera_led", true)
128
130
  store.set("bluetooth", "button_max_recording_time", 10)
129
- store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roiPosition" to 0))
131
+ store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
130
132
  store.set("bluetooth", "button_video_width", 1280)
131
133
  store.set("bluetooth", "button_video_height", 720)
132
134
  store.set("bluetooth", "button_video_fps", 30)
@@ -137,7 +139,7 @@ object DeviceStore {
137
139
  store.set("bluetooth", "should_send_pcm", false)
138
140
  store.set("bluetooth", "should_send_lc3", false)
139
141
  store.set("bluetooth", "should_send_transcript", false)
140
- store.set("bluetooth", "bypass_vad", true)
142
+ store.set("bluetooth", "use_native_dashboard", false)
141
143
  }
142
144
 
143
145
  fun get(category: String, key: String): Any? {
@@ -239,9 +241,12 @@ object DeviceStore {
239
241
  DeviceManager.getInstance().sgc?.setDashboardMenu(items)
240
242
  }
241
243
  }
242
- "bluetooth" to "galleryModeAuto" -> {
244
+ "bluetooth" to "gallery_mode" -> {
243
245
  DeviceManager.getInstance().sgc?.sendGalleryMode()
244
246
  }
247
+ "bluetooth" to "voice_activity_detection_enabled" -> {
248
+ DeviceManager.getInstance().sgc?.sendVoiceActivityDetectionSetting()
249
+ }
245
250
  "bluetooth" to "screen_disabled" -> {
246
251
  (value as? Boolean)?.let { disabled ->
247
252
  if (disabled) {