@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.1

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 (114) hide show
  1. package/README.md +27 -12
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  13. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
  14. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  31. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  32. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
  33. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  34. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  37. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  46. package/build/BluetoothSdk.types.d.ts +100 -22
  47. package/build/BluetoothSdk.types.d.ts.map +1 -1
  48. package/build/BluetoothSdk.types.js +2 -1
  49. package/build/BluetoothSdk.types.js.map +1 -1
  50. package/build/_internal.d.ts +3 -0
  51. package/build/_internal.d.ts.map +1 -1
  52. package/build/_internal.js +3 -0
  53. package/build/_internal.js.map +1 -1
  54. package/build/_private/BluetoothSdkModule.d.ts +31 -4
  55. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  56. package/build/_private/BluetoothSdkModule.js +26 -3
  57. package/build/_private/BluetoothSdkModule.js.map +1 -1
  58. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  59. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  60. package/build/_private/MentraLocalNetworkModule.js +3 -0
  61. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  62. package/build/_private/cameraRequestPayload.d.ts +1 -1
  63. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  64. package/build/_private/cameraRequestPayload.js +7 -0
  65. package/build/_private/cameraRequestPayload.js.map +1 -1
  66. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  67. package/build/_private/photoRequestPayload.js +10 -0
  68. package/build/_private/photoRequestPayload.js.map +1 -1
  69. package/build/_private/sdkOtaManifest.d.ts +20 -0
  70. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  71. package/build/_private/sdkOtaManifest.js +28 -0
  72. package/build/_private/sdkOtaManifest.js.map +1 -0
  73. package/build/index.d.ts +1 -1
  74. package/build/index.d.ts.map +1 -1
  75. package/build/index.js +11 -1
  76. package/build/index.js.map +1 -1
  77. package/expo-module.config.json +1 -0
  78. package/ios/BluetoothSdkModule.swift +113 -2
  79. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  80. package/ios/Source/Bridge.swift +87 -21
  81. package/ios/Source/DeviceManager.swift +118 -20
  82. package/ios/Source/DeviceStore.swift +4 -0
  83. package/ios/Source/MentraBluetoothSDK.swift +326 -33
  84. package/ios/Source/OtaManifest.swift +47 -4
  85. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  86. package/ios/Source/camera/CameraModels.swift +26 -6
  87. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  88. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  89. package/ios/Source/sgcs/Ar99.swift +2451 -0
  90. package/ios/Source/sgcs/G1.swift +1 -1
  91. package/ios/Source/sgcs/G2.swift +19 -7
  92. package/ios/Source/sgcs/Mach1.swift +1 -1
  93. package/ios/Source/sgcs/MentraLive.swift +478 -61
  94. package/ios/Source/sgcs/MentraNex.swift +1 -1
  95. package/ios/Source/sgcs/Nimo.swift +1 -1
  96. package/ios/Source/sgcs/SGCManager.swift +13 -2
  97. package/ios/Source/sgcs/Simulated.swift +1 -1
  98. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  99. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  100. package/ios/Source/streaming/StreamModels.swift +68 -2
  101. package/ios/Source/tts/TTSTools.swift +1 -1
  102. package/ios/Source/types/DeviceModels.swift +13 -0
  103. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  104. package/ios/Source/utils/Constants.swift +2 -0
  105. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  106. package/package.json +1 -2
  107. package/src/BluetoothSdk.types.ts +106 -24
  108. package/src/_internal.ts +3 -0
  109. package/src/_private/BluetoothSdkModule.ts +83 -18
  110. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  111. package/src/_private/cameraRequestPayload.ts +9 -2
  112. package/src/_private/photoRequestPayload.ts +13 -4
  113. package/src/_private/sdkOtaManifest.ts +31 -0
  114. package/src/index.ts +13 -1
package/README.md CHANGED
@@ -152,11 +152,13 @@ reordering rows just because RSSI metadata arrives later.
152
152
 
153
153
  ## Mentra SDK Usage Analytics
154
154
 
155
- The SDK sends two anonymous usage events to Mentra's PostHog project by default
156
- so Mentra can understand SDK adoption and successful glasses connections:
155
+ The SDK sends three usage events to Mentra's PostHog project by default so
156
+ Mentra can understand SDK adoption, successful glasses connections, and
157
+ enterprise device deployments:
157
158
 
158
159
  - `bluetooth_sdk_started`: sent once per app runtime after the native SDK starts.
159
160
  - `bluetooth_sdk_glasses_connected`: sent when SDK status transitions from not connected to connected.
161
+ - `bluetooth_sdk_glasses_identified`: sent once per connection after the SDK receives a valid manufacturing serial from the glasses. This fires for every supported model that reports a serial. G1 and Ar99 decode the serial from the glasses' BLE advertisement, so they emit this event as soon as this SDK version ships. Mentra Live instead reports the serial provisioned in BES NV storage, which requires compatible BES firmware and `asg_client` software.
160
162
 
