@mentra/bluetooth-sdk 0.1.4 → 0.1.6

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 (72) hide show
  1. package/README.md +61 -42
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +20 -12
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +38 -12
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +45 -51
  6. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +125 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +187 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +78 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +60 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +132 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +39 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +605 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/RuntimeState.kt +199 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +173 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +348 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +75 -0
  17. package/build/BluetoothSdk.types.d.ts +24 -7
  18. package/build/BluetoothSdk.types.d.ts.map +1 -1
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_internal.d.ts +1 -1
  21. package/build/_internal.js +1 -1
  22. package/build/_internal.js.map +1 -1
  23. package/build/_private/BluetoothSdkModule.d.ts +5 -4
  24. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  25. package/build/_private/BluetoothSdkModule.js +4 -1
  26. package/build/_private/BluetoothSdkModule.js.map +1 -1
  27. package/build/index.d.ts +4 -3
  28. package/build/index.d.ts.map +1 -1
  29. package/build/index.js +81 -2
  30. package/build/index.js.map +1 -1
  31. package/build/react/index.d.ts +2 -2
  32. package/build/react/index.d.ts.map +1 -1
  33. package/build/react/index.js +1 -2
  34. package/build/react/index.js.map +1 -1
  35. package/build/react/useBluetoothStatus.js +1 -1
  36. package/build/react/useBluetoothStatus.js.map +1 -1
  37. package/build/react/useGlassesConnection.d.ts.map +1 -1
  38. package/build/react/useGlassesConnection.js +13 -0
  39. package/build/react/useGlassesConnection.js.map +1 -1
  40. package/build/react/useMentraBluetooth.d.ts +98 -0
  41. package/build/react/useMentraBluetooth.d.ts.map +1 -0
  42. package/build/react/useMentraBluetooth.js +156 -0
  43. package/build/react/useMentraBluetooth.js.map +1 -0
  44. package/ios/BluetoothSdkModule.swift +9 -9
  45. package/ios/Source/Audio/AudioModels.swift +159 -0
  46. package/ios/Source/Bridge.swift +42 -8
  47. package/ios/Source/Camera/CameraModels.swift +246 -0
  48. package/ios/Source/Connection/ScanSession.swift +27 -0
  49. package/ios/Source/DeviceManager.swift +0 -1
  50. package/ios/Source/DeviceStore.swift +2 -1
  51. package/ios/Source/Errors/BluetoothError.swift +19 -0
  52. package/ios/Source/Events/BluetoothEvents.swift +115 -0
  53. package/ios/Source/Internal/BluetoothAvailability.swift +58 -0
  54. package/ios/Source/Internal/ValueParsing.swift +90 -0
  55. package/ios/Source/MentraBluetoothSDK.swift +32 -2040
  56. package/ios/Source/Requests/DisplayRequests.swift +58 -0
  57. package/ios/Source/Status/DeviceStatus.swift +463 -0
  58. package/ios/Source/Status/RuntimeState.swift +350 -0
  59. package/ios/Source/Status/WifiHotspotStatus.swift +326 -0
  60. package/ios/Source/Streaming/StreamModels.swift +436 -0
  61. package/ios/Source/Types/DeviceModels.swift +134 -0
  62. package/ios/Source/sgcs/G2.swift +97 -36
  63. package/package.json +1 -1
  64. package/src/BluetoothSdk.types.ts +24 -7
  65. package/src/_internal.ts +1 -1
  66. package/src/_private/BluetoothSdkModule.ts +10 -5
  67. package/src/index.ts +93 -4
  68. package/src/react/index.ts +13 -11
  69. package/src/react/useBluetoothStatus.ts +1 -1
  70. package/src/react/useGlassesConnection.ts +15 -0
  71. package/src/react/useMentraBluetooth.ts +307 -0
  72. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothModels.kt +0 -1725
package/README.md CHANGED
@@ -91,7 +91,7 @@ Android apps should request the permissions required by the features they use:
91
91
  }
92
92
  ```
93
93
 
94
- Some Android 12+ devices still require Location permission and Location services before BLE scan callbacks are delivered.
94
+ Some Android 12+ devices require Location permission and Location services before BLE scan callbacks are delivered.
95
95
 
96
96
  iOS apps should include usage descriptions:
97
97
 
@@ -114,13 +114,8 @@ Use `scan()` when your app needs to show a picker. It calls `onResults` every ti
114
114
  ```ts
