@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.0

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 (73) hide show
  1. package/README.md +5 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  19. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
  22. package/build/BluetoothSdk.types.d.ts +46 -1
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_internal.d.ts +2 -0
  26. package/build/_internal.d.ts.map +1 -1
  27. package/build/_internal.js +2 -0
  28. package/build/_internal.js.map +1 -1
  29. package/build/_private/BluetoothSdkModule.d.ts +23 -2
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +20 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  34. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  35. package/build/_private/MentraLocalNetworkModule.js +3 -0
  36. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  37. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  38. package/build/_private/cameraRequestPayload.js +1 -0
  39. package/build/_private/cameraRequestPayload.js.map +1 -1
  40. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  41. package/build/_private/photoRequestPayload.js +1 -0
  42. package/build/_private/photoRequestPayload.js.map +1 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +4 -0
  45. package/build/index.js.map +1 -1
  46. package/expo-module.config.json +1 -0
  47. package/ios/BluetoothSdkModule.swift +72 -0
  48. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  49. package/ios/Source/Bridge.swift +72 -18
  50. package/ios/Source/DeviceManager.swift +51 -3
  51. package/ios/Source/DeviceStore.swift +1 -1
  52. package/ios/Source/MentraBluetoothSDK.swift +289 -25
  53. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  54. package/ios/Source/camera/CameraModels.swift +8 -3
  55. package/ios/Source/sgcs/G1.swift +1 -1
  56. package/ios/Source/sgcs/G2.swift +19 -7
  57. package/ios/Source/sgcs/Mach1.swift +1 -1
  58. package/ios/Source/sgcs/MentraLive.swift +318 -37
  59. package/ios/Source/sgcs/MentraNex.swift +1 -1
  60. package/ios/Source/sgcs/Nimo.swift +1 -1
  61. package/ios/Source/sgcs/SGCManager.swift +5 -2
  62. package/ios/Source/sgcs/Simulated.swift +1 -1
  63. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  64. package/ios/Source/streaming/StreamModels.swift +9 -0
  65. package/ios/Source/tts/TTSTools.swift +1 -1
  66. package/package.json +1 -2
  67. package/src/BluetoothSdk.types.ts +49 -0
  68. package/src/_internal.ts +2 -0
  69. package/src/_private/BluetoothSdkModule.ts +64 -13
  70. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  71. package/src/_private/cameraRequestPayload.ts +1 -0
  72. package/src/_private/photoRequestPayload.ts +2 -3
  73. package/src/index.ts +4 -0
@@ -34,6 +34,7 @@ import com.mentra.bluetoothsdk.Bridge
34
34
  import com.mentra.bluetoothsdk.DeviceManager
35
35
  import com.mentra.bluetoothsdk.PhotoRequest
36
36
  import com.mentra.bluetoothsdk.PhotoSize
37
+ import com.mentra.bluetoothsdk.PhotoMode
37
38
  import com.mentra.bluetoothsdk.DeviceStore
38
39
  import com.mentra.bluetoothsdk.ObservableStore