161
163
  Analytics delivery is fire-and-forget: events are submitted asynchronously, do
162
164
  not block Bluetooth SDK behavior, and are not retried if delivery fails.
@@ -190,14 +192,23 @@ write token, not a private PostHog personal API key. Apps do not configure the
190
192
  analytics destination; these SDK usage events are always sent to Mentra's
191
193
  PostHog project unless analytics are disabled.
192
194
 
193
- Captured properties are limited to non-sensitive SDK/app metadata:
194
- `event_source`, `sdk_platform`, `sdk_surface`, `sdk_version`, the app package or
195
- bundle identifier, OS platform/version, `event_kind`, and for connection events
196
- only `fully_booted` plus a glasses model value when the SDK knows it. The SDK
197
- does not upload BLE MAC addresses, CoreBluetooth identifiers, serial numbers,
198
- Bluetooth device names, user ids, tokens, Wi-Fi credentials, microphone data,
199
- photos, or transcripts. PostHog receives a locally generated anonymous SDK
200
- install id as `distinct_id`, and events include `$process_person_profile: false`.
195
+ Captured properties include `event_source`, `sdk_platform`, `sdk_surface`,
196
+ `sdk_version`, `app_identifier` (the Android package or iOS bundle identifier),
197
+ the platform-specific `app_package` or `app_bundle_identifier`, OS
198
+ platform/version, and `event_kind`. Connection events also include
199
+ `fully_booted` and a glasses model value when known. The identification event
200
+ intentionally includes the glasses manufacturing serial as `glasses_device_id`,
201
+ with `glasses_device_id_type=manufacturing_serial`, so Mentra can correlate
202
+ fleet deployments across supported models. This serial identifies the glasses
203
+ hardware, not the user or the host phone. Its source depends on the model:
204
+ Mentra Live reports the serial provisioned in BES NV storage, while G1 and Ar99
205
+ decode it from the glasses' BLE advertisement / manufacturer data.
206
+
207
+ Apart from that glasses serial, the SDK does not upload BLE MAC addresses,
208
+ CoreBluetooth identifiers, the host phone's Android device serial, Bluetooth
209
+ device names, user ids, tokens, Wi-Fi credentials, microphone data, photos, or
210
+ transcripts. PostHog receives a locally generated anonymous SDK install id as
211
+ `distinct_id`, and events include `$process_person_profile: false`.
201
212
 
202
213
  ## React Hooks
203
214
 
