@mentra/bluetooth-sdk 0.1.18 → 0.1.19
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 +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +134 -29
- package/ios/Source/DeviceStore.swift +0 -5
- package/ios/Source/MentraBluetoothSDK.swift +26 -23
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +308 -30
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +13 -1
- package/src/types/index.ts +7 -0
package/README.md
CHANGED
|
@@ -475,7 +475,7 @@ For bare native iOS apps, use the public SwiftPM repository:
|
|
|
475
475
|
https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
|
|
476
476
|
```
|
|
477
477
|
|
|
478
|
-
Select version `0.1.
|
|
478
|
+
Select version `0.1.19`, then add the `MentraBluetoothSDK` product to your app target.
|
|
479
479
|
|
|
480
480
|
For local SDK development, add this package folder directly in Xcode:
|
|
481
481
|
|
|
@@ -16,6 +16,8 @@ import com.mentra.bluetoothsdk.services.ForegroundService
|
|
|
16
16
|
import com.mentra.bluetoothsdk.services.PhoneMic
|
|
17
17
|
import com.mentra.bluetoothsdk.sgcs.G1
|
|
18
18
|
import com.mentra.bluetoothsdk.sgcs.G2
|
|
19
|
+
import com.mentra.bluetoothsdk.sgcs.SceneElement
|
|
20
|
+
import com.mentra.bluetoothsdk.sgcs.SceneFrame
|
|
19
21
|
import com.mentra.bluetoothsdk.sgcs.Mach1
|
|
20
22
|
import com.mentra.bluetoothsdk.sgcs.MentraLive
|
|
21
23
|
import com.mentra.bluetoothsdk.sgcs.MentraNex
|
|
@@ -144,10 +146,6 @@ class DeviceManager {
|
|
|
144
146
|
get() = DeviceStore.store.get("bluetooth", "bypass_vad") as? Boolean ?: false
|
|
145
147
|
set(value) = DeviceStore.apply("bluetooth", "bypass_vad", value)
|
|
146
148
|
|
|
147
|
-
private var offlineCaptionsRunning: Boolean
|
|
148
|
-
get() = DeviceStore.store.get("bluetooth", "offline_captions_running") as? Boolean ?: false
|
|
149
|
-
set(value) = DeviceStore.apply("bluetooth", "offline_captions_running", value)
|
|
150
|
-
|
|
151
149
|
private var localSttFallbackActive: Boolean
|
|
152
150
|
get() = DeviceStore.store.get("bluetooth", "local_stt_fallback_active") as? Boolean ?: false
|
|
153
151
|
set(value) = DeviceStore.apply("bluetooth", "local_stt_fallback_active", value)
|
|
@@ -660,6 +658,13 @@ class DeviceManager {
|
|
|
660
658
|
var borderWidth: Int? = null,
|
|
661
659
|
var borderRadius: Int? = null
|
|
662
660
|
)
|
|
661
|
+
|
|
662
|
+
// Scene slots — one whole SceneFrame per view (main/dashboard), parallel to
|
|
663
|
+
// viewStates. When a slot holds a scene, viewStates carries a "scene"
|
|
664
|
+
// sentinel so sendCurrentState routes here. Holding the WHOLE frame keeps
|
|
665
|
+
// native re-dispatch coherent (dashboard exit re-applies a complete scene,
|
|
666
|
+
// not whatever element happened to arrive last).
|
|
667
|
+
private val sceneStates = arrayOfNulls<SceneFrame>(2)
|
|
663
668
|
// MARK: - End Unique
|
|
664
669
|
|
|
665
670
|
// MARK: - Voice Data Handling
|
|
@@ -740,7 +745,7 @@ class DeviceManager {
|
|
|
740
745
|
// class to fire `vad_status` (a separate signal the cloud SDK
|
|
741
746
|
// surfaces as `session.audio.isSpeaking`).
|
|
742
747
|
handleSendingPcm(pcmData)
|
|
743
|
-
if (shouldSendTranscript ||
|
|
748
|
+
if (shouldSendTranscript || localSttFallbackActive) {
|
|
744
749
|
if (ensureTranscriberInitialized()) {
|
|
745
750
|
transcriber?.acceptAudio(pcmData)
|
|
746
751
|
}
|
|
@@ -877,16 +882,8 @@ class DeviceManager {
|
|
|
877
882
|
}
|
|
878
883
|
|
|
879
884
|
// executor.execute {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
currentViewState = viewStates[1]
|
|
883
|
-
} else {
|
|
884
|
-
currentViewState = viewStates[0]
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (hUp && !contextualDashboard) {
|
|
888
|
-
currentViewState = viewStates[0]
|
|
889
|
-
}
|
|
885
|
+
val currentStateIndex = if (hUp && contextualDashboard) 1 else 0
|
|
886
|
+
val currentViewState: ViewState = viewStates[currentStateIndex]
|
|
890
887
|
|
|
891
888
|
if (sgc?.type?.contains(DeviceTypes.SIMULATED) == true) {
|
|
892
889
|
// dont send the event to glasses that aren't there:
|
|
@@ -944,6 +941,10 @@ class DeviceManager {
|
|
|
944
941
|
)
|
|
945
942
|
}
|
|
946
943
|
|
|
944
|
+
"scene" -> {
|
|
945
|
+
sceneStates[currentStateIndex]?.let { sgc?.applySceneFrame(it) }
|
|
946
|
+
}
|
|
947
|
+
|
|
947
948
|
"clear_view" -> sgc?.clearDisplay()
|
|
948
949
|
else -> Bridge.log("MAN: UNHANDLED LAYOUT_TYPE ${currentViewState.layoutType}")
|
|
949
950
|
}
|
|
@@ -1362,9 +1363,31 @@ class DeviceManager {
|
|
|
1362
1363
|
val isDashboard = view == "dashboard"
|
|
1363
1364
|
val stateIndex = if (isDashboard) 1 else 0
|
|
1364
1365
|
|
|
1366
|
+
// Scene frames (display.render() pipeline) take their own path: the
|
|
1367
|
+
// whole frame is the unit, not a layout, and the host's per-element
|
|
1368
|
+
// annotations make redundant frames self-deduping (all-"unchanged"
|
|
1369
|
+
// frames no-op in the SGC base handler).
|
|
1370
|
+
@Suppress("UNCHECKED_CAST") val sceneMap = event["scene"] as? Map<String, Any>
|
|
1371
|
+
if (sceneMap != null) {
|
|
1372
|
+
handleSceneEvent(stateIndex, sceneMap)
|
|
1373
|
+
return
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1365
1376
|
@Suppress("UNCHECKED_CAST") val layout = event["layout"] as? Map<String, Any> ?: return
|
|
1366
1377
|
|
|
1367
1378
|
val layoutType = layout["layoutType"] as? String
|
|
1379
|
+
|
|
1380
|
+
// Scene→legacy handoff: a legacy layout is about to draw over a scene
|
|
1381
|
+
// (e.g. a cloud app taking the view from a miniapp). Sweep the scene's
|
|
1382
|
+
// elements first so they don't linger under the new content; clear_view
|
|
1383
|
+
// wipes everything anyway.
|
|
1384
|
+
sceneStates[stateIndex]?.let { prevFrame ->
|
|
1385
|
+
sceneStates[stateIndex] = null
|
|
1386
|
+
if (layoutType != "clear_view") {
|
|
1387
|
+
sgc?.clearSceneElements(prevFrame.elements.map { it.id })
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1368
1391
|
val text = parsePlaceholders(layout.getString("text", " "))
|
|
1369
1392
|
val topText = parsePlaceholders(layout.getString("topText", " "))
|
|
1370
1393
|
val bottomText = parsePlaceholders(layout.getString("bottomText", " "))
|
|
@@ -1414,6 +1437,86 @@ class DeviceManager {
|
|
|
1414
1437
|
}
|
|
1415
1438
|
}
|
|
1416
1439
|
|
|
1440
|
+
/** Parse + store a scene frame, then dispatch it if its view is visible. */
|
|
1441
|
+
private fun handleSceneEvent(stateIndex: Int, sceneMap: Map<String, Any>) {
|
|
1442
|
+
var frame = parseSceneFrame(sceneMap) ?: return
|
|
1443
|
+
val prevFrame = sceneStates[stateIndex]
|
|
1444
|
+
|
|
1445
|
+
if (prevFrame == null) {
|
|
1446
|
+
// Legacy→scene handoff: stale legacy content (e.g. a cloud app's
|
|
1447
|
+
// text wall) must not linger under the scene's elements.
|
|
1448
|
+
// clearDisplay is the per-device "wipe what's there" (blank-in-place
|
|
1449
|
+
// on G2 — no page rebuild).
|
|
1450
|
+
val prevLegacyType = viewStates[stateIndex].layoutType
|
|
1451
|
+
if (prevLegacyType.isNotEmpty() && prevLegacyType != "clear_view" && prevLegacyType != "scene") {
|
|
1452
|
+
sgc?.clearDisplay()
|
|
1453
|
+
}
|
|
1454
|
+
} else if (prevFrame.appId != frame.appId) {
|
|
1455
|
+
// Cross-app switch: the host's diff baseline is per-app, so the new
|
|
1456
|
+
// app's annotations don't know the old app's elements are on the
|
|
1457
|
+
// glasses. Sweep the old app's elements (SGC registries still map
|
|
1458
|
+
// them), then paint the new frame from scratch. In practice the
|
|
1459
|
+
// boot message interposes between apps, so this isn't visible as a
|
|
1460
|
+
// blank.
|
|
1461
|
+
sgc?.clearSceneElements(prevFrame.elements.map { it.id })
|
|
1462
|
+
frame = frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Store the REDISPATCH form: any later sendCurrentState (dashboard
|
|
1466
|
+
// exit, head-up return) must repaint the whole frame — the original
|
|
1467
|
+
// annotations are only valid for the first dispatch right now.
|
|
1468
|
+
sceneStates[stateIndex] =
|
|
1469
|
+
frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
|
|
1470
|
+
viewStates[stateIndex] = ViewState(" ", " ", " ", "scene", " ", null, null)
|
|
1471
|
+
|
|
1472
|
+
val hUp = headUp && contextualDashboard
|
|
1473
|
+
if ((stateIndex == 0 && !hUp) || (stateIndex == 1 && hUp)) {
|
|
1474
|
+
dispatchSceneFrame(frame)
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
|
|
1479
|
+
private fun dispatchSceneFrame(frame: SceneFrame) {
|
|
1480
|
+
if (screenDisabled) return
|
|
1481
|
+
if (sgc?.type?.contains(DeviceTypes.SIMULATED) == true) return
|
|
1482
|
+
if (sgc?.fullyBooted != true) {
|
|
1483
|
+
Bridge.log("MAN: dispatchSceneFrame(): sgc not ready")
|
|
1484
|
+
return
|
|
1485
|
+
}
|
|
1486
|
+
sgc?.applySceneFrame(frame)
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
@Suppress("UNCHECKED_CAST")
|
|
1490
|
+
private fun parseSceneFrame(sceneMap: Map<String, Any>): SceneFrame? {
|
|
1491
|
+
val elementsRaw = sceneMap["elements"] as? List<Map<String, Any>> ?: return null
|
|
1492
|
+
val elements =
|
|
1493
|
+
elementsRaw.mapNotNull { el ->
|
|
1494
|
+
val box = el["box"] as? Map<String, Any> ?: return@mapNotNull null
|
|
1495
|
+
val style = el["style"] as? Map<String, Any>
|
|
1496
|
+
SceneElement(
|
|
1497
|
+
id = el["id"] as? String ?: return@mapNotNull null,
|
|
1498
|
+
type = el["type"] as? String ?: return@mapNotNull null,
|
|
1499
|
+
x = (box["x"] as? Number)?.toInt() ?: 0,
|
|
1500
|
+
y = (box["y"] as? Number)?.toInt() ?: 0,
|
|
1501
|
+
w = (box["w"] as? Number)?.toInt() ?: 0,
|
|
1502
|
+
h = (box["h"] as? Number)?.toInt() ?: 0,
|
|
1503
|
+
text = (el["text"] as? String)?.let { parsePlaceholders(it) },
|
|
1504
|
+
data = el["data"] as? String,
|
|
1505
|
+
border = (style?.get("border") as? Number)?.toInt() ?: 0,
|
|
1506
|
+
radius = (style?.get("radius") as? Number)?.toInt() ?: 0,
|
|
1507
|
+
change = el["change"] as? String ?: "created",
|
|
1508
|
+
contentHash = el["contentHash"] as? String ?: ""
|
|
1509
|
+
)
|
|
1510
|
+
}
|
|
1511
|
+
return SceneFrame(
|
|
1512
|
+
appId = sceneMap["appId"] as? String ?: "",
|
|
1513
|
+
epoch = (sceneMap["sceneEpoch"] as? Number)?.toInt() ?: 0,
|
|
1514
|
+
replay = sceneMap["replay"] as? Boolean ?: false,
|
|
1515
|
+
elements = elements,
|
|
1516
|
+
removed = (sceneMap["removed"] as? List<*>)?.filterIsInstance<String>() ?: emptyList()
|
|
1517
|
+
)
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1417
1520
|
fun showDashboard() {
|
|
1418
1521
|
sgc?.showDashboard()
|
|
1419
1522
|
}
|
|
@@ -1669,7 +1772,7 @@ class DeviceManager {
|
|
|
1669
1772
|
|
|
1670
1773
|
fun setMicState() {
|
|
1671
1774
|
val willSendPcm = shouldSendPcm || shouldSendLc3
|
|
1672
|
-
val willSendTranscript = shouldSendTranscript ||
|
|
1775
|
+
val willSendTranscript = shouldSendTranscript || localSttFallbackActive
|
|
1673
1776
|
val nextEnabled = willSendPcm || willSendTranscript
|
|
1674
1777
|
// Tell VAD when the mic is shutting down so it doesn't get stuck in
|
|
1675
1778
|
// a stale "speaking" state and keep emitting vad_status=true after
|
|
@@ -303,12 +303,6 @@ object DeviceStore {
|
|
|
303
303
|
DeviceManager.getInstance().setMicState()
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
-
"bluetooth" to "offline_captions_running" -> {
|
|
307
|
-
(value as? Boolean)?.let { running ->
|
|
308
|
-
Bridge.log("DeviceStore: offline_captions_running changed to $running")
|
|
309
|
-
DeviceManager.getInstance().setMicState()
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
306
|
"bluetooth" to "local_stt_fallback_active" -> {
|
|
313
307
|
(value as? Boolean)?.let { active ->
|
|
314
308
|
Bridge.log("DeviceStore: local_stt_fallback_active changed to $active")
|
|
@@ -67,6 +67,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
67
67
|
private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
|
|
68
68
|
private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
|
|
69
69
|
private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
|
|
70
|
+
private const val WIFI_SCAN_TIMEOUT_MS = 20_000L
|
|
70
71
|
private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
|
|
71
72
|
private const val STREAM_START_TIMEOUT_MS = 30_000L
|
|
72
73
|
private const val STREAM_STOP_TIMEOUT_MS = 15_000L
|
|
@@ -633,35 +634,35 @@ class MentraBluetoothSdk private constructor(
|
|
|
633
634
|
fun requestWifiScan(): List<WifiScanResult> {
|
|
634
635
|
val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
|
|
635
636
|
val request = PendingWifiScan(pending)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
637
|
+
val existing =
|
|
638
|
+
synchronized(oneShotLock) {
|
|
639
|
+
pendingWifiScan ?: run {
|
|
640
|
+
pendingWifiScan = request
|
|
641
|
+
null
|
|
642
|
+
}
|
|
642
643
|
}
|
|
643
|
-
|
|
644
|
+
if (existing != null) {
|
|
645
|
+
// Join the in-flight scan instead of failing with request_in_flight;
|
|
646
|
+
// the scan screen auto-starts a scan on mount and can be pushed twice.
|
|
647
|
+
return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
|
|
644
648
|
}
|
|
645
649
|
try {
|
|
646
650
|
deviceManager.requestWifiScan()
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (pendingWifiScan === request) {
|
|
654
|
-
pendingWifiScan = null
|
|
655
|
-
}
|
|
656
|
-
request.latestResults
|
|
657
|
-
} else {
|
|
658
|
-
emptyList()
|
|
659
|
-
}
|
|
660
|
-
}
|
|
651
|
+
// The glasses wait up to 15s for scan-results broadcasts before sending
|
|
652
|
+
// scan_complete, so give them longer than that before falling back.
|
|
653
|
+
return pending.await(WIFI_SCAN_TIMEOUT_MS)
|
|
654
|
+
} catch (error: Throwable) {
|
|
655
|
+
if (error is BluetoothSdkException && error.code == "request_timeout") {
|
|
656
|
+
val fallbackResults = synchronized(oneShotLock) { request.latestResults }
|
|
661
657
|
if (fallbackResults.isNotEmpty()) {
|
|
658
|
+
// Resolve so callers joined on the same scan get the fallback too.
|
|
659
|
+
pending.resolve(fallbackResults)
|
|
662
660
|
return fallbackResults
|
|
663
661
|
}
|
|
664
662
|
}
|
|
663
|
+
// Fail joined callers immediately instead of letting them run out their
|
|
664
|
+
// own timeout.
|
|
665
|
+
pending.reject(error)
|
|
665
666
|
throw error
|
|
666
667
|
} finally {
|
|
667
668
|
synchronized(oneShotLock) {
|
|
@@ -57,7 +57,7 @@ internal object OtaManifestChecker {
|
|
|
57
57
|
return try {
|
|
58
58
|
val status = connection.responseCode
|
|
59
59
|
if (status !in 200..299) {
|
|
60
|
-
throw BluetoothSdkException("ota_manifest_request_failed", "OTA manifest request failed with HTTP $status.")
|
|
60
|
+
throw BluetoothSdkException("ota_manifest_request_failed", "OTA manifest request failed with HTTP $status for $otaVersionUrl.")
|
|
61
61
|
}
|
|
62
62
|
JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
|
63
63
|
} finally {
|