39
40
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
@@ -146,6 +147,10 @@ class MentraLive : SGCManager() {
146
147
 
147
148
  // Heartbeat parameters
148
149
  private const val HEARTBEAT_INTERVAL_MS = 30000 // 30 seconds
150
+ // Grace period before an unanswered BLE wire v2 handshake is re-armed for retry,
151
+ // and the per-session cap on automatic retries (see sendWireHandshake).
152
+ private const val WIRE_HANDSHAKE_RETRY_GRACE_MS = 5000L
153
+ private const val WIRE_HANDSHAKE_MAX_ATTEMPTS = 3
149
154
  private const val BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10 // Every 10 heartbeats (5 minutes)
150
155
  private const val RSSI_READ_INTERVAL_MS = 10000L // 10 seconds
151
156
 
@@ -213,6 +218,15 @@ class MentraLive : SGCManager() {
213
218
  private var peerWireProtocolVersion = 0
214
219
  private var useBinaryWireProtocol = false
215
220
  private var wireHandshakeQueued = false
221
+ private var wireHandshakeAttempts = 0
222
+ // Session generation in which the LAST outgoing v2 handshake was sent. A handshake
223
+ // reply only activates v2 when it answers a handshake from the CURRENT epoch - a
224
+ // stale handshake that survived in flight across an epoch reset must not flip the
225
+ // new session to v2 before it negotiated its own build and capabilities.
226
+ private var wireHandshakeSentGeneration = -1
227
+ // Bumped on every BLE session reset; scheduled handshake-retry callbacks capture it
228
+ // and no-op if the session changed, so a timer from a dead session can't poke a new one.
229
+ private var wireSessionGeneration = 0
216
230
  // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to
217
231
  // legacy big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le
218
232
  // (or a v2 binary handshake succeeds, which implies wire-v2 LE).
@@ -313,6 +327,8 @@ class MentraLive : SGCManager() {
313
327
  // So we temporarily suspend the LC3 mic during phone audio playback
314
328
  private var micIntentEnabled = false // User/system WANTS mic enabled
315
329
  private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
330
+ override val isMicSuspendedForAudio: Boolean
331
+ get() = micSuspendedForAudio
316
332
  private var phoneAudioMonitor: PhoneAudioMonitor? = null
317
333
  private var micOnCount = 0
318
334
  private var micOffCount = 0
@@ -780,14 +796,12 @@ class MentraLive : SGCManager() {
780
796
  val isEqual = state == connectionState
781
797
  if (isEqual) {
782
798
  if (state == ConnTypes.DISCONNECTED) {
783
- incomingChunkReassembler.clear()
784
- peerWireProtocolVersion = 0
785
- useBinaryWireProtocol = false
786
- wireHandshakeQueued = false
787
- peerK900Le = false
788
- peerWireCapsBinary = false
789
- peerFilePayloadV2 = false
790
- BleJsonCompact.resetSession()
799
+ resetWireNegotiationState()
800
+ // Queued writes are session-bound: transmitting them into the NEXT session
801
+ // (e.g. a stale handshake whose reply activates v2 before the new session
802
+ // negotiated) is the bug class this clear removes. Higher layers re-send
803
+ // what still matters via their own ACK/retry tracking.
804
+ sendQueue.clear()
791
805
  }
792
806
  return
793
807
  }
@@ -815,14 +829,9 @@ class MentraLive : SGCManager() {
815
829
  DeviceStore.apply("glasses", "connected", false)
816
830
  DeviceStore.apply("glasses", "signalStrength", -1)
817
831
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
818
- incomingChunkReassembler.clear()
819
- peerWireProtocolVersion = 0
820
- useBinaryWireProtocol = false
821
- wireHandshakeQueued = false
822
- peerK900Le = false
823
- peerWireCapsBinary = false
824
- peerFilePayloadV2 = false
825
- BleJsonCompact.resetSession()
832
+ resetWireNegotiationState()
833
+ sendQueue.clear() // see the disconnect reset above: stale writes die with the session
834
+
826
835
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
827
836
  // from a previous pairing into the next one.
828
837
  resetOtaCache()
@@ -3136,7 +3145,12 @@ class MentraLive : SGCManager() {
3136
3145
  DeviceStore.apply("glasses", "wifiError", "")
3137
3146
  }
3138
3147
 
3139
- updateWifiStatus(wifiConnectedStatus, ssid, localIp)
3148
+ updateWifiStatus(
3149
+ wifiConnectedStatus,
3150
+ ssid,
3151
+ localIp,
3152
+ wifiError.takeIf { it.isNotEmpty() }
3153
+ )
3140
3154
  }
3141
3155
  "hotspot_status_update" -> {
3142
3156
  // Process hotspot status information (same pattern as "wifi_status")
@@ -3239,7 +3253,8 @@ class MentraLive : SGCManager() {
3239
3253
 
3240
3254
  val scanComplete =
3241
3255
  json.optBoolean("scan_complete", json.optBoolean("scanComplete", false))
3242
- Bridge.updateWifiScanResults(networks, scanComplete)
3256
+ val scanId = json.optString("scanId", "").ifEmpty { null }
3257
+ Bridge.updateWifiScanResults(networks, scanComplete, scanId)
3243
3258
  }
3244
3259
  "token_status" -> {
3245
3260
  // Process coreToken acknowledgment
@@ -3492,8 +3507,14 @@ class MentraLive : SGCManager() {
3492
3507
  // Glasses SOC has booted and is ready for communication
3493
3508
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
3494
3509
 
3495
- // Negotiate wire capabilities advertised in the glasses_ready handshake.
3510
+ // glasses_ready is a REMOTE wire-session reset: the glasses ran
3511
+ // onTransportReset() before sending it, so their side is back on legacy
3512
+ // framing regardless of what this side negotiated earlier. Start a fresh
3513
+ // wire epoch (clears v2-active state that would otherwise gate the
3514
+ // handshake off), then negotiate from the caps this message advertises.
3515
+ resetWireNegotiationState()
3496
3516
  parsePeerWireCaps(json)
3517
+ maybeSendWireHandshake()
3497
3518
 
3498
3519
  // Set the ready flag to stop any future readiness checks
3499
3520
  glassesReady = true
@@ -3884,8 +3905,6 @@ class MentraLive : SGCManager() {
3884
3905
  val buildNumInt = Integer.parseInt(buildNum)
3885
3906
  buildNumberInt = buildNumInt
3886
3907
  Bridge.log("LIVE: Parsed build number as integer: " + buildNumInt)
3887
- parsePeerWireCaps(json)
3888
- maybeSendWireHandshake()
3889
3908
  } catch (e: NumberFormatException) {
3890
3909
  buildNumberInt = 0
3891
3910
  Log.e(TAG, "Failed to parse build number as integer: " + buildNum)
@@ -3953,6 +3972,16 @@ class MentraLive : SGCManager() {
3953
3972
  }
3954
3973
  }
3955
3974
 
3975
+ // Negotiate wire caps from ANY version_info chunk, not only the one
3976
+ // carrying build_number: the glasses put wire_caps in version_info_3
3977
+ // (firmware chunk) while build_number travels in version_info_1, so
3978
+ // gating negotiation on build_number silently discards the caps.
3979
+ // parsePeerWireCaps no-ops without a wire_caps key and
3980
+ // maybeSendWireHandshake gates on caps + build + not-yet-queued, so
3981
+ // running them per chunk is safe.
3982
+ parsePeerWireCaps(json)
3983
+ maybeSendWireHandshake()
3984
+
3956
3985
  Bridge.log("LIVE: Processed version_info fields and sent to RN")
3957
3986
  Bridge.sendVersionInfo(fields)
3958
3987
  } else {
@@ -4457,7 +4486,20 @@ class MentraLive : SGCManager() {
4457
4486
 
4458
4487
  val syntheticStatus: String
4459
4488
  if ("FINISHED" == besOtaStatus) {
4460
- syntheticStatus = "step_complete"
4489
+ // The glasses power-cycle right after the final BES tick, so a
4490
+ // session whose BES step is the LAST step never gets a follow-up
4491
+ // ota_status from the glasses — consumers mapping on this synthetic
4492
+ // status would otherwise never see a terminal state. Emit "complete"
4493
+ // for the final step; mid-session BES steps keep "step_complete" so
4494
+ // session-level trackers advance normally. Unknown sessions
4495
+ // (cachedOtaTotalSteps == 0, e.g. legacy glasses that never sent an
4496
+ // ota_status) conservatively keep "step_complete".
4497
+ syntheticStatus =
4498
+ if (cachedOtaTotalSteps > 0 &&
4499
+ cachedOtaCurrentStep >= cachedOtaTotalSteps
4500
+ )
4501
+ "complete"
4502
+ else "step_complete"
4461
4503
  } else if ("FAILED" == besOtaStatus) {
4462
4504
  syntheticStatus = "failed"
4463
4505
  } else {
@@ -4701,7 +4743,12 @@ class MentraLive : SGCManager() {
4701
4743
  /**
4702
4744
  * Update WiFi status and notify listeners Matches iOS MentraLive.swift updateWifiStatus pattern
4703
4745
  */
4704
- private fun updateWifiStatus(connected: Boolean, ssid: String, localIp: String) {
4746
+ private fun updateWifiStatus(
4747
+ connected: Boolean,
4748
+ ssid: String,
4749
+ localIp: String,
4750
+ error: String? = null
4751
+ ) {
4705
4752
  Bridge.log("LIVE: 🌐 Updating WiFi status - connected: " + connected + ", SSID: " + ssid)
4706
4753
 
4707
4754
  // Update parent SGCManager fields
@@ -4710,7 +4757,7 @@ class MentraLive : SGCManager() {
4710
4757
  DeviceStore.apply("glasses", "wifiLocalIp", localIp)
4711
4758
 
4712
4759
  // Send event to bridge for cloud communication
4713
- Bridge.sendWifiStatusChange(connected, ssid, localIp)
4760
+ Bridge.sendWifiStatusChange(connected, ssid, localIp, error)
4714
4761
  }
4715
4762
 
4716
4763
  /**
@@ -4784,10 +4831,13 @@ class MentraLive : SGCManager() {
4784
4831
  /**
4785
4832
  * Request WiFi scan from the glasses This will ask the glasses to scan for available networks
4786
4833
  */
4787
- override fun requestWifiScan() {
4834
+ override fun requestWifiScan(scanId: String?) {
4788
4835
  try {
4789
4836
  val json = JSONObject()
4790
4837
  json.put("type", "request_wifi_scan")
4838
+ if (!scanId.isNullOrEmpty()) {
4839
+ json.put("scanId", scanId)
4840
+ }
4791
4841
  sendJson(json, true)
4792
4842
  Bridge.log("LIVE: Sending WiFi scan request to glasses")
4793
4843
  } catch (e: JSONException) {
@@ -5458,8 +5508,9 @@ class MentraLive : SGCManager() {
5458
5508
  val bleImgId = "I" + String.format("%09d", System.currentTimeMillis() % 1000000000)
5459
5509
  json.put("bleImgId", bleImgId)
5460
5510
 
5461
- // Use auto mode by default - glasses try WiFi direct upload, BLE fallback via bleImgId
5462
- json.put("transferMethod", "auto")
5511
+ // Miniapps may explicitly skip direct Wi-Fi upload and force the
5512
+ // phone-relayed BLE path. Auto remains the default.
5513
+ json.put("transferMethod", request.transferMethod)
5463
5514
 
5464
5515
  // Always prepare for potential BLE transfer
5465
5516
  if (webhookUrl != null && !webhookUrl.isEmpty()) {
@@ -5469,7 +5520,7 @@ class MentraLive : SGCManager() {
5469
5520
  blePhotoTransfers[bleImgId] = transfer
5470
5521
  }
5471
5522
 
5472
- Bridge.log("LIVE: Using auto transfer mode with BLE fallback ID: " + bleImgId)
5523
+ Bridge.log("LIVE: Using " + request.transferMethod + " transfer mode with BLE fallback ID: " + bleImgId)
5473
5524
  Bridge.log(
5474
5525
  "LIVE: PHOTO PIPELINE [5b/6] JSON ready mode=" +
5475
5526
  mode +
@@ -5488,6 +5539,7 @@ class MentraLive : SGCManager() {
5488
5539
  fun warmUpCamera(
5489
5540
  requestId: String,
5490
5541
  size: PhotoSize,
5542
+ mode: PhotoMode = PhotoMode.PHOTO,
5491
5543
  exposureTimeNs: Long?,
5492
5544
  durationMs: Int,
5493
5545
  ) {
@@ -5496,6 +5548,8 @@ class MentraLive : SGCManager() {
5496
5548
  requestId +
5497
5549
  ", size=" +
5498
5550
  size.value +
5551
+ ", mode=" +
5552
+ mode.value +
5499
5553
  ", durationMs=" +
5500
5554
  durationMs
5501
5555
  )
@@ -5506,6 +5560,7 @@ class MentraLive : SGCManager() {
5506
5560
  json.put("requestId", requestId)
5507
5561
  val sizeValue = size.value
5508
5562
  json.put("size", if (sizeValue.isNotEmpty()) sizeValue else "medium")
5563
+ json.put("mode", mode.value)
5509
5564
  if (exposureTimeNs != null && exposureTimeNs > 0L) {
5510
5565
  json.put("exposureTimeNs", exposureTimeNs)
5511
5566
  }
@@ -5516,6 +5571,16 @@ class MentraLive : SGCManager() {
5516
5571
  }
5517
5572
  }
5518
5573
 
5574
+ fun stopCameraWarmUp(requestId: String) {
5575
+ if (!isConnected) {
5576
+ throw IllegalStateException("not_connected")
5577
+ }
5578
+ val json = JSONObject()
5579
+ json.put("type", "camera_warm_up_stop")
5580
+ json.put("requestId", requestId)
5581
+ sendJson(json, true)
5582
+ }
5583
+
5519
5584
  override fun startStream(message: MutableMap<String, Any>) {
5520
5585
  Bridge.log("LIVE: Starting RTMP stream")
5521
5586
 
@@ -7162,6 +7227,28 @@ class MentraLive : SGCManager() {
7162
7227
  * negotiated per-link endianness and binary support. Missing wire_caps leaves the legacy
7163
7228
  * defaults (BE, no binary) untouched so older glasses keep working.
7164
7229
  */
7230
+ /**
7231
+ * Drop every negotiated wire-session artifact and bump the session generation so
7232
+ * scheduled callbacks from the old session stand down. Called on BLE disconnect and on
7233
+ * glasses_ready: the glasses run onTransportReset() before sending glasses_ready, so a
7234
+ * mid-link ASG restart (no BLE disconnect) resets THEIR side to legacy framing - the
7235
+ * phone must treat every glasses_ready as a new wire epoch and renegotiate (bounded by
7236
+ * the per-session handshake attempt cap) instead of trusting stale v2-active state.
7237
+ */
7238
+ private fun resetWireNegotiationState() {
7239
+ incomingChunkReassembler.clear()
7240
+ peerWireProtocolVersion = 0
7241
+ useBinaryWireProtocol = false
7242
+ wireHandshakeQueued = false
7243
+ wireHandshakeAttempts = 0
7244
+ wireSessionGeneration++
7245
+ peerK900Le = false
7246
+ peerWireCapsBinary = false
7247
+ peerFilePayloadV2 = false
7248
+ BleJsonCompact.resetSession()
7249
+ wireHandshakeSentGeneration = -1
7250
+ }
7251
+
7165
7252
  private fun parsePeerWireCaps(json: JSONObject) {
7166
7253
  val caps = json.optJSONObject("wire_caps") ?: return
7167
7254
  if (caps.optBoolean("k900_le", false)) {
@@ -7233,7 +7320,39 @@ class MentraLive : SGCManager() {
7233
7320
  )
7234
7321
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
7235
7322
  wireHandshakeQueued = true
7323
+ wireHandshakeSentGeneration = wireSessionGeneration
7236
7324
  queueData(packed, null)
7325
+ // The handshake and its reply are fire-and-forget binary frames with no ACK
7326
+ // tracking; if either is lost, wireHandshakeQueued would block every future
7327
+ // attempt and the session would silently stay on the legacy string wire.
7328
+ // Re-arm after a grace period so negotiation can retry, bounded per session
7329
+ // so a peer that advertises binary but never answers doesn't get pinged
7330
+ // forever (later caps/version triggers may still retry explicitly).
7331
+ wireHandshakeAttempts++
7332
+ val scheduledGeneration = wireSessionGeneration
7333
+ handler.postDelayed(
7334
+ {
7335
+ if (scheduledGeneration == wireSessionGeneration &&
7336
+ wireHandshakeQueued &&
7337
+ peerWireProtocolVersion < BleWireProtocol.PROTOCOL_V2
7338
+ ) {
7339
+ Bridge.log(
7340
+ "LIVE: BLE wire v2 handshake unanswered after " +
7341
+ WIRE_HANDSHAKE_RETRY_GRACE_MS +
7342
+ "ms (attempt " +
7343
+ wireHandshakeAttempts +
7344
+ "/" +
7345
+ WIRE_HANDSHAKE_MAX_ATTEMPTS +
7346
+ ") - re-arming"
7347
+ )
7348
+ wireHandshakeQueued = false
7349
+ if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7350
+ maybeSendWireHandshake()
7351
+ }
7352
+ }
7353
+ },
7354
+ WIRE_HANDSHAKE_RETRY_GRACE_MS
7355
+ )
7237
7356
  } catch (e: Exception) {
7238
7357
  Log.e(TAG, "Failed to send wire handshake", e)
7239
7358
  }
@@ -7243,12 +7362,17 @@ class MentraLive : SGCManager() {
7243
7362
  peerWireProtocolVersion = BleWireProtocol.PROTOCOL_V2
7244
7363
  useBinaryWireProtocol = true
7245
7364
  wireHandshakeQueued = false
7365
+ wireHandshakeAttempts = 0
7246
7366
  peerK900Le = true
7247
7367
  BleJsonCompact.markSessionConnected(System.currentTimeMillis())
7248
7368
  Bridge.log(logMessage)
7249
7369
  }
7250
7370
 
7251
7371
  private fun handlePeerWireHandshake() {
7372
+ if (wireHandshakeSentGeneration != wireSessionGeneration) {
7373
+ Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7374
+ return
7375
+ }
7252
7376
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7253
7377
  }
7254
7378
 
@@ -7947,7 +8071,7 @@ class MentraLive : SGCManager() {
7947
8071
  val type = json.optString("type", "")
7948
8072
 
7949
8073
  when (type) {
7950
- "request_wifi_scan" -> requestWifiScan()
8074
+ "request_wifi_scan" -> requestWifiScan(null)
7951
8075
  "rgb_led_control_on", "rgb_led_control_off" -> {
7952
8076
  // Forward LED control commands directly to glasses via BLE
7953
8077
  Log.d(TAG, "💡 Forwarding LED control command to glasses: " + type)
@@ -8826,7 +8950,7 @@ class MentraLive : SGCManager() {
8826
8950
 
8827
8951
  /** Send camera FOV setting to glasses (K900 / Mentra Live). */
8828
8952
  override fun sendCameraFovSetting() {
8829
- var fov = 118
8953
+ var fov = 102
8830
8954
  var roiPosition = 0
8831
8955
  try {
8832
8956
  val raw = DeviceStore.get("bluetooth", "camera_fov")
@@ -8868,6 +8992,35 @@ class MentraLive : SGCManager() {
8868
8992
  }
8869
8993
  }
8870
8994
 
8995
+ fun sendCameraFovOverride(
8996
+ requestId: String,
8997
+ leaseId: String,
8998
+ fov: Int,
8999
+ roiPosition: Int,
9000
+ ttlMs: Int,
9001
+ ) {
9002
+ val json = JSONObject()
9003
+ json.put("type", "camera_fov_override")
9004
+ json.put("request_id", requestId)
9005
+ json.put(
9006
+ "params",
9007
+ JSONObject()
9008
+ .put("lease_id", leaseId)
9009
+ .put("fov", fov)
9010
+ .put("roi_position", roiPosition)
9011
+ .put("ttl_ms", ttlMs),
9012
+ )
9013
+ sendJson(json, true)
9014
+ }
9015
+
9016
+ fun releaseCameraFovOverride(requestId: String, leaseId: String) {
9017
+ val json = JSONObject()
9018
+ json.put("type", "camera_fov_override_release")
9019
+ json.put("request_id", requestId)
9020
+ json.put("params", JSONObject().put("lease_id", leaseId))
9021
+ sendJson(json, true)
9022
+ }
9023
+
8871
9024
  /**
8872
9025
  * Send camera tuning config (ANR / gain) to the glasses via the {@code camera_tuning_config}
8873
9026
  * command. The ASG client relays this as a {@code camconfig} broadcast to the camera HAL.
@@ -334,7 +334,7 @@ class MentraNex : SGCManager() {
334
334
  }
335
335
 
336
336
  // Network Management
337
- override fun requestWifiScan () { Bridge.log("Nex: requestWifiScan operation not supported") }
337
+ override fun requestWifiScan (scanId: String?) { Bridge.log("Nex: requestWifiScan operation not supported") }
338
338
  override fun sendWifiCredentials(ssid: String, password: String) { Bridge.log("Nex: sendWifiCredentials operation not supported") }
339
339
  override fun forgetWifiNetwork(ssid: String) { }
340
340
  override fun sendHotspotState(enabled: Boolean) { Bridge.log("Nex: sendHotspotState operation not supported") }
@@ -1427,7 +1427,7 @@ class Nimo : SGCManager() {
1427
1427
 
1428
1428
  // ---------- SGCManager: Network (no WiFi) ----------
1429
1429
 
1430
- override fun requestWifiScan() {}
1430
+ override fun requestWifiScan(scanId: String?) {}
1431
1431
  override fun sendWifiCredentials(ssid: String, password: String) {}
1432
1432
  override fun forgetWifiNetwork(ssid: String) {}
1433
1433
  override fun sendHotspotState(enabled: Boolean) {}
@@ -46,6 +46,8 @@ abstract class SGCManager {
46
46
 
47
47
  // Audio Control
48
48
  abstract fun setMicEnabled(enabled: Boolean)
49
+ open val isMicSuspendedForAudio: Boolean
50
+ get() = false
49
51
  abstract fun sortMicRanking(list: MutableList<String>): MutableList<String>
50
52
 
51
53
  // Camera & Media
@@ -296,12 +298,12 @@ abstract class SGCManager {
296
298
  abstract fun dbg2()
297
299
 
298
300
  // Network Management
299
- abstract fun requestWifiScan()
301
+ abstract fun requestWifiScan(scanId: String?)
300
302
  abstract fun sendWifiCredentials(ssid: String, password: String)
301
303
  abstract fun forgetWifiNetwork(ssid: String)
302
304
  abstract fun sendHotspotState(enabled: Boolean)
303
305
 
304
- /** Set glasses system clock (Mentra Live only; no-op on other devices). */
306
+ /** Set glasses system clock (Mentra Live and G2; no-op on other devices). */
305
307
  open fun sendSetSystemTime(timestampMs: Long) {
306
308
  Bridge.log("SGC: sendSetSystemTime not supported on $type")
307
309
  }
@@ -188,7 +188,7 @@ class Simulated : SGCManager() {
188
188
  override fun dbg2() {}
189
189
 
190
190
  // Network Management
191
- override fun requestWifiScan() {
191
+ override fun requestWifiScan(scanId: String?) {
192
192
  Bridge.log("requestWifiScan")
193
193
  }
194
194
 
@@ -66,15 +66,31 @@ sealed interface WifiStatus {
66
66
  }
67
67
  }
68
68
 
69
- data class WifiStatusEvent(
69
+ data class WifiStatusEvent @JvmOverloads constructor(
70
70
  val status: WifiStatus,
71
+ /**
72
+ * Glasses-reported provisioning failure reason when THIS event is the verdict of a
73
+ * failed connect attempt; null for routine link-state updates. An attempt property,
74
+ * not a link property — which is why it lives on the event and not on [WifiStatus]:
75
+ * "connect_timeout" arrives with a disconnected status (never associated), while
76
+ * "connected_to_other_network" arrives with a *connected* status — the attempt
77
+ * failed and Android's auto-join left the glasses on (or returned them to) a
78
+ * different SSID than requested, so the link is genuinely up while the request
79
+ * genuinely failed. Sent by ASG client builds that include the WiFi error
80
+ * surfacing (v40+); older glasses never set it.
81
+ */
82
+ val error: String? = null,
71
83
  ) {
72
- internal constructor(values: Map<String, Any>) : this(WifiStatus.fromMap(values) ?: WifiStatus.Disconnected)
84
+ internal constructor(values: Map<String, Any>) : this(
85
+ WifiStatus.fromMap(values) ?: WifiStatus.Disconnected,
86
+ stringValue(values, "error")?.takeIf { it.isNotEmpty() },
87
+ )
73
88
  internal constructor(connected: Boolean, ssid: String?, localIp: String?) : this(
74
89
  WifiStatus.fromStoreFields(connected, ssid, localIp) ?: WifiStatus.Disconnected
75
90
  )
76
91
 
77
- val values: Map<String, Any> get() = status.toEventMap()
92
+ val values: Map<String, Any>
93
+ get() = status.toEventMap() + (error?.let { mapOf("error" to it) } ?: emptyMap())
78
94
  }
79
95
 
80
96
  sealed interface HotspotStatus {
@@ -436,12 +436,25 @@ sealed interface StreamStatus {
436
436
  data class StreamStatusEvent(
437
437
  val status: StreamStatus,
438
438
  ) {
439
- constructor(values: Map<String, Any>) : this(StreamStatus.fromMap(values))
439
+ // True when the glasses will retry the failed publisher themselves
440
+ // (emitting side lands in PR #3488); absent on older firmware and on
441
+ // events not parsed from a glasses status map. Carried here instead of
442
+ // on StreamStatus.Error so the public Error shape stays unchanged.
443
+ var willRetry: Boolean? = null
444
+ private set
445
+
446
+ constructor(values: Map<String, Any>) : this(StreamStatus.fromMap(values)) {
447
+ willRetry = boolValue(values, "willRetry")
448
+ }
440
449
 
441
450
  val state: StreamState get() = status.state
442
451
  val streamId: String? get() = status.streamId
443
452
  val resolvedConfig: StreamResolvedConfig? get() = status.resolvedConfig
444
- val values: Map<String, Any> get() = status.toEventMap()
453
+ val values: Map<String, Any>
454
+ get() = buildMap {
455
+ putAll(status.toEventMap())
456
+ willRetry?.let { put("willRetry", it) }
457
+ }
445
458
  }
446
459
 
447
460
  data class KeepAliveAckEvent(