115
115
  import BluetoothSdk, {
116
116
  DeviceModels,
117
- isReadyGlassesConnectionStatus,
118
117
  } from '@mentra/bluetooth-sdk'
119
118
 
120
- const removeGlassesListener = BluetoothSdk.onGlassesStatus((status) => {
121
- console.log('Glasses status changed', status)
122
- })
123
-
124
119
  const devices = await BluetoothSdk.scan(DeviceModels.MentraLive, {
125
120
  timeoutMs: 10_000,
126
121
  onResults: (nextDevices) => {
@@ -128,36 +123,46 @@ const devices = await BluetoothSdk.scan(DeviceModels.MentraLive, {
128
123
  },
129
124
  })
130
125
 
131
- const device = devices[0]
126
+ const device = await chooseDevice(devices)
132
127
  if (!device) {
133
- throw new Error('No Mentra Live glasses found')
128
+ throw new Error('No Mentra Live glasses selected')
134
129
  }
135
130
 
136
131
  await BluetoothSdk.connect(device)
132
+ await BluetoothSdk.requestVersionInfo()
133
+ ```
137
134
 
138
- const glasses = await BluetoothSdk.getGlassesStatus()
139
- if (isReadyGlassesConnectionStatus(glasses.connection)) {
140
- await BluetoothSdk.displayText('Hello from Mentra', 0, 0, 24)
141
- }
135
+ In multi-device environments, present an explicit picker instead of
136
+ auto-connecting to the first nearby device.
142
137
 
143
- removeGlassesListener()
144
- ```
138
+ Mentra Live does not have a display. Use display commands only after gating by
139
+ model capability.
140
+
141
+ Use `Device.id` as the stable app-facing key for scan rows, selected devices,
142
+ and persisted default devices. Do not parse it for model, name, or address
143
+ information; use the typed `model`, `name`, `address`, and `rssi` fields
144
+ instead. Android commonly uses a Bluetooth address when available, iOS commonly
145
+ uses a CoreBluetooth identifier when available, and the SDK falls back to
146
+ `model:name` when no platform identifier is available.
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.
145
151
 
146
152
  ## React Hooks
147
153
 
148
154
  React Native apps can import optional lifecycle helpers from the `react`
149
- subpath. The hooks use the same SDK types and still leave commands such as
150
- `requestPhoto()`, `startStream()`, and `setMicState()` on the root
151
- `BluetoothSdk` object.
155
+ subpath for common lifecycle plumbing. Use the root `BluetoothSdk` object for
156
+ commands such as `requestPhoto()`, `startStream()`, and `setMicState()`.
152
157
 
153
158
  ```tsx
154
159
  import {Button, Text, View} from 'react-native'
155
160
  import {DeviceModels} from '@mentra/bluetooth-sdk'
156
- import {useBluetoothEvent, useGlassesConnection} from '@mentra/bluetooth-sdk/react'
161
+ import {useBluetoothEvent, useMentraBluetooth} from '@mentra/bluetooth-sdk/react'
157
162
 
158
163
  export function DeviceScreen() {
159
- const glasses = useGlassesConnection({
160
- scanModel: DeviceModels.MentraLive,
164
+ const mentra = useMentraBluetooth({
165
+ defaultModel: DeviceModels.MentraLive,
161
166
  scanTimeoutMs: 10_000,
162
167
  })
163
168
 
@@ -167,12 +172,12 @@ export function DeviceScreen() {
167
172
 
168
173
  return (
169
174
  <View>
170
- <Text>{glasses.connected ? 'Connected' : 'Disconnected'}</Text>
171
- <Button disabled={glasses.busy} title="Scan" onPress={() => glasses.scan.startScan()} />
172
- {glasses.scan.devices.map((device) => (
173
- <Button key={device.id} title={device.name} onPress={() => glasses.connect(device)} />
175
+ <Text>{mentra.glasses.connected ? 'Connected' : 'Disconnected'}</Text>
176
+ <Button disabled={mentra.busy} title="Scan" onPress={() => mentra.scan.start()} />
177
+ {mentra.scan.devices.map((device) => (
178
+ <Button key={device.id} title={device.name} onPress={() => mentra.connect(device)} />
174
179
  ))}
175
- <Button disabled={!glasses.connected} title="Disconnect" onPress={glasses.disconnect} />
180
+ <Button disabled={!mentra.glasses.connected} title="Disconnect" onPress={mentra.disconnect} />
176
181
  </View>
177
182
  )
178
183
  }
@@ -180,10 +185,13 @@ export function DeviceScreen() {
180
185
 
181
186
  The hooks do not request Android permissions or choose a persistence package for
182
187
  you. Ask for permissions in your app before calling scan/connect actions, and
183
- pass a `defaultDeviceStorage` adapter to `useGlassesConnection` if you want a
188
+ pass a `defaultDeviceStorage` adapter to `useMentraBluetooth` if you want a
184
189
  default device to survive app restarts.
185
190
 
186
- React Native status exposes `GlassesStatus.connection` as a discriminated union:
191
+ Use `useMentraBluetooth()` as the React status API for connection, battery,
192
+ Wi-Fi, hotspot, scan, and SDK runtime state.
193
+
194
+ The React hook exposes `glasses.connection` as a discriminated union:
187
195
 
188
196
  ```ts
189
197
  type GlassesConnectionStatus =
@@ -198,7 +206,7 @@ Use `connection.state` for link progress. `fullyBooted` only exists when `state
198
206
 
199
207
  ## Default Device
200
208
 
201
- `connectDefault()` connects to the default glasses target currently stored in the SDK. Apps that want this target to survive app restarts should persist the scanned `Device` in app storage and restore it with `setDefaultDevice()` before calling `connectDefault()`.
209
+ `connectDefault()` connects to the default glasses target stored in SDK state. Apps that want this target to survive app restarts should persist the scanned `Device` in app storage and restore it with `setDefaultDevice()` before calling `connectDefault()`.
202
210
 
203
211
  ```ts
204
212
  const savedDevice = await loadSavedDeviceFromYourAppStorage()
@@ -231,7 +239,7 @@ await BluetoothSdk.setGalleryMode('auto')
231
239
  await BluetoothSdk.setGalleryMode('manual')
232
240
 
233
241
  await BluetoothSdk.setPreferredMic('auto')
234
- await BluetoothSdk.setMicState(true, true, false)
242
+ await BluetoothSdk.setMicState(true)
235
243
  await BluetoothSdk.setOwnAppAudioPlaying(false)
236
244
 
237
245
  await BluetoothSdk.rgbLedControl(
@@ -245,6 +253,8 @@ await BluetoothSdk.rgbLedControl(
245
253
  )
246
254
  ```
247
255
 
256
+ `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.
257
+
248
258
  ## Photo Upload
249
259
 
250
260
  ```ts
@@ -285,27 +295,36 @@ Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https:
285
295
 
286
296
  ## Events
287
297
 
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) => console.log(event.pcm)),
295
- ]
296
-
297
- subscriptions.forEach((subscription) => subscription.remove())
298
+ React Native components should use `useBluetoothEvent()` for hardware events:
299
+
300
+ ```tsx
301
+ import {useBluetoothEvent} from '@mentra/bluetooth-sdk/react'
302
+
303
+ export function HardwareEventLogger() {
304
+ useBluetoothEvent('button_press', (event) => console.log(event))
305
+ useBluetoothEvent('touch_event', (event) => console.log(event))
306
+ useBluetoothEvent('photo_response', (event) => console.log(event))
307
+ useBluetoothEvent('stream_status', (event) => console.log(event))
308
+ useBluetoothEvent('mic_pcm', (event) => {
309
+ console.log(event.sampleRate, event.bitsPerSample, event.channels, event.encoding)
310
+ console.log(event.pcm)
311
+ })
312
+
313
+ return null
314
+ }
298
315
  ```
299
316
 
317
+ 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.
318
+
300
319
  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`.
301
320
 
302
- 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`.
321
+ 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`.
303
322
 
304
- Only the documented root import is supported for partner apps. Package subpaths or symbols with a leading underscore are private implementation details and can change without notice.
323
+ Only documented imports are supported for app developers. Undocumented package subpaths or symbols with a leading underscore can change without notice.
305
324
 
306
325
  ## Local SDK Development
307
326
 
308
- For normal app development, install the published JavaScript package. For SDK development before a package release, install a local checkout and point Metro/native resolution at the same path:
327
+ For normal app development, install the JavaScript package from npm. For SDK source development or release testing, install a local checkout and point Metro/native resolution at the same path:
309
328
 
310
329
  ```sh
311
330
  bun add --no-save /path/to/MentraOS/mobile/modules/bluetooth-sdk
@@ -9,12 +9,20 @@ 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
+ ?: DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY)
25
+ )
18
26
  }
19
27
 
20
28
  override fun onDeviceDiscovered(device: Device) {
@@ -31,7 +39,7 @@ class BluetoothSdkModule : Module() {
31
39
 
32
40
  override fun onScanStopped(reason: ScanStopReason) {
33
41
  if (reason == ScanStopReason.COMPLETED) {
34
- val status = sdk?.getBluetoothStatus()
42
+ val status = sdk?.getRawBluetoothStatus()
35
43
  val deviceModel =
36
44
  status?.pendingWearable?.takeIf { it.isNotBlank() }
37
45
  ?: status?.defaultWearable
@@ -97,12 +105,12 @@ class BluetoothSdkModule : Module() {
97
105
  sendEvent("keep_alive_ack", event.values)
98
106
  }
99
107
 
100
- override fun onMicPcm(frame: ByteArray) {
101
- sendEvent("mic_pcm", mapOf("pcm" to frame))
108
+ override fun onMicPcm(event: MicPcmEvent) {
109
+ sendEvent("mic_pcm", event.toMap())
102
110
  }
103
111
 
104
- override fun onMicLc3(frame: ByteArray) {
105
- sendEvent("mic_lc3", mapOf("lc3" to frame))
112
+ override fun onMicLc3(event: MicLc3Event) {
113
+ sendEvent("mic_lc3", event.toMap())
106
114
  }
107
115
 
108
116
  override fun onLocalTranscription(event: LocalTranscriptionEvent) {
@@ -196,12 +204,12 @@ class BluetoothSdkModule : Module() {
196
204
  // MARK: - Observable Store Functions
197
205
 
198
206
  Function("getGlassesStatus") {
199
- sdk?.getGlassesStatus()?.toMap()
207
+ sdk?.getRawGlassesStatus()?.toMap()
200
208
  ?: GlassesStatus.fromMap(DeviceStore.store.getCategory("glasses")).toMap()
201
209
  }
202
210
 
203
211
  Function("getBluetoothStatus") {
204
- sdk?.getBluetoothStatus()?.toMap() ?: DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY)
212
+ sdk?.getRawBluetoothStatus()?.toMap() ?: DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY)
205
213
  }
206
214
 
207
215
  Function("getDefaultDevice") { sdk?.getDefaultDevice()?.toMap() }
@@ -418,7 +426,7 @@ class BluetoothSdkModule : Module() {
418
426
  sdk?.setMicState(
419
427
  enabled = enabled,
420
428
  useGlassesMic = useGlassesMic ?: true,
421
- bypassVad = bypassVad ?: false,
429
+ bypassVad = bypassVad ?: true,
422
430
  sendTranscript = sendTranscript ?: false,
423
431
  sendLc3Data = sendLc3Data ?: false,
424
432
  )
@@ -24,6 +24,11 @@ public class Bridge private constructor() {
24
24
 
25
25
  companion object {
26
26
  private const val TAG = "Bridge"
27
+ private const val MIC_SAMPLE_RATE = 16_000
28
+ private const val PCM_BITS_PER_SAMPLE = 16
29
+ private const val MIC_CHANNELS = 1
30
+ private const val LC3_FRAME_DURATION_MS = 10
31
+ private const val DEFAULT_LC3_FRAME_SIZE_BYTES = 60
27
32
 
28
33
  @Volatile private var instance: Bridge? = null
29
34
 
@@ -134,26 +139,47 @@ public class Bridge private constructor() {
134
139
 
135
140
  @JvmStatic
136
141
  fun sendMicPcm(data: ByteArray) {
137
- // val base64String = Base64.encodeToString(data, Base64.NO_WRAP)
138
- // val body = HashMap<String, Any>()
139
- // body["base64"] = base64String
140
- // sendTypedMessage("mic_pcm", body as Map<String, Any>)
141
- val body = HashMap<String, Any>()
142
- body["pcm"] = data
142
+ val body = micPcmEventBody(data)
143
143
  sendTypedMessage("mic_pcm", body as Map<String, Any>)
144
144
  }
145
145
 
146
146
  @JvmStatic
147
147
  fun sendMicLc3(data: ByteArray) {
148
- // val base64String = Base64.encodeToString(data, Base64.NO_WRAP)
149
- // val body = HashMap<String, Any>()
150
- // body["base64"] = base64String
151
- // sendTypedMessage("mic_lc3", body as Map<String, Any>)
152
- val body = HashMap<String, Any>()
153
- body["lc3"] = data
148
+ val body = micLc3EventBody(data)
154
149
  sendTypedMessage("mic_lc3", body as Map<String, Any>)
155
150
  }
156
151
 
152
+ private fun micPcmEventBody(data: ByteArray): HashMap<String, Any> {
153
+ val body = HashMap<String, Any>()
154
+ body["pcm"] = data
155
+ body["sampleRate"] = MIC_SAMPLE_RATE
156
+ body["bitsPerSample"] = PCM_BITS_PER_SAMPLE
157
+ body["channels"] = MIC_CHANNELS
158
+ body["encoding"] = "pcm_s16le"
159
+ body["vadGated"] = isVadGated()
160
+ return body
161
+ }
162
+
163
+ private fun micLc3EventBody(data: ByteArray): HashMap<String, Any> {
164
+ val frameSizeBytes =
165
+ (DeviceStore.store.get("bluetooth", "lc3_frame_size") as? Number)?.toInt()
166
+ ?: DEFAULT_LC3_FRAME_SIZE_BYTES
167
+ val body = HashMap<String, Any>()
168
+ body["lc3"] = data
169
+ body["sampleRate"] = MIC_SAMPLE_RATE
170
+ body["channels"] = MIC_CHANNELS
171
+ body["encoding"] = "lc3"
172
+ body["frameDurationMs"] = LC3_FRAME_DURATION_MS
173
+ body["frameSizeBytes"] = frameSizeBytes
174
+ body["bitrate"] = frameSizeBytes * 8 * (1000 / LC3_FRAME_DURATION_MS)
175
+ body["packetizedFromGlasses"] = false
176
+ body["vadGated"] = isVadGated()
177
+ return body
178
+ }
179
+
180
+ private fun isVadGated(): Boolean =
181
+ !((DeviceStore.store.get("bluetooth", "bypass_vad") as? Boolean) ?: true)
182
+
157
183
  /** Save a setting */
158
184
  @JvmStatic
159
185
  fun saveSetting(key: String, value: Any) {
@@ -137,7 +137,8 @@ object DeviceStore {
137
137
  store.set("bluetooth", "should_send_pcm", false)
138
138
  store.set("bluetooth", "should_send_lc3", false)
139
139
  store.set("bluetooth", "should_send_transcript", false)
140
- store.set("bluetooth", "bypass_vad", false)
140
+ store.set("bluetooth", "bypass_vad", true)
141
+ store.set("bluetooth", "use_native_dashboard", false)
141
142
  }
142
143
 
143
144
  fun get(category: String, key: String): Any? {
@@ -4,10 +4,10 @@ import android.bluetooth.BluetoothManager
4
4
  import android.content.Context
5
5
  import android.os.Handler
6
6
  import android.os.Looper
7
- import com.mentra.bluetoothsdk.utils.ConnTypes
8
7
  import com.mentra.bluetoothsdk.utils.ControllerTypes
9
8
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
10
9
  import java.util.Collections
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
11
 
12
12
  class MentraBluetoothSdk private constructor(
13
13
  context: Context,
@@ -34,6 +34,7 @@ class MentraBluetoothSdk private constructor(
34
34
 
35
35
  companion object {
36
36
  private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address")
37
+ private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
37
38
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
38
39
 
39
40
  @JvmStatic
@@ -58,10 +59,22 @@ class MentraBluetoothSdk private constructor(
58
59
  listeners.remove(listener)
59
60
  }
60
61
 
61
- fun getGlassesStatus(): GlassesStatus =
62
+ fun getState(): MentraBluetoothState =
63
+ MentraBluetoothState.from(getRawGlassesStatus(), getRawBluetoothStatus())
64
+
65
+ fun getGlasses(): GlassesRuntimeState =
66
+ getState().glasses
67
+
68
+ fun getSdkState(): PhoneSdkRuntimeState =
69
+ getState().sdk
70
+
71
+ fun getScanState(): BluetoothScanState =
72
+ getState().scan
73
+
74
+ internal fun getRawGlassesStatus(): GlassesStatus =
62
75
  GlassesStatus.fromMap(DeviceStore.store.getCategory("glasses"))
63
76
 
64
- fun getBluetoothStatus(): BluetoothStatus =
77
+ internal fun getRawBluetoothStatus(): BluetoothStatus =
65
78
  BluetoothStatus.fromMap(DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY))
66
79
 
67
80
  fun getDefaultDevice(): Device? = currentDefaultDevice()
@@ -144,7 +157,7 @@ class MentraBluetoothSdk private constructor(
144
157
  val latestResults = mutableListOf<Device>()
145
158
  lateinit var timeoutRunnable: Runnable
146
159
  lateinit var session: ScanSession
147
- var finished = false
160
+ val finished = AtomicBoolean(false)
148
161
 
149
162
  fun emitResults(devices: List<Device>) {
150
163
  latestResults.clear()
@@ -154,16 +167,13 @@ class MentraBluetoothSdk private constructor(
154
167
 
155
168
  val scanListener =
156
169
  object : MentraBluetoothSdkCallback() {
157
- override fun onBluetoothStatusChanged(status: BluetoothStatusUpdate) {
158
- status.searchResults?.let { results ->
159
- emitResults(results.filter { it.model == model })
160
- }
170
+ override fun onScanChanged(scan: BluetoothScanState) {
171
+ emitResults(scan.devices.filter { it.model == model })
161
172
  }
162
- }
173
+ }
163
174
 
164
175
  fun finish(reason: ScanStopReason) {
165
- if (finished) return
166
- finished = true
176
+ if (!finished.compareAndSet(false, true)) return
167
177
  removeListener(scanListener)
168
178
  mainHandler.removeCallbacks(timeoutRunnable)
169
179
  session.markStopped()
@@ -178,7 +188,7 @@ class MentraBluetoothSdk private constructor(
178
188
  try {
179
189
  emitResults(emptyList())
180
190
  startScan(model)
181
- emitResults(getBluetoothStatus().searchResults.filter { it.model == model })
191
+ emitResults(getRawBluetoothStatus().searchResults.filter { it.model == model })
182
192
  mainHandler.postDelayed(timeoutRunnable, normalizedTimeoutMs)
183
193
  return session
184
194
  } catch (error: Throwable) {
@@ -339,7 +349,7 @@ class MentraBluetoothSdk private constructor(
339
349
  fun setMicState(
340
350
  enabled: Boolean,
341
351
  useGlassesMic: Boolean = true,
342
- bypassVad: Boolean = false,
352
+ bypassVad: Boolean = true,
343
353
  sendTranscript: Boolean = false,
344
354
  sendLc3Data: Boolean = false,
345
355
  ) {
@@ -483,13 +493,22 @@ class MentraBluetoothSdk private constructor(
483
493
 
484
494
  private fun dispatchStoreUpdate(category: String, changes: Map<String, Any>) {
485
495
  when (ObservableStore.normalizeCategory(category)) {
486
- "glasses" ->
496
+ "glasses" -> {
497
+ val state = getState()
487
498
  dispatchToListeners {
488
- it.onGlassesStatusChanged(GlassesStatusUpdate.fromMap(glassesStatusChanges(changes)))
499
+ it.onStateChanged(state)
500
+ it.onGlassesChanged(state.glasses)
489
501
  }
502
+ }
490
503
  ObservableStore.BLUETOOTH_CATEGORY -> {
504
+ val state = getState()
505
+ val scanChanged = changes.keys.any { it in SCAN_STATE_KEYS }
491
506
  dispatchToListeners {
492
- it.onBluetoothStatusChanged(BluetoothStatusUpdate.fromMap(changes))
507
+ it.onStateChanged(state)
508
+ it.onSdkStateChanged(state.sdk)
509
+ if (scanChanged) {
510
+ it.onScanChanged(state.scan)
511
+ }
493
512
  }
494
513
  if (!suppressDefaultDeviceEvents && changes.keys.any { it in DEFAULT_DEVICE_KEYS }) {
495
514
  dispatchDefaultDeviceChanged()
@@ -499,37 +518,6 @@ class MentraBluetoothSdk private constructor(
499
518
  }
500
519
  }
501
520
 
502
- private fun glassesStatusChanges(changes: Map<String, Any>): Map<String, Any> {
503
- var merged = changes
504
-
505
- if (changes.keys.any { it in setOf("wifiConnected", "wifiSsid", "wifiLocalIp") }) {
506
- merged =
507
- merged +
508
- mapOf(
509
- "wifiConnected" to ((DeviceStore.get("glasses", "wifiConnected") as? Boolean) ?: false),
510
- "wifiSsid" to ((DeviceStore.get("glasses", "wifiSsid") as? String) ?: ""),
511
- "wifiLocalIp" to ((DeviceStore.get("glasses", "wifiLocalIp") as? String) ?: ""),
512
- )
513
- }
514
-
515
- if (changes.keys.any { it in setOf("connected", "fullyBooted", "connectionState") }) {
516
- merged =
517
- merged +
518
- mapOf(
519
- "connected" to ((DeviceStore.get("glasses", "connected") as? Boolean) ?: false),
520
- "fullyBooted" to ((DeviceStore.get("glasses", "fullyBooted") as? Boolean) ?: false),
521
- "connectionState" to ((DeviceStore.get("glasses", "connectionState") as? String) ?: ConnTypes.DISCONNECTED),
522
- )
523
- }
524
-
525
- if (changes.containsKey("signalStrengthUpdatedAt") && !changes.containsKey("signalStrength")) {
526
- val signalStrength = (DeviceStore.get("glasses", "signalStrength") as? Number)?.toInt() ?: -1
527
- merged = merged + ("signalStrength" to signalStrength)
528
- }
529
-
530
- return merged
531
- }
532
-
533
521
  private fun dispatchDefaultDeviceChanged() {
534
522
  val defaultDevice = currentDefaultDevice()
535
523
  dispatchToListeners { it.onDefaultDeviceChanged(defaultDevice) }
@@ -634,11 +622,17 @@ class MentraBluetoothSdk private constructor(
634
622
  "photo_response" -> dispatchToListeners { it.onPhotoResponse(PhotoResponseEvent(data)) }
635
623
  "stream_status" -> dispatchToListeners { it.onStreamStatus(StreamStatusEvent(data)) }
636
624
  "keep_alive_ack" -> dispatchToListeners { it.onKeepAliveAck(KeepAliveAckEvent(data)) }
637
- "mic_pcm" -> (data["pcm"] as? ByteArray)?.let { frame ->
638
- dispatchToListeners { it.onMicPcm(frame) }
625
+ "mic_pcm" -> {
626
+ val event = MicPcmEvent(data)
627
+ if (event.pcm.isNotEmpty()) {
628
+ dispatchToListeners { it.onMicPcm(event) }
629
+ }
639
630
  }
640
- "mic_lc3" -> (data["lc3"] as? ByteArray)?.let { frame ->
641
- dispatchToListeners { it.onMicLc3(frame) }
631
+ "mic_lc3" -> {
632
+ val event = MicLc3Event(data)
633
+ if (event.lc3.isNotEmpty()) {
634
+ dispatchToListeners { it.onMicLc3(event) }
635
+ }
642
636
  }
643
637
  "local_transcription" ->
644
638
  dispatchToListeners {
@@ -0,0 +1,125 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ enum class MicPreference(val value: String) {
4
+ AUTO("auto"),
5
+ PHONE("phone"),
6
+ GLASSES("glasses"),
7
+ BLUETOOTH("bluetooth"),
8
+ }
9
+
10
+ data class MicPcmEvent(
11
+ val pcm: ByteArray,
12
+ val sampleRate: Int,
13
+ val bitsPerSample: Int,
14
+ val channels: Int,
15
+ val encoding: String,
16
+ val vadGated: Boolean,
17
+ ) {
18
+ constructor(values: Map<String, Any>) : this(
19
+ pcm = values["pcm"] as? ByteArray ?: ByteArray(0),
20
+ sampleRate = numberValue(values, "sampleRate") ?: SAMPLE_RATE,
21
+ bitsPerSample = numberValue(values, "bitsPerSample") ?: BITS_PER_SAMPLE,
22
+ channels = numberValue(values, "channels") ?: CHANNELS,
23
+ encoding = stringValue(values, "encoding") ?: ENCODING,
24
+ vadGated = boolValue(values, "vadGated") ?: false,
25
+ )
26
+
27
+ fun toMap(): Map<String, Any> =
28
+ mapOf(
29
+ "type" to "mic_pcm",
30
+ "pcm" to pcm,
31
+ "sampleRate" to sampleRate,
32
+ "bitsPerSample" to bitsPerSample,
33
+ "channels" to channels,
34
+ "encoding" to encoding,
35
+ "vadGated" to vadGated,
36
+ )
37
+
38
+ companion object {
39
+ const val SAMPLE_RATE = 16_000
40
+ const val BITS_PER_SAMPLE = 16
41
+ const val CHANNELS = 1
42
+ const val ENCODING = "pcm_s16le"
43
+ }
44
+ }
45
+
46
+ data class MicLc3Event(
47
+ val lc3: ByteArray,
48
+ val sampleRate: Int,
49
+ val channels: Int,
50
+ val encoding: String,
51
+ val frameDurationMs: Int,
52
+ val frameSizeBytes: Int,
53
+ val bitrate: Int,
54
+ val packetizedFromGlasses: Boolean,
55
+ val vadGated: Boolean,
56
+ ) {
57
+ constructor(values: Map<String, Any>) : this(
58
+ lc3 = values["lc3"] as? ByteArray ?: ByteArray(0),
59
+ sampleRate = numberValue(values, "sampleRate") ?: SAMPLE_RATE,
60
+ channels = numberValue(values, "channels") ?: CHANNELS,
61
+ encoding = stringValue(values, "encoding") ?: ENCODING,
62
+ frameDurationMs = numberValue(values, "frameDurationMs") ?: FRAME_DURATION_MS,
63
+ frameSizeBytes = numberValue(values, "frameSizeBytes") ?: DEFAULT_FRAME_SIZE_BYTES,
64
+ bitrate = numberValue(values, "bitrate") ?: DEFAULT_BITRATE,
65
+ packetizedFromGlasses = boolValue(values, "packetizedFromGlasses") ?: false,
66
+ vadGated = boolValue(values, "vadGated") ?: false,
67
+ )
68
+
69
+ fun toMap(): Map<String, Any> =
70
+ mapOf(
71
+ "type" to "mic_lc3",
72
+ "lc3" to lc3,
73
+ "sampleRate" to sampleRate,
74
+ "channels" to channels,
75
+ "encoding" to encoding,
76
+ "frameDurationMs" to frameDurationMs,
77
+ "frameSizeBytes" to frameSizeBytes,
78
+ "bitrate" to bitrate,
79
+ "packetizedFromGlasses" to packetizedFromGlasses,
80
+ "vadGated" to vadGated,
81
+ )
82
+
83
+ companion object {
84
+ const val SAMPLE_RATE = 16_000
85
+ const val CHANNELS = 1
86
+ const val ENCODING = "lc3"
87
+ const val FRAME_DURATION_MS = 10
88
+ const val DEFAULT_FRAME_SIZE_BYTES = 60
89
+ const val DEFAULT_BITRATE = DEFAULT_FRAME_SIZE_BYTES * 8 * (1000 / FRAME_DURATION_MS)
90
+ }
91
+ }
92
+
93
+ data class LocalTranscriptionEvent(
94
+ val text: String,
95
+ val isFinal: Boolean,
96
+ val values: Map<String, Any>,
97
+ )
98
+
99
+ data class GlassesMediaVolumeGetResult(
100
+ val level: Int?,
101
+ val statusCode: Int?,
102
+ val values: Map<String, Any>,
103
+ ) {
104
+ companion object {
105
+ fun fromMap(values: Map<String, Any>): GlassesMediaVolumeGetResult =
106
+ GlassesMediaVolumeGetResult(
107
+ level = numberValue(values, "level"),
108
+ statusCode = (values["statusCode"] as? Number)?.toInt(),
109
+ values = values,
110
+ )
111
+ }
112
+ }
113
+
114
+ data class GlassesMediaVolumeSetResult(
115
+ val statusCode: Int?,
116
+ val values: Map<String, Any>,
117
+ ) {
118
+ companion object {
119
+ fun fromMap(values: Map<String, Any>): GlassesMediaVolumeSetResult =
120
+ GlassesMediaVolumeSetResult(
121
+ statusCode = (values["statusCode"] as? Number)?.toInt(),
122
+ values = values,
123
+ )
124
+ }
125
+ }