@mentra/bluetooth-sdk 0.1.12 → 0.1.14

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 (69) hide show
  1. package/README.md +30 -11
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +47 -12
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +75 -29
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +208 -45
  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 +80 -14
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +4 -16
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +4 -8
  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 +3831 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -28
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +514 -561
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8674 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +5 -17
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +9 -20
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/build/BluetoothSdk.types.d.ts +48 -17
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_private/BluetoothSdkModule.d.ts +9 -9
  26. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  27. package/build/_private/BluetoothSdkModule.js +0 -6
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/_private/photoRequestPayload.d.ts +3 -1
  30. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  31. package/build/_private/photoRequestPayload.js +46 -2
  32. package/build/_private/photoRequestPayload.js.map +1 -1
  33. package/build/debug.d.ts +3 -0
  34. package/build/debug.d.ts.map +1 -0
  35. package/build/debug.js +8 -0
  36. package/build/debug.js.map +1 -0
  37. package/build/index.d.ts +1 -1
  38. package/build/index.d.ts.map +1 -1
  39. package/build/index.js +8 -8
  40. package/build/index.js.map +1 -1
  41. package/ios/BluetoothSdkModule.swift +44 -68
  42. package/ios/Source/BluetoothSdkDefaults.swift +23 -3
  43. package/ios/Source/DeviceManager.swift +38 -33
  44. package/ios/Source/DeviceStore.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +217 -50
  46. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  47. package/ios/Source/ObservableStore.swift +11 -0
  48. package/ios/Source/OtaManifest.swift +170 -0
  49. package/ios/Source/camera/CameraModels.swift +219 -13
  50. package/ios/Source/controllers/ControllerManager.swift +3 -7
  51. package/ios/Source/controllers/R1.swift +3 -7
  52. package/ios/Source/sgcs/Frame.swift +0 -2
  53. package/ios/Source/sgcs/G1.swift +7 -8
  54. package/ios/Source/sgcs/G2.swift +21 -20
  55. package/ios/Source/sgcs/Mach1.swift +7 -8
  56. package/ios/Source/sgcs/MentraLive.swift +132 -63
  57. package/ios/Source/sgcs/MentraNex.swift +7 -8
  58. package/ios/Source/sgcs/SGCManager.swift +7 -10
  59. package/ios/Source/sgcs/Simulated.swift +8 -8
  60. package/ios/Source/status/DeviceStatus.swift +1 -9
  61. package/ios/Source/streaming/StreamModels.swift +7 -32
  62. package/package.json +6 -1
  63. package/src/BluetoothSdk.types.ts +49 -17
  64. package/src/_private/BluetoothSdkModule.ts +9 -17
  65. package/src/_private/photoRequestPayload.ts +48 -3
  66. package/src/debug.ts +9 -0
  67. package/src/index.ts +19 -10
  68. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  69. 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)
