@mentra/bluetooth-sdk 0.1.12 → 0.1.13

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 (65) hide show
  1. package/README.md +16 -9
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +51 -3
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +73 -21
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +190 -26
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +78 -10
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
  12. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -13
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
  20. package/build/BluetoothSdk.types.d.ts +39 -9
  21. package/build/BluetoothSdk.types.d.ts.map +1 -1
  22. package/build/BluetoothSdk.types.js.map +1 -1
  23. package/build/_private/BluetoothSdkModule.d.ts +8 -6
  24. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  25. package/build/_private/BluetoothSdkModule.js +0 -2
  26. package/build/_private/BluetoothSdkModule.js.map +1 -1
  27. package/build/_private/photoRequestPayload.d.ts +3 -1
  28. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  29. package/build/_private/photoRequestPayload.js +43 -1
  30. package/build/_private/photoRequestPayload.js.map +1 -1
  31. package/build/debug.d.ts +3 -0
  32. package/build/debug.d.ts.map +1 -0
  33. package/build/debug.js +8 -0
  34. package/build/debug.js.map +1 -0
  35. package/build/index.d.ts +1 -1
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +14 -5
  38. package/build/index.js.map +1 -1
  39. package/ios/BluetoothSdkModule.swift +38 -57
  40. package/ios/Source/BluetoothSdkDefaults.swift +23 -3
  41. package/ios/Source/DeviceManager.swift +37 -26
  42. package/ios/Source/DeviceStore.swift +5 -1
  43. package/ios/Source/MentraBluetoothSDK.swift +197 -26
  44. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  45. package/ios/Source/ObservableStore.swift +11 -0
  46. package/ios/Source/OtaManifest.swift +170 -0
  47. package/ios/Source/camera/CameraModels.swift +218 -8
  48. package/ios/Source/controllers/ControllerManager.swift +2 -5
  49. package/ios/Source/controllers/R1.swift +2 -5
  50. package/ios/Source/sgcs/G1.swift +6 -5
  51. package/ios/Source/sgcs/G2.swift +20 -18
  52. package/ios/Source/sgcs/Mach1.swift +6 -5
  53. package/ios/Source/sgcs/MentraLive.swift +129 -33
  54. package/ios/Source/sgcs/MentraNex.swift +6 -5
  55. package/ios/Source/sgcs/SGCManager.swift +3 -5
  56. package/ios/Source/sgcs/Simulated.swift +7 -3
  57. package/ios/Source/status/DeviceStatus.swift +1 -1
  58. package/package.json +6 -1
  59. package/src/BluetoothSdk.types.ts +40 -9
  60. package/src/_private/BluetoothSdkModule.ts +8 -9
  61. package/src/_private/photoRequestPayload.ts +45 -2
  62. package/src/debug.ts +9 -0
  63. package/src/index.ts +23 -6
  64. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  65. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7434
package/README.md CHANGED
@@ -325,7 +325,7 @@ React Native narrows returned values to success shapes where the raw listener ev
325
325
  | `startVideoRecording(...)` | `VideoRecordingStartedStatusEvent` with `success: true` and `status: "recording_started"`. | Rejects on `success: false` statuses such as `already_recording`, send failure, or timeout. |
326
326
  | `stopVideoRecording(...)` | `VideoRecordingStoppedStatusEvent` with `success: true` and `status: "recording_stopped"`. When a webhook URL is supplied, resolves after the video upload succeeds. | Rejects on `success: false` statuses such as `not_recording`, webhook upload failure, send failure, or timeout. |
327
327
  | `rgbLedControl(...)` | `RgbLedControlSuccessResponseEvent` with `state: "success"`. | Rejects when raw `rgb_led_control_response.state === "error"` or the response times out. |
328
- | `checkForOtaUpdate()` / `retryOtaVersionCheck()` | `OtaQueryResult`, either `ota_update_available` or current `ota_status`. | Rejects on command transport timeout/failure. A returned `ota_status.status === "failed"` is glasses OTA state, not a command rejection. |
328
+ | `checkForOtaUpdate()` | `boolean`, true when the configured OTA manifest has an ASG APK, MTK, or BES update for the connected glasses; false only when the manifest was checked successfully and no update is available. | Rejects when the glasses are disconnected, version info is unavailable, the manifest cannot be fetched, or the manifest response is invalid/missing required ASG app version fields. |
329
329
 
330
330
  Android and iOS async APIs use `BluetoothException` / `BluetoothError` for the same error paths. Their returned event structs are the successful response in normal `try`/`await` code, while raw listener/delegate events still include both success and error payloads.
331
331
 
