@mentra/bluetooth-sdk 0.1.21-beta.2 → 0.1.21-beta.5

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.
@@ -27,6 +27,7 @@ import android.os.Build
27
27
  import android.os.Handler
28
28
  import android.os.HandlerThread
29
29
  import android.os.Looper
30
+ import android.os.SystemClock
30
31
  import android.util.Log
31
32
  import androidx.core.app.ActivityCompat
32
33
  import com.mentra.bluetoothsdk.BluetoothSdkDefaults
@@ -83,6 +84,7 @@ import org.json.JSONObject
83
84
  * display-related methods are stubbed out and will log a message but not actually display anything.
84
85
  */
85
86
  class MentraLive : SGCManager() {
87
+ private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
86
88
 
87
89
  companion object {
88
90
  private const val TAG = "Live"
@@ -310,7 +312,11 @@ class MentraLive : SGCManager() {
310
312
  val queuedAtMs: Long
311
313
  )
312
314
 
313
- private data class QueuedBleWrite(val data: ByteArray, val trace: BleWriteTrace?)
315
+ private data class QueuedBleWrite(
316
+ val data: ByteArray,
317
+ val trace: BleWriteTrace?,
318
+ val sessionGeneration: Long
319
+ )
314
320
 
315
321
  private var sendQueue = ConcurrentLinkedQueue<QueuedBleWrite>()
316
322
  private val bleWriteTraceSequence = AtomicLong(1)
@@ -612,6 +618,7 @@ class MentraLive : SGCManager() {
612
618
  // Message tracking for reliable delivery
613
619
  private val pendingMessages = ConcurrentHashMap<Long, PendingMessage>()
614
620
  private val messageIdCounter = AtomicLong(1)
621
+ private val bleSessionGeneration = AtomicLong(0)
615
622
 
616
623
  // Esoteric message ID generation
617
624
  private val secureRandom = SecureRandom()
@@ -637,7 +644,8 @@ class MentraLive : SGCManager() {
637
644
  val messageData: String,
638
645
  val timestamp: Long,
639
646
  val retryCount: Int,
640
- val retryRunnable: Runnable
647
+ val retryRunnable: Runnable,
648
+ val sessionGeneration: Long
641
649
  )
642
650
 
643
651
  private var lc3AudioFileStream: FileOutputStream? = null
@@ -807,6 +815,7 @@ class MentraLive : SGCManager() {
807
815
  if (isEqual) {
808
816
  if (state == ConnTypes.DISCONNECTED) {
809
817
  resetWireNegotiationState()
818
+ resetPendingAckState()
810
819
  // Queued writes are session-bound: transmitting them into the NEXT session
811
820
  // (e.g. a stale handshake whose reply activates v2 before the new session
812
821
  // negotiated) is the bug class this clear removes. Higher layers re-send
@@ -817,12 +826,13 @@ class MentraLive : SGCManager() {
817
826
  }
818
827
 
819
828
  if (state == ConnTypes.DISCONNECTED) {
820
- // A manufacturing serial is session-bound. Clear it on disconnect so a previous
821
- // pair's serial can never be associated with the next connection. Connect must NOT
822
- // clear it: DeviceManager.disconnect already wipes it before any new connection, and
823
- // clearing on CONNECTED would wipe a still-valid serial mid-session when a same-link
824
- // glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
829
+ // Device identity is session-bound. Clear it on disconnect so a previous pair's
830
+ // identifiers can never be associated with the next connection. Connect must NOT
831
+ // clear them: DeviceManager.disconnect already wipes them before any new connection,
832
+ // and clearing on CONNECTED would wipe still-valid identity mid-session when a
833
+ // same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
825
834
  DeviceStore.apply("glasses", "serialNumber", "")
835
+ DeviceStore.apply("glasses", "bluetoothMacAddress", "")
826
836
  }
827
837
 
828
838
  // Actually update the connection state!
@@ -853,6 +863,7 @@ class MentraLive : SGCManager() {
853
863
  DeviceStore.apply("glasses", "signalStrength", -1)
854
864
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
855
865
  resetWireNegotiationState()
866
+ resetPendingAckState()
856
867
  sendQueue.clear() // see the disconnect reset above: stale writes die with the session
857
868
 
858
869
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
@@ -873,6 +884,17 @@ class MentraLive : SGCManager() {
873
884
  cachedOtaCurrentStep = 0
874
885
  cachedOtaStepSequence = null
875
886
  lastBesOtaProgress = -1
887
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
888
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
889
+ }
890
+
891
+ private fun updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
892
+ besOtaHeartbeatGuard.observeOtaStatus(
893
+ stepType,
894
+ phase,
895
+ status,
896
+ SystemClock.elapsedRealtime(),
897
+ )
876
898
  }
877
899
 
878
900
  protected fun setFontSizes() {
@@ -1175,6 +1197,26 @@ class MentraLive : SGCManager() {
1175
1197
  }
1176
1198
  }
1177
1199
 
1200
+ /**
1201
+ * Reject callbacks delivered by a GATT object from an earlier connection attempt. Without
1202
+ * this identity check, a late disconnect can tear down the active connection and clear its
1203
+ * session-bound send state.
1204
+ */
1205
+ private fun isCurrentGattCallback(gatt: BluetoothGatt): Boolean {
1206
+ if (gatt === bluetoothGatt) {
1207
+ return true
1208
+ }
1209
+
1210
+ Log.w(TAG, "🔌 Ignoring callback from stale BluetoothGatt")
1211
+ Bridge.log("LIVE: 🔌 Ignoring callback from stale BluetoothGatt session")
1212
+ try {
1213
+ gatt.close()
1214
+ } catch (e: Exception) {
1215
+ Log.w(TAG, "🔌 Failed to close stale BluetoothGatt: " + e)
1216
+ }
1217
+ return false
1218
+ }
1219
+
1178
1220
  /** Connect to a specific BLE device */
1179
1221
  private fun connectToDevice(device: BluetoothDevice?) {
1180
1222
  if (device == null) {
@@ -1497,6 +1539,10 @@ class MentraLive : SGCManager() {
1497
1539
  status: Int,
1498
1540
  newState: Int
1499
1541
  ) {
1542
+ if (!isCurrentGattCallback(gatt)) {
1543
+ return
1544
+ }
1545
+
1500
1546
  // Cancel the connection timeout
1501
1547
  if (connectionTimeoutRunnable != null) {
1502
1548
  connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
@@ -2309,8 +2355,20 @@ class MentraLive : SGCManager() {
2309
2355
  return
2310
2356
  }
2311
2357
 
2312
- // Send the next item from the queue
2313
- val queuedWrite = sendQueue.poll()
2358
+ // Send the next item from this BLE session. A retry can race disconnect teardown and land
2359
+ // after sendQueue.clear(); the generation check is the deterministic backstop.
2360
+ var queuedWrite = sendQueue.poll()
2361
+ val currentGeneration = bleSessionGeneration.get()
2362
+ while (queuedWrite != null && queuedWrite.sessionGeneration != currentGeneration) {
2363
+ Bridge.log(
2364
+ "LIVE: Dropping stale queued write from BLE session " +
2365
+ queuedWrite.sessionGeneration +
2366
+ " (current: " +
2367
+ currentGeneration +
2368
+ ")"
2369
+ )
2370
+ queuedWrite = sendQueue.poll()
2371
+ }
2314
2372
  if (queuedWrite != null) {
2315
2373
  // Update last send time before sending
2316
2374
  lastSendTimeMs = currentTimeMs
@@ -2337,25 +2395,35 @@ class MentraLive : SGCManager() {
2337
2395
 
2338
2396
  /** Send data through BLE */
2339
2397
  private fun sendDataInternal(write: QueuedBleWrite?) {
2340
- if (!isConnected || bluetoothGatt == null || txCharacteristic == null || write == null) {
2398
+ if (!isConnected || write == null) {
2399
+ return
2400
+ }
2401
+ if (write.sessionGeneration != bleSessionGeneration.get()) {
2402
+ Bridge.log("LIVE: Dropping stale BLE write before GATT transmission")
2403
+ return
2404
+ }
2405
+
2406
+ val gatt = bluetoothGatt
2407
+ val characteristic = txCharacteristic
2408
+ if (gatt == null || characteristic == null) {
2341
2409
  return
2342
2410
  }
2343
2411
 
2344
2412
  try {
2345
2413
  val writeStartedAtMs = System.currentTimeMillis()
2346
- txCharacteristic!!.value = write.data
2414
+ characteristic.value = write.data
2347
2415
  inFlightBleWriteTrace = write.trace
2348
2416
  inFlightBleWriteStartedAtMs = writeStartedAtMs
2349
- val writeAccepted = bluetoothGatt!!.writeCharacteristic(txCharacteristic)
2417
+ val writeAccepted = gatt.writeCharacteristic(characteristic)
2350
2418
  logBleWriteTrace(
2351
2419
  "write_call",
2352
2420
  write.trace,
2353
2421
  mapOf(
2354
2422
  "writeAccepted" to writeAccepted,
2355
2423
  "currentMtu" to currentMtu,
2356
- "writeType" to txCharacteristic!!.writeType,
2424
+ "writeType" to characteristic.writeType,
2357
2425
  "queueSize" to sendQueue.size,
2358
- "characteristicUuid" to txCharacteristic!!.uuid.toString()
2426
+ "characteristicUuid" to characteristic.uuid.toString()
2359
2427
  )
2360
2428
  )
2361
2429
  if (!writeAccepted) {
@@ -2378,7 +2446,11 @@ class MentraLive : SGCManager() {
2378
2446
  }
2379
2447
 
2380
2448
  /** Queue data to be sent */
2381
- private fun queueData(data: ByteArray?, trace: BleWriteTrace? = null) {
2449
+ private fun queueData(
2450
+ data: ByteArray?,
2451
+ trace: BleWriteTrace? = null,
2452
+ sessionGeneration: Long = bleSessionGeneration.get()
2453
+ ) {
2382
2454
  if (data != null) {
2383
2455
  val queuedTrace =
2384
2456
  trace?.copy(
@@ -2387,7 +2459,7 @@ class MentraLive : SGCManager() {
2387
2459
  trace.queuedAtMs
2388
2460
  else System.currentTimeMillis()
2389
2461
  )
2390
- sendQueue.add(QueuedBleWrite(data, queuedTrace))
2462
+ sendQueue.add(QueuedBleWrite(data, queuedTrace, sessionGeneration))
2391
2463
  logBleChunkTrace(
2392
2464
  "queued",
2393
2465
  queuedTrace,
@@ -2431,6 +2503,7 @@ class MentraLive : SGCManager() {
2431
2503
  private fun sendJson(json: JSONObject?, wakeup: Boolean) {
2432
2504
  if (json != null) {
2433
2505
  try {
2506
+ val sessionGeneration = bleSessionGeneration.get()
2434
2507
  if (buildNumberInt < 5) {
2435
2508
  val jsonStr = json.toString()
2436
2509
  // Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
@@ -2446,7 +2519,7 @@ class MentraLive : SGCManager() {
2446
2519
  json,
2447
2520
  jsonStr.length
2448
2521
  )
2449
- sendDataToGlasses(jsonStr, wakeup)
2522
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2450
2523
  } else {
2451
2524
  // Add esoteric message ID to the JSON
2452
2525
  val messageId = generateEsotericMessageId()
@@ -2488,7 +2561,7 @@ class MentraLive : SGCManager() {
2488
2561
  }
2489
2562
 
2490
2563
  // Track the message for ACK with appropriate timeout
2491
- trackMessageForAck(messageId, jsonStr, ackTimeout)
2564
+ trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
2492
2565
 
2493
2566
  // Send the data
2494
2567
  if ("take_photo" == json.optString("type", "")) {
@@ -2507,7 +2580,7 @@ class MentraLive : SGCManager() {
2507
2580
  json,
2508
2581
  jsonStr.length
2509
2582
  )
2510
- sendDataToGlasses(jsonStr, wakeup)
2583
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2511
2584
  }
2512
2585
  } catch (e: JSONException) {
2513
2586
  Log.e(TAG, "Error adding message ID to JSON", e)
@@ -2527,14 +2600,14 @@ class MentraLive : SGCManager() {
2527
2600
  return list
2528
2601
  }
2529
2602
 
2530
- /** Track a message for ACK response */
2531
- private fun trackMessageForAck(messageId: Long, messageData: String) {
2532
- trackMessageForAck(messageId, messageData, ACK_TIMEOUT_MS)
2533
- }
2534
-
2535
2603
  /** Track a message for ACK response with custom timeout */
2536
- private fun trackMessageForAck(messageId: Long, messageData: String, timeoutMs: Long) {
2537
- if (!isConnected) {
2604
+ private fun trackMessageForAck(
2605
+ messageId: Long,
2606
+ messageData: String,
2607
+ timeoutMs: Long,
2608
+ sessionGeneration: Long
2609
+ ) {
2610
+ if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
2538
2611
  Bridge.log("LIVE: Not connected, skipping ACK tracking for message " + messageId)
2539
2612
  return
2540
2613
  }
@@ -2551,25 +2624,50 @@ class MentraLive : SGCManager() {
2551
2624
  }
2552
2625
 
2553
2626
  // Create retry runnable
2554
- val retryRunnable = Runnable { retryMessage(messageId) }
2627
+ val retryRunnable = Runnable { retryMessage(messageId, sessionGeneration) }
2555
2628
 
2556
2629
  // Create pending message
2557
2630
  val pendingMessage =
2558
- PendingMessage(messageData, System.currentTimeMillis(), 0, retryRunnable)
2631
+ PendingMessage(
2632
+ messageData,
2633
+ System.currentTimeMillis(),
2634
+ 0,
2635
+ retryRunnable,
2636
+ sessionGeneration
2637
+ )
2559
2638
  pendingMessages[messageId] = pendingMessage
2560
2639
 
2561
2640
  // Schedule ACK timeout with custom timeout
2562
- handler.postDelayed({ checkMessageAck(messageId) }, timeoutMs)
2641
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
2563
2642
 
2564
2643
  Bridge.log(
2565
2644
  "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
2566
2645
  )
2567
2646
  }
2568
2647
 
2648
+ /**
2649
+ * Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
2650
+ * the old generation, and queued retries retain it, so a retry racing this clear cannot become
2651
+ * valid in the next session.
2652
+ */
2653
+ private fun resetPendingAckState() {
2654
+ val nextGeneration = bleSessionGeneration.incrementAndGet()
2655
+ val pendingCount = pendingMessages.size
2656
+ pendingMessages.clear()
2657
+ if (pendingCount > 0) {
2658
+ Bridge.log(
2659
+ "LIVE: Cleared $pendingCount pending ACK message(s); BLE session is now $nextGeneration"
2660
+ )
2661
+ }
2662
+ }
2663
+
2569
2664
  /** Check if a message has been acknowledged */
2570
- private fun checkMessageAck(messageId: Long) {
2665
+ private fun checkMessageAck(messageId: Long, sessionGeneration: Long) {
2571
2666
  val pendingMessage = pendingMessages[messageId]
2572
- if (pendingMessage != null) {
2667
+ if (pendingMessage != null &&
2668
+ pendingMessage.sessionGeneration == sessionGeneration &&
2669
+ sessionGeneration == bleSessionGeneration.get()
2670
+ ) {
2573
2671
  Log.w(
2574
2672
  TAG,
2575
2673
  "⏰ ACK timeout for message " +
@@ -2590,7 +2688,7 @@ class MentraLive : SGCManager() {
2590
2688
  MAX_RETRY_ATTEMPTS +
2591
2689
  ")"
2592
2690
  )
2593
- retryMessage(messageId)
2691
+ retryMessage(messageId, sessionGeneration)
2594
2692
  } else {
2595
2693
  // Max retries reached
2596
2694
  Log.e(
@@ -2601,22 +2699,31 @@ class MentraLive : SGCManager() {
2601
2699
  MAX_RETRY_ATTEMPTS +
2602
2700
  " attempts"
2603
2701
  )
2604
- pendingMessages.remove(messageId)
2702
+ pendingMessages.remove(messageId, pendingMessage)
2605
2703
  }
2704
+ } else if (pendingMessage != null && pendingMessage.sessionGeneration == sessionGeneration) {
2705
+ pendingMessages.remove(messageId, pendingMessage)
2606
2706
  }
2607
2707
  }
2608
2708
 
2609
2709
  /** Retry a message */
2610
- private fun retryMessage(messageId: Long) {
2710
+ private fun retryMessage(messageId: Long, sessionGeneration: Long) {
2611
2711
  val pendingMessage = pendingMessages[messageId]
2612
2712
  if (pendingMessage == null) {
2613
2713
  Log.w(TAG, "Message " + messageId + " no longer tracked for retry")
2614
2714
  return
2615
2715
  }
2716
+ if (pendingMessage.sessionGeneration != sessionGeneration ||
2717
+ sessionGeneration != bleSessionGeneration.get()
2718
+ ) {
2719
+ pendingMessages.remove(messageId, pendingMessage)
2720
+ Bridge.log("LIVE: Dropping stale ACK retry for message $messageId")
2721
+ return
2722
+ }
2616
2723
 
2617
2724
  if (pendingMessage.retryCount >= MAX_RETRY_ATTEMPTS) {
2618
2725
  Log.e(TAG, "Max retries reached for message " + messageId)
2619
- pendingMessages.remove(messageId)
2726
+ pendingMessages.remove(messageId, pendingMessage)
2620
2727
  return
2621
2728
  }
2622
2729
 
@@ -2626,11 +2733,18 @@ class MentraLive : SGCManager() {
2626
2733
  pendingMessage.messageData,
2627
2734
  System.currentTimeMillis(),
2628
2735
  pendingMessage.retryCount + 1,
2629
- pendingMessage.retryRunnable
2736
+ pendingMessage.retryRunnable,
2737
+ sessionGeneration
2630
2738
  )
2631
2739
 
2632
- // Update the tracked message
2633
- pendingMessages[messageId] = retryMessage
2740
+ // Replace only the entry inspected above. An ACK or disconnect may have removed it.
2741
+ if (!pendingMessages.replace(messageId, pendingMessage, retryMessage)) {
2742
+ return
2743
+ }
2744
+ if (sessionGeneration != bleSessionGeneration.get()) {
2745
+ pendingMessages.remove(messageId, retryMessage)
2746
+ return
2747
+ }
2634
2748
 
2635
2749
  // Send the message again
2636
2750
  Bridge.log(
@@ -2640,10 +2754,10 @@ class MentraLive : SGCManager() {
2640
2754
  retryMessage.retryCount +
2641
2755
  ")"
2642
2756
  )
2643
- sendDataToGlasses(pendingMessage.messageData, false)
2757
+ sendDataToGlasses(pendingMessage.messageData, false, sessionGeneration)
2644
2758
 
2645
2759
  // Schedule next ACK check
2646
- handler.postDelayed({ checkMessageAck(messageId) }, ACK_TIMEOUT_MS)
2760
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, ACK_TIMEOUT_MS)
2647
2761
  }
2648
2762
 
2649
2763
  /** Process ACK response from glasses */
@@ -3327,6 +3441,7 @@ class MentraLive : SGCManager() {
3327
3441
  if (osStepSequence != null && osStepSequence.length() > 0) {
3328
3442
  cachedOtaStepSequence = osStepSequence
3329
3443
  }
3444
+ updateBesOtaHeartbeatGuard(osStepType, osPhase, osStatus)
3330
3445
 
3331
3446
  Bridge.log(
3332
3447
  "LIVE: 📱 OTA status - step " +
@@ -3377,6 +3492,7 @@ class MentraLive : SGCManager() {
3377
3492
  } else {
3378
3493
  unified = "in_progress"
3379
3494
  }
3495
+ updateBesOtaHeartbeatGuard(currentUpdate, legacyPhase, unified)
3380
3496
  Bridge.log(
3381
3497
  "LIVE: 📱 Legacy ota_progress → ota_status: " +
3382
3498
  legacyStage +
@@ -3729,9 +3845,11 @@ class MentraLive : SGCManager() {
3729
3845
  if (otaVersionUrlLegacy != null) otaVersionUrlLegacy else ""
3730
3846
  )
3731
3847
  DeviceStore.apply("glasses", "firmwareVersion", firmwareVersionLegacy)
3732
- DeviceStore.apply("glasses", "bluetoothMacAddress", btMacAddressLegacy)
3733
- if (serialNumberLegacy.isNotBlank()) {
3734
- DeviceStore.apply("glasses", "serialNumber", serialNumberLegacy)
3848
+ btMacAddressLegacy.trim().takeIf { it.isNotEmpty() }?.let {
3849
+ DeviceStore.apply("glasses", "bluetoothMacAddress", it)
3850
+ }
3851
+ serialNumberLegacy.trim().takeIf { it.isNotEmpty() }?.let {
3852
+ DeviceStore.apply("glasses", "serialNumber", it)
3735
3853
  }
3736
3854
 
3737
3855
  val versionInfoLegacy = HashMap<String, Any>()
@@ -3994,16 +4112,11 @@ class MentraLive : SGCManager() {
3994
4112
  fields["mtk_fw_version"] as String
3995
4113
  )
3996
4114
  }
3997
- if (fields.containsKey("bt_mac_address")) {
3998
- DeviceStore.apply(
3999
- "glasses",
4000
- "bluetoothMacAddress",
4001
- fields["bt_mac_address"] as String
4002
- )
4115
+ (fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4116
+ DeviceStore.apply("glasses", "bluetoothMacAddress", it)
4003
4117
  }
4004
- val serialNumber = fields["serial_number"] as? String
4005
- if (!serialNumber.isNullOrBlank()) {
4006
- DeviceStore.apply("glasses", "serialNumber", serialNumber)
4118
+ (fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4119
+ DeviceStore.apply("glasses", "serialNumber", it)
4007
4120
  }
4008
4121
  if (fields.containsKey("system_time_ms")) {
4009
4122
  val v = fields["system_time_ms"]
@@ -4470,15 +4583,28 @@ class MentraLive : SGCManager() {
4470
4583
  var progress = ((rawProgress + 2) / 5) * 5
4471
4584
  if (progress > 100) progress = 100
4472
4585
 
4473
- // Only send if progress changed to a new 5% increment
4474
- if (progress == lastBesOtaProgress &&
4475
- "success" != type &&
4476
- "error" != type &&
4477
- "fail" != type
4478
- ) {
4479
- return // Skip duplicate progress
4586
+ val mapping =
4587
+ mapBesOtaProgress(
4588
+ type,
4589
+ rawProgress,
4590
+ bodyObj.optString("message", "BES update failed"),
4591
+ )
4592
+ val besOtaStatus = mapping.status
4593
+ val besOtaProgressVal = mapping.progress
4594
+ val besOtaErrorMessage = mapping.errorMessage
4595
+
4596
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
4597
+ besOtaHeartbeatGuard.clear()
4598
+ } else {
4599
+ // Refresh before progress de-duplication: raw progress proves the BES
4600
+ // transfer is active even when the rounded UI value is unchanged.
4601
+ besOtaHeartbeatGuard.refresh(SystemClock.elapsedRealtime())
4602
+ }
4603
+
4604
+ // Only send if nonterminal display progress changed to a new 5% increment.
4605
+ if ("PROGRESS" == besOtaStatus && besOtaProgressVal == lastBesOtaProgress) {
4606
+ return
4480
4607
  }
4481
- lastBesOtaProgress = progress
4482
4608
 
4483
4609
  Bridge.log(
4484
4610
  "LIVE: 📱 BES OTA progress via sr_adota - type: " +
@@ -4487,58 +4613,21 @@ class MentraLive : SGCManager() {
4487
4613
  rawProgress +
4488
4614
  "%, rounded: " +
4489
4615
  progress +
4616
+ "%, display: " +
4617
+ besOtaProgressVal +
4490
4618
  "%"
4491
4619
  )
4492
4620
 
4493
- // Determine status and error message based on type
4494
- val besOtaStatus: String
4495
- val besOtaProgressVal: Int
4496
- var besOtaErrorMessage: String? = null
4497
-
4498
- // Order matters here: check completion (rawProgress >= 100 OR success)
4499
- // BEFORE
4500
- // type=="update", because some BES firmware emits the final 100% tick with
4501
- // type=="update" rather than type=="success". Treating that as PROGRESS
4502
- // would
4503
- // leave the UI stuck at 100% forever.
4504
- if ("success" == type || rawProgress >= 100) {
4505
- besOtaStatus = "FINISHED"
4506
- besOtaProgressVal = 100
4507
- lastBesOtaProgress = -1 // Reset for next OTA
4508
- } else if ("error" == type || "fail" == type) {
4509
- besOtaStatus = "FAILED"
4510
- besOtaProgressVal = progress
4511
- besOtaErrorMessage = bodyObj.optString("message", "BES update failed")
4621
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
4512
4622
  lastBesOtaProgress = -1 // Reset for next OTA
4513
- } else if ("update" == type) {
4514
- besOtaStatus = "PROGRESS"
4515
- besOtaProgressVal = progress
4516
4623
  } else {
4517
- // Unknown type, treat as progress
4518
- besOtaStatus = "PROGRESS"
4519
- besOtaProgressVal = progress
4624
+ lastBesOtaProgress = besOtaProgressVal
4520
4625
  }
4521
4626
 
4522
- val syntheticStatus: String
4523
- if ("FINISHED" == besOtaStatus) {
4524
- // The glasses power-cycle right after the final BES tick, so a
4525
- // session whose BES step is the LAST step never gets a follow-up
4526
- // ota_status from the glasses — consumers mapping on this synthetic
4527
- // status would otherwise never see a terminal state. Emit "complete"
4528
- // for the final step; mid-session BES steps keep "step_complete" so
4529
- // session-level trackers advance normally. Unknown sessions
4530
- // (cachedOtaTotalSteps == 0, e.g. legacy glasses that never sent an
4531
- // ota_status) conservatively keep "step_complete".
4532
- syntheticStatus =
4533
- if (cachedOtaTotalSteps > 0 &&
4534
- cachedOtaCurrentStep >= cachedOtaTotalSteps
4535
- )
4536
- "complete"
4537
- else "step_complete"
4538
- } else if ("FAILED" == besOtaStatus) {
4539
- syntheticStatus = "failed"
4540
- } else {
4541
- syntheticStatus = "in_progress"
4627
+ val syntheticStatus = when (besOtaStatus) {
4628
+ "FINISHED" -> "step_complete"
4629
+ "FAILED" -> "failed"
4630
+ else -> "in_progress"
4542
4631
  }
4543
4632
  val sid = if (cachedOtaSessionId != null) cachedOtaSessionId!! else ""
4544
4633
  val totalSteps = if (cachedOtaTotalSteps > 0) cachedOtaTotalSteps else 1
@@ -5003,6 +5092,10 @@ class MentraLive : SGCManager() {
5003
5092
  Bridge.log("LIVE: Skipping heartbeat - glasses not ready or not connected")
5004
5093
  return
5005
5094
  }
5095
+ if (besOtaHeartbeatGuard.shouldSuppress(SystemClock.elapsedRealtime())) {
5096
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5097
+ return
5098
+ }
5006
5099
 
5007
5100
  try {
5008
5101
  // Send ping message (no ACK needed for heartbeats)
@@ -7572,10 +7665,22 @@ class MentraLive : SGCManager() {
7572
7665
  * @param data The string data to be sent to the glasses
7573
7666
  */
7574
7667
  fun sendDataToGlasses(data: String?, wakeup: Boolean) {
7668
+ sendDataToGlasses(data, wakeup, bleSessionGeneration.get())
7669
+ }
7670
+
7671
+ private fun sendDataToGlasses(
7672
+ data: String?,
7673
+ wakeup: Boolean,
7674
+ sessionGeneration: Long
7675
+ ) {
7575
7676
  if (data == null || data.isEmpty()) {
7576
7677
  Log.e(TAG, "Cannot send empty data to glasses")
7577
7678
  return
7578
7679
  }
7680
+ if (sessionGeneration != bleSessionGeneration.get()) {
7681
+ Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
7682
+ return
7683
+ }
7579
7684
 
7580
7685
  val wireData =
7581
7686
  try {
@@ -7602,7 +7707,13 @@ class MentraLive : SGCManager() {
7602
7707
  }
7603
7708
 
7604
7709
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
7605
- sendDataToGlassesBinary(wireData, wakeup, commandTraceInfo, isPhotoRequest)
7710
+ sendDataToGlassesBinary(
7711
+ wireData,
7712
+ wakeup,
7713
+ commandTraceInfo,
7714
+ isPhotoRequest,
7715
+ sessionGeneration
7716
+ )
7606
7717
  return
7607
7718
  }
7608
7719
 
@@ -7681,7 +7792,7 @@ class MentraLive : SGCManager() {
7681
7792
  )
7682
7793
 
7683
7794
  // Queue the chunk for sending
7684
- queueData(packedData, trace)
7795
+ queueData(packedData, trace, sessionGeneration)
7685
7796
 
7686
7797
  // Add small delay between chunks to avoid overwhelming the connection
7687
7798
  if (i < chunks.size - 1) {
@@ -7722,7 +7833,7 @@ class MentraLive : SGCManager() {
7722
7833
  )
7723
7834
 
7724
7835
  // Queue the data for sending
7725
- queueData(packedData, trace)
7836
+ queueData(packedData, trace, sessionGeneration)
7726
7837
  if (isPhotoRequest) {
7727
7838
  Bridge.log(
7728
7839
  "LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
@@ -7740,7 +7851,8 @@ class MentraLive : SGCManager() {
7740
7851
  data: String,
7741
7852
  wakeup: Boolean,
7742
7853
  commandTraceInfo: OutgoingBleCommandTraceInfo,
7743
- isPhotoRequest: Boolean
7854
+ isPhotoRequest: Boolean,
7855
+ sessionGeneration: Long
7744
7856
  ) {
7745
7857
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
7746
7858
  var messageId = 0
@@ -7792,7 +7904,7 @@ class MentraLive : SGCManager() {
7792
7904
  "wireProtocol" to "v2"
7793
7905
  )
7794
7906
  )
7795
- queueData(packedData, trace)
7907
+ queueData(packedData, trace, sessionGeneration)
7796
7908
 
7797
7909
  if (i < fragments.size - 1) {
7798
7910
  try {