@@ -380,12 +391,16 @@ const photo = await BluetoothSdk.requestPhoto({
380
391
  console.log('photo delivered', photo.photoUrl ?? photo.uploadUrl, photo.fileSizeBytes)
381
392
  ```
382
393
 
383
- `requestPhoto(...)` resolves only after the full photo action reaches terminal success: capture completed and the photo was delivered to the webhook, either directly from the glasses over Wi-Fi or through the phone's Bluetooth fallback relay. If you omit `requestId`, the SDK generates one and the terminal response includes it. It rejects if the ASG reports `state: "error"`, if phone-side fallback upload fails, if the SDK cannot send the command, or if no terminal `photo_response` arrives before the command timeout. Use `photo_status` for intermediate stages such as `accepted`, `configuring`, `capturing`, `captured`, `uploading`, `ble_fallback_compression`, `ready_for_transfer`, and `transferring`; `photo_status` is progress, while `photo_response` is terminal success/error. The raw `photo_response` event stream still includes both success and error events for subscribers. 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.
394
+ `requestPhoto(...)` resolves only after the full photo action reaches terminal success: capture completed and the photo was delivered to the webhook, either directly from the glasses over Wi-Fi or through the phone's Bluetooth fallback relay. If you omit `requestId`, the SDK generates one and the terminal response includes it. It rejects if the ASG reports `state: "error"`, if phone-side fallback upload fails, if the SDK cannot send the command, or if no terminal `photo_response` arrives within 30 seconds. Photo requests use this longer operation-specific deadline because max-quality BLE fallback can legitimately exceed the 15-second deadline used by ordinary commands. Use `photo_status` for intermediate stages such as `accepted`, `configuring`, `capturing`, `captured`, `uploading`, `ble_fallback_compression`, `ready_for_transfer`, and `transferring`; `photo_status` is progress, while `photo_response` is terminal success/error. The raw `photo_response` event stream still includes both success and error events for subscribers. 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.
384
395
 
385
396
  For one-shot manual capture tuning, pass `exposureTimeNs` and `iso` together. `exposureTimeNs` is sensor exposure time in nanoseconds; `iso` is sensor ISO. If `exposureTimeNs` is omitted, `null`, invalid, or unsupported by the connected glasses, the camera uses auto exposure and ignores `iso`.
386
397
 
398
+ To own and explicitly release a warm camera, pass a request ID to `warmUpCamera({requestId, ...})`, then call `stopCameraWarmUp(requestId)` when the foreground UI closes. Warm holds default to 15 seconds and are capped at 60 seconds. Stopping while the camera is opening rejects the pending warm-up with `camera_warm_up_cancelled`; stopping after `ready` emits `stopped`. Compatible ready leases share the camera and expire independently.
399
+
387
400
  Use `setCameraFov({fov, roiPosition})` to configure Mentra Live camera field of view and crop position. FOV is clamped to 62-118 degrees; ROI position is `"center"`, `"bottom"`, or `"top"`. You can also call `setCameraFov({preset: "narrow" | "standard" | "wide"})`; presets map to 82, 102, and 118 degrees with center ROI. The returned `CameraFovResult` resolves only after the ASG client reports that the setting was applied to camera hardware after the restart cooldown, and the promise rejects if the glasses report an error, persist the setting without hardware application, or time out. Raw `settings_ack` events remain available through `addListener("settings_ack", ...)` for diagnostic fields such as `hardwareApplied`. Treat FOV as a framing/ROI control; output resolution and effective detail can vary by capture path, firmware, and camera mode.
388
401
 
402
+ The missing/factory persistent FOV base is 102 degrees with centered ROI; existing saved values are not migrated. Use `setCameraFovOverride({leaseId, fov, roiPosition, ttlMs})` for a memory-only crop and `releaseCameraFovOverride(leaseId)` to restore the persistent base. A stale release is a safe no-op, and refreshing the same lease/config extends its TTL without another HAL restart.
403
+
389
404
  `startVideoRecording(...)` resolves from the ASG `video_recording_status` event whose status is `recording_started`. `stopVideoRecording(...)` without a webhook resolves from `recording_stopped`; with a webhook it waits for `recording_stopped` plus a video `media_success`, and rejects on `media_error`. Raw `video_recording_status`, `media_success`, and `media_error` events remain available through listeners.
390
405
 
391
406
  ## Streaming
@@ -404,7 +419,7 @@ await BluetoothSdk.stopStream()
404
419
  ```
405
420
 
406
421
  Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https://` for WHIP/WebRTC ingest. `startStream()` resolves with the correlated `stream_status` event once the glasses report `status: "streaming"`; `stopStream()` resolves when the glasses report `status: "stopped"` or confirms the stream was already stopped / not streaming. `stopStream()` returns a normalized stopped event for that already-stopped case. Stream starts reject if the glasses report an error before streaming; stream stops reject for real stop errors, send failure, another stop in flight, or timeout. Stream video input fields are `width`, `height`, `bitrate`, and `fps`. The SDK sends stream keep-alives automatically while streaming and reports keep-alive failures through `stream_status`. The camera light is always enabled while streaming.
407
- `stream_status` events may include `resolvedConfig`, which reports the effective transport, video, and audio settings after glasses defaults, clamps, and camera preflight. The resolved effective frame rate is reported as `resolvedConfig.video.fps`.
422
+ `stream_status` events may include `resolvedConfig`, which reports the effective transport, video, and audio settings after glasses defaults, clamps, and camera preflight. The resolved effective frame rate is reported as `resolvedConfig.video.fps`. While live, supported firmware emits periodic `streaming` events whose `stats` object contains `bitrate` (bits per second), `fps`, `droppedFrames`, `duration` (seconds), and `temperatureC` when the CPU sensor is available.
408
423
 
409
424
  ## Events
410
425
 
@@ -279,6 +279,9 @@ dependencies {
279
279
  // Local photo upload receiver
280
280
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
281
281
 
282
+ // AR99 GATT Opus to PCM decoder.
283
+ implementation 'io.github.jaredmdobson:concentus:1.0.1'
284
+
282
285
  // LC3 audio codec library
283
286
  implementation project(':lc3Lib')
284
287
 
@@ -28,6 +28,8 @@
28
28
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
29
29
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
30
30
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
31
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
32
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
31
33
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
32
34
 
33
35
  <!-- Other permissions -->
@@ -35,6 +37,6 @@
35
37
  <uses-permission android:name="android.permission.VIBRATE"/>
36
38
 
37
39
  <application>
38
- <service android:name="com.mentra.bluetoothsdk.services.ForegroundService" android:exported="false" android:foregroundServiceType="dataSync|connectedDevice|microphone"/>
40
+ <service android:name="com.mentra.bluetoothsdk.services.ForegroundService" android:exported="false" android:foregroundServiceType="dataSync|connectedDevice|location|mediaPlayback|microphone"/>
39
41
  </application>
40
42
  </manifest>
@@ -48,10 +48,17 @@ internal class BluetoothSdkAnalytics(
48
48
  private val config = initialConfig.toRuntimeConfig().resolvedForApp(appContext)
49
49
  private var startedCaptured = false
50
50
  private var lastConnected = false
51
+ private var identifiedCapturedForConnection = false
51
52
 
52
53
  @Synchronized
53
54
  fun initializeGlassesStatus(status: GlassesStatus) {
54
55
  lastConnected = status.analyticsConnected
56
+ // Only treat identification as already captured when a valid serial is
57
+ // present at init. If the glasses are connected but the serial has not
58
+ // arrived yet (Mentra Live fills it via version_info after connect), leave
59
+ // this false so the identify event still fires once the serial arrives.
60
+ identifiedCapturedForConnection =
61
+ status.analyticsConnected && status.serialNumber.validManufacturingSerial() != null
55
62
  }
56
63
 
57
64
  @Synchronized
@@ -67,7 +74,12 @@ internal class BluetoothSdkAnalytics(
67
74
  val wasConnected = lastConnected
68
75
  lastConnected = isConnected
69
76
  if (!config.isReady) return
77
+ if (!isConnected) {
78
+ identifiedCapturedForConnection = false
79
+ return
80
+ }
70
81
  if (isConnected && !wasConnected) {
82
+ identifiedCapturedForConnection = false
71
83
  capture(
72
84
  "bluetooth_sdk_glasses_connected",
73
85
  buildMap {
@@ -76,6 +88,24 @@ internal class BluetoothSdkAnalytics(
76
88
  status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
77
89
  },
78
90
  )
91
+ // Fall through: a serial already present at connect time (G1/Ar99 report
92
+ // it in the advertisement) should be identified now rather than waiting
93
+ // for some later, unrelated glasses-store update to run.
94
+ }
95
+
96
+ val serialNumber = status.serialNumber.validManufacturingSerial() ?: return
97
+ if (!identifiedCapturedForConnection) {
98
+ identifiedCapturedForConnection = true
99
+ capture(
100
+ "bluetooth_sdk_glasses_identified",
101
+ buildMap {
102
+ put("event_kind", "glasses_identified")
103
+ put("fully_booted", status.fullyBooted)
104
+ put("glasses_device_id", serialNumber)
105
+ put("glasses_device_id_type", "manufacturing_serial")
106
+ status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
107
+ },
108
+ )
79
109
  }
80
110
  }
81
111
 
@@ -131,6 +161,7 @@ internal class BluetoothSdkAnalytics(
131
161
  put("sdk_platform", "android")
132
162
  put("sdk_surface", activeConfig.surface)
133
163
  put("sdk_version", BuildConfig.SDK_VERSION)
164
+ put("app_identifier", appContext.packageName)
134
165
  put("app_package", appContext.packageName)
135
166
  put("os_platform", "android")
136
167
  put("os_version", Build.VERSION.SDK_INT)
@@ -180,3 +211,6 @@ private fun BluetoothSdkAnalyticsRuntimeConfig.resolvedForApp(context: Context):
180
211
 
181
212
  private val GlassesStatus.analyticsConnected: Boolean
182
213
  get() = connectionState.isConnected || connected || fullyBooted
214
+
215
+ private fun String.validManufacturingSerial(): String? =
216
+ trim().takeIf { it.isNotEmpty() && !it.matches(Regex("0+")) }
@@ -3,4 +3,5 @@ package com.mentra.bluetoothsdk
3
3
  /** Defaults for the public Bluetooth SDK surface. */
4
4
  object BluetoothSdkDefaults {
5
5
  const val VOICE_ACTIVITY_DETECTION_ENABLED = false
6
+ const val LOUDNESS_GATE_ENABLED = true
6
7
  }
@@ -2,8 +2,11 @@
2
2
 
3
3
  package com.mentra.bluetoothsdk
4
4
 
5
+ import androidx.lifecycle.Lifecycle
6
+ import androidx.lifecycle.LifecycleOwner
5
7
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
6
8
  import com.mentra.bluetoothsdk.utils.DeviceTypes
9
+ import com.mentra.bluetoothsdk.utils.audio.PcmStreamManager
7
10
  import expo.modules.kotlin.exception.CodedException
8
11
  import expo.modules.kotlin.functions.Coroutine
9
12
  import expo.modules.kotlin.modules.Module
@@ -11,6 +14,7 @@ import expo.modules.kotlin.modules.ModuleDefinition
11
14
  import expo.modules.kotlin.modules.ModuleDefinitionBuilder
12
15
  import kotlinx.coroutines.Dispatchers
13
16
  import kotlinx.coroutines.withContext
17
+ import java.security.MessageDigest
14
18
 
15
19
  // Expo has no module-level error mapper, so SDK-backed registrations translate
16
20
  // core exceptions here while keeping Expo types out of the native SDK API.
@@ -87,6 +91,45 @@ private inline fun <reified R> ModuleDefinitionBuilder.SdkCoroutineFunction(
87
91
  crossinline body: suspend () -> R,
88
92
  ) = AsyncFunction(name) Coroutine { -> withExpoSdkErrorSuspend { body() } }
89
93
 
94
+ private inline fun <reified R, reified P0> ModuleDefinitionBuilder.SdkCoroutineFunction(
95
+ name: String,
96
+ crossinline body: suspend (P0) -> R,
97
+ ) = AsyncFunction(name) Coroutine { p0: P0 -> withExpoSdkErrorSuspend { body(p0) } }
98
+
99
+ private inline fun <reified R, reified P0, reified P1> ModuleDefinitionBuilder.SdkCoroutineFunction(
100
+ name: String,
101
+ crossinline body: suspend (P0, P1) -> R,
102
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1 -> withExpoSdkErrorSuspend { body(p0, p1) } }
103
+
104
+ private inline fun <reified R, reified P0, reified P1, reified P2> ModuleDefinitionBuilder.SdkCoroutineFunction(
105
+ name: String,
106
+ crossinline body: suspend (P0, P1, P2) -> R,
107
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1, p2: P2 -> withExpoSdkErrorSuspend { body(p0, p1, p2) } }
108
+
109
+ private inline fun <reified R, reified P0, reified P1, reified P2, reified P3>
110
+ ModuleDefinitionBuilder.SdkCoroutineFunction(
111
+ name: String,
112
+ crossinline body: suspend (P0, P1, P2, P3) -> R,
113
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1, p2: P2, p3: P3 ->
114
+ withExpoSdkErrorSuspend { body(p0, p1, p2, p3) }
115
+ }
116
+
117
+ private inline fun <
118
+ reified R,
119
+ reified P0,
120
+ reified P1,
121
+ reified P2,
122
+ reified P3,
123
+ reified P4,
124
+ reified P5,
125
+ reified P6,
126
+ > ModuleDefinitionBuilder.SdkCoroutineFunction(
127
+ name: String,
128
+ crossinline body: suspend (P0, P1, P2, P3, P4, P5, P6) -> R,
129
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6 ->
130
+ withExpoSdkErrorSuspend { body(p0, p1, p2, p3, p4, p5, p6) }
131
+ }
132
+
90
133
  class BluetoothSdkModule : Module() {
91
134
  private var sdk: MentraBluetoothSdk? = null
92
135
  private var deviceManager: DeviceManager? = null
@@ -325,10 +368,12 @@ class BluetoothSdkModule : Module() {
325
368
  "stream_status",
326
369
  "keep_alive_ack",
327
370
  "mtk_update_complete",
371
+ "glasses_session_changed",
328
372
  "ota_progress",
329
373
  "ota_start_ack",
330
374
  "ota_status",
331
- // Nex / BLE debug (NexEventUtils → Bridge.sendTypedMessage)
375
+ "ar99_ota_status",
376
+ // Nex / BLE debug (NexEventUtils —Bridge.sendTypedMessage)
332
377
  "send_command_to_ble",
333
378
  "receive_command_from_ble",
334
379
  "miniapp_selected",
@@ -351,6 +396,21 @@ class BluetoothSdkModule : Module() {
351
396
  sdkListener,
352
397
  )
353
398
  deviceManager = DeviceManager.getInstance()
399
+ val activity = appContext.currentActivity
400
+ val activityIsResumed =
401
+ (activity as? LifecycleOwner)
402
+ ?.lifecycle
403
+ ?.currentState
404
+ ?.isAtLeast(Lifecycle.State.RESUMED) == true
405
+ if (activityIsResumed) {
406
+ deviceManager?.refreshForegroundServiceTypes()
407
+ }
408
+ }
409
+
410
+ OnActivityEntersForeground {
411
+ // Re-run after runtime permission dialogs and every app resume. This is the safe
412
+ // point to add Android's while-in-use location foreground-service type.
413
+ deviceManager?.refreshForegroundServiceTypes()
354
414
  }
355
415
 
356
416
  OnDestroy {
@@ -360,6 +420,7 @@ class BluetoothSdkModule : Module() {
360
420
  "module_destroy"
361
421
  )
362
422
  sdk?.close()
423
+ PcmStreamManager.abortAll()
363
424
  sdk = null
364
425
  deviceManager = null
365
426
  }
@@ -496,17 +557,17 @@ class BluetoothSdkModule : Module() {
496
557
 
497
558
  // MARK: - WiFi Commands
498
559
 
499
- SdkAsyncFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
560
+ SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
500
561
 
501
- SdkAsyncFunction("sendWifiCredentials") { ssid: String, password: String ->
562
+ SdkCoroutineFunction("sendWifiCredentials") { ssid: String, password: String ->
502
563
  requireSdk().sendWifiCredentials(ssid, password).values
503
564
  }
504
565
 
505
- SdkAsyncFunction("forgetWifiNetwork") { ssid: String ->
566
+ SdkCoroutineFunction("forgetWifiNetwork") { ssid: String ->
506
567
  requireSdk().forgetWifiNetwork(ssid).values
507
568
  }
508
569
 
509
- SdkAsyncFunction("setHotspotState") { enabled: Boolean ->
570
+ SdkCoroutineFunction("setHotspotState") { enabled: Boolean ->
510
571
  requireSdk().setHotspotState(enabled).values
511
572
  }
512
573
 
@@ -516,7 +577,7 @@ class BluetoothSdkModule : Module() {
516
577
 
517
578
  // MARK: - Gallery Commands
518
579
 
519
- SdkAsyncFunction("setGalleryModeEnabled") { enabled: Boolean ->
580
+ SdkCoroutineFunction("setGalleryModeEnabled") { enabled: Boolean ->
520
581
  requireSdk().setGalleryModeEnabled(enabled).values
521
582
  }
522
583
 
@@ -524,20 +585,24 @@ class BluetoothSdkModule : Module() {
524
585
  sdk?.setVoiceActivityDetectionEnabled(enabled)
525
586
  }
526
587
 
588
+ SdkAsyncFunction("setLoudnessGateEnabled") { enabled: Boolean ->
589
+ sdk?.setLoudnessGateEnabled(enabled)
590
+ }
591
+
527
592
  @Suppress("DEPRECATION")
528
- SdkAsyncFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
593
+ SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
529
594
  requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
530
595
  }
531
596
 
532
- SdkAsyncFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
597
+ SdkCoroutineFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
533
598
  requireSdk().setVideoRecordingDefaults(VideoRecordingDefaults(width, height, fps)).values
534
599
  }
535
600
 
536
- SdkAsyncFunction("setMaxVideoRecordingDuration") { minutes: Int ->
601
+ SdkCoroutineFunction("setMaxVideoRecordingDuration") { minutes: Int ->
537
602
  requireSdk().setMaxVideoRecordingDuration(minutes).values
538
603
  }
539
604
 
540
- SdkAsyncFunction("setCameraFov") { fov: Map<String, Any> ->
605
+ SdkCoroutineFunction("setCameraFov") { fov: Map<String, Any> ->
541
606
  val value = (fov["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
542
607
  val roiPosition = CameraRoiPosition.fromValue(
543
608
  (fov["roiPosition"] as? Number)?.toInt()
@@ -546,13 +611,41 @@ class BluetoothSdkModule : Module() {
546
611
  requireSdk().setCameraFov(CameraFov(value, roiPosition)).values
547
612
  }
548
613
 
549
- SdkAsyncFunction("setCameraTuningConfig") { anrOn: Boolean, gainOn: Boolean ->
614
+ SdkCoroutineFunction("setLegacyCameraFov") { fov: Map<String, Any> ->
615
+ val value = (fov["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
616
+ val roiPosition = CameraRoiPosition.fromValue(
617
+ (fov["roiPosition"] as? Number)?.toInt()
618
+ ?: (fov["roi_position"] as? Number)?.toInt(),
619
+ )
620
+ requireSdk().setLegacyCameraFov(CameraFov(value, roiPosition)).values
621
+ }
622
+
623
+ SdkCoroutineFunction("restoreLegacyCameraFov") { ->
624
+ requireSdk().restoreLegacyCameraFov()
625
+ }
626
+
627
+ SdkCoroutineFunction("setCameraFovOverride") { params: Map<String, Any> ->
628
+ val leaseId = params["leaseId"] as? String ?: throw IllegalArgumentException("leaseId is required")
629
+ val value = (params["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
630
+ val roiPosition = CameraRoiPosition.fromValue(
631
+ (params["roiPosition"] as? Number)?.toInt()
632
+ ?: (params["roi_position"] as? Number)?.toInt(),
633
+ )
634
+ val ttlMs = (params["ttlMs"] as? Number)?.toInt() ?: 300000
635
+ requireSdk().setCameraFovOverride(leaseId, CameraFov(value, roiPosition), ttlMs).values
636
+ }
637
+
638
+ SdkCoroutineFunction("releaseCameraFovOverride") { leaseId: String ->
639
+ requireSdk().releaseCameraFovOverride(leaseId).values
640
+ }
641
+
642
+ SdkCoroutineFunction("setCameraTuningConfig") { anrOn: Boolean, gainOn: Boolean ->
550
643
  requireSdk().setCameraTuningConfig(anrOn, gainOn).values
551
644
  }
552
645
 
553
- SdkAsyncFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
646
+ SdkCoroutineFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
554
647
 
555
- SdkAsyncFunction("requestPhoto") { params: Map<String, Any?> ->
648
+ SdkCoroutineFunction("requestPhoto") { params: Map<String, Any?> ->
556
649
  // JS may pass null for optional fields; Map<String, Any> rejects null values at the bridge.
557
650
  val sanitized =
558
651
  params.mapNotNull { (key, value) ->
@@ -565,15 +658,22 @@ class BluetoothSdkModule : Module() {
565
658
  requireSdk().requestPhoto(req).values
566
659
  }
567
660
 
568
- SdkAsyncFunction("warmUpCamera") { params: Map<String, Any?> ->
661
+ SdkCoroutineFunction("warmUpCamera") { params: Map<String, Any?> ->
569
662
  val requestId = params["requestId"] as? String
570
663
  val size = PhotoSize.fromValue(params["size"] as? String)
664
+ val mode = PhotoMode.fromValue(params["mode"] as? String)
571
665
  val exposureRaw = (params["exposureTimeNs"] as? Number)?.toDouble()
572
666
  val exposureTimeNs =
573
667
  if (exposureRaw != null && exposureRaw.isFinite() && exposureRaw > 0) exposureRaw.toLong() else null
574
668
  val durationRaw = (params["durationMs"] as? Number)?.toInt() ?: 0
575
669
  val durationMs = if (durationRaw > 0) durationRaw else 15000
576
- requireSdk().warmUpCamera(requestId, size, exposureTimeNs, durationMs).values
670
+ val zsl = params["zsl"] as? Boolean
671
+ val mfnr = params["mfnr"] as? Boolean
672
+ requireSdk().warmUpCamera(requestId, size, mode, exposureTimeNs, durationMs, zsl, mfnr).values
673
+ }
674
+
675
+ SdkAsyncFunction("stopCameraWarmUp") { requestId: String ->
676
+ requireSdk().stopCameraWarmUp(requestId)
577
677
  }
578
678
 
579
679
  // MARK: - OTA Commands
@@ -586,11 +686,11 @@ class BluetoothSdkModule : Module() {
586
686
 
587
687
  // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
588
688
  // manifest fetches and version waits can block for several seconds.
589
- SdkCoroutineFunction("checkForOtaUpdate") {
689
+ SdkCoroutineFunction("checkForOtaUpdate") { ->
590
690
  withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
591
691
  }
592
692
 
593
- SdkAsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
693
+ SdkCoroutineFunction("startOtaUpdate") { otaVersionUrl: String? ->
594
694
  val sdk = requireSdk()
595
695
  if (otaVersionUrl.isNullOrBlank()) {
596
696
  sdk.startOtaUpdate().values
@@ -599,11 +699,23 @@ class BluetoothSdkModule : Module() {
599
699
  }
600
700
  }
601
701
 
602
- SdkAsyncFunction("sendOtaQueryStatus") { -> requireSdk().sendOtaQueryStatus().values }
702
+ SdkCoroutineFunction("sendOtaQueryStatus") { -> requireSdk().sendOtaQueryStatus().values }
703
+
704
+ AsyncFunction("startAr99OtaFromFile") { path: String -> requireSdk().startAr99OtaFromFile(path) }
705
+
706
+ AsyncFunction("cancelAr99Ota") { requireSdk().cancelAr99Ota() }
707
+
708
+ AsyncFunction("sendAr99FactoryReset") { requireSdk().sendAr99FactoryReset() }
709
+
603
710
 
711
+ Function("buildAr99OtaSignature") { secret: String, appName: String, currentVersion: String, serialNumber: String, nonce: String ->
712
+ val raw = secret + appName + "juxinOTA" + currentVersion + serialNumber.trim() + nonce
713
+ val digest = MessageDigest.getInstance("MD5").digest(raw.toByteArray(Charsets.UTF_8))
714
+ digest.joinToString("") { "%02x".format(it.toInt() and 0xff) }
715
+ }
604
716
  // MARK: - Version Info Commands
605
717
 
606
- SdkAsyncFunction("requestVersionInfo") { -> requireSdk().requestVersionInfo().toMap() }
718
+ SdkCoroutineFunction("requestVersionInfo") { -> requireSdk().requestVersionInfo().toMap() }
607
719
 
608
720
  // MARK: - Power Control Commands
609
721
 
@@ -613,7 +725,7 @@ class BluetoothSdkModule : Module() {
613
725
 
614
726
  // MARK: - Video Recording Commands
615
727
 
616
- SdkAsyncFunction("startVideoRecording") {
728
+ SdkCoroutineFunction("startVideoRecording") {
617
729
  requestId: String,
618
730
  save: Boolean,
619
731
  sound: Boolean,
@@ -637,7 +749,7 @@ class BluetoothSdkModule : Module() {
637
749
 
638
750
  // webhookUrl/authToken are supplied at stop (not start) so the token is
639
751
  // fresh when the upload runs. Empty/null webhook = keep on device.
640
- SdkAsyncFunction("stopVideoRecording") {
752
+ SdkCoroutineFunction("stopVideoRecording") {
641
753
  requestId: String,
642
754
  webhookUrl: String?,
643
755
  authToken: String? ->
@@ -646,15 +758,15 @@ class BluetoothSdkModule : Module() {
646
758
 
647
759
  // MARK: - Stream Commands
648
760
 
649
- SdkAsyncFunction("startStream") { params: Map<String, Any> ->
761
+ SdkCoroutineFunction("startStream") { params: Map<String, Any> ->
650
762
  requireSdk().startStream(StreamRequest.fromMap(params)).values
651
763
  }
652
764
 
653
- SdkAsyncFunction("startExternallyManagedStream") { params: Map<String, Any> ->
765
+ SdkCoroutineFunction("startExternallyManagedStream") { params: Map<String, Any> ->
654
766
  requireSdk().startExternallyManagedStream(StreamRequest.fromMap(params)).values
655
767
  }
656
768
 
657
- SdkAsyncFunction("stopStream") { -> requireSdk().stopStream().values }
769
+ SdkCoroutineFunction("stopStream") { -> requireSdk().stopStream().values }
658
770
 
659
771
  SdkAsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
660
772
  sdk?.sendExternallyManagedStreamKeepAlive(StreamKeepAliveRequest.fromMap(params))
@@ -688,6 +800,35 @@ class BluetoothSdkModule : Module() {
688
800
  sdk?.setOwnAppAudioPlaying(playing)
689
801
  }
690
802
 
803
+ // MARK: - Live PCM output stream (miniapp speaker.createStream)
804
+ //
805
+ // MentraOS-internal. 16-bit LE PCM chunks into a streaming AudioTrack
806
+ // (USAGE_MEDIA → follows the media route, e.g. A2DP to glasses).
807
+ // write/close run *blocking on Dispatchers.IO*, not the shared Expo
808
+ // AsyncFunctionQueue: write intentionally blocks for backpressure and
809
+ // close blocks until the backlog drains — either would otherwise stall
810
+ // every other native call queued behind them.
811
+
812
+ AsyncFunction("pcmStreamOpen") { streamId: String, sampleRate: Int, channels: Int, volume: Double ->
813
+ PcmStreamManager.open(streamId, sampleRate, channels, volume.toFloat())
814
+ }
815
+
816
+ AsyncFunction("pcmStreamWrite") Coroutine { streamId: String, base64: String ->
817
+ withContext(Dispatchers.IO) {
818
+ mapOf("bufferedMs" to PcmStreamManager.write(streamId, base64))
819
+ }
820
+ }
821
+
822
+ AsyncFunction("pcmStreamClose") Coroutine { streamId: String ->
823
+ withContext(Dispatchers.IO) {
824
+ mapOf("durationMs" to PcmStreamManager.close(streamId))
825
+ }
826
+ }
827
+
828
+ AsyncFunction("pcmStreamAbort") { streamId: String ->
829
+ PcmStreamManager.abort(streamId)
830
+ }
831
+
691
832
  // *Blocking on Dispatchers.IO, not the shared AsyncFunctionQueue: these wait on
692
833
  // a CountDownLatch (up to 5s) for a BLE round-trip, which would otherwise stall
693
834
  // every other native call queued behind them.
@@ -703,7 +844,7 @@ class BluetoothSdkModule : Module() {
703
844
 
704
845
  // MARK: - RGB LED Control
705
846
 
706
- SdkAsyncFunction("rgbLedControl") {
847
+ SdkCoroutineFunction("rgbLedControl") {
707
848
  requestId: String,
708
849
  packageName: String?,
709
850
  action: String,
@@ -755,7 +896,7 @@ class BluetoothSdkModule : Module() {
755
896
  }
756
897
 
757
898
  // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue: bz2/tar
758
- // extraction of the 100–350MB model is a multi-minute, CPU-bound job. On the
899
+ // extraction of the 100—50MB model is a multi-minute, CPU-bound job. On the
759
900
  // shared queue it froze every other native call in the app until it finished.
760
901
  AsyncFunction("extractTarBz2") Coroutine { sourcePath: String, destinationPath: String ->
761
902
  withContext(Dispatchers.IO) {
@@ -832,12 +973,14 @@ private fun Map<String, Any>?.toMentraDevice(): Device? {
832
973
  val model = values["model"] as? String ?: return null
833
974
  val name = values["name"] as? String ?: return null
834
975
  val address = values["address"] as? String
976
+ val projectName = values["projectName"] as? String
835
977
  val rssi = (values["rssi"] as? Number)?.toInt()
836
978
  val id = values["id"] as? String
837
979
  return Device(
838
980
  model = DeviceModel.fromDeviceType(model),
839
981
  name = name,
840
982
  address = address?.takeIf { it.isNotBlank() },
983
+ projectName = projectName?.takeIf { it.isNotBlank() },
841
984
  rssi = rssi,
842
985
  id = id?.takeIf { it.isNotBlank() } ?: address?.takeIf { it.isNotBlank() } ?: "$model:$name",
843
986
  )