@@ -335,9 +335,16 @@ Android and iOS async APIs use `BluetoothException` / `BluetoothError` for the s
335
335
 
336
336
  Mentra Live firmware owns the OTA flow. The SDK mirrors the MentraOS app commands and events:
337
337
 
338
- - `checkForOtaUpdate()` sends `ota_query_status` and resolves with the ASG response (`ota_update_available` or the current `ota_status`).
339
- - `startOtaUpdate()` sends `ota_start` and resolves with the ASG start ack after your app presents the update and the user accepts it.
340
- - `retryOtaVersionCheck()` sends `ota_retry_version_check` and resolves with the same ASG response shape as `checkForOtaUpdate()`; use it only after fixing a known clock-skew/TLS failure.
338
+ - `checkForOtaUpdate()` fetches the configured manifest and resolves with `true` when an ASG APK, MTK, or BES update is available.
339
+ - `startOtaUpdate()` sends `ota_start` with the same configured manifest URL and resolves with the ASG start ack after your app presents the update and the user accepts it.
340
+
341
+ The default manifest is derived from the SDK version:
342
+ `https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota/bluetooth-sdk-<sdkVersion>-version.json`.
343
+ Each published SDK version points at a durable ASG client APK and firmware
344
+ manifest that were built for that SDK release. Pre-wall-clock ASG builds that
345
+ ignore `ota_start.ota_version_url` are checked against the URL they advertise,
346
+ or the production default if they do not advertise one, so the app does not
347
+ prompt for an update the glasses cannot install.
341
348
 
342
349
  ```ts
343
350
  import BluetoothSdk from '@mentra/bluetooth-sdk'
@@ -346,9 +353,9 @@ BluetoothSdk.addListener('ota_status', (event) => {
346
353
  console.log(`OTA ${event.status}: ${event.overall_percent}%`)
347
354
  })
348
355
 
349
- const ota = await BluetoothSdk.checkForOtaUpdate()
350
- if (ota.type === 'ota_update_available') {
351
- const userAccepted = await promptUserToInstallUpdate(ota) // your app's UI
356
+ const hasUpdate = await BluetoothSdk.checkForOtaUpdate()
357
+ if (hasUpdate) {
358
+ const userAccepted = await promptUserToInstallUpdate() // your app's UI
352
359
  if (userAccepted) {
353
360
  const startAck = await BluetoothSdk.startOtaUpdate()
354
361
  console.log('OTA start acknowledged', startAck.timestamp)
@@ -425,7 +432,7 @@ export function HardwareEventLogger() {
425
432
 
426
433
  For non-React modules, `BluetoothSdk.addListener(...)` is the low-level subscription API. Keep the returned subscription and call `remove()` when the listener is no longer needed.
427
434
 
428
- Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `wifi_scan_result`, `hotspot_status_change`, `photo_status`, `photo_response`, `gallery_status`, `settings_ack`, `version_info`, `stream_status`, `ota_update_available`, `ota_start_ack`, `ota_status`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
435
+ Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `wifi_scan_result`, `hotspot_status_change`, `photo_status`, `photo_response`, `gallery_status`, `settings_ack`, `version_info`, `stream_status`, `ota_start_ack`, `ota_status`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
429
436
 
430
437
  React Native event payload fields usually use camelCase. OTA events intentionally mirror the glasses firmware field names, such as `overall_percent` and `version_name`. For example, `touch_event` includes `gestureName`, `wifi_scan_result` includes `networks` and `scanComplete`, `version_info` matches the `requestVersionInfo()` result shape, `photo_response` success includes `uploadUrl` and may include webhook-returned `photoUrl`, `statusUrl`, `contentType`, and `fileSizeBytes`, and `gallery_status` includes `hasContent`, `cameraBusy`, and optional `cameraBusyReason`. `photo_status` reports intermediate photo states such as `accepted`, `queued`, `configuring`, `capturing`, `captured`, `compressing`, `ble_fallback_compression`, `uploading`, `ready_for_transfer`, `transferring`, and `failed`; the `configuring` event includes `resolvedConfig` with the effective JPEG dimensions, quality, requested size, transfer method, compression, and manual exposure fields when present. The `capturing` event may include `requestedCaptureConfig` and `meteredPreview`; the `captured` event may include `captureMetadata` with the HAL-applied exposure, ISO, frame duration, and AE state. `mic_pcm` includes `sampleRate`, `bitsPerSample`, `channels`, and `encoding`; `mic_lc3` includes `sampleRate`, `channels`, `encoding`, `frameDurationMs`, `frameSizeBytes`, `bitrate`, and `packetizedFromGlasses`.
431
438
 
@@ -458,7 +465,7 @@ For bare native iOS apps, use the public SwiftPM repository:
458
465
  https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
459
466
  ```
460
467
 
461
- Add the `MentraBluetoothSDK` product to your app target at version `0.1.12` or newer.
468
+ Select version `0.1.13`, then add the `MentraBluetoothSDK` product to your app target.
462
469
 
463
470
  For local SDK development, add this package folder directly in Xcode:
464
471
 
@@ -441,8 +441,37 @@ class BluetoothSdkModule : Module() {
441
441
  sdk?.setVoiceActivityDetectionEnabled(enabled)
442
442
  }
443
443
 
444
- AsyncFunction("setButtonPhotoSettings") { size: String ->
445
- requireSdk().setButtonPhotoSettings(ButtonPhotoSize.fromValue(size)).values
444
+ AsyncFunction("setButtonPhotoCaptureSettings") { params: Map<String, Any?> ->
445
+ val size = (params["size"] as? String)?.let { ButtonPhotoSize.fromValue(it) }
446
+ val mfnr = params["mfnr"] as? Boolean
447
+ val zsl = params["zsl"] as? Boolean
448
+ val noiseReduction = params["noiseReduction"] as? Boolean
449
+ val edgeEnhancement = params["edgeEnhancement"] as? Boolean
450
+ val ispDigitalGain = (params["ispDigitalGain"] as? Number)?.toInt()
451
+ val ispAnalogGain = params["ispAnalogGain"] as? String
452
+ val aeExposureDivisor = (params["aeExposureDivisor"] as? Number)?.toInt()
453
+ val isoCap = (params["isoCap"] as? Number)?.toInt()
454
+ val compress = params["compress"] as? String
455
+ val sound = params["sound"] as? Boolean
456
+ val resetCaptureTuning = params["resetCaptureTuning"] as? Boolean == true
457
+ requireSdk()
458
+ .setButtonPhotoSettings(
459
+ ButtonPhotoSettings(
460
+ size = size,
461
+ mfnr = mfnr,
462
+ zsl = zsl,
463
+ noiseReduction = noiseReduction,
464
+ edgeEnhancement = edgeEnhancement,
465
+ ispDigitalGain = ispDigitalGain,
466
+ ispAnalogGain = ispAnalogGain,
467
+ aeExposureDivisor = aeExposureDivisor,
468
+ isoCap = isoCap,
469
+ compress = compress,
470
+ sound = sound,
471
+ resetCaptureTuning = resetCaptureTuning,
472
+ ),
473
+ )
474
+ .values
446
475
  }
447
476
 
448
477
  AsyncFunction("setButtonVideoRecordingSettings") { width: Int, height: Int, fps: Int ->
@@ -483,7 +512,26 @@ class BluetoothSdkModule : Module() {
483
512
 
484
513
  // MARK: - OTA Commands
485
514
 
486
- AsyncFunction("sendOtaStart") { requireSdk().sendOtaStart().values }
515
+ Function("setOtaVersionUrl") { otaVersionUrl: String ->
516
+ requireSdk().setOtaVersionUrl(otaVersionUrl)
517
+ }
518
+
519
+ Function("getOtaVersionUrl") { requireSdk().getOtaVersionUrl() }
520
+
521
+ // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
522
+ // manifest fetches and version waits can block for several seconds.
523
+ AsyncFunction("checkForOtaUpdate") Coroutine { ->
524
+ withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
525
+ }
526
+
527
+ AsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
528
+ val sdk = requireSdk()
529
+ if (otaVersionUrl.isNullOrBlank()) {
530
+ sdk.startOtaUpdate().values
531
+ } else {
532
+ sdk.startOtaUpdate(otaVersionUrl).values
533
+ }
534
+ }
487
535
 
488
536
  AsyncFunction("sendOtaQueryStatus") { requireSdk().sendOtaQueryStatus().values }
489
537
 
@@ -247,6 +247,7 @@ class DeviceManager {
247
247
  private var audioOutputFormat: AudioOutputFormat = AudioOutputFormat.LC3
248
248
  private var lastLc3Event: Long? = null
249
249
  private var micReinitRunnable: Runnable? = null
250
+ private var systemMicAvailabilityRecheckRunnable: Runnable? = null
250
251
 
251
252
  // VAD
252
253
  private val vadBuffer = mutableListOf<ByteArray>()
@@ -358,6 +359,37 @@ class DeviceManager {
358
359
  }
359
360
  }
360
361
 
362
+ private fun scheduleSystemMicAvailabilityRecheck(reason: String) {
363
+ if (systemMicAvailabilityRecheckRunnable != null) {
364
+ return
365
+ }
366
+
367
+ val recheck =
368
+ object : Runnable {
369
+ override fun run() {
370
+ systemMicAvailabilityRecheckRunnable = null
371
+
372
+ if (!micEnabled || !systemMicUnavailable) {
373
+ return
374
+ }
375
+
376
+ val stillBlocked = phoneMic?.hasBlockingMicInterruption() ?: true
377
+ if (stillBlocked) {
378
+ scheduleSystemMicAvailabilityRecheck(reason)
379
+ return
380
+ }
381
+
382
+ systemMicUnavailable = false
383
+ Bridge.log("MAN: MIC_UNAVAILABLE: FALSE recheck_after_$reason")
384
+ appendLog("MAN: MIC_UNAVAILABLE: FALSE recheck_after_$reason")
385
+ updateMicState()
386
+ }
387
+ }
388
+
389
+ systemMicAvailabilityRecheckRunnable = recheck
390
+ mainHandler.postDelayed(recheck, 2_000)
391
+ }
392
+
361
393
  // MARK: - Unique (Android)
362
394
  private fun setupPermissionMonitoring() {
363
395
  val context = Bridge.getContext()
@@ -947,6 +979,7 @@ class DeviceManager {
947
979
  systemMicUnavailable = true
948
980
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE external_app_recording")
949
981
  appendLog("MAN: MIC_UNAVAILABLE: TRUE external_app_recording")
982
+ scheduleSystemMicAvailabilityRecheck("external_app_recording")
950
983
  }
951
984
  "audio_focus_available" -> {
952
985
  // Audio focus is available again
@@ -965,6 +998,7 @@ class DeviceManager {
965
998
  systemMicUnavailable = true
966
999
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE phone_call_interruption")
967
1000
  appendLog("MAN: MIC_UNAVAILABLE: TRUE phone_call_interruption")
1001
+ scheduleSystemMicAvailabilityRecheck("phone_call_interruption")
968
1002
  }
969
1003
  "phone_call_ended" -> {
970
1004
  // Phone call ended - mark mic as available again
@@ -977,12 +1011,14 @@ class DeviceManager {
977
1011
  systemMicUnavailable = true
978
1012
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE phone_call_active")
979
1013
  appendLog("MAN: MIC_UNAVAILABLE: TRUE phone_call_active")
1014
+ scheduleSystemMicAvailabilityRecheck("phone_call_active")
980
1015
  }
981
1016
  "audio_focus_denied" -> {
982
1017
  // Another app has audio focus
983
1018
  systemMicUnavailable = true
984
1019
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE audio_focus_denied")
985
1020
  appendLog("MAN: MIC_UNAVAILABLE: TRUE audio_focus_denied")
1021
+ scheduleSystemMicAvailabilityRecheck("audio_focus_denied")
986
1022
  }
987
1023
  "permission_denied" -> {
988
1024
  // Microphone permission not granted
@@ -1010,6 +1046,9 @@ class DeviceManager {
1010
1046
  // systemMicUnavailable = false
1011
1047
  Bridge.log("MAN: MIC_UNAVAILABLE: UNKNOWN recording_stopped")
1012
1048
  appendLog("MAN: MIC_UNAVAILABLE: UNKNOWN recording_stopped")
1049
+ if (systemMicUnavailable) {
1050
+ scheduleSystemMicAvailabilityRecheck("recording_stopped")
1051
+ }
1013
1052
  }
1014
1053
  else -> {
1015
1054
  // Other route changes (headset plug/unplug, BT connect/disconnect, etc.)
@@ -1380,9 +1419,9 @@ class DeviceManager {
1380
1419
  * Send OTA start command to glasses. Called when user approves an update (onboarding or
1381
1420
  * background mode). Triggers glasses to begin download and installation.
1382
1421
  */
1383
- fun sendOtaStart() {
1422
+ fun sendOtaStart(otaVersionUrl: String? = null) {
1384
1423
  Bridge.log("MAN: 📱 Sending OTA start command to glasses")
1385
- (sgc as? MentraLive)?.sendOtaStart()
1424
+ (sgc as? MentraLive)?.sendOtaStart(otaVersionUrl)
1386
1425
  }
1387
1426
 
1388
1427
  fun sendOtaQueryStatus() {
@@ -1396,8 +1435,26 @@ class DeviceManager {
1396
1435
  }
1397
1436
 
1398
1437
  fun sendButtonPhotoSettings(requestId: String, size: String) {
1438
+ sendButtonPhotoSettings(requestId, ButtonPhotoSettings(ButtonPhotoSize.fromValue(size)))
1439
+ }
1440
+
1441
+ fun sendButtonPhotoSettings(requestId: String, settings: ButtonPhotoSettings) {
1399
1442
  val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1400
- live.sendButtonPhotoSettings(requestId, size)
1443
+ live.sendButtonPhotoSettings(
1444
+ requestId,
1445
+ settings.size?.value,
1446
+ settings.mfnr,
1447
+ settings.zsl,
1448
+ settings.noiseReduction,
1449
+ settings.edgeEnhancement,
1450
+ settings.ispDigitalGain,
1451
+ settings.ispAnalogGain,
1452
+ settings.aeExposureDivisor,
1453
+ settings.isoCap,
1454
+ settings.compress,
1455
+ settings.sound,
1456
+ settings.resetCaptureTuning == true,
1457
+ )
1401
1458
  }
1402
1459
 
1403
1460
  fun sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) {
@@ -1538,38 +1595,31 @@ class DeviceManager {
1538
1595
  updateMicState()
1539
1596
  }
1540
1597
 
1541
- fun requestPhoto(
1542
- requestId: String,
1543
- appId: String,
1544
- size: String,
1545
- webhookUrl: String,
1546
- authToken: String?,
1547
- compress: String,
1548
- flash: Boolean,
1549
- save: Boolean,
1550
- sound: Boolean,
1551
- exposureTimeNs: Double? = null,
1552
- iso: Int? = null,
1553
- ) {
1598
+ fun requestPhoto(request: PhotoRequest) {
1554
1599
  val exposureNs: Long? =
1555
- exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
1600
+ request.exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
1556
1601
  when {
1557
1602
  v > Long.MAX_VALUE.toDouble() -> Long.MAX_VALUE
1558
1603
  else -> v.toLong()
1559
1604
  }
1560
1605
  }
1561
- val manualIso = if (exposureNs != null) iso?.takeIf { it > 0 } else null
1606
+ val manualIso = if (exposureNs != null) request.iso?.takeIf { it > 0 } else null
1607
+ val routed =
1608
+ request.copy(
1609
+ exposureTimeNs = exposureNs?.toDouble(),
1610
+ iso = manualIso,
1611
+ )
1562
1612
  Bridge.log(
1563
- "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash save=$save sound=$sound exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1613
+ "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=${routed.requestId} appId=${routed.appId} size=${routed.size.value} compress=${routed.compress.value} flash=${routed.flash} save=${routed.save} sound=${routed.sound} exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} aeDivisor=${routed.aeExposureDivisor} isoCap=${routed.isoCap} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1564
1614
  )
1565
1615
  val activeSgc = sgc
1566
1616
  if (activeSgc == null) {
1567
1617
  Bridge.log(
1568
- "MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=$requestId"
1618
+ "MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=${routed.requestId}"
1569
1619
  )
1570
1620
  return
1571
1621
  }
1572
- activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, save, sound, exposureNs, manualIso)
1622
+ activeSgc.requestPhoto(routed)
1573
1623
  }
1574
1624
 
1575
1625
  fun rgbLedControl(
@@ -1781,6 +1831,8 @@ class DeviceManager {
1781
1831
 
1782
1832
  micReinitRunnable?.let { mainHandler.removeCallbacks(it) }
1783
1833
  micReinitRunnable = null
1834
+ systemMicAvailabilityRecheckRunnable?.let { mainHandler.removeCallbacks(it) }
1835
+ systemMicAvailabilityRecheckRunnable = null
1784
1836
 
1785
1837
  // Clean up transcriber resources
1786
1838
  transcriber?.shutdown()
@@ -126,7 +126,7 @@ object DeviceStore {
126
126
  store.set("bluetooth", "gallery_mode", true)
127
127
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
128
128
  store.set("bluetooth", "screen_disabled", false)
129
- store.set("bluetooth", "button_photo_size", "medium")
129
+ store.set("bluetooth", "button_photo_size", "max")
130
130
  store.set("bluetooth", "button_camera_led", true)
131
131
  store.set("bluetooth", "button_max_recording_time", 10)
132
132
  store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
@@ -45,6 +45,7 @@ class MentraBluetoothSdk private constructor(
45
45
  private var pendingWifiStatus: PendingWifiStatusRequest? = null
46
46
  private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
47
47
  private var pendingVersionInfo: PendingResponse<VersionInfoResult>? = null
48
+ @Volatile private var configuredOtaVersionUrl: String? = null
48
49
 
49
50
  init {
50
51
  listeners.add(listener)
@@ -68,6 +69,9 @@ class MentraBluetoothSdk private constructor(
68
69
  private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
69
70
  private const val STREAM_START_TIMEOUT_MS = 30_000L
70
71
  private const val STREAM_STOP_TIMEOUT_MS = 15_000L
72
+ private const val OTA_BES_VERSION_WAIT_MS = 5_000L
73
+ private const val OTA_MTK_VERSION_WAIT_MS = 2_000L
74
+ private const val OTA_VERSION_POLL_MS = 100L
71
75
  private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
72
76
  private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
73
77
 
@@ -470,16 +474,59 @@ class MentraBluetoothSdk private constructor(
470
474
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
471
475
  }
472
476
 
473
- fun setButtonPhotoSettings(size: ButtonPhotoSize): SettingsAckEvent =
477
+ fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
474
478
  performSettingsCommand(
475
479
  setting = "button_photo",
476
- updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", size.value) },
477
- send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, size.value) },
480
+ updateStore = { _ ->
481
+ if (settings.resetCaptureTuning) {
482
+ // Clear all stored scan-tuning keys from phone cache
483
+ listOf(
484
+ "button_photo_mfnr",
485
+ "button_photo_zsl",
486
+ "button_photo_noise_reduction",
487
+ "button_photo_edge_enhancement",
488
+ "button_photo_isp_digital_gain",
489
+ "button_photo_isp_analog_gain",
490
+ "button_photo_ae_exposure_divisor",
491
+ "button_photo_iso_cap",
492
+ "button_photo_compress",
493
+ "button_photo_sound",
494
+ ).forEach { key ->
495
+ DeviceStore.store.remove(ObservableStore.BLUETOOTH_CATEGORY, key)
496
+ }
497
+ }
498
+ // Only update size if explicitly provided; omitted size = leave stored value unchanged
499
+ settings.size?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", it.value) }
500
+ settings.mfnr?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_mfnr", it) }
501
+ settings.zsl?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_zsl", it) }
502
+ settings.noiseReduction?.let {
503
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_noise_reduction", it)
504
+ }
505
+ settings.edgeEnhancement?.let {
506
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_edge_enhancement", it)
507
+ }
508
+ settings.ispDigitalGain?.let {
509
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_digital_gain", it)
510
+ }
511
+ settings.ispAnalogGain?.let {
512
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_analog_gain", it)
513
+ }
514
+ settings.aeExposureDivisor?.let {
515
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_ae_exposure_divisor", it)
516
+ }
517
+ settings.isoCap?.let {
518
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_iso_cap", it)
519
+ }
520
+ settings.compress?.let {
521
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it)
522
+ }
523
+ settings.sound?.let {
524
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_sound", it)
525
+ }
526
+ },
527
+ send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
478
528
  )
479
529
 
480
- fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
481
- setButtonPhotoSettings(size = settings.size)
482
-
483
530
  fun setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int): SettingsAckEvent =
484
531
  performSettingsCommand(
485
532
  setting = "button_video_recording",
@@ -677,19 +724,7 @@ class MentraBluetoothSdk private constructor(
677
724
  val pending = PendingResponse<PhotoResponseEvent>("photo request ${request.requestId}")
678
725
  pendingPhotoRequests[request.requestId] = pending
679
726
  try {
680
- deviceManager.requestPhoto(
681
- request.requestId,
682
- request.appId,
683
- request.size.value,
684
- request.webhookUrl,
685
- request.authToken,
686
- request.compress.value,
687
- request.flash,
688
- request.save,
689
- request.sound,
690
- request.exposureTimeNs,
691
- request.iso,
692
- )
727
+ deviceManager.requestPhoto(request)
693
728
  return pending.await()
694
729
  } finally {
695
730
  pendingPhotoRequests.remove(request.requestId, pending)
@@ -871,8 +906,41 @@ class MentraBluetoothSdk private constructor(
871
906
  }
872
907
  }
873
908
 
874
- /** Ask connected Mentra Live glasses to check/report OTA availability and status. */
875
- fun checkForOtaUpdate(): OtaQueryResult =
909
+ internal fun setOtaVersionUrl(otaVersionUrl: String) {
910
+ configuredOtaVersionUrl = OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
911
+ }
912
+
913
+ internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
914
+
915
+ /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
916
+ fun checkForOtaUpdate(): Boolean {
917
+ val status = getFreshGlassesStatus()
918
+ if (!status.connected) {
919
+ throw BluetoothException(
920
+ "glasses_not_connected",
921
+ "Cannot check OTA update because glasses are not connected.",
922
+ )
923
+ }
924
+ if (status.buildNumber.isBlank()) {
925
+ throw BluetoothException(
926
+ "missing_glasses_version",
927
+ "Cannot check OTA update because glasses build number is unavailable.",
928
+ )
929
+ }
930
+
931
+ val manifestUrl = resolveOtaVersionUrl(status)
932
+ val manifest = OtaManifestChecker.fetch(manifestUrl)
933
+ val otaStatus = waitForOtaManifestStatus(status, manifest)
934
+ return OtaManifestChecker.hasUpdate(
935
+ otaStatus.buildNumber,
936
+ otaStatus.mtkFirmwareVersion,
937
+ otaStatus.besFirmwareVersion,
938
+ manifest,
939
+ )
940
+ }
941
+
942
+ /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
943
+ private fun queryOtaStatus(): OtaQueryResult =
876
944
  performOtaQuery("OTA status query") {
877
945
  deviceManager.sendOtaQueryStatus()
878
946
  }
@@ -905,6 +973,11 @@ class MentraBluetoothSdk private constructor(
905
973
 
906
974
  /** Start the OTA flow after your app has presented the available update to the user. */
907
975
  fun startOtaUpdate(): OtaStartAckEvent {
976
+ val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
977
+ return startOtaUpdate(otaVersionUrl)
978
+ }
979
+
980
+ private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
908
981
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
909
982
  synchronized(oneShotLock) {
910
983
  if (pendingOtaStart != null) {
@@ -916,7 +989,7 @@ class MentraBluetoothSdk private constructor(
916
989
  pendingOtaStart = pending
917
990
  }
918
991
  try {
919
- deviceManager.sendOtaStart()
992
+ deviceManager.sendOtaStart(otaVersionUrl)
920
993
  return pending.await()
921
994
  } finally {
922
995
  synchronized(oneShotLock) {
@@ -927,15 +1000,98 @@ class MentraBluetoothSdk private constructor(
927
1000
  }
928
1001
  }
929
1002
 
930
- /** Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures. */
931
- fun retryOtaVersionCheck(): OtaQueryResult =
1003
+ internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1004
+ startOtaCommand(otaVersionUrl)
1005
+
1006
+ internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1007
+
1008
+ /** Re-run the glasses-side OTA version check after an internal clock-skew recovery. */
1009
+ @JvmSynthetic
1010
+ internal fun retryOtaVersionCheck(): OtaQueryResult =
932
1011
  performOtaQuery("OTA version retry") {
933
1012
  deviceManager.retryOtaVersionCheck()
934
1013
  }
935
1014
 
936
- internal fun sendOtaStart(): OtaStartAckEvent = startOtaUpdate()
1015
+ private fun getFreshGlassesStatus(): GlassesStatus {
1016
+ val status = getRawGlassesStatus()
1017
+ if (!status.connected || status.buildNumber.isNotBlank()) {
1018
+ return status
1019
+ }
1020
+
1021
+ return try {
1022
+ val versionInfo = requestVersionInfo()
1023
+ status.copy(
1024
+ androidVersion = versionInfo.androidVersion.ifBlank { status.androidVersion },
1025
+ firmwareVersion = versionInfo.firmwareVersion.ifBlank { status.firmwareVersion },
1026
+ besFirmwareVersion = versionInfo.besFirmwareVersion.ifBlank { status.besFirmwareVersion },
1027
+ mtkFirmwareVersion = versionInfo.mtkFirmwareVersion.ifBlank { status.mtkFirmwareVersion },
1028
+ buildNumber = versionInfo.buildNumber.ifBlank { status.buildNumber },
1029
+ systemTimeMs = versionInfo.systemTimeMs ?: status.systemTimeMs,
1030
+ otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1031
+ appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1032
+ )
1033
+ } catch (_: Throwable) {
1034
+ status
1035
+ }
1036
+ }
1037
+
1038
+ private fun waitForOtaManifestStatus(
1039
+ initialStatus: GlassesStatus,
1040
+ manifest: org.json.JSONObject,
1041
+ ): GlassesStatus {
1042
+ var status = initialStatus
1043
+ if (OtaManifestChecker.hasBesFirmware(manifest) && status.besFirmwareVersion.isBlank()) {
1044
+ status = waitForGlassesStatus(status, OTA_BES_VERSION_WAIT_MS) {
1045
+ !it.connected || it.besFirmwareVersion.isNotBlank()
1046
+ }
1047
+ }
1048
+
1049
+ if (OtaManifestChecker.hasMtkPatches(manifest) && status.mtkFirmwareVersion.isBlank()) {
1050
+ status = waitForGlassesStatus(status, OTA_MTK_VERSION_WAIT_MS) {
1051
+ !it.connected || it.mtkFirmwareVersion.isNotBlank()
1052
+ }
1053
+ }
1054
+
1055
+ if (!status.connected) {
1056
+ throw BluetoothException(
1057
+ "glasses_not_connected",
1058
+ "Cannot check OTA update because glasses disconnected.",
1059
+ )
1060
+ }
1061
+ return status
1062
+ }
937
1063
 
938
- internal fun sendOtaQueryStatus(): OtaQueryResult = checkForOtaUpdate()
1064
+ private fun waitForGlassesStatus(
1065
+ initialStatus: GlassesStatus,
1066
+ timeoutMs: Long,
1067
+ isReady: (GlassesStatus) -> Boolean,
1068
+ ): GlassesStatus {
1069
+ val deadline = System.currentTimeMillis() + timeoutMs
1070
+ var status = initialStatus
1071
+ while (System.currentTimeMillis() < deadline) {
1072
+ status = getRawGlassesStatus()
1073
+ if (isReady(status)) return status
1074
+ val remainingMs = deadline - System.currentTimeMillis()
1075
+ if (remainingMs <= 0L) break
1076
+ Thread.sleep(minOf(OTA_VERSION_POLL_MS, remainingMs))
1077
+ }
1078
+ return getRawGlassesStatus()
1079
+ }
1080
+
1081
+ private fun resolveOtaVersionUrl(status: GlassesStatus): String {
1082
+ val deviceUrl = status.otaVersionUrl.trim()
1083
+ if (isLegacyAsgOtaStartBuild(status.buildNumber)) {
1084
+ return deviceUrl.ifBlank { OtaManifestDefaults.PROD_OTA_VERSION_URL }
1085
+ }
1086
+ // SDK consumers are pinned to the manifest built for their SDK version.
1087
+ // A future glasses-advertised URL should not silently change that pairing.
1088
+ return configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1089
+ }
1090
+
1091
+ private fun isLegacyAsgOtaStartBuild(buildNumber: String): Boolean {
1092
+ val parsed = buildNumber.toIntOrNull()
1093
+ return parsed != null && parsed < 100_000
1094
+ }
939
1095
 
940
1096
  internal fun sendShutdown() {
941
1097
  deviceManager.sendShutdown()
@@ -1333,6 +1489,14 @@ class MentraBluetoothSdk private constructor(
1333
1489
  return streamId to pending
1334
1490
  }
1335
1491
  if (pendingStreamStarts.size == 1) {
1492
+ // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1493
+ // stream (their stop ack carries no streamId), not a verdict on the
1494
+ // pending start — a start_stream that replaces a running publisher
1495
+ // emits exactly this sequence (stopped -> initializing -> streaming).
1496
+ // Attributing it here would reject a start that is about to succeed.
1497
+ if (event.state == StreamState.STOPPED) {
1498
+ return null
1499
+ }
1336
1500
  val entry = pendingStreamStarts.entries.first()
1337
1501
  return entry.key to entry.value
1338
1502
  }
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ object MentraBluetoothSdkDebug {
4
+ @JvmStatic
5
+ fun setOtaVersionUrl(
6
+ sdk: MentraBluetoothSdk,
7
+ otaVersionUrl: String,
8
+ ) {
9
+ sdk.setOtaVersionUrl(otaVersionUrl)
10
+ }
11
+
12
+ @JvmStatic
13
+ fun getOtaVersionUrl(sdk: MentraBluetoothSdk): String = sdk.getOtaVersionUrl()
14
+ }
@@ -53,6 +53,26 @@ class ObservableStore {
53
53
  listeners.forEach { it(normalizedCategory, mapOf(key to value)) }
54
54
  }
55
55
 
56
+ fun remove(category: String, key: String) {
57
+ val normalizedCategory: String
58
+ val updatedSnapshot: Map<String, Any>
59
+ val listeners: List<(String, Map<String, Any>) -> Unit>
60
+
61
+ synchronized(this) {
62
+ normalizedCategory = normalizeCategory(category)
63
+ val fullKey = "$normalizedCategory.$key"
64
+ if (!values.containsKey(fullKey)) return // already absent, no notification needed
65
+ values.remove(fullKey)
66
+ val prefix = "$normalizedCategory."
67
+ updatedSnapshot = values.filterKeys { it.startsWith(prefix) }
68
+ .mapKeys { it.key.removePrefix(prefix) }
69
+ listeners = emitListeners.values.toList()
70
+ }
71
+
72
+ // Emit the full updated category snapshot so UI listeners can clear the removed key.
73
+ listeners.forEach { it(normalizedCategory, updatedSnapshot) }
74
+ }
75
+
56
76
  @Synchronized
57
77
  fun get(category: String, key: String): Any? = values["${normalizeCategory(category)}.$key"]
58
78