@mentra/bluetooth-sdk 0.1.10 → 0.1.12
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.
- package/README.md +52 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +61 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +32 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +76 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +34 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +23 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
- package/build/BluetoothSdk.types.d.ts +45 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +14 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +24 -7
- package/ios/Source/BluetoothSdkDefaults.swift +27 -0
- package/ios/Source/Bridge.swift +16 -0
- package/ios/Source/DeviceManager.swift +33 -27
- package/ios/Source/DeviceStore.swift +8 -2
- package/ios/Source/MentraBluetoothSDK.swift +160 -8
- package/ios/Source/camera/CameraModels.swift +50 -1
- package/ios/Source/events/BluetoothEvents.swift +3 -0
- package/ios/Source/internal/BluetoothAvailability.swift +20 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
- package/ios/Source/sgcs/G1.swift +3 -3
- package/ios/Source/sgcs/G2.swift +1022 -640
- package/ios/Source/sgcs/Mach1.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +31 -7
- package/ios/Source/sgcs/MentraNex.swift +215 -85
- package/ios/Source/sgcs/SGCManager.swift +26 -6
- package/ios/Source/sgcs/Simulated.swift +2 -2
- package/ios/Source/types/DeviceModels.swift +5 -1
- package/package.json +1 -1
- package/plugin/build/index.d.ts +4 -0
- package/plugin/build/withAndroid.d.ts +2 -3
- package/plugin/build/withAndroid.js +46 -0
- package/plugin/build/withIos.d.ts +2 -3
- package/plugin/build/withIos.js +32 -0
- package/src/BluetoothSdk.types.ts +53 -2
- package/src/_private/BluetoothSdkModule.ts +27 -4
- package/src/index.ts +5 -0
package/README.md
CHANGED
|
@@ -150,6 +150,55 @@ uses a CoreBluetooth identifier when available, and the SDK falls back to
|
|
|
150
150
|
platform reports RSSI, so picker UI should handle `undefined` and avoid
|
|
151
151
|
reordering rows just because RSSI metadata arrives later.
|
|
152
152
|
|
|
153
|
+
## Mentra SDK Usage Analytics
|
|
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:
|
|
157
|
+
|
|
158
|
+
- `bluetooth_sdk_started`: sent once per app runtime after the native SDK starts.
|
|
159
|
+
- `bluetooth_sdk_glasses_connected`: sent when SDK status transitions from not connected to connected.
|
|
160
|
+
|
|
161
|
+
Analytics delivery is fire-and-forget: events are submitted asynchronously, do
|
|
162
|
+
not block Bluetooth SDK behavior, and are not retried if delivery fails.
|
|
163
|
+
|
|
164
|
+
React Native / Expo apps can disable these events before SDK startup through
|
|
165
|
+
the config plugin:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"expo": {
|
|
170
|
+
"plugins": [
|
|
171
|
+
[
|
|
172
|
+
"@mentra/bluetooth-sdk",
|
|
173
|
+
{
|
|
174
|
+
"analytics": false
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Native Android apps can pass `BluetoothSdkAnalyticsConfig.disabled()` in
|
|
183
|
+
`MentraBluetoothSdkConfig` or add
|
|
184
|
+
`com.mentra.bluetoothsdk.analytics.disabled=true` as application metadata.
|
|
185
|
+
Native iOS apps can pass `.disabled` in `MentraBluetoothSDKConfiguration` or set
|
|
186
|
+
`MentraBluetoothSdkAnalyticsDisabled` to `true` in `Info.plist`.
|
|
187
|
+
|
|
188
|
+
Mentra's PostHog project API key is embedded in the SDK as a public analytics
|
|
189
|
+
write token, not a private PostHog personal API key. Apps do not configure the
|
|
190
|
+
analytics destination; these SDK usage events are always sent to Mentra's
|
|
191
|
+
PostHog project unless analytics are disabled.
|
|
192
|
+
|
|
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`.
|
|
201
|
+
|
|
153
202
|
## React Hooks
|
|
154
203
|
|
|
155
204
|
React Native apps can import optional lifecycle helpers from the `react`
|
|
@@ -274,7 +323,7 @@ React Native narrows returned values to success shapes where the raw listener ev
|
|
|
274
323
|
| --- | --- | --- |
|
|
275
324
|
| `requestPhoto(...)` | Terminal `PhotoSuccessResponseEvent` with `state: "success"` after capture and delivery finish. `uploadUrl` is always present; webhook JSON metadata such as `photoUrl`, `statusUrl`, `contentType`, or `fileSizeBytes` is included when the receiver returns it. | Rejects when raw `photo_response.state === "error"`, the SDK cannot send the command, or the terminal photo response times out. |
|
|
276
325
|
| `startVideoRecording(...)` | `VideoRecordingStartedStatusEvent` with `success: true` and `status: "recording_started"`. | Rejects on `success: false` statuses such as `already_recording`, send failure, or timeout. |
|
|
277
|
-
| `stopVideoRecording(...)` | `VideoRecordingStoppedStatusEvent` with `success: true` and `status: "recording_stopped"`. | Rejects on `success: false` statuses such as `not_recording`, send failure, or timeout. |
|
|
326
|
+
| `stopVideoRecording(...)` | `VideoRecordingStoppedStatusEvent` with `success: true` and `status: "recording_stopped"`. When a webhook URL is supplied, resolves after the video upload succeeds. | Rejects on `success: false` statuses such as `not_recording`, webhook upload failure, send failure, or timeout. |
|
|
278
327
|
| `rgbLedControl(...)` | `RgbLedControlSuccessResponseEvent` with `state: "success"`. | Rejects when raw `rgb_led_control_response.state === "error"` or the response times out. |
|
|
279
328
|
| `checkForOtaUpdate()` / `retryOtaVersionCheck()` | `OtaQueryResult`, either `ota_update_available` or current `ota_status`. | Rejects on command transport timeout/failure. A returned `ota_status.status === "failed"` is glasses OTA state, not a command rejection. |
|
|
280
329
|
|
|
@@ -332,7 +381,7 @@ For one-shot manual capture tuning, pass `exposureTimeNs` and `iso` together. `e
|
|
|
332
381
|
|
|
333
382
|
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.
|
|
334
383
|
|
|
335
|
-
`startVideoRecording(...)`
|
|
384
|
+
`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.
|
|
336
385
|
|
|
337
386
|
## Streaming
|
|
338
387
|
|
|
@@ -409,7 +458,7 @@ For bare native iOS apps, use the public SwiftPM repository:
|
|
|
409
458
|
https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
|
|
410
459
|
```
|
|
411
460
|
|
|
412
|
-
Add the `MentraBluetoothSDK` product to your app target at version `0.1.
|
|
461
|
+
Add the `MentraBluetoothSDK` product to your app target at version `0.1.12` or newer.
|
|
413
462
|
|
|
414
463
|
For local SDK development, add this package folder directly in Xcode:
|
|
415
464
|
|
package/android/build.gradle
CHANGED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import java.net.HttpURLConnection
|
|
8
|
+
import java.net.URL
|
|
9
|
+
import java.util.UUID
|
|
10
|
+
import java.util.concurrent.ExecutorService
|
|
11
|
+
import java.util.concurrent.Executors
|
|
12
|
+
import java.util.concurrent.RejectedExecutionException
|
|
13
|
+
|
|
14
|
+
class BluetoothSdkAnalyticsConfig private constructor(
|
|
15
|
+
val enabled: Boolean,
|
|
16
|
+
internal val surface: String,
|
|
17
|
+
) {
|
|
18
|
+
@JvmOverloads
|
|
19
|
+
constructor(enabled: Boolean = true) : this(enabled, "android")
|
|
20
|
+
|
|
21
|
+
internal fun withSurface(surface: String): BluetoothSdkAnalyticsConfig =
|
|
22
|
+
BluetoothSdkAnalyticsConfig(enabled, surface)
|
|
23
|
+
|
|
24
|
+
companion object {
|
|
25
|
+
@JvmStatic
|
|
26
|
+
fun disabled(): BluetoothSdkAnalyticsConfig = BluetoothSdkAnalyticsConfig(enabled = false)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private data class BluetoothSdkAnalyticsRuntimeConfig(
|
|
31
|
+
val enabled: Boolean = true,
|
|
32
|
+
val surface: String = "android",
|
|
33
|
+
) {
|
|
34
|
+
val isReady: Boolean
|
|
35
|
+
get() = enabled
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
internal class BluetoothSdkAnalytics(
|
|
39
|
+
private val context: Context,
|
|
40
|
+
initialConfig: BluetoothSdkAnalyticsConfig,
|
|
41
|
+
) {
|
|
42
|
+
private val appContext = context.applicationContext
|
|
43
|
+
private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
|
44
|
+
Thread(runnable, "MentraBluetoothSdkAnalytics").apply { isDaemon = true }
|
|
45
|
+
}
|
|
46
|
+
// startedCaptured/lastConnected are touched from store listeners and SDK
|
|
47
|
+
// entry points, hence @Synchronized on methods that read or write them.
|
|
48
|
+
private val config = initialConfig.toRuntimeConfig().resolvedForApp(appContext)
|
|
49
|
+
private var startedCaptured = false
|
|
50
|
+
private var lastConnected = false
|
|
51
|
+
|
|
52
|
+
@Synchronized
|
|
53
|
+
fun initializeGlassesStatus(status: GlassesStatus) {
|
|
54
|
+
lastConnected = status.analyticsConnected
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Synchronized
|
|
58
|
+
fun captureStarted() {
|
|
59
|
+
if (startedCaptured || !config.isReady) return
|
|
60
|
+
startedCaptured = true
|
|
61
|
+
capture("bluetooth_sdk_started", mapOf("event_kind" to "sdk_started"))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Synchronized
|
|
65
|
+
fun observeGlassesStatus(status: GlassesStatus) {
|
|
66
|
+
val isConnected = status.analyticsConnected
|
|
67
|
+
val wasConnected = lastConnected
|
|
68
|
+
lastConnected = isConnected
|
|
69
|
+
if (!config.isReady) return
|
|
70
|
+
if (isConnected && !wasConnected) {
|
|
71
|
+
capture(
|
|
72
|
+
"bluetooth_sdk_glasses_connected",
|
|
73
|
+
buildMap {
|
|
74
|
+
put("event_kind", "glasses_connected")
|
|
75
|
+
put("fully_booted", status.fullyBooted)
|
|
76
|
+
status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun shutdown() {
|
|
83
|
+
executor.shutdown()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun capture(
|
|
87
|
+
eventName: String,
|
|
88
|
+
eventProperties: Map<String, Any>,
|
|
89
|
+
) {
|
|
90
|
+
val activeConfig = config
|
|
91
|
+
if (!activeConfig.isReady) return
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Payload construction stays on the executor: distinctId() reads
|
|
95
|
+
// SharedPreferences, which must not run on the caller (often main) thread.
|
|
96
|
+
executor.execute {
|
|
97
|
+
try {
|
|
98
|
+
val payload =
|
|
99
|
+
JSONObject(
|
|
100
|
+
mapOf(
|
|
101
|
+
"api_key" to DEFAULT_POSTHOG_API_KEY,
|
|
102
|
+
"event" to eventName,
|
|
103
|
+
"distinct_id" to distinctId(),
|
|
104
|
+
"properties" to baseProperties(activeConfig) + eventProperties,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
val connection = URL(captureUrl()).openConnection() as HttpURLConnection
|
|
108
|
+
connection.requestMethod = "POST"
|
|
109
|
+
connection.connectTimeout = 4_000
|
|
110
|
+
connection.readTimeout = 4_000
|
|
111
|
+
connection.doOutput = true
|
|
112
|
+
connection.setRequestProperty("Content-Type", "application/json")
|
|
113
|
+
connection.outputStream.use { output ->
|
|
114
|
+
output.write(payload.toString().toByteArray(Charsets.UTF_8))
|
|
115
|
+
}
|
|
116
|
+
connection.inputStream.close()
|
|
117
|
+
connection.disconnect()
|
|
118
|
+
} catch (_: Exception) {
|
|
119
|
+
// Analytics must never affect Bluetooth SDK behavior.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (_: RejectedExecutionException) {
|
|
123
|
+
// The executor was shut down (SDK closed); dropping the event is fine.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun baseProperties(activeConfig: BluetoothSdkAnalyticsRuntimeConfig): Map<String, Any> =
|
|
128
|
+
buildMap {
|
|
129
|
+
put("\$process_person_profile", false)
|
|
130
|
+
put("event_source", "mentra_bluetooth_sdk")
|
|
131
|
+
put("sdk_platform", "android")
|
|
132
|
+
put("sdk_surface", activeConfig.surface)
|
|
133
|
+
put("sdk_version", BuildConfig.SDK_VERSION)
|
|
134
|
+
put("app_package", appContext.packageName)
|
|
135
|
+
put("os_platform", "android")
|
|
136
|
+
put("os_version", Build.VERSION.SDK_INT)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun distinctId(): String {
|
|
140
|
+
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
141
|
+
prefs.getString(PREFS_DISTINCT_ID, null)?.let { return it }
|
|
142
|
+
val generated = "mentra-bt-sdk-${UUID.randomUUID()}"
|
|
143
|
+
prefs.edit().putString(PREFS_DISTINCT_ID, generated).apply()
|
|
144
|
+
return generated
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private fun captureUrl(): String {
|
|
148
|
+
val normalized = DEFAULT_POSTHOG_HOST.trim().trimEnd('/')
|
|
149
|
+
return "$normalized/i/v0/e/"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
companion object {
|
|
153
|
+
internal const val META_ANALYTICS_DISABLED = "com.mentra.bluetoothsdk.analytics.disabled"
|
|
154
|
+
private const val PREFS_NAME = "mentra_bluetooth_sdk_analytics"
|
|
155
|
+
private const val PREFS_DISTINCT_ID = "distinct_id"
|
|
156
|
+
private const val DEFAULT_POSTHOG_API_KEY = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
|
|
157
|
+
private const val DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private fun BluetoothSdkAnalyticsConfig.toRuntimeConfig(): BluetoothSdkAnalyticsRuntimeConfig =
|
|
162
|
+
BluetoothSdkAnalyticsRuntimeConfig(enabled = enabled, surface = surface)
|
|
163
|
+
|
|
164
|
+
private fun BluetoothSdkAnalyticsRuntimeConfig.resolvedForApp(context: Context): BluetoothSdkAnalyticsRuntimeConfig {
|
|
165
|
+
val metadata =
|
|
166
|
+
try {
|
|
167
|
+
context.packageManager
|
|
168
|
+
.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
|
|
169
|
+
.metaData
|
|
170
|
+
} catch (_: Exception) {
|
|
171
|
+
null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
val disabledByApp = metadata?.getBoolean(BluetoothSdkAnalytics.META_ANALYTICS_DISABLED, false) == true
|
|
175
|
+
|
|
176
|
+
return copy(
|
|
177
|
+
enabled = enabled && !disabledByApp,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private val GlassesStatus.analyticsConnected: Boolean
|
|
182
|
+
get() = connectionState.isConnected || connected || fullyBooted
|
|
@@ -2,8 +2,11 @@ package com.mentra.bluetoothsdk
|
|
|
2
2
|
|
|
3
3
|
import com.mentra.bluetoothsdk.debug.BleTraceLogger
|
|
4
4
|
import com.mentra.bluetoothsdk.utils.DeviceTypes
|
|
5
|
+
import expo.modules.kotlin.functions.Coroutine
|
|
5
6
|
import expo.modules.kotlin.modules.Module
|
|
6
7
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
8
|
+
import kotlinx.coroutines.Dispatchers
|
|
9
|
+
import kotlinx.coroutines.withContext
|
|
7
10
|
|
|
8
11
|
class BluetoothSdkModule : Module() {
|
|
9
12
|
private var sdk: MentraBluetoothSdk? = null
|
|
@@ -117,6 +120,10 @@ class BluetoothSdkModule : Module() {
|
|
|
117
120
|
sendEvent("video_recording_status", event.values)
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
override fun onMediaUpload(event: MediaUploadEvent) {
|
|
124
|
+
sendEvent(event.type, event.values)
|
|
125
|
+
}
|
|
126
|
+
|
|
120
127
|
override fun onRgbLedControlResponse(event: RgbLedControlResponseEvent) {
|
|
121
128
|
sendEvent("rgb_led_control_response", event.values)
|
|
122
129
|
}
|
|
@@ -195,6 +202,9 @@ class BluetoothSdkModule : Module() {
|
|
|
195
202
|
"glasses_not_ready",
|
|
196
203
|
"button_press",
|
|
197
204
|
"touch_event",
|
|
205
|
+
"accel_event",
|
|
206
|
+
"CompassHeadingEvent",
|
|
207
|
+
"CompassCalibrationEvent",
|
|
198
208
|
"head_up",
|
|
199
209
|
"voice_activity_detection_status",
|
|
200
210
|
"speaking_status",
|
|
@@ -207,6 +217,8 @@ class BluetoothSdkModule : Module() {
|
|
|
207
217
|
"photo_response",
|
|
208
218
|
"photo_status",
|
|
209
219
|
"video_recording_status",
|
|
220
|
+
"media_success",
|
|
221
|
+
"media_error",
|
|
210
222
|
"gallery_status",
|
|
211
223
|
"compatible_glasses_search_stop",
|
|
212
224
|
"heartbeat_sent",
|
|
@@ -250,7 +262,14 @@ class BluetoothSdkModule : Module() {
|
|
|
250
262
|
?: appContext.currentActivity
|
|
251
263
|
?: throw IllegalStateException("No context available")
|
|
252
264
|
BleTraceLogger.logLifecycle(context, "BluetoothSdkModule", "module_create")
|
|
253
|
-
sdk =
|
|
265
|
+
sdk =
|
|
266
|
+
MentraBluetoothSdk.create(
|
|
267
|
+
context,
|
|
268
|
+
MentraBluetoothSdkConfig(
|
|
269
|
+
analytics = BluetoothSdkAnalyticsConfig().withSurface("react_native")
|
|
270
|
+
),
|
|
271
|
+
sdkListener,
|
|
272
|
+
)
|
|
254
273
|
deviceManager = DeviceManager.getInstance()
|
|
255
274
|
}
|
|
256
275
|
|
|
@@ -499,12 +518,18 @@ class BluetoothSdkModule : Module() {
|
|
|
499
518
|
dim("width"),
|
|
500
519
|
dim("height"),
|
|
501
520
|
dim("fps"),
|
|
521
|
+
dim("maxRecordingTimeMinutes"),
|
|
502
522
|
)
|
|
503
523
|
).values
|
|
504
524
|
}
|
|
505
525
|
|
|
506
|
-
|
|
507
|
-
|
|
526
|
+
// webhookUrl/authToken are supplied at stop (not start) so the token is
|
|
527
|
+
// fresh when the upload runs. Empty/null webhook = keep on device.
|
|
528
|
+
AsyncFunction("stopVideoRecording") {
|
|
529
|
+
requestId: String,
|
|
530
|
+
webhookUrl: String?,
|
|
531
|
+
authToken: String? ->
|
|
532
|
+
requireSdk().stopVideoRecording(requestId, webhookUrl, authToken).values
|
|
508
533
|
}
|
|
509
534
|
|
|
510
535
|
// MARK: - Stream Commands
|
|
@@ -534,7 +559,12 @@ class BluetoothSdkModule : Module() {
|
|
|
534
559
|
)
|
|
535
560
|
}
|
|
536
561
|
|
|
537
|
-
|
|
562
|
+
// Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue: restart()
|
|
563
|
+
// does a synchronous JNI model reload that would otherwise block every other
|
|
564
|
+
// native call in the app until it completes.
|
|
565
|
+
AsyncFunction("restartTranscriber") Coroutine { ->
|
|
566
|
+
withContext(Dispatchers.IO) { deviceManager?.restartTranscriber() }
|
|
567
|
+
}
|
|
538
568
|
|
|
539
569
|
// MARK: - Audio Playback Monitoring
|
|
540
570
|
|
|
@@ -542,14 +572,17 @@ class BluetoothSdkModule : Module() {
|
|
|
542
572
|
sdk?.setOwnAppAudioPlaying(playing)
|
|
543
573
|
}
|
|
544
574
|
|
|
545
|
-
|
|
575
|
+
// *Blocking on Dispatchers.IO, not the shared AsyncFunctionQueue: these wait on
|
|
576
|
+
// a CountDownLatch (up to 5s) for a BLE round-trip, which would otherwise stall
|
|
577
|
+
// every other native call queued behind them.
|
|
578
|
+
AsyncFunction("getGlassesMediaVolume") Coroutine { ->
|
|
546
579
|
val cm = deviceManager ?: throw IllegalStateException("device_manager_null")
|
|
547
|
-
cm.getGlassesMediaVolumeBlocking()
|
|
580
|
+
withContext(Dispatchers.IO) { cm.getGlassesMediaVolumeBlocking() }
|
|
548
581
|
}
|
|
549
582
|
|
|
550
|
-
AsyncFunction("setGlassesMediaVolume") { level: Int ->
|
|
583
|
+
AsyncFunction("setGlassesMediaVolume") Coroutine { level: Int ->
|
|
551
584
|
val cm = deviceManager ?: throw IllegalStateException("device_manager_null")
|
|
552
|
-
cm.setGlassesMediaVolumeBlocking(level)
|
|
585
|
+
withContext(Dispatchers.IO) { cm.setGlassesMediaVolumeBlocking(level) }
|
|
553
586
|
}
|
|
554
587
|
|
|
555
588
|
// MARK: - RGB LED Control
|
|
@@ -605,8 +638,13 @@ class BluetoothSdkModule : Module() {
|
|
|
605
638
|
com.mentra.bluetoothsdk.stt.STTTools.validateSTTModel(path)
|
|
606
639
|
}
|
|
607
640
|
|
|
608
|
-
|
|
609
|
-
|
|
641
|
+
// Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue: bz2/tar
|
|
642
|
+
// extraction of the 100–350MB model is a multi-minute, CPU-bound job. On the
|
|
643
|
+
// shared queue it froze every other native call in the app until it finished.
|
|
644
|
+
AsyncFunction("extractTarBz2") Coroutine { sourcePath: String, destinationPath: String ->
|
|
645
|
+
withContext(Dispatchers.IO) {
|
|
646
|
+
com.mentra.bluetoothsdk.stt.STTTools.extractTarBz2(sourcePath, destinationPath)
|
|
647
|
+
}
|
|
610
648
|
}
|
|
611
649
|
|
|
612
650
|
// MARK: - TTS Commands
|
|
@@ -647,7 +685,9 @@ class BluetoothSdkModule : Module() {
|
|
|
647
685
|
com.mentra.core.tts.TTSTools.validateTTSModel(path)
|
|
648
686
|
}
|
|
649
687
|
|
|
650
|
-
|
|
688
|
+
// Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue: TTS synthesis
|
|
689
|
+
// is a synchronous JNI call that would otherwise block other native calls.
|
|
690
|
+
AsyncFunction("generateTtsAudio") Coroutine {
|
|
651
691
|
text: String,
|
|
652
692
|
modelPath: String,
|
|
653
693
|
outputPath: String,
|
|
@@ -657,14 +697,16 @@ class BluetoothSdkModule : Module() {
|
|
|
657
697
|
appContext.reactContext
|
|
658
698
|
?: appContext.currentActivity
|
|
659
699
|
?: throw IllegalStateException("No context available")
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
700
|
+
withContext(Dispatchers.IO) {
|
|
701
|
+
com.mentra.core.tts.TTSTools.generateTtsAudio(
|
|
702
|
+
context,
|
|
703
|
+
text,
|
|
704
|
+
modelPath,
|
|
705
|
+
outputPath,
|
|
706
|
+
speakerId,
|
|
707
|
+
speed.toFloat()
|
|
708
|
+
)
|
|
709
|
+
}
|
|
668
710
|
}
|
|
669
711
|
}
|
|
670
712
|
}
|
|
@@ -417,6 +417,16 @@ public class Bridge private constructor() {
|
|
|
417
417
|
sendTypedMessage("video_recording_status", body)
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
@JvmStatic
|
|
421
|
+
fun sendMediaUploadEvent(type: String, values: Map<String, Any>) {
|
|
422
|
+
val body = HashMap<String, Any>()
|
|
423
|
+
body["type"] = type
|
|
424
|
+
values.forEach { (key, value) ->
|
|
425
|
+
body[key] = value
|
|
426
|
+
}
|
|
427
|
+
sendTypedMessage(type, body)
|
|
428
|
+
}
|
|
429
|
+
|
|
420
430
|
@JvmStatic
|
|
421
431
|
fun sendVersionInfo(values: Map<String, Any>) {
|
|
422
432
|
fun stringField(vararg keys: String): String =
|
|
@@ -681,6 +691,20 @@ public class Bridge private constructor() {
|
|
|
681
691
|
sendTypedMessage("imu_gesture_event", eventBody as Map<String, Any>)
|
|
682
692
|
}
|
|
683
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Send a single accelerometer reading from the glasses IMU - matches iOS
|
|
696
|
+
* Bridge.sendAccelEvent. A richer combined IMU event (gyro + magnetometer) is future work.
|
|
697
|
+
*/
|
|
698
|
+
@JvmStatic
|
|
699
|
+
fun sendAccelEvent(x: Float, y: Float, z: Float, timestamp: Long) {
|
|
700
|
+
val body = HashMap<String, Any>()
|
|
701
|
+
body["x"] = x
|
|
702
|
+
body["y"] = y
|
|
703
|
+
body["z"] = z
|
|
704
|
+
body["timestamp"] = timestamp
|
|
705
|
+
sendTypedMessage("accel_event", body as Map<String, Any>)
|
|
706
|
+
}
|
|
707
|
+
|
|
684
708
|
// Arbitrary WS Comms (don't use these, make a dedicated function for your use case):
|
|
685
709
|
|
|
686
710
|
/** Send WebSocket text message */
|
|
@@ -27,6 +27,9 @@ import com.mentra.bluetoothsdk.utils.MicMap
|
|
|
27
27
|
import com.mentra.bluetoothsdk.utils.MicTypes
|
|
28
28
|
import com.mentra.lc3Lib.Lc3Cpp
|
|
29
29
|
import com.mentra.bluetoothsdk.stt.SherpaOnnxTranscriber
|
|
30
|
+
import kotlinx.coroutines.CoroutineScope
|
|
31
|
+
import kotlinx.coroutines.Dispatchers
|
|
32
|
+
import kotlinx.coroutines.launch
|
|
30
33
|
import java.text.SimpleDateFormat
|
|
31
34
|
import java.util.*
|
|
32
35
|
import java.util.concurrent.CountDownLatch
|
|
@@ -1124,8 +1127,22 @@ class DeviceManager {
|
|
|
1124
1127
|
|
|
1125
1128
|
syncSystemTimeOnceForConnection(readyKey)
|
|
1126
1129
|
|
|
1127
|
-
//
|
|
1128
|
-
|
|
1130
|
+
// re-apply display height/depth after reconnection
|
|
1131
|
+
mainHandler.postDelayed(
|
|
1132
|
+
{
|
|
1133
|
+
val h =
|
|
1134
|
+
(DeviceStore.store.get("bluetooth", "dashboard_height") as? Number)
|
|
1135
|
+
?.toInt()
|
|
1136
|
+
?: 4
|
|
1137
|
+
val rawDepth =
|
|
1138
|
+
(DeviceStore.store.get("bluetooth", "dashboard_depth") as? Number)
|
|
1139
|
+
?.toInt()
|
|
1140
|
+
?: dashboardDepth // canonical default (2), not 1
|
|
1141
|
+
val d = rawDepth.coerceIn(1, 4)
|
|
1142
|
+
sgc?.setDashboardPosition(h, d)
|
|
1143
|
+
},
|
|
1144
|
+
2000
|
|
1145
|
+
)
|
|
1129
1146
|
|
|
1130
1147
|
// Show welcome message on first connect for all display glasses
|
|
1131
1148
|
if (shouldSendBootingMessage) {
|
|
@@ -1288,7 +1305,9 @@ class DeviceManager {
|
|
|
1288
1305
|
}
|
|
1289
1306
|
|
|
1290
1307
|
fun showNotificationsPanel() {
|
|
1291
|
-
|
|
1308
|
+
CoroutineScope(Dispatchers.Main).launch {
|
|
1309
|
+
sgc?.showNotificationsPanel()
|
|
1310
|
+
}
|
|
1292
1311
|
}
|
|
1293
1312
|
|
|
1294
1313
|
fun ping() {
|
|
@@ -1486,17 +1505,22 @@ class DeviceManager {
|
|
|
1486
1505
|
width: Int = 0,
|
|
1487
1506
|
height: Int = 0,
|
|
1488
1507
|
fps: Int = 0,
|
|
1508
|
+
maxRecordingTimeMinutes: Int = 0,
|
|
1489
1509
|
) {
|
|
1490
1510
|
Bridge.log(
|
|
1491
1511
|
"MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound, " +
|
|
1492
|
-
"resolution=${width}x${height}@${fps}fps"
|
|
1512
|
+
"resolution=${width}x${height}@${fps}fps, maxRecordingTimeMinutes=$maxRecordingTimeMinutes"
|
|
1493
1513
|
)
|
|
1494
|
-
sgc?.startVideoRecording(
|
|
1514
|
+
sgc?.startVideoRecording(
|
|
1515
|
+
requestId, save, true, sound, width, height, fps, maxRecordingTimeMinutes)
|
|
1495
1516
|
}
|
|
1496
1517
|
|
|
1497
|
-
fun stopVideoRecording(requestId: String) {
|
|
1498
|
-
Bridge.log(
|
|
1499
|
-
|
|
1518
|
+
fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
1519
|
+
Bridge.log(
|
|
1520
|
+
"MAN: onStopVideoRecording: requestId=$requestId, webhook=" +
|
|
1521
|
+
if (webhookUrl.isNullOrEmpty()) "none" else "set"
|
|
1522
|
+
)
|
|
1523
|
+
sgc?.stopVideoRecording(requestId, webhookUrl, authToken)
|
|
1500
1524
|
}
|
|
1501
1525
|
|
|
1502
1526
|
fun setMicState() {
|
|
@@ -121,6 +121,7 @@ object DeviceStore {
|
|
|
121
121
|
store.set("bluetooth", "dashboard_height", 4)
|
|
122
122
|
store.set("bluetooth", "dashboard_depth", 2)
|
|
123
123
|
store.set("bluetooth", "head_up_angle", 30)
|
|
124
|
+
store.set("bluetooth", "imu_enabled", false)
|
|
124
125
|
store.set("bluetooth", "contextual_dashboard", true)
|
|
125
126
|
store.set("bluetooth", "gallery_mode", true)
|
|
126
127
|
store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
|
|
@@ -238,6 +239,13 @@ object DeviceStore {
|
|
|
238
239
|
DeviceManager.getInstance().sgc?.setHeadUpAngle(angle)
|
|
239
240
|
}
|
|
240
241
|
}
|
|
242
|
+
"bluetooth" to "imu_enabled" -> {
|
|
243
|
+
(value as? Boolean)?.let { enabled ->
|
|
244
|
+
CoroutineScope(Dispatchers.Main).launch {
|
|
245
|
+
DeviceManager.getInstance().sgc?.setImuEnabled(enabled)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
241
249
|
"bluetooth" to "menu_apps" -> {
|
|
242
250
|
@Suppress("UNCHECKED_CAST")
|
|
243
251
|
(value as? List<Map<String, Any>>)?.let { items ->
|