@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.
@@ -704,14 +704,16 @@ struct ViewState {
704
704
  let layoutType = currentViewState.layoutType
705
705
  switch layoutType {
706
706
  case "text_wall":
707
- let text = currentViewState.text
707
+ let text = parsePlaceholders(currentViewState.text)
708
708
  await sgc?.sendTextWall(text)
709
709
  case "double_text_wall":
710
- let topText = currentViewState.topText
711
- let bottomText = currentViewState.bottomText
710
+ let topText = parsePlaceholders(currentViewState.topText)
711
+ let bottomText = parsePlaceholders(currentViewState.bottomText)
712
712
  await sgc?.sendDoubleTextWall(topText, bottomText)
713
713
  case "reference_card":
714
- await sgc?.sendTextWall(currentViewState.title + "\n\n" + currentViewState.text)
714
+ let title = parsePlaceholders(currentViewState.title)
715
+ let text = parsePlaceholders(currentViewState.text)
716
+ await sgc?.sendTextWall(title + "\n\n" + text)
715
717
  case "bitmap_view":
716
718
  // Bridge.log("MAN: Processing bitmap_view layout")
717
719
  guard let data = currentViewState.data else {
@@ -727,11 +729,12 @@ struct ViewState {
727
729
  height: currentViewState.bmpHeight
728
730
  )
729
731
  case "positioned_text":
732
+ let text = parsePlaceholders(currentViewState.text)
730
733
  Bridge.log(
731
- "MAN: positioned_text -> text='\(currentViewState.text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
734
+ "MAN: positioned_text -> text='\(text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
732
735
  )
733
736
  await sgc?.sendPositionedText(
734
- currentViewState.text,
737
+ text,
735
738
  x: currentViewState.bmpX ?? 0,
736
739
  y: currentViewState.bmpY ?? 0,
737
740
  width: currentViewState.bmpWidth ?? 576,
@@ -753,6 +756,8 @@ struct ViewState {
753
756
  }
754
757
 
755
758
  func parsePlaceholders(_ text: String) -> String {
759
+ guard text.contains("$") else { return text }
760
+
756
761
  let dateFormatter = DateFormatter()
757
762
  dateFormatter.dateFormat = "M/dd, h:mm"
758
763
  let formattedDate = dateFormatter.string(from: Date())
@@ -1712,11 +1717,13 @@ struct ViewState {
1712
1717
  shouldSendBootingMessage = true // Reset for next first connect
1713
1718
  // clear glasses properties:
1714
1719
  DeviceStore.shared.apply("glasses", "deviceModel", "")
1715
- // A manufacturing serial is session-bound. Clear it on every disconnect so a
1716
- // previously connected pair's serial can never be reported for the next
1717
- // connection (e.g. switching from G1/Ar99, which populate it from the
1718
- // advertisement, to a model that never writes it, like G2).
1720
+ // Device identifiers are session-bound. Clear them on every disconnect so a
1721
+ // previously connected pair can never be reported for the next connection.
1719
1722
  DeviceStore.shared.apply("glasses", "serialNumber", "")
1723
+ DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
1724
+ DeviceStore.shared.apply("glasses", "leftMacAddress", "")
1725
+ DeviceStore.shared.apply("glasses", "rightMacAddress", "")
1726
+ DeviceStore.shared.apply("glasses", "macAddress", "")
1720
1727
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
1721
1728
  DeviceStore.shared.apply("glasses", "connected", false)
1722
1729
  DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
@@ -1807,4 +1814,3 @@ struct ViewState {
1807
1814
  cancellables.removeAll()
1808
1815
  }
1809
1816
  }
1810
-
@@ -35,6 +35,9 @@ class DeviceStore {
35
35
  store.set("glasses", "caseCharging", false)
36
36
  store.set("glasses", "caseBatteryLevel", -1)
37
37
  store.set("glasses", "headUp", false)
38
+ store.set("glasses", "bluetoothMacAddress", "")
39
+ store.set("glasses", "leftMacAddress", "")
40
+ store.set("glasses", "rightMacAddress", "")
38
41
  store.set("glasses", "serialNumber", "")
39
42
  store.set("glasses", "style", "")
40
43
  store.set("glasses", "color", "")
@@ -11,6 +11,14 @@ import CoreBluetooth
11
11
  import Foundation
12
12
  import UIKit
13
13
 
14
+ /// Keep a low-rate, non-audio EvenHub sensor stream active so iOS continues receiving BLE
15
+ /// notifications while the Mentra App is backgrounded. The samples stay internal unless IMU was
16
+ /// explicitly enabled through Mentra's developer-facing setting.
17
+ private let g2ImuKeepaliveEnabled = true
18
+ private let g2ImuKeepaliveReportPace: Int32 = 1000
19
+ private let g2ImuConfirmationDelay: TimeInterval = 2
20
+ private let g2ImuMaxControlAttempts = 3
21
+
14
22
  // MARK: - Data Little-Endian Helpers (for BMP construction)
15
23
 
16
24
  extension Data {
@@ -1524,6 +1532,8 @@ class G2: NSObject, SGCManager {
1524
1532
  private var foregroundObserver: NSObjectProtocol?
1525
1533
  private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1526
1534
  private var pageCreated: Bool = false
1535
+ private var pageGeneration: UInt64 = 0
1536
+ private var lastImuReportTimestamp: Int64?
1527
1537
  // Live hardware truth: is the firmware mic actually streaming. DISTINCT from the
1528
1538
  // glasses/micEnabled DeviceStore flag, which is *intent* (does the user want the mic on).
1529
1539
  // Cleared on every page teardown (the firmware kills the mic with the page) WITHOUT touching
@@ -2959,11 +2969,17 @@ class G2: NSObject, SGCManager {
2959
2969
  }
2960
2970
  signalDisplayDirty()
2961
2971
 
2962
- try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
2972
+ try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
2973
+ syncImuReportingWithIntent()
2974
+ confirmImuReporting(
2975
+ forPageGeneration: pageGeneration,
2976
+ commandSentAt: Int64(Date().timeIntervalSince1970 * 1000),
2977
+ attemptsRemaining: g2ImuMaxControlAttempts - 1
2978
+ )
2963
2979
  restartMicIfAlreadyEnabled()
2964
2980
  }
2965
2981
 
2966
- /// Single coalesced recovery: rebuild the page + re-arm the mic from intent, but never
2982
+ /// Single coalesced recovery: rebuild the page + re-arm sensor streams from intent, but never
2967
2983
  /// stack rebuilds. The firmware spams systemExit/dashboard-close ~1×/sec; without this
2968
2984
  /// guard each one triggered a rebuild that was torn down again → rebuild→exit→rebuild
2969
2985
  /// storm. At most one rebuild in flight, and at most one per RECOVERY_DEBOUNCE_MS.
@@ -3481,6 +3497,8 @@ class G2: NSObject, SGCManager {
3481
3497
  appId: activeMenuAppId
3482
3498
  )
3483
3499
  }
3500
+ pageGeneration &+= 1
3501
+ lastImuReportTimestamp = nil
3484
3502
  sendEvenHubCommand(msg)
3485
3503
  pageCreated = true
3486
3504
  }
@@ -3492,6 +3510,62 @@ class G2: NSObject, SGCManager {
3492
3510
  }
3493
3511
  }
3494
3512
 
3513
+ /// Keep the firmware IMU stream alive at its slowest supported pace even when no miniapp
3514
+ /// requested motion data. Incoming keepalive samples are discarded in `handleTouchEvent`.
3515
+ private func syncImuReportingWithIntent(
3516
+ _ intentEnabled: Bool? = nil,
3517
+ requestedReportPace: Int32 = EvenHubProto.imuPaceP100
3518
+ ) {
3519
+ guard pageCreated else { return }
3520
+
3521
+ let shouldForwardSamples =
3522
+ intentEnabled
3523
+ ?? (DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false)
3524
+ let shouldStream = shouldForwardSamples || g2ImuKeepaliveEnabled
3525
+ let reportPace =
3526
+ shouldForwardSamples ? requestedReportPace : g2ImuKeepaliveReportPace
3527
+
3528
+ Bridge.log(
3529
+ "G2: syncing IMU stream — hardware=\(shouldStream), forward=\(shouldForwardSamples), pace=\(reportPace)"
3530
+ )
3531
+ let msg = EvenHubProto.imuControlMessage(
3532
+ enable: shouldStream,
3533
+ reportFrq: reportPace,
3534
+ magicRandom: sendManager.nextMagicRandom()
3535
+ )
3536
+ sendEvenHubCommand(msg)
3537
+ }
3538
+
3539
+ /// EvenHub does not ACK IMU control commands, so use the data stream itself as confirmation.
3540
+ /// A page-generation guard prevents a delayed retry from targeting a replacement page.
3541
+ private func confirmImuReporting(
3542
+ forPageGeneration generation: UInt64,
3543
+ commandSentAt: Int64,
3544
+ attemptsRemaining: Int
3545
+ ) {
3546
+ guard attemptsRemaining > 0 else { return }
3547
+
3548
+ DispatchQueue.main.asyncAfter(deadline: .now() + g2ImuConfirmationDelay) { [weak self] in
3549
+ guard let self,
3550
+ self.pageCreated,
3551
+ self.pageGeneration == generation,
3552
+ g2ImuKeepaliveEnabled
3553
+ || (DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false),
3554
+ !(self.lastImuReportTimestamp.map { $0 >= commandSentAt } ?? false)
3555
+ else { return }
3556
+
3557
+ Bridge.log(
3558
+ "G2: no IMU report received for page generation \(generation); retrying control (\(attemptsRemaining) remaining)"
3559
+ )
3560
+ self.syncImuReportingWithIntent()
3561
+ self.confirmImuReporting(
3562
+ forPageGeneration: generation,
3563
+ commandSentAt: Int64(Date().timeIntervalSince1970 * 1000),
3564
+ attemptsRemaining: attemptsRemaining - 1
3565
+ )
3566
+ }
3567
+ }
3568
+
3495
3569
  func restartMic() {
3496
3570
  // Intent is "mic on". The mic only exists inside a live EvenHub page, so we
3497
3571
  // toggle it off then back on (the firmware needs the off→on edge to re-arm).
@@ -3829,21 +3903,7 @@ class G2: NSObject, SGCManager {
3829
3903
  await rebuildState()
3830
3904
  }
3831
3905
 
3832
- let send = { [weak self] in
3833
- guard let self = self else { return }
3834
- let msg = EvenHubProto.imuControlMessage(
3835
- enable: enabled, reportFrq: reportFrq,
3836
- magicRandom: self.sendManager.nextMagicRandom()
3837
- )
3838
- self.sendEvenHubCommand(msg)
3839
- }
3840
-
3841
- // If we just asked for a page, give it a moment to be created first.
3842
- if enabled, !pageCreated {
3843
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: send)
3844
- } else {
3845
- send()
3846
- }
3906
+ syncImuReportingWithIntent(enabled, requestedReportPace: reportFrq)
3847
3907
  }
3848
3908
 
3849
3909
  // MARK: - SGCManager: Device Control
@@ -4367,16 +4427,9 @@ class G2: NSObject, SGCManager {
4367
4427
  return
4368
4428
  }
4369
4429
 
4370
-
4371
4430
  if cmdValue == EvenHubResponseCmd.osNotifyEventToApp.rawValue {
4372
- // Touch/gesture event from glasses
4431
+ // Device event from glasses (touch/gesture or sensor data)
4373
4432
  guard let devEventData = fields[13] as? Data else { return }
4374
- let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
4375
- if lastClickTimestamp != nil && timestamp - lastClickTimestamp! < 100 {
4376
- // Bridge.log("G2: Double click ignored (too soon)")
4377
- return
4378
- }
4379
- lastClickTimestamp = timestamp
4380
4433
  handleTouchEvent(devEventData)
4381
4434
  } else if cmdValue == 17 {
4382
4435
  // Miniapp selection from glasses dashboard menu (cmdId=17)
@@ -4526,6 +4579,35 @@ class G2: NSObject, SGCManager {
4526
4579
 
4527
4580
  let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
4528
4581
 
4582
+ // Classify IMU reports before applying the click debounce. Keepalive samples arrive
4583
+ // continuously and must not update `lastClickTimestamp`, or a real gesture delivered
4584
+ // within 100 ms of a sample can be dropped.
4585
+ var parsedSysFields: [Int: Any]?
4586
+ if let sysData = fields[3] as? Data {
4587
+ var sysReader = ProtobufReader(sysData)
4588
+ let sysFields = sysReader.parseFields()
4589
+ if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue {
4590
+ lastImuReportTimestamp = timestamp
4591
+ let shouldForwardSamples =
4592
+ DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false
4593
+ if shouldForwardSamples,
4594
+ let imuData = sysFields[3] as? Data,
4595
+ let imu = parseImuReportData(imuData)
4596
+ {
4597
+ Bridge.log("G2: IMU data report: \(imu.x), \(imu.y), \(imu.z)")
4598
+ Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
4599
+ }
4600
+ return
4601
+ }
4602
+ parsedSysFields = sysFields
4603
+ }
4604
+
4605
+ if lastClickTimestamp != nil && timestamp - lastClickTimestamp! < 100 {
4606
+ // Bridge.log("G2: Double click ignored (too soon)")
4607
+ return
4608
+ }
4609
+ lastClickTimestamp = timestamp
4610
+
4529
4611
  // if we are receiving touch events we are fully booted:
4530
4612
  setFullyConnected()
4531
4613
 
@@ -4534,22 +4616,7 @@ class G2: NSObject, SGCManager {
4534
4616
  // "G2: handleTouchEvent: \(devEventData.map { String(format: "%02X", $0) }.joined())")
4535
4617
 
4536
4618
  // SysEvent (field 3) - system-level gestures
4537
- if let sysData = fields[3] as? Data {
4538
- var sysReader = ProtobufReader(sysData)
4539
- let sysFields = sysReader.parseFields()
4540
-
4541
- // IMU data report: eventType == IMU_DATA_REPORT (8), imuData in field 3
4542
- // (IMU_Report_Data { x, y, z } as 32-bit floats). Handle and return before
4543
- // the gesture-mapping path.
4544
- if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue,
4545
- let imuData = sysFields[3] as? Data,
4546
- let imu = parseImuReportData(imuData)
4547
- {
4548
- Bridge.log("G2: IMU data report: \(imu.x), \(imu.y), \(imu.z)")
4549
- Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
4550
- return
4551
- }
4552
-
4619
+ if let sysFields = parsedSysFields {
4553
4620
  var eventType: OsEventType? = nil
4554
4621
  var eventSource: Int32? = nil
4555
4622
  if let normalType = sysFields[1] as? Int32 {
@@ -1282,13 +1282,14 @@ class MentraLive: NSObject, SGCManager {
1282
1282
  /// Mirrors Android `updateConnectionState` — RN home reads `glasses.connectionState` for reconnecting UI.
1283
1283
  private func updateConnectionState(_ state: String) {
1284
1284
  if state == ConnTypes.DISCONNECTED {
1285
- // A manufacturing serial is session-bound. Clear it on disconnect so a previous
1286
- // pair's serial cannot be associated with the next connection. Connect must NOT
1287
- // clear it: DeviceManager.disconnect already wipes it before any new connection, and
1288
- // clearing on CONNECTED would wipe a still-valid serial mid-session when a same-link
1289
- // glasses_ready (e.g. ASG restart) re-publishes CONNECTED (iOS has no equal-state
1290
- // early return, unlike Android).
1285
+ // Device identity is session-bound. Clear it on disconnect so a previous pair's
1286
+ // identifiers cannot be associated with the next connection. Connect must NOT clear
1287
+ // them: DeviceManager.disconnect already wipes them before any new connection, and
1288
+ // clearing on CONNECTED would wipe still-valid identity mid-session when a same-link
1289
+ // glasses_ready (e.g. ASG restart) re-publishes CONNECTED (iOS has no equal-state early
1290
+ // return, unlike Android).
1291
1291
  DeviceStore.shared.apply("glasses", "serialNumber", "")
1292
+ DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
1292
1293
  }
1293
1294
  connectionState = state
1294
1295
  DeviceStore.shared.apply("glasses", "connectionState", state)
@@ -1296,6 +1297,7 @@ class MentraLive: NSObject, SGCManager {
1296
1297
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
1297
1298
  // or stale lastBesOtaProgress on the next OTA).
1298
1299
  if state == ConnTypes.DISCONNECTED {
1300
+ resetPendingAckState(reason: "BLE session ended")
1299
1301
  resetWireNegotiationState()
1300
1302
  resetAncsRelayState()
1301
1303
  // Queued writes are session-bound: transmitting them into the NEXT session
@@ -1451,6 +1453,7 @@ class MentraLive: NSObject, SGCManager {
1451
1453
  private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
1452
1454
  private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
1453
1455
  private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
1456
+ private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
1454
1457
  private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
1455
1458
  private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
1456
1459
  private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
@@ -1545,6 +1548,7 @@ class MentraLive: NSObject, SGCManager {
1545
1548
 
1546
1549
  /// BES OTA progress tracking - only send to UI on 5% increments
1547
1550
  private var lastBesOtaProgress = -1
1551
+ private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
1548
1552
 
1549
1553
  // Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
1550
1554
  private var cachedOtaSessionId: String?
@@ -1940,6 +1944,18 @@ class MentraLive: NSObject, SGCManager {
1940
1944
  private var pending: PendingMessage?
1941
1945
  private var pendingMessageTimer: Timer?
1942
1946
 
1947
+ /// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
1948
+ /// is outstanding, keeping `pending` set permanently blocks the command queue because the
1949
+ /// timeout timer is stopped during disconnect. Release both together before reconnecting.
1950
+ private func resetPendingAckState(reason: String) {
1951
+ pendingMessageTimer?.invalidate()
1952
+ pendingMessageTimer = nil
1953
+
1954
+ guard let pendingMessage = pending else { return }
1955
+ pending = nil
1956
+ Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
1957
+ }
1958
+
1943
1959
  actor CommandQueue {
1944
1960
  private var commands: [PendingMessage] = []
1945
1961
 
@@ -2668,6 +2684,7 @@ class MentraLive: NSObject, SGCManager {
2668
2684
  if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
2669
2685
  cachedOtaStepSequence = seq
2670
2686
  }
2687
+ updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
2671
2688
 
2672
2689
  Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
2673
2690
 
@@ -2701,6 +2718,7 @@ class MentraLive: NSObject, SGCManager {
2701
2718
  } else {
2702
2719
  unified = "in_progress"
2703
2720
  }
2721
+ updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
2704
2722
  Bridge.log(
2705
2723
  "LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
2706
2724
  )
@@ -2760,10 +2778,10 @@ class MentraLive: NSObject, SGCManager {
2760
2778
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2761
2779
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2762
2780
  }
2763
- if let bluetoothMacAddress = fields["bt_mac_address"] as? String {
2781
+ if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
2764
2782
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2765
2783
  }
2766
- if let serialNumber = fields["serial_number"] as? String, !serialNumber.isEmpty {
2784
+ if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
2767
2785
  DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2768
2786
  }
2769
2787
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
@@ -2828,6 +2846,19 @@ class MentraLive: NSObject, SGCManager {
2828
2846
  cachedOtaCurrentStep = 0
2829
2847
  cachedOtaStepSequence = nil
2830
2848
  lastBesOtaProgress = -1
2849
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
2850
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
2851
+ }
2852
+
2853
+ private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
2854
+ guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
2855
+ switch status.lowercased() {
2856
+ case "failed", "complete", "step_complete", "finished", "success":
2857
+ besOtaHeartbeatSuppressedUntil = 0
2858
+ default:
2859
+ besOtaHeartbeatSuppressedUntil =
2860
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
2861
+ }
2831
2862
  }
2832
2863
 
2833
2864
  /// Falls back to raw besProgress when step sequence is unavailable.
@@ -2999,62 +3030,41 @@ class MentraLive: NSObject, SGCManager {
2999
3030
  var progress = ((rawProgress + 2) / 5) * 5
3000
3031
  if progress > 100 { progress = 100 }
3001
3032
 
3002
- // Only send if progress changed to a new 5% increment
3003
3033
  let isTerminalStatus = type == "success" || type == "error" || type == "fail"
3004
- if progress == lastBesOtaProgress && !isTerminalStatus {
3005
- break // Skip duplicate progress
3006
- }
3007
- lastBesOtaProgress = progress
3008
-
3009
- Bridge.log(
3010
- "LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%"
3011
- )
3012
-
3013
- // Determine status and error message based on type
3014
- var besOtaStatus: String
3015
3034
  var besOtaProgressVal: Int
3016
3035
  var besOtaErrorMessage: String? = nil
3017
3036
 
3018
- // Order matters here: check completion (rawProgress >= 100 OR success) BEFORE
3019
- // type=="update", because some BES firmware emits the final 100% tick with
3020
- // type=="update" rather than type=="success". Treating that as PROGRESS would
3021
- // leave the UI stuck at 100% forever.
3022
- if type == "success" || rawProgress >= 100 {
3023
- besOtaStatus = "FINISHED"
3037
+ let failed = type == "error" || type == "fail"
3038
+ let succeeded = type == "success"
3039
+ if isTerminalStatus {
3040
+ besOtaHeartbeatSuppressedUntil = 0
3041
+ } else {
3042
+ // Refresh before progress de-duplication: raw progress proves the BES
3043
+ // transfer is active even when the rounded UI value is unchanged.
3044
+ besOtaHeartbeatSuppressedUntil =
3045
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
3046
+ }
3047
+ if succeeded {
3024
3048
  besOtaProgressVal = 100
3025
- lastBesOtaProgress = -1 // Reset for next OTA
3026
- } else if type == "error" || type == "fail" {
3027
- besOtaStatus = "FAILED"
3049
+ } else if failed {
3028
3050
  besOtaProgressVal = progress
3029
3051
  besOtaErrorMessage = bodyObj["message"] as? String ?? "BES update failed"
3030
- lastBesOtaProgress = -1 // Reset for next OTA
3031
- } else if type == "update" {
3032
- besOtaStatus = "PROGRESS"
3033
- besOtaProgressVal = progress
3034
3052
  } else {
3035
- // Unknown type, treat as progress
3036
- besOtaStatus = "PROGRESS"
3037
- besOtaProgressVal = progress
3053
+ // A raw update:100 precedes whole-image CRC/apply and is not terminal.
3054
+ besOtaProgressVal = min(progress, 95)
3038
3055
  }
3039
3056
 
3040
- let syntheticStatus: String
3041
- if besOtaStatus == "FINISHED" {
3042
- // The glasses power-cycle right after the final BES tick, so a session
3043
- // whose BES step is the LAST step never gets a follow-up ota_status from
3044
- // the glasses — consumers mapping on this synthetic status would otherwise
3045
- // never see a terminal state. Emit "complete" for the final step;
3046
- // mid-session BES steps keep "step_complete" so session-level trackers
3047
- // advance normally. Unknown sessions (cachedOtaTotalSteps == 0, e.g.
3048
- // legacy glasses that never sent an ota_status) conservatively keep
3049
- // "step_complete".
3050
- syntheticStatus = (cachedOtaTotalSteps > 0 && cachedOtaCurrentStep >= cachedOtaTotalSteps)
3051
- ? "complete"
3052
- : "step_complete"
3053
- } else if besOtaStatus == "FAILED" {
3054
- syntheticStatus = "failed"
3055
- } else {
3056
- syntheticStatus = "in_progress"
3057
+ // Only send if nonterminal display progress changed to a new 5% increment.
3058
+ if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
3059
+ break
3057
3060
  }
3061
+ lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
3062
+
3063
+ Bridge.log(
3064
+ "LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
3065
+ )
3066
+
3067
+ let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
3058
3068
  let sid = cachedOtaSessionId ?? ""
3059
3069
  let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
3060
3070
  let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
@@ -3441,15 +3451,17 @@ class MentraLive: NSObject, SGCManager {
3441
3451
  let androidVersion = json["android_version"] as? String ?? ""
3442
3452
  let otaVersionUrl = json["ota_version_url"] as? String ?? ""
3443
3453
  let firmwareVersion = json["firmware_version"] as? String ?? ""
3444
- let bluetoothMacAddress = json["bt_mac_address"] as? String ?? ""
3445
- let serialNumber = json["serial_number"] as? String ?? ""
3454
+ let bluetoothMacAddress = nonEmptyStringValue(json, "bt_mac_address")
3455
+ let serialNumber = nonEmptyStringValue(json, "serial_number")
3446
3456
 
3447
3457
  DeviceStore.shared.apply("glasses", "appVersion", appVersion)
3448
3458
  DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
3449
3459
  DeviceStore.shared.apply("glasses", "otaVersionUrl", otaVersionUrl)
3450
3460
  DeviceStore.shared.apply("glasses", "firmwareVersion", firmwareVersion)
3451
- DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
3452
- if !serialNumber.isEmpty {
3461
+ if let bluetoothMacAddress {
3462
+ DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
3463
+ }
3464
+ if let serialNumber {
3453
3465
  DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
3454
3466
  }
3455
3467
  isNewVersion = (Int(buildNumber) ?? 0) >= 5
@@ -3462,14 +3474,14 @@ class MentraLive: NSObject, SGCManager {
3462
3474
  // hasMic = supportsLC3Audio
3463
3475
 
3464
3476
  Bridge.log(
3465
- "Glasses Version - App: \(appVersion), Build: \(buildNumber), Device: \(deviceModel), Android: \(androidVersion), Firmware: \(firmwareVersion), BT MAC: \(bluetoothMacAddress), OTA URL: \(otaVersionUrl)"
3477
+ "Glasses Version - App: \(appVersion), Build: \(buildNumber), Device: \(deviceModel), Android: \(androidVersion), Firmware: \(firmwareVersion), BT MAC available: \(bluetoothMacAddress != nil), OTA URL: \(otaVersionUrl)"
3466
3478
  )
3467
3479
  Bridge.log("LIVE: LC3 Audio Support: \(supportsLC3Audio), Has Mic: \(hasMic)")
3468
3480
  emitVersionInfo(
3469
3481
  appVersion: appVersion, buildNumber: buildNumber, deviceModel: deviceModel,
3470
3482
  androidVersion: androidVersion, otaVersionUrl: otaVersionUrl,
3471
3483
  firmwareVersion: firmwareVersion,
3472
- bluetoothMacAddress: bluetoothMacAddress
3484
+ bluetoothMacAddress: bluetoothMacAddress ?? ""
3473
3485
  )
3474
3486
  }
3475
3487
 
@@ -5065,6 +5077,10 @@ class MentraLive: NSObject, SGCManager {
5065
5077
  Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
5066
5078
  return
5067
5079
  }
5080
+ if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
5081
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5082
+ return
5083
+ }
5068
5084
 
5069
5085
  // Send ping message to glasses hardware (no ACK needed for heartbeats)
5070
5086
  let pingJson: [String: Any] = ["type": "ping"]
@@ -5178,8 +5194,7 @@ class MentraLive: NSObject, SGCManager {
5178
5194
  stopReadinessCheckLoop()
5179
5195
  stopConnectionTimeout()
5180
5196
  stopMicBeat() // Stop LC3 audio micbeat
5181
- pendingMessageTimer?.invalidate()
5182
- pendingMessageTimer = nil
5197
+ resetPendingAckState(reason: "transport timers stopped")
5183
5198
  reconnectionWorkItem?.cancel()
5184
5199
  reconnectionWorkItem = nil
5185
5200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "0.1.21-beta.2",
3
+ "version": "0.1.21-beta.5",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -1201,6 +1201,8 @@ export interface OtaUpdateInfo {
1201
1201
  updates: string[] // ["apk", "mtk", "bes"]
1202
1202
  totalSize: number
1203
1203
  cacheReady?: boolean
1204
+ /** Exact BES target selected from the OTA manifest, when a BES step is pending. */
1205
+ besVersion?: string
1204
1206
  /** True when the APK step installs an older build than the glasses currently run (exact-pin manifests only). */
1205
1207
  isDowngrade?: boolean
1206
1208
  }