@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.
- package/README.md +27 -12
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
- package/build/BluetoothSdk.types.d.ts +100 -22
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +2 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +3 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +3 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +31 -4
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +26 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts +1 -1
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +7 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +10 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +20 -0
- package/build/_private/sdkOtaManifest.d.ts.map +1 -0
- package/build/_private/sdkOtaManifest.js +28 -0
- package/build/_private/sdkOtaManifest.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +113 -2
- package/ios/Source/BluetoothSdkDefaults.swift +2 -1
- package/ios/Source/Bridge.swift +87 -21
- package/ios/Source/DeviceManager.swift +118 -20
- package/ios/Source/DeviceStore.swift +4 -0
- package/ios/Source/MentraBluetoothSDK.swift +326 -33
- package/ios/Source/OtaManifest.swift +47 -4
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +26 -6
- package/ios/Source/internal/BleTraceLogger.swift +1 -1
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
- package/ios/Source/sgcs/Ar99.swift +2451 -0
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +478 -61
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +13 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +68 -2
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +13 -0
- package/ios/Source/utils/BleJsonCompact.swift +8 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +106 -24
- package/src/_internal.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +83 -18
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +9 -2
- package/src/_private/photoRequestPayload.ts +13 -4
- package/src/_private/sdkOtaManifest.ts +31 -0
- 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
|
|
156
|
-
|
|
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
|
|
194
|
-
`
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
|
package/android/build.gradle
CHANGED
|
@@ -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+")) }
|
|
@@ -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
|
-
|
|
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
|
-
|
|
560
|
+
SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
|
|
500
561
|
|
|
501
|
-
|
|
562
|
+
SdkCoroutineFunction("sendWifiCredentials") { ssid: String, password: String ->
|
|
502
563
|
requireSdk().sendWifiCredentials(ssid, password).values
|
|
503
564
|
}
|
|
504
565
|
|
|
505
|
-
|
|
566
|
+
SdkCoroutineFunction("forgetWifiNetwork") { ssid: String ->
|
|
506
567
|
requireSdk().forgetWifiNetwork(ssid).values
|
|
507
568
|
}
|
|
508
569
|
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
+
SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
|
|
529
594
|
requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
|
|
530
595
|
}
|
|
531
596
|
|
|
532
|
-
|
|
597
|
+
SdkCoroutineFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
|
|
533
598
|
requireSdk().setVideoRecordingDefaults(VideoRecordingDefaults(width, height, fps)).values
|
|
534
599
|
}
|
|
535
600
|
|
|
536
|
-
|
|
601
|
+
SdkCoroutineFunction("setMaxVideoRecordingDuration") { minutes: Int ->
|
|
537
602
|
requireSdk().setMaxVideoRecordingDuration(minutes).values
|
|
538
603
|
}
|
|
539
604
|
|
|
540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
646
|
+
SdkCoroutineFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
|
|
554
647
|
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
761
|
+
SdkCoroutineFunction("startStream") { params: Map<String, Any> ->
|
|
650
762
|
requireSdk().startStream(StreamRequest.fromMap(params)).values
|
|
651
763
|
}
|
|
652
764
|
|
|
653
|
-
|
|
765
|
+
SdkCoroutineFunction("startExternallyManagedStream") { params: Map<String, Any> ->
|
|
654
766
|
requireSdk().startExternallyManagedStream(StreamRequest.fromMap(params)).values
|
|
655
767
|
}
|
|
656
768
|
|
|
657
|
-
|
|
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
|
-
|
|
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
|
|
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
|
)
|