@mentra/bluetooth-sdk 0.1.21-beta.3 → 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
@@ -854,6 +863,7 @@ class MentraLive : SGCManager() {
854
863
  DeviceStore.apply("glasses", "signalStrength", -1)
855
864
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
856
865
  resetWireNegotiationState()
866
+ resetPendingAckState()
857
867
  sendQueue.clear() // see the disconnect reset above: stale writes die with the session
858
868
 
859
869
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
@@ -874,6 +884,17 @@ class MentraLive : SGCManager() {
874
884
  cachedOtaCurrentStep = 0
875
885
  cachedOtaStepSequence = null
876
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
+ )
877
898
  }
878
899
 
879
900
  protected fun setFontSizes() {
@@ -1176,6 +1197,26 @@ class MentraLive : SGCManager() {
1176
1197
  }
1177
1198
  }
1178
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
+
1179
1220
  /** Connect to a specific BLE device */
1180
1221
  private fun connectToDevice(device: BluetoothDevice?) {
1181
1222
  if (device == null) {
@@ -1498,6 +1539,10 @@ class MentraLive : SGCManager() {
1498
1539
  status: Int,
1499
1540
  newState: Int
1500
1541
  ) {
1542
+ if (!isCurrentGattCallback(gatt)) {
1543
+ return
1544
+ }
1545
+
1501
1546
  // Cancel the connection timeout
1502
1547
  if (connectionTimeoutRunnable != null) {
1503
1548
  connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
@@ -2310,8 +2355,20 @@ class MentraLive : SGCManager() {
2310
2355
  return
2311
2356
  }
2312
2357
 
2313
- // Send the next item from the queue
2314
- 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
+ }
2315
2372
  if (queuedWrite != null) {
2316
2373
  // Update last send time before sending
2317
2374
  lastSendTimeMs = currentTimeMs
@@ -2338,25 +2395,35 @@ class MentraLive : SGCManager() {
2338
2395
 
2339
2396
  /** Send data through BLE */
2340
2397
  private fun sendDataInternal(write: QueuedBleWrite?) {
2341
- 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) {
2342
2409
  return
2343
2410
  }
2344
2411
 
2345
2412
  try {
2346
2413
  val writeStartedAtMs = System.currentTimeMillis()
2347
- txCharacteristic!!.value = write.data
2414
+ characteristic.value = write.data
2348
2415
  inFlightBleWriteTrace = write.trace
2349
2416
  inFlightBleWriteStartedAtMs = writeStartedAtMs
2350
- val writeAccepted = bluetoothGatt!!.writeCharacteristic(txCharacteristic)
2417
+ val writeAccepted = gatt.writeCharacteristic(characteristic)
2351
2418
  logBleWriteTrace(
2352
2419
  "write_call",
2353
2420
  write.trace,
2354
2421
  mapOf(
2355
2422
  "writeAccepted" to writeAccepted,
2356
2423
  "currentMtu" to currentMtu,
2357
- "writeType" to txCharacteristic!!.writeType,
2424
+ "writeType" to characteristic.writeType,
2358
2425
  "queueSize" to sendQueue.size,
2359
- "characteristicUuid" to txCharacteristic!!.uuid.toString()
2426
+ "characteristicUuid" to characteristic.uuid.toString()
2360
2427
  )
2361
2428
  )
2362
2429
  if (!writeAccepted) {
@@ -2379,7 +2446,11 @@ class MentraLive : SGCManager() {
2379
2446
  }
2380
2447
 
2381
2448
  /** Queue data to be sent */
2382
- 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
+ ) {
2383
2454
  if (data != null) {
2384
2455
  val queuedTrace =
2385
2456
  trace?.copy(
@@ -2388,7 +2459,7 @@ class MentraLive : SGCManager() {
2388
2459
  trace.queuedAtMs
2389
2460
  else System.currentTimeMillis()
2390
2461
  )
2391
- sendQueue.add(QueuedBleWrite(data, queuedTrace))
2462
+ sendQueue.add(QueuedBleWrite(data, queuedTrace, sessionGeneration))
2392
2463
  logBleChunkTrace(
2393
2464
  "queued",
2394
2465
  queuedTrace,
@@ -2432,6 +2503,7 @@ class MentraLive : SGCManager() {
2432
2503
  private fun sendJson(json: JSONObject?, wakeup: Boolean) {
2433
2504
  if (json != null) {
2434
2505
  try {
2506
+ val sessionGeneration = bleSessionGeneration.get()
2435
2507
  if (buildNumberInt < 5) {
2436
2508
  val jsonStr = json.toString()
2437
2509
  // Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
@@ -2447,7 +2519,7 @@ class MentraLive : SGCManager() {
2447
2519
  json,
2448
2520
  jsonStr.length
2449
2521
  )
2450
- sendDataToGlasses(jsonStr, wakeup)
2522
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2451
2523
  } else {
2452
2524
  // Add esoteric message ID to the JSON
2453
2525
  val messageId = generateEsotericMessageId()
@@ -2489,7 +2561,7 @@ class MentraLive : SGCManager() {
2489
2561
  }
2490
2562
 
2491
2563
  // Track the message for ACK with appropriate timeout
2492
- trackMessageForAck(messageId, jsonStr, ackTimeout)
2564
+ trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
2493
2565
 
2494
2566
  // Send the data
2495
2567
  if ("take_photo" == json.optString("type", "")) {
@@ -2508,7 +2580,7 @@ class MentraLive : SGCManager() {
2508
2580
  json,
2509
2581
  jsonStr.length
2510
2582
  )
2511
- sendDataToGlasses(jsonStr, wakeup)
2583
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2512
2584
  }
2513
2585
  } catch (e: JSONException) {
2514
2586
  Log.e(TAG, "Error adding message ID to JSON", e)
@@ -2528,14 +2600,14 @@ class MentraLive : SGCManager() {
2528
2600
  return list
2529
2601
  }
2530
2602
 
2531
- /** Track a message for ACK response */
2532
- private fun trackMessageForAck(messageId: Long, messageData: String) {
2533
- trackMessageForAck(messageId, messageData, ACK_TIMEOUT_MS)
2534
- }
2535
-
2536
2603
  /** Track a message for ACK response with custom timeout */
2537
- private fun trackMessageForAck(messageId: Long, messageData: String, timeoutMs: Long) {
2538
- 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()) {
2539
2611
  Bridge.log("LIVE: Not connected, skipping ACK tracking for message " + messageId)
2540
2612
  return
2541
2613
  }
@@ -2552,25 +2624,50 @@ class MentraLive : SGCManager() {
2552
2624
  }
2553
2625
 
2554
2626
  // Create retry runnable
2555
- val retryRunnable = Runnable { retryMessage(messageId) }
2627
+ val retryRunnable = Runnable { retryMessage(messageId, sessionGeneration) }
2556
2628
 
2557
2629
  // Create pending message
2558
2630
  val pendingMessage =
2559
- PendingMessage(messageData, System.currentTimeMillis(), 0, retryRunnable)
2631
+ PendingMessage(
2632
+ messageData,
2633
+ System.currentTimeMillis(),
2634
+ 0,
2635
+ retryRunnable,
2636
+ sessionGeneration
2637
+ )
2560
2638
  pendingMessages[messageId] = pendingMessage
2561
2639
 
2562
2640
  // Schedule ACK timeout with custom timeout
2563
- handler.postDelayed({ checkMessageAck(messageId) }, timeoutMs)
2641
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
2564
2642
 
2565
2643
  Bridge.log(
2566
2644
  "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
2567
2645
  )
2568
2646
  }
2569
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
+
2570
2664
  /** Check if a message has been acknowledged */
2571
- private fun checkMessageAck(messageId: Long) {
2665
+ private fun checkMessageAck(messageId: Long, sessionGeneration: Long) {
2572
2666
  val pendingMessage = pendingMessages[messageId]
2573
- if (pendingMessage != null) {
2667
+ if (pendingMessage != null &&
2668
+ pendingMessage.sessionGeneration == sessionGeneration &&
2669
+ sessionGeneration == bleSessionGeneration.get()
2670
+ ) {
2574
2671
  Log.w(
2575
2672
  TAG,
2576
2673
  "⏰ ACK timeout for message " +
@@ -2591,7 +2688,7 @@ class MentraLive : SGCManager() {
2591
2688
  MAX_RETRY_ATTEMPTS +
2592
2689
  ")"
2593
2690
  )
2594
- retryMessage(messageId)
2691
+ retryMessage(messageId, sessionGeneration)
2595
2692
  } else {
2596
2693
  // Max retries reached
2597
2694
  Log.e(
@@ -2602,22 +2699,31 @@ class MentraLive : SGCManager() {
2602
2699
  MAX_RETRY_ATTEMPTS +
2603
2700
  " attempts"
2604
2701
  )
2605
- pendingMessages.remove(messageId)
2702
+ pendingMessages.remove(messageId, pendingMessage)
2606
2703
  }
2704
+ } else if (pendingMessage != null && pendingMessage.sessionGeneration == sessionGeneration) {
2705
+ pendingMessages.remove(messageId, pendingMessage)
2607
2706
  }
2608
2707
  }
2609
2708
 
2610
2709
  /** Retry a message */
2611
- private fun retryMessage(messageId: Long) {
2710
+ private fun retryMessage(messageId: Long, sessionGeneration: Long) {
2612
2711
  val pendingMessage = pendingMessages[messageId]
2613
2712
  if (pendingMessage == null) {
2614
2713
  Log.w(TAG, "Message " + messageId + " no longer tracked for retry")
2615
2714
  return
2616
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
+ }
2617
2723
 
2618
2724
  if (pendingMessage.retryCount >= MAX_RETRY_ATTEMPTS) {
2619
2725
  Log.e(TAG, "Max retries reached for message " + messageId)
2620
- pendingMessages.remove(messageId)
2726
+ pendingMessages.remove(messageId, pendingMessage)
2621
2727
  return
2622
2728
  }
2623
2729
 
@@ -2627,11 +2733,18 @@ class MentraLive : SGCManager() {
2627
2733
  pendingMessage.messageData,
2628
2734
  System.currentTimeMillis(),
2629
2735
  pendingMessage.retryCount + 1,
2630
- pendingMessage.retryRunnable
2736
+ pendingMessage.retryRunnable,
2737
+ sessionGeneration
2631
2738
  )
2632
2739
 
2633
- // Update the tracked message
2634
- 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
+ }
2635
2748
 
2636
2749
  // Send the message again
2637
2750
  Bridge.log(
@@ -2641,10 +2754,10 @@ class MentraLive : SGCManager() {
2641
2754
  retryMessage.retryCount +
2642
2755
  ")"
2643
2756
  )
2644
- sendDataToGlasses(pendingMessage.messageData, false)
2757
+ sendDataToGlasses(pendingMessage.messageData, false, sessionGeneration)
2645
2758
 
2646
2759
  // Schedule next ACK check
2647
- handler.postDelayed({ checkMessageAck(messageId) }, ACK_TIMEOUT_MS)
2760
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, ACK_TIMEOUT_MS)
2648
2761
  }
2649
2762
 
2650
2763
  /** Process ACK response from glasses */
@@ -3328,6 +3441,7 @@ class MentraLive : SGCManager() {
3328
3441
  if (osStepSequence != null && osStepSequence.length() > 0) {
3329
3442
  cachedOtaStepSequence = osStepSequence
3330
3443
  }
3444
+ updateBesOtaHeartbeatGuard(osStepType, osPhase, osStatus)
3331
3445
 
3332
3446
  Bridge.log(
3333
3447
  "LIVE: 📱 OTA status - step " +
@@ -3378,6 +3492,7 @@ class MentraLive : SGCManager() {
3378
3492
  } else {
3379
3493
  unified = "in_progress"
3380
3494
  }
3495
+ updateBesOtaHeartbeatGuard(currentUpdate, legacyPhase, unified)
3381
3496
  Bridge.log(
3382
3497
  "LIVE: 📱 Legacy ota_progress → ota_status: " +
3383
3498
  legacyStage +
@@ -4468,15 +4583,28 @@ class MentraLive : SGCManager() {
4468
4583
  var progress = ((rawProgress + 2) / 5) * 5
4469
4584
  if (progress > 100) progress = 100
4470
4585
 
4471
- // Only send if progress changed to a new 5% increment
4472
- if (progress == lastBesOtaProgress &&
4473
- "success" != type &&
4474
- "error" != type &&
4475
- "fail" != type
4476
- ) {
4477
- 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
4478
4607
  }
4479
- lastBesOtaProgress = progress
4480
4608
 
4481
4609
  Bridge.log(
4482
4610
  "LIVE: 📱 BES OTA progress via sr_adota - type: " +
@@ -4485,58 +4613,21 @@ class MentraLive : SGCManager() {
4485
4613
  rawProgress +
4486
4614
  "%, rounded: " +
4487
4615
  progress +
4616
+ "%, display: " +
4617
+ besOtaProgressVal +
4488
4618
  "%"
4489
4619
  )
4490
4620
 
4491
- // Determine status and error message based on type
4492
- val besOtaStatus: String
4493
- val besOtaProgressVal: Int
4494
- var besOtaErrorMessage: String? = null
4495
-
4496
- // Order matters here: check completion (rawProgress >= 100 OR success)
4497
- // BEFORE
4498
- // type=="update", because some BES firmware emits the final 100% tick with
4499
- // type=="update" rather than type=="success". Treating that as PROGRESS
4500
- // would
4501
- // leave the UI stuck at 100% forever.
4502
- if ("success" == type || rawProgress >= 100) {
4503
- besOtaStatus = "FINISHED"
4504
- besOtaProgressVal = 100
4621
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
4505
4622
  lastBesOtaProgress = -1 // Reset for next OTA
4506
- } else if ("error" == type || "fail" == type) {
4507
- besOtaStatus = "FAILED"
4508
- besOtaProgressVal = progress
4509
- besOtaErrorMessage = bodyObj.optString("message", "BES update failed")
4510
- lastBesOtaProgress = -1 // Reset for next OTA
4511
- } else if ("update" == type) {
4512
- besOtaStatus = "PROGRESS"
4513
- besOtaProgressVal = progress
4514
4623
  } else {
4515
- // Unknown type, treat as progress
4516
- besOtaStatus = "PROGRESS"
4517
- besOtaProgressVal = progress
4624
+ lastBesOtaProgress = besOtaProgressVal
4518
4625
  }
4519
4626
 
4520
- val syntheticStatus: String
4521
- if ("FINISHED" == besOtaStatus) {
4522
- // The glasses power-cycle right after the final BES tick, so a
4523
- // session whose BES step is the LAST step never gets a follow-up
4524
- // ota_status from the glasses — consumers mapping on this synthetic
4525
- // status would otherwise never see a terminal state. Emit "complete"
4526
- // for the final step; mid-session BES steps keep "step_complete" so
4527
- // session-level trackers advance normally. Unknown sessions
4528
- // (cachedOtaTotalSteps == 0, e.g. legacy glasses that never sent an
4529
- // ota_status) conservatively keep "step_complete".
4530
- syntheticStatus =
4531
- if (cachedOtaTotalSteps > 0 &&
4532
- cachedOtaCurrentStep >= cachedOtaTotalSteps
4533
- )
4534
- "complete"
4535
- else "step_complete"
4536
- } else if ("FAILED" == besOtaStatus) {
4537
- syntheticStatus = "failed"
4538
- } else {
4539
- syntheticStatus = "in_progress"
4627
+ val syntheticStatus = when (besOtaStatus) {
4628
+ "FINISHED" -> "step_complete"
4629
+ "FAILED" -> "failed"
4630
+ else -> "in_progress"
4540
4631
  }
4541
4632
  val sid = if (cachedOtaSessionId != null) cachedOtaSessionId!! else ""
4542
4633
  val totalSteps = if (cachedOtaTotalSteps > 0) cachedOtaTotalSteps else 1
@@ -5001,6 +5092,10 @@ class MentraLive : SGCManager() {
5001
5092
  Bridge.log("LIVE: Skipping heartbeat - glasses not ready or not connected")
5002
5093
  return
5003
5094
  }
5095
+ if (besOtaHeartbeatGuard.shouldSuppress(SystemClock.elapsedRealtime())) {
5096
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5097
+ return
5098
+ }
5004
5099
 
5005
5100
  try {
5006
5101
  // Send ping message (no ACK needed for heartbeats)
@@ -7570,10 +7665,22 @@ class MentraLive : SGCManager() {
7570
7665
  * @param data The string data to be sent to the glasses
7571
7666
  */
7572
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
+ ) {
7573
7676
  if (data == null || data.isEmpty()) {
7574
7677
  Log.e(TAG, "Cannot send empty data to glasses")
7575
7678
  return
7576
7679
  }
7680
+ if (sessionGeneration != bleSessionGeneration.get()) {
7681
+ Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
7682
+ return
7683
+ }
7577
7684
 
7578
7685
  val wireData =
7579
7686
  try {
@@ -7600,7 +7707,13 @@ class MentraLive : SGCManager() {
7600
7707
  }
7601
7708
 
7602
7709
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
7603
- sendDataToGlassesBinary(wireData, wakeup, commandTraceInfo, isPhotoRequest)
7710
+ sendDataToGlassesBinary(
7711
+ wireData,
7712
+ wakeup,
7713
+ commandTraceInfo,
7714
+ isPhotoRequest,
7715
+ sessionGeneration
7716
+ )
7604
7717
  return
7605
7718
  }
7606
7719
 
@@ -7679,7 +7792,7 @@ class MentraLive : SGCManager() {
7679
7792
  )
7680
7793
 
7681
7794
  // Queue the chunk for sending
7682
- queueData(packedData, trace)
7795
+ queueData(packedData, trace, sessionGeneration)
7683
7796
 
7684
7797
  // Add small delay between chunks to avoid overwhelming the connection
7685
7798
  if (i < chunks.size - 1) {
@@ -7720,7 +7833,7 @@ class MentraLive : SGCManager() {
7720
7833
  )
7721
7834
 
7722
7835
  // Queue the data for sending
7723
- queueData(packedData, trace)
7836
+ queueData(packedData, trace, sessionGeneration)
7724
7837
  if (isPhotoRequest) {
7725
7838
  Bridge.log(
7726
7839
  "LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
@@ -7738,7 +7851,8 @@ class MentraLive : SGCManager() {
7738
7851
  data: String,
7739
7852
  wakeup: Boolean,
7740
7853
  commandTraceInfo: OutgoingBleCommandTraceInfo,
7741
- isPhotoRequest: Boolean
7854
+ isPhotoRequest: Boolean,
7855
+ sessionGeneration: Long
7742
7856
  ) {
7743
7857
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
7744
7858
  var messageId = 0
@@ -7790,7 +7904,7 @@ class MentraLive : SGCManager() {
7790
7904
  "wireProtocol" to "v2"
7791
7905
  )
7792
7906
  )
7793
- queueData(packedData, trace)
7907
+ queueData(packedData, trace, sessionGeneration)
7794
7908
 
7795
7909
  if (i < fragments.size - 1) {
7796
7910
  try {
@@ -0,0 +1,73 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class BesOtaHeartbeatGuardTest {
8
+ @Test
9
+ fun leaseSuppressesUntilDeadlineAndThenExpires() {
10
+ val guard = BesOtaHeartbeatGuard(120_000)
11
+ guard.refresh(1_000)
12
+
13
+ assertTrue(guard.shouldSuppress(120_999))
14
+ assertFalse(guard.shouldSuppress(121_000))
15
+ }
16
+
17
+ @Test
18
+ fun progressRenewsLease() {
19
+ val guard = BesOtaHeartbeatGuard(120_000)
20
+ guard.refresh(1_000)
21
+ guard.refresh(60_000)
22
+
23
+ assertTrue(guard.shouldSuppress(121_000))
24
+ assertFalse(guard.shouldSuppress(180_000))
25
+ }
26
+
27
+ @Test
28
+ fun terminalStatusClearsLeaseImmediately() {
29
+ val guard = BesOtaHeartbeatGuard(120_000)
30
+ guard.refresh(1_000)
31
+ guard.clear()
32
+
33
+ assertFalse(guard.shouldSuppress(1_001))
34
+ }
35
+
36
+ @Test
37
+ fun besInstallStatusArmsLease() {
38
+ val guard = BesOtaHeartbeatGuard(120_000)
39
+
40
+ guard.observeOtaStatus("BES", "INSTALL", "in_progress", 1_000)
41
+
42
+ assertTrue(guard.shouldSuppress(120_999))
43
+ }
44
+
45
+ @Test
46
+ fun legacyBesInstallStatusUsesSameLease() {
47
+ val guard = BesOtaHeartbeatGuard(120_000)
48
+
49
+ guard.observeOtaStatus("bes", "install", "in_progress", 5_000)
50
+
51
+ assertTrue(guard.shouldSuppress(124_999))
52
+ }
53
+
54
+ @Test
55
+ fun unrelatedStatusDoesNotClearActiveLease() {
56
+ val guard = BesOtaHeartbeatGuard(120_000)
57
+ guard.observeOtaStatus("bes", "install", "in_progress", 1_000)
58
+
59
+ guard.observeOtaStatus("apk", "install", "complete", 2_000)
60
+
61
+ assertTrue(guard.shouldSuppress(120_999))
62
+ }
63
+
64
+ @Test
65
+ fun besStepCompleteClearsLease() {
66
+ val guard = BesOtaHeartbeatGuard(120_000)
67
+ guard.observeOtaStatus("bes", "install", "in_progress", 1_000)
68
+
69
+ guard.observeOtaStatus("bes", "install", "step_complete", 2_000)
70
+
71
+ assertFalse(guard.shouldSuppress(2_001))
72
+ }
73
+ }