@@ -398,8 +405,8 @@ await BluetoothSdk.startStream({
398
405
  await BluetoothSdk.stopStream()
399
406
  ```
400
407
 
401
- Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https://` for WHIP/WebRTC ingest. `startStream()` resolves with the correlated `stream_status` event once the glasses report `status: "streaming"`; `stopStream()` resolves when the glasses report `status: "stopped"` or confirms the stream was already stopped / not streaming. `stopStream()` returns a normalized stopped event for that already-stopped case. Stream starts reject if the glasses report an error before streaming; stream stops reject for real stop errors, send failure, another stop in flight, or timeout. The SDK sends stream keep-alives automatically while streaming and reports keep-alive failures through `stream_status`. The camera light is always enabled while streaming.
402
- `stream_status` events may include `resolvedConfig`, which reports the effective transport, video, and audio settings after glasses defaults, clamps, and camera preflight.
408
+ Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https://` for WHIP/WebRTC ingest. `startStream()` resolves with the correlated `stream_status` event once the glasses report `status: "streaming"`; `stopStream()` resolves when the glasses report `status: "stopped"` or confirms the stream was already stopped / not streaming. `stopStream()` returns a normalized stopped event for that already-stopped case. Stream starts reject if the glasses report an error before streaming; stream stops reject for real stop errors, send failure, another stop in flight, or timeout. Stream video input fields are `width`, `height`, `bitrate`, and `fps`. The SDK sends stream keep-alives automatically while streaming and reports keep-alive failures through `stream_status`. The camera light is always enabled while streaming.
409
+ `stream_status` events may include `resolvedConfig`, which reports the effective transport, video, and audio settings after glasses defaults, clamps, and camera preflight. The resolved effective frame rate is reported as `resolvedConfig.video.fps`.
403
410
 
404
411
  ## Events
405
412
 
@@ -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
 
@@ -452,13 +459,25 @@ MENTRA_BLUETOOTH_SDK_PACKAGE_PATH=/path/to/MentraOS/mobile/modules/bluetooth-sdk
452
459
 
453
460
  Use `bunx expo run:android` for Android. Keep local paths in your shell or CI environment, not in committed app config.
454
461
 
462
+ For local Android source compile checks inside this monorepo, run from the
463
+ MentraOS repo root:
464
+
465
+ ```sh
466
+ ./scripts/check-android-compile.sh bluetooth-sdk
467
+ ```
468
+
469
+ The `android/` folder in this package is source for the generated Expo Android
470
+ project, not the local Gradle entrypoint. The check script prepares
471
+ `mobile/android` and uses its Gradle wrapper with `-PmentraPublicSdk=true`,
472
+ matching the CI release workflow's public SDK dependency mode.
473
+
455
474
  For bare native iOS apps, use the public SwiftPM repository:
456
475
 
457
476
  ```text
458
477
  https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
459
478
  ```
460
479
 
461
- Add the `MentraBluetoothSDK` product to your app target at version `0.1.12` or newer.
480
+ Select version `0.1.14`, then add the `MentraBluetoothSDK` product to your app target.
462
481
 
463
482
  For local SDK development, add this package folder directly in Xcode:
464
483
 
@@ -441,20 +441,16 @@ 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("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
445
+ requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
446
446
  }
447
447
 
448
- AsyncFunction("setButtonVideoRecordingSettings") { width: Int, height: Int, fps: Int ->
449
- requireSdk().setButtonVideoRecordingSettings(width, height, fps).values
448
+ AsyncFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
449
+ requireSdk().setVideoRecordingDefaults(VideoRecordingDefaults(width, height, fps)).values
450
450
  }
451
451
 
452
- AsyncFunction("setButtonCameraLed") { enabled: Boolean ->
453
- requireSdk().setButtonCameraLed(enabled).values
454
- }
455
-
456
- AsyncFunction("setButtonMaxRecordingTime") { minutes: Int ->
457
- requireSdk().setButtonMaxRecordingTime(minutes).values
452
+ AsyncFunction("setMaxVideoRecordingDuration") { minutes: Int ->
453
+ requireSdk().setMaxVideoRecordingDuration(minutes).values
458
454
  }
459
455
 
460
456
  AsyncFunction("setCameraFov") { fov: Map<String, Any> ->
@@ -476,14 +472,33 @@ class BluetoothSdkModule : Module() {
476
472
  }.toMap()
477
473
  val req = PhotoRequest.fromMap(sanitized)
478
474
  Bridge.log(
479
- "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} appId=${req.appId} size=${req.size} compress=${req.compress} flash=${req.flash} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
475
+ "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} appId=${req.appId} size=${req.size} compress=${req.compress} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
480
476
  )
481
477
  requireSdk().requestPhoto(req).values
482
478
  }
483
479
 
484
480
  // MARK: - OTA Commands
485
481
 
486
- AsyncFunction("sendOtaStart") { requireSdk().sendOtaStart().values }
482
+ Function("setOtaVersionUrl") { otaVersionUrl: String ->
483
+ requireSdk().setOtaVersionUrl(otaVersionUrl)
484
+ }
485
+
486
+ Function("getOtaVersionUrl") { requireSdk().getOtaVersionUrl() }
487
+
488
+ // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
489
+ // manifest fetches and version waits can block for several seconds.
490
+ AsyncFunction("checkForOtaUpdate") Coroutine { ->
491
+ withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
492
+ }
493
+
494
+ AsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
495
+ val sdk = requireSdk()
496
+ if (otaVersionUrl.isNullOrBlank()) {
497
+ sdk.startOtaUpdate().values
498
+ } else {
499
+ sdk.startOtaUpdate(otaVersionUrl).values
500
+ }
501
+ }
487
502
 
488
503
  AsyncFunction("sendOtaQueryStatus") { requireSdk().sendOtaQueryStatus().values }
489
504
 
@@ -538,6 +553,10 @@ class BluetoothSdkModule : Module() {
538
553
  requireSdk().startStream(StreamRequest.fromMap(params)).values
539
554
  }
540
555
 
556
+ AsyncFunction("startExternallyManagedStream") { params: Map<String, Any> ->
557
+ requireSdk().startExternallyManagedStream(StreamRequest.fromMap(params)).values
558
+ }
559
+
541
560
  AsyncFunction("stopStream") { requireSdk().stopStream().values }
542
561
 
543
562
  AsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
@@ -727,6 +746,22 @@ private fun Map<String, Any>?.toMentraDevice(): Device? {
727
746
  )
728
747
  }
729
748
 
749
+ private fun Map<String, Any?>.toPhotoCaptureDefaults(): PhotoCaptureDefaults =
750
+ PhotoCaptureDefaults(
751
+ size = (this["size"] as? String)?.let { PhotoSize.fromValue(it) },
752
+ mfnr = this["mfnr"] as? Boolean,
753
+ zsl = this["zsl"] as? Boolean,
754
+ noiseReduction = this["noiseReduction"] as? Boolean,
755
+ edgeEnhancement = this["edgeEnhancement"] as? Boolean,
756
+ ispDigitalGain = (this["ispDigitalGain"] as? Number)?.toInt(),
757
+ ispAnalogGain = this["ispAnalogGain"] as? String,
758
+ aeExposureDivisor = (this["aeExposureDivisor"] as? Number)?.toInt(),
759
+ isoCap = (this["isoCap"] as? Number)?.toInt(),
760
+ compress = this["compress"] as? String,
761
+ sound = this["sound"] as? Boolean,
762
+ resetCaptureTuning = this["resetCaptureTuning"] as? Boolean == true,
763
+ )
764
+
730
765
  private fun Map<String, Any>?.toMentraConnectOptions(): ConnectOptions {
731
766
  val values = this ?: return ConnectOptions()
732
767
  return ConnectOptions(
@@ -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.)
@@ -1326,7 +1365,6 @@ class DeviceManager {
1326
1365
 
1327
1366
  fun startStream(message: MutableMap<String, Any>) {
1328
1367
  Bridge.log("MAN: startStream")
1329
- message["flash"] = true
1330
1368
  sgc?.startStream(message)
1331
1369
  }
1332
1370
 
@@ -1380,9 +1418,9 @@ class DeviceManager {
1380
1418
  * Send OTA start command to glasses. Called when user approves an update (onboarding or
1381
1419
  * background mode). Triggers glasses to begin download and installation.
1382
1420
  */
1383
- fun sendOtaStart() {
1421
+ fun sendOtaStart(otaVersionUrl: String? = null) {
1384
1422
  Bridge.log("MAN: 📱 Sending OTA start command to glasses")
1385
- (sgc as? MentraLive)?.sendOtaStart()
1423
+ (sgc as? MentraLive)?.sendOtaStart(otaVersionUrl)
1386
1424
  }
1387
1425
 
1388
1426
  fun sendOtaQueryStatus() {
@@ -1396,18 +1434,31 @@ class DeviceManager {
1396
1434
  }
1397
1435
 
1398
1436
  fun sendButtonPhotoSettings(requestId: String, size: String) {
1399
- val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1400
- live.sendButtonPhotoSettings(requestId, size)
1437
+ sendButtonPhotoSettings(requestId, PhotoCaptureDefaults(PhotoSize.fromValue(size)))
1401
1438
  }
1402
1439
 
1403
- fun sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) {
1440
+ fun sendButtonPhotoSettings(requestId: String, settings: PhotoCaptureDefaults) {
1404
1441
  val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1405
- live.sendButtonVideoRecordingSettings(requestId, width, height, fps)
1442
+ live.sendButtonPhotoSettings(
1443
+ requestId,
1444
+ settings.size?.value,
1445
+ settings.mfnr,
1446
+ settings.zsl,
1447
+ settings.noiseReduction,
1448
+ settings.edgeEnhancement,
1449
+ settings.ispDigitalGain,
1450
+ settings.ispAnalogGain,
1451
+ settings.aeExposureDivisor,
1452
+ settings.isoCap,
1453
+ settings.compress,
1454
+ settings.sound,
1455
+ settings.resetCaptureTuning == true,
1456
+ )
1406
1457
  }
1407
1458
 
1408
- fun sendButtonCameraLedSetting(requestId: String, enabled: Boolean) {
1459
+ fun sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) {
1409
1460
  val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1410
- live.sendButtonCameraLedSetting(requestId, enabled)
1461
+ live.sendButtonVideoRecordingSettings(requestId, width, height, fps)
1411
1462
  }
1412
1463
 
1413
1464
  fun sendButtonMaxRecordingTime(requestId: String, minutes: Int) {
@@ -1508,11 +1559,11 @@ class DeviceManager {
1508
1559
  maxRecordingTimeMinutes: Int = 0,
1509
1560
  ) {
1510
1561
  Bridge.log(
1511
- "MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound, " +
1562
+ "MAN: onStartVideoRecording: requestId=$requestId, save=$save, sound=$sound, " +
1512
1563
  "resolution=${width}x${height}@${fps}fps, maxRecordingTimeMinutes=$maxRecordingTimeMinutes"
1513
1564
  )
1514
1565
  sgc?.startVideoRecording(
1515
- requestId, save, true, sound, width, height, fps, maxRecordingTimeMinutes)
1566
+ requestId, save, sound, width, height, fps, maxRecordingTimeMinutes)
1516
1567
  }
1517
1568
 
1518
1569
  fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
@@ -1538,38 +1589,31 @@ class DeviceManager {
1538
1589
  updateMicState()
1539
1590
  }
1540
1591
 
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
- ) {
1592
+ fun requestPhoto(request: PhotoRequest) {
1554
1593
  val exposureNs: Long? =
1555
- exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
1594
+ request.exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
1556
1595
  when {
1557
1596
  v > Long.MAX_VALUE.toDouble() -> Long.MAX_VALUE
1558
1597
  else -> v.toLong()
1559
1598
  }
1560
1599
  }
1561
- val manualIso = if (exposureNs != null) iso?.takeIf { it > 0 } else null
1600
+ val manualIso = if (exposureNs != null) request.iso?.takeIf { it > 0 } else null
1601
+ val routed =
1602
+ request.copy(
1603
+ exposureTimeNs = exposureNs?.toDouble(),
1604
+ iso = manualIso,
1605
+ )
1562
1606
  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"}"
1607
+ "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=${routed.requestId} appId=${routed.appId} 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"}"
1564
1608
  )
1565
1609
  val activeSgc = sgc
1566
1610
  if (activeSgc == null) {
1567
1611
  Bridge.log(
1568
- "MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=$requestId"
1612
+ "MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=${routed.requestId}"
1569
1613
  )
1570
1614
  return
1571
1615
  }
1572
- activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, save, sound, exposureNs, manualIso)
1616
+ activeSgc.requestPhoto(routed)
1573
1617
  }
1574
1618
 
1575
1619
  fun rgbLedControl(
@@ -1781,6 +1825,8 @@ class DeviceManager {
1781
1825
 
1782
1826
  micReinitRunnable?.let { mainHandler.removeCallbacks(it) }
1783
1827
  micReinitRunnable = null
1828
+ systemMicAvailabilityRecheckRunnable?.let { mainHandler.removeCallbacks(it) }
1829
+ systemMicAvailabilityRecheckRunnable = null
1784
1830
 
1785
1831
  // Clean up transcriber resources
1786
1832
  transcriber?.shutdown()
@@ -126,8 +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")
130
- store.set("bluetooth", "button_camera_led", true)
129
+ store.set("bluetooth", "button_photo_size", "max")
131
130
  store.set("bluetooth", "button_max_recording_time", 10)
132
131
  store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
133
132
  store.set("bluetooth", "button_video_width", 1280)
@@ -287,9 +286,6 @@ object DeviceStore {
287
286
  "bluetooth" to "button_photo_size" -> {
288
287
  DeviceManager.getInstance().sgc?.sendButtonPhotoSettings()
289
288
  }
290
- "bluetooth" to "button_camera_led" -> {
291
- DeviceManager.getInstance().sgc?.sendButtonCameraLedSetting()
292
- }
293
289
  "bluetooth" to "button_max_recording_time" -> {
294
290
  DeviceManager.getInstance().sgc?.sendButtonMaxRecordingTime()
295
291
  }