@mentra/bluetooth-sdk 0.1.18 → 0.1.20

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 (77) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  27. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  31. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  36. package/build/BluetoothSdk.types.d.ts +37 -1
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  40. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  43. package/build/_private/photoRequestPayload.js +1 -0
  44. package/build/_private/photoRequestPayload.js.map +1 -1
  45. package/build/index.d.ts +1 -1
  46. package/build/index.d.ts.map +1 -1
  47. package/build/index.js +5 -0
  48. package/build/index.js.map +1 -1
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -0
  51. package/build/types/index.js +2 -0
  52. package/build/types/index.js.map +1 -0
  53. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  54. package/ios/Source/DeviceManager.swift +136 -30
  55. package/ios/Source/DeviceStore.swift +5 -5
  56. package/ios/Source/MentraBluetoothSDK.swift +40 -24
  57. package/ios/Source/OtaManifest.swift +1 -1
  58. package/ios/Source/camera/CameraModels.swift +17 -3
  59. package/ios/Source/sgcs/G1.swift +1 -1
  60. package/ios/Source/sgcs/G2.swift +518 -11
  61. package/ios/Source/sgcs/MentraLive.swift +443 -42
  62. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  63. package/ios/Source/sgcs/MentraNex.swift +416 -32
  64. package/ios/Source/sgcs/SGCManager.swift +155 -0
  65. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  66. package/ios/Source/status/DeviceStatus.swift +0 -8
  67. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  68. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  69. package/ios/Source/utils/G1Text.swift +30 -1
  70. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  71. package/ios/Source/utils/MessageChunker.swift +73 -0
  72. package/package.json +14 -7
  73. package/src/BluetoothSdk.types.ts +39 -1
  74. package/src/_private/BluetoothSdkModule.ts +4 -0
  75. package/src/_private/photoRequestPayload.ts +1 -0
  76. package/src/index.ts +6 -0
  77. 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.20`, 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
 
@@ -1,13 +1,92 @@
1
+ @file:Suppress("FunctionName")
2
+
1
3
  package com.mentra.bluetoothsdk
2
4
 
3
5
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
4
6
  import com.mentra.bluetoothsdk.utils.DeviceTypes
7
+ import expo.modules.kotlin.exception.CodedException
5
8
  import expo.modules.kotlin.functions.Coroutine
6
9
  import expo.modules.kotlin.modules.Module
7
10
  import expo.modules.kotlin.modules.ModuleDefinition
11
+ import expo.modules.kotlin.modules.ModuleDefinitionBuilder
8
12
  import kotlinx.coroutines.Dispatchers
9
13
  import kotlinx.coroutines.withContext
10
14
 
15
+ // Expo has no module-level error mapper, so SDK-backed registrations translate
16
+ // core exceptions here while keeping Expo types out of the native SDK API.
17
+ private inline fun <T> withExpoSdkError(block: () -> T): T =
18
+ try {
19
+ block()
20
+ } catch (error: BluetoothSdkException) {
21
+ throw CodedException(error.code, error.message ?: error.code, error)
22
+ }
23
+
24
+ private suspend inline fun <T> withExpoSdkErrorSuspend(crossinline block: suspend () -> T): T =
25
+ try {
26
+ block()
27
+ } catch (error: BluetoothSdkException) {
28
+ throw CodedException(error.code, error.message ?: error.code, error)
29
+ }
30
+
31
+ private inline fun <reified R> ModuleDefinitionBuilder.SdkAsyncFunction(
32
+ name: String,
33
+ crossinline body: () -> R,
34
+ ) = AsyncFunction<R>(name) { withExpoSdkError { body() } }
35
+
36
+ private inline fun <reified R, reified P0> ModuleDefinitionBuilder.SdkAsyncFunction(
37
+ name: String,
38
+ crossinline body: (P0) -> R,
39
+ ) = AsyncFunction<R, P0>(name) { p0 -> withExpoSdkError { body(p0) } }
40
+
41
+ private inline fun <reified R, reified P0, reified P1> ModuleDefinitionBuilder.SdkAsyncFunction(
42
+ name: String,
43
+ crossinline body: (P0, P1) -> R,
44
+ ) = AsyncFunction<R, P0, P1>(name) { p0, p1 -> withExpoSdkError { body(p0, p1) } }
45
+
46
+ private inline fun <reified R, reified P0, reified P1, reified P2> ModuleDefinitionBuilder.SdkAsyncFunction(
47
+ name: String,
48
+ crossinline body: (P0, P1, P2) -> R,
49
+ ) = AsyncFunction<R, P0, P1, P2>(name) { p0, p1, p2 -> withExpoSdkError { body(p0, p1, p2) } }
50
+
51
+ private inline fun <reified R, reified P0, reified P1, reified P2, reified P3>
52
+ ModuleDefinitionBuilder.SdkAsyncFunction(
53
+ name: String,
54
+ crossinline body: (P0, P1, P2, P3) -> R,
55
+ ) = AsyncFunction<R, P0, P1, P2, P3>(name) { p0, p1, p2, p3 ->
56
+ withExpoSdkError { body(p0, p1, p2, p3) }
57
+ }
58
+
59
+ private inline fun <
60
+ reified R,
61
+ reified P0,
62
+ reified P1,
63
+ reified P2,
64
+ reified P3,
65
+ reified P4,
66
+ reified P5,
67
+ reified P6,
68
+ > ModuleDefinitionBuilder.SdkAsyncFunction(
69
+ name: String,
70
+ crossinline body: (P0, P1, P2, P3, P4, P5, P6) -> R,
71
+ ) = AsyncFunction<R, P0, P1, P2, P3, P4, P5, P6>(name) { p0, p1, p2, p3, p4, p5, p6 ->
72
+ withExpoSdkError { body(p0, p1, p2, p3, p4, p5, p6) }
73
+ }
74
+
75
+ private inline fun <reified R> ModuleDefinitionBuilder.SdkFunction(
76
+ name: String,
77
+ crossinline body: () -> R,
78
+ ) = Function<R>(name) { withExpoSdkError { body() } }
79
+
80
+ private inline fun <reified R, reified P0> ModuleDefinitionBuilder.SdkFunction(
81
+ name: String,
82
+ crossinline body: (P0) -> R,
83
+ ) = Function<R, P0>(name) { p0 -> withExpoSdkError { body(p0) } }
84
+
85
+ private inline fun <reified R> ModuleDefinitionBuilder.SdkCoroutineFunction(
86
+ name: String,
87
+ crossinline body: suspend () -> R,
88
+ ) = AsyncFunction(name) Coroutine { -> withExpoSdkErrorSuspend { body() } }
89
+
11
90
  class BluetoothSdkModule : Module() {
12
91
  private var sdk: MentraBluetoothSdk? = null
13
92
  private var deviceManager: DeviceManager? = null
@@ -183,9 +262,10 @@ class BluetoothSdkModule : Module() {
183
262
 
184
263
  private fun requireSdk(): MentraBluetoothSdk =
185
264
  sdk
186
- ?: throw BluetoothSdkException(
265
+ ?: throw CodedException(
187
266
  "sdk_not_initialized",
188
267
  "Bluetooth SDK is not initialized.",
268
+ null,
189
269
  )
190
270
 
191
271
  override fun definition() = ModuleDefinition {
@@ -335,11 +415,11 @@ class BluetoothSdkModule : Module() {
335
415
 
336
416
  // MARK: - Display Commands
337
417
 
338
- AsyncFunction("displayEvent") { params: Map<String, Any> ->
418
+ SdkAsyncFunction("displayEvent") { params: Map<String, Any> ->
339
419
  sdk?.displayEvent(DisplayEventRequest(params))
340
420
  }
341
421
 
342
- AsyncFunction("displayText") { text: String, x: Int?, y: Int?, size: Int? ->
422
+ SdkAsyncFunction("displayText") { text: String, x: Int?, y: Int?, size: Int? ->
343
423
  sdk?.displayText(
344
424
  text = text,
345
425
  x = x ?: 0,
@@ -348,34 +428,35 @@ class BluetoothSdkModule : Module() {
348
428
  )
349
429
  }
350
430
 
351
- AsyncFunction("clearDisplay") { sdk?.clearDisplay() }
431
+ SdkAsyncFunction("clearDisplay") { -> sdk?.clearDisplay() }
352
432
 
353
433
  // MARK: - Connection Commands
354
434
 
355
- AsyncFunction("connectDefault") { sdk?.connectDefault() }
435
+ SdkAsyncFunction("connectDefault") { -> sdk?.connectDefault() }
356
436
 
357
- AsyncFunction("connectDefaultWithOptions") { options: Map<String, Any> ->
437
+ SdkAsyncFunction("connectDefaultWithOptions") { options: Map<String, Any> ->
358
438
  sdk?.connectDefault(options.toMentraConnectOptions())
359
439
  }
360
440
 
361
- AsyncFunction("setDefaultDevice") { device: Map<String, Any>? ->
441
+ SdkAsyncFunction("setDefaultDevice") { device: Map<String, Any>? ->
362
442
  sdk?.setDefaultDevice(device.toMentraDevice())
363
443
  }
364
444
 
365
- AsyncFunction("clearDefaultDevice") { sdk?.clearDefaultDevice() }
445
+ SdkAsyncFunction("clearDefaultDevice") { -> sdk?.clearDefaultDevice() }
366
446
 
367
- AsyncFunction("connectWithOptions") { device: Map<String, Any>, options: Map<String, Any> ->
447
+ SdkAsyncFunction("connectWithOptions") { device: Map<String, Any>, options: Map<String, Any> ->
368
448
  sdk?.connect(
369
- device.toMentraDevice() ?: throw IllegalArgumentException("connect requires a Device with model and name."),
449
+ device.toMentraDevice()
450
+ ?: throw IllegalArgumentException("connect requires a Device with model and name."),
370
451
  options.toMentraConnectOptions(),
371
452
  )
372
453
  }
373
454
 
374
- AsyncFunction("connectSimulated") { sdk?.connectSimulated() }
455
+ SdkAsyncFunction("connectSimulated") { -> sdk?.connectSimulated() }
375
456
 
376
- AsyncFunction("disconnect") { sdk?.disconnect() }
457
+ SdkAsyncFunction("disconnect") { -> sdk?.disconnect() }
377
458
 
378
- AsyncFunction("forget") { sdk?.forget() }
459
+ SdkAsyncFunction("forget") { -> sdk?.forget() }
379
460
 
380
461
  AsyncFunction("connectDefaultController") { deviceManager?.connectDefaultController() }
381
462
 
@@ -383,15 +464,15 @@ class BluetoothSdkModule : Module() {
383
464
 
384
465
  AsyncFunction("forgetController") { deviceManager?.forgetController() }
385
466
 
386
- AsyncFunction("startScan") { model: String ->
467
+ SdkAsyncFunction("startScan") { model: String ->
387
468
  sdk?.startScan(DeviceModel.fromDeviceType(model))
388
469
  }
389
470
 
390
- AsyncFunction("stopScan") { sdk?.stopScan() }
471
+ SdkAsyncFunction("stopScan") { -> sdk?.stopScan() }
391
472
 
392
- AsyncFunction("cancelConnectionAttempt") { sdk?.cancelConnectionAttempt() }
473
+ SdkAsyncFunction("cancelConnectionAttempt") { -> sdk?.cancelConnectionAttempt() }
393
474
 
394
- AsyncFunction("showDashboard") { sdk?.showDashboard() }
475
+ SdkAsyncFunction("showDashboard") { -> sdk?.showDashboard() }
395
476
 
396
477
  AsyncFunction("ping") { deviceManager?.ping() }
397
478
 
@@ -409,51 +490,54 @@ class BluetoothSdkModule : Module() {
409
490
 
410
491
  // MARK: - Incident Reporting
411
492
 
412
- AsyncFunction("sendIncidentId") { incidentId: String, apiBaseUrl: String? ->
493
+ SdkAsyncFunction("sendIncidentId") { incidentId: String, apiBaseUrl: String? ->
413
494
  sdk?.sendIncidentId(incidentId, apiBaseUrl)
414
495
  }
415
496
 
416
497
  // MARK: - WiFi Commands
417
498
 
418
- AsyncFunction("requestWifiScan") { requireSdk().requestWifiScan().map { it.toMap() } }
499
+ SdkAsyncFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
419
500
 
420
- AsyncFunction("sendWifiCredentials") { ssid: String, password: String ->
501
+ SdkAsyncFunction("sendWifiCredentials") { ssid: String, password: String ->
421
502
  requireSdk().sendWifiCredentials(ssid, password).values
422
503
  }
423
504
 
424
- AsyncFunction("forgetWifiNetwork") { ssid: String -> requireSdk().forgetWifiNetwork(ssid).values }
505
+ SdkAsyncFunction("forgetWifiNetwork") { ssid: String ->
506
+ requireSdk().forgetWifiNetwork(ssid).values
507
+ }
425
508
 
426
- AsyncFunction("setHotspotState") { enabled: Boolean ->
509
+ SdkAsyncFunction("setHotspotState") { enabled: Boolean ->
427
510
  requireSdk().setHotspotState(enabled).values
428
511
  }
429
512
 
430
- AsyncFunction("setSystemTime") { timestampMs: Double ->
513
+ SdkAsyncFunction("setSystemTime") { timestampMs: Double ->
431
514
  sdk?.setSystemTime(timestampMs.toLong())
432
515
  }
433
516
 
434
517
  // MARK: - Gallery Commands
435
518
 
436
- AsyncFunction("setGalleryModeEnabled") { enabled: Boolean ->
519
+ SdkAsyncFunction("setGalleryModeEnabled") { enabled: Boolean ->
437
520
  requireSdk().setGalleryModeEnabled(enabled).values
438
521
  }
439
522
 
440
- AsyncFunction("setVoiceActivityDetectionEnabled") { enabled: Boolean ->
523
+ SdkAsyncFunction("setVoiceActivityDetectionEnabled") { enabled: Boolean ->
441
524
  sdk?.setVoiceActivityDetectionEnabled(enabled)
442
525
  }
443
526
 
444
- AsyncFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
527
+ @Suppress("DEPRECATION")
528
+ SdkAsyncFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
445
529
  requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
446
530
  }
447
531
 
448
- AsyncFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
532
+ SdkAsyncFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
449
533
  requireSdk().setVideoRecordingDefaults(VideoRecordingDefaults(width, height, fps)).values
450
534
  }
451
535
 
452
- AsyncFunction("setMaxVideoRecordingDuration") { minutes: Int ->
536
+ SdkAsyncFunction("setMaxVideoRecordingDuration") { minutes: Int ->
453
537
  requireSdk().setMaxVideoRecordingDuration(minutes).values
454
538
  }
455
539
 
456
- AsyncFunction("setCameraFov") { fov: Map<String, Any> ->
540
+ SdkAsyncFunction("setCameraFov") { fov: Map<String, Any> ->
457
541
  val value = (fov["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
458
542
  val roiPosition = CameraRoiPosition.fromValue(
459
543
  (fov["roiPosition"] as? Number)?.toInt()
@@ -462,13 +546,13 @@ class BluetoothSdkModule : Module() {
462
546
  requireSdk().setCameraFov(CameraFov(value, roiPosition)).values
463
547
  }
464
548
 
465
- AsyncFunction("setCameraTuningConfig") { anrOn: Boolean, gainOn: Boolean ->
549
+ SdkAsyncFunction("setCameraTuningConfig") { anrOn: Boolean, gainOn: Boolean ->
466
550
  requireSdk().setCameraTuningConfig(anrOn, gainOn).values
467
551
  }
468
552
 
469
- AsyncFunction("queryGalleryStatus") { requireSdk().queryGalleryStatus().values }
553
+ SdkAsyncFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
470
554
 
471
- AsyncFunction("requestPhoto") { params: Map<String, Any?> ->
555
+ SdkAsyncFunction("requestPhoto") { params: Map<String, Any?> ->
472
556
  // JS may pass null for optional fields; Map<String, Any> rejects null values at the bridge.
473
557
  val sanitized =
474
558
  params.mapNotNull { (key, value) ->
@@ -476,12 +560,12 @@ class BluetoothSdkModule : Module() {
476
560
  }.toMap()
477
561
  val req = PhotoRequest.fromMap(sanitized)
478
562
  Bridge.log(
479
- "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} size=${req.size} compress=${req.compress} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
563
+ "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} size=${req.size} mode=${req.mode.value} compress=${req.compress} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
480
564
  )
481
565
  requireSdk().requestPhoto(req).values
482
566
  }
483
567
 
484
- AsyncFunction("warmUpCamera") { params: Map<String, Any?> ->
568
+ SdkAsyncFunction("warmUpCamera") { params: Map<String, Any?> ->
485
569
  val requestId = params["requestId"] as? String
486
570
  val size = PhotoSize.fromValue(params["size"] as? String)
487
571
  val exposureRaw = (params["exposureTimeNs"] as? Number)?.toDouble()
@@ -494,19 +578,19 @@ class BluetoothSdkModule : Module() {
494
578
 
495
579
  // MARK: - OTA Commands
496
580
 
497
- Function("setOtaVersionUrl") { otaVersionUrl: String ->
581
+ SdkFunction("setOtaVersionUrl") { otaVersionUrl: String ->
498
582
  requireSdk().setOtaVersionUrl(otaVersionUrl)
499
583
  }
500
584
 
501
- Function("getOtaVersionUrl") { requireSdk().getOtaVersionUrl() }
585
+ SdkFunction("getOtaVersionUrl") { -> requireSdk().getOtaVersionUrl() }
502
586
 
503
587
  // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
504
588
  // manifest fetches and version waits can block for several seconds.
505
- AsyncFunction("checkForOtaUpdate") Coroutine { ->
589
+ SdkCoroutineFunction("checkForOtaUpdate") {
506
590
  withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
507
591
  }
508
592
 
509
- AsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
593
+ SdkAsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
510
594
  val sdk = requireSdk()
511
595
  if (otaVersionUrl.isNullOrBlank()) {
512
596
  sdk.startOtaUpdate().values
@@ -515,21 +599,21 @@ class BluetoothSdkModule : Module() {
515
599
  }
516
600
  }
517
601
 
518
- AsyncFunction("sendOtaQueryStatus") { requireSdk().sendOtaQueryStatus().values }
602
+ SdkAsyncFunction("sendOtaQueryStatus") { -> requireSdk().sendOtaQueryStatus().values }
519
603
 
520
604
  // MARK: - Version Info Commands
521
605
 
522
- AsyncFunction("requestVersionInfo") { requireSdk().requestVersionInfo().toMap() }
606
+ SdkAsyncFunction("requestVersionInfo") { -> requireSdk().requestVersionInfo().toMap() }
523
607
 
524
608
  // MARK: - Power Control Commands
525
609
 
526
- AsyncFunction("sendShutdown") { sdk?.sendShutdown() }
610
+ SdkAsyncFunction("sendShutdown") { -> sdk?.sendShutdown() }
527
611
 
528
- AsyncFunction("sendReboot") { sdk?.sendReboot() }
612
+ SdkAsyncFunction("sendReboot") { -> sdk?.sendReboot() }
529
613
 
530
614
  // MARK: - Video Recording Commands
531
615
 
532
- AsyncFunction("startVideoRecording") {
616
+ SdkAsyncFunction("startVideoRecording") {
533
617
  requestId: String,
534
618
  save: Boolean,
535
619
  sound: Boolean,
@@ -553,7 +637,7 @@ class BluetoothSdkModule : Module() {
553
637
 
554
638
  // webhookUrl/authToken are supplied at stop (not start) so the token is
555
639
  // fresh when the upload runs. Empty/null webhook = keep on device.
556
- AsyncFunction("stopVideoRecording") {
640
+ SdkAsyncFunction("stopVideoRecording") {
557
641
  requestId: String,
558
642
  webhookUrl: String?,
559
643
  authToken: String? ->
@@ -562,23 +646,23 @@ class BluetoothSdkModule : Module() {
562
646
 
563
647
  // MARK: - Stream Commands
564
648
 
565
- AsyncFunction("startStream") { params: Map<String, Any> ->
649
+ SdkAsyncFunction("startStream") { params: Map<String, Any> ->
566
650
  requireSdk().startStream(StreamRequest.fromMap(params)).values
567
651
  }
568
652
 
569
- AsyncFunction("startExternallyManagedStream") { params: Map<String, Any> ->
653
+ SdkAsyncFunction("startExternallyManagedStream") { params: Map<String, Any> ->
570
654
  requireSdk().startExternallyManagedStream(StreamRequest.fromMap(params)).values
571
655
  }
572
656
 
573
- AsyncFunction("stopStream") { requireSdk().stopStream().values }
657
+ SdkAsyncFunction("stopStream") { -> requireSdk().stopStream().values }
574
658
 
575
- AsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
659
+ SdkAsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
576
660
  sdk?.sendExternallyManagedStreamKeepAlive(StreamKeepAliveRequest.fromMap(params))
577
661
  }
578
662
 
579
663
  // MARK: - Microphone Commands
580
664
 
581
- AsyncFunction("setMicState") {
665
+ SdkAsyncFunction("setMicState") {
582
666
  enabled: Boolean,
583
667
  useGlassesMic: Boolean?,
584
668
  sendTranscript: Boolean?,
@@ -600,7 +684,7 @@ class BluetoothSdkModule : Module() {
600
684
 
601
685
  // MARK: - Audio Playback Monitoring
602
686
 
603
- AsyncFunction("setOwnAppAudioPlaying") { playing: Boolean ->
687
+ SdkAsyncFunction("setOwnAppAudioPlaying") { playing: Boolean ->
604
688
  sdk?.setOwnAppAudioPlaying(playing)
605
689
  }
606
690
 
@@ -619,7 +703,7 @@ class BluetoothSdkModule : Module() {
619
703
 
620
704
  // MARK: - RGB LED Control
621
705
 
622
- AsyncFunction("rgbLedControl") {
706
+ SdkAsyncFunction("rgbLedControl") {
623
707
  requestId: String,
624
708
  packageName: String?,
625
709
  action: String,
@@ -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
@@ -1697,7 +1800,7 @@ class DeviceManager {
1697
1800
  iso = manualIso,
1698
1801
  )
1699
1802
  Bridge.log(
1700
- "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=${routed.requestId} size=${routed.size.value} compress=${routed.compress.value} save=${routed.save} sound=${routed.sound} exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} aeDivisor=${routed.aeExposureDivisor} isoCap=${routed.isoCap} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1803
+ "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=${routed.requestId} size=${routed.size.value} mode=${routed.mode.value} compress=${routed.compress.value} save=${routed.save} sound=${routed.sound} exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} aeDivisor=${routed.aeExposureDivisor} isoCap=${routed.isoCap} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1701
1804
  )
1702
1805
  val activeSgc = sgc
1703
1806
  if (activeSgc == null) {
@@ -292,6 +292,11 @@ object DeviceStore {
292
292
  "bluetooth" to "camera_fov" -> {
293
293
  DeviceManager.getInstance().sgc?.sendCameraFovSetting()
294
294
  }
295
+ "bluetooth" to "button_video_settings" -> {
296
+ DeviceManager.getInstance().sgc?.sendButtonVideoRecordingSettings()
297
+ }
298
+ // Legacy scalar keys remain supported for older hosts. New code should write the
299
+ // canonical button_video_settings object so width/height/fps update atomically.
295
300
  "bluetooth" to "button_video_width",
296
301
  "bluetooth" to "button_video_height",
297
302
  "bluetooth" to "button_video_fps" -> {
@@ -303,12 +308,6 @@ object DeviceStore {
303
308
  DeviceManager.getInstance().setMicState()
304
309
  }
305
310
  }
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
311
  "bluetooth" to "local_stt_fallback_active" -> {
313
312
  (value as? Boolean)?.let { active ->
314
313
  Bridge.log("DeviceStore: local_stt_fallback_active changed to $active")