@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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  4. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  5. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  22. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  23. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  28. package/build/BluetoothSdk.types.d.ts +12 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/types/index.d.ts +3 -0
  32. package/build/types/index.d.ts.map +1 -0
  33. package/build/types/index.js +2 -0
  34. package/build/types/index.js.map +1 -0
  35. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  36. package/ios/Source/DeviceManager.swift +134 -29
  37. package/ios/Source/DeviceStore.swift +0 -5
  38. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  39. package/ios/Source/OtaManifest.swift +1 -1
  40. package/ios/Source/sgcs/G2.swift +518 -11
  41. package/ios/Source/sgcs/MentraLive.swift +308 -30
  42. package/ios/Source/sgcs/MentraNex.swift +416 -32
  43. package/ios/Source/sgcs/SGCManager.swift +155 -0
  44. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  45. package/ios/Source/status/DeviceStatus.swift +0 -8
  46. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  47. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  48. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  49. package/ios/Source/utils/MessageChunker.swift +73 -0
  50. package/package.json +14 -7
  51. package/src/BluetoothSdk.types.ts +13 -1
  52. 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.18`, then add the `MentraBluetoothSDK` product to your app target.
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 || offlineCaptionsRunning || localSttFallbackActive) {
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
- var currentViewState: ViewState
881
- if (hUp) {
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 || offlineCaptionsRunning || localSttFallbackActive
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
- synchronized(oneShotLock) {
637
- if (pendingWifiScan != null) {
638
- throw BluetoothSdkException(
639
- "request_in_flight",
640
- "A WiFi scan is already waiting for a glasses response.",
641
- )
637
+ val existing =
638
+ synchronized(oneShotLock) {
639
+ pendingWifiScan ?: run {
640
+ pendingWifiScan = request
641
+ null
642
+ }
642
643
  }
643
- pendingWifiScan = request
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
- return pending.await()
648
- } catch (error: BluetoothSdkException) {
649
- if (error.code == "request_timeout") {
650
- val fallbackResults =
651
- synchronized(oneShotLock) {
652
- if (request.latestResults.isNotEmpty()) {
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 {