@mentra/bluetooth-sdk 0.1.10 → 0.1.11

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 (36) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +39 -16
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +22 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -1
  10. package/build/BluetoothSdk.types.d.ts +12 -1
  11. package/build/BluetoothSdk.types.d.ts.map +1 -1
  12. package/build/BluetoothSdk.types.js.map +1 -1
  13. package/build/_private/BluetoothSdkModule.d.ts +1 -0
  14. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  15. package/build/_private/BluetoothSdkModule.js +14 -1
  16. package/build/_private/BluetoothSdkModule.js.map +1 -1
  17. package/build/index.d.ts +1 -1
  18. package/build/index.d.ts.map +1 -1
  19. package/build/index.js +2 -0
  20. package/build/index.js.map +1 -1
  21. package/ios/BluetoothSdkModule.swift +3 -0
  22. package/ios/Source/Bridge.swift +10 -0
  23. package/ios/Source/DeviceManager.swift +24 -21
  24. package/ios/Source/DeviceStore.swift +8 -2
  25. package/ios/Source/camera/CameraModels.swift +1 -0
  26. package/ios/Source/sgcs/G1.swift +3 -3
  27. package/ios/Source/sgcs/G2.swift +1022 -640
  28. package/ios/Source/sgcs/Mach1.swift +2 -2
  29. package/ios/Source/sgcs/MentraLive.swift +2 -2
  30. package/ios/Source/sgcs/MentraNex.swift +215 -85
  31. package/ios/Source/sgcs/SGCManager.swift +13 -4
  32. package/ios/Source/sgcs/Simulated.swift +2 -2
  33. package/package.json +1 -1
  34. package/src/BluetoothSdk.types.ts +13 -1
  35. package/src/_private/BluetoothSdkModule.ts +22 -3
  36. package/src/index.ts +3 -0
@@ -325,7 +325,7 @@ class Mach1: UltraliteBaseViewController, SGCManager {
325
325
  UltraliteManager.shared.stopScan()
326
326
  }
327
327
 
328
- func sendTextWall(_ text: String) {
328
+ func sendTextWall(_ text: String) async {
329
329
  // displayTextWall(text)
330
330
  guard let device = UltraliteManager.shared.currentDevice else {
331
331
  Bridge.log("Mach1Manager: No current device")
@@ -355,7 +355,7 @@ class Mach1: UltraliteBaseViewController, SGCManager {
355
355
  ///
356
356
  /// Column composition is handled by DisplayProcessor in React Native.
357
357
  /// This method is a "dumb pipe" - it just combines and sends the text.
358
- func sendDoubleTextWall(_ topText: String, _ bottomText: String) {
358
+ func sendDoubleTextWall(_ topText: String, _ bottomText: String) async {
359
359
  guard let device = UltraliteManager.shared.currentDevice else {
360
360
  Bridge.log("Mach1Manager: No current device")
361
361
  ready = false
@@ -1229,12 +1229,12 @@ class MentraLive: NSObject, SGCManager {
1229
1229
  return true
1230
1230
  }
1231
1231
 
1232
- func sendDoubleTextWall(_: String, _: String) {}
1232
+ func sendDoubleTextWall(_: String, _: String) async {}
1233
1233
  func setHeadUpAngle(_: Int) {}
1234
1234
  func getBatteryStatus() {}
1235
1235
  func setBrightness(_: Int, autoMode _: Bool) {}
1236
1236
  func clearDisplay() {}
1237
- func sendTextWall(_: String) {}
1237
+ func sendTextWall(_: String) async {}
1238
1238
  func ping() {
1239
1239
  Bridge.log("LIVE: ping()")
1240
1240
  keepAwake()
@@ -81,8 +81,8 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
81
81
  updateGlassesAutoBrightness(autoMode)
82
82
  }
83
83
 
84
- func sendDoubleTextWall(_ top: String, _ bottom: String) {
85
- sendTextWall("\(top)\n\(bottom)")
84
+ func sendDoubleTextWall(_ top: String, _ bottom: String) async {
85
+ await sendTextWall("\(top)\n\(bottom)")
86
86
  }
87
87
 
88
88
  func displayBitmap(base64ImageData: String, x _: Int32? = nil, y _: Int32? = nil, width _: Int32? = nil, height _: Int32? = nil) async -> Bool {
@@ -211,6 +211,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
211
211
  private let maxReconnectionAttempts = -1 // -1 for unlimited
212
212
  private let reconnectionInterval: TimeInterval = 2.0
213
213
  private var peripheralToConnectName: String?
214
+ /// True while the current scan is a user-initiated discovery scan (the "scan for devices"
215
+ /// list). Discovery must NOT short-circuit into reconnecting the last paired device by
216
+ /// stored UUID / saved name — otherwise, once a device has been connected, every later
217
+ /// discovery scan silently reconnects the old glasses instead of listing nearby devices.
218
+ private var isDiscoveryScan = false
214
219
  private let INITIAL_CONNECTION_DELAY_MS: UInt64 = 350
215
220
  private let DELAY_BETWEEN_CHUNKS_SEND_MS: UInt64 = 10
216
221
 
@@ -396,6 +401,24 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
396
401
  private let commandQueue = CommandQueue()
397
402
  private var isQueueWorkerRunning = false
398
403
 
404
+ // MARK: - Text wall coalescing (G2-style)
405
+
406
+ // Captions arrive per interim transcript (several/sec while speaking) but are
407
+ // only worth showing if they're the freshest text. A single latest-wins slot +
408
+ // a 100 ms drain ticker caps glasses-bound text writes at 10/sec and discards
409
+ // stale interim results instead of queueing them. Without this, continuous
410
+ // speech while the phone is locked backlogs the FIFO commandQueue (drain slows
411
+ // at relaxed connection intervals) and the app does unbounded background work.
412
+ // Mirrors G2's pendingTextMsg/drainEvenHubQueue. One repair resend covers a
413
+ // dropped final caption (writes are no-ack); mid-stream drops are repaired by
414
+ // the next update anyway.
415
+ private let textWallLock = NSLock()
416
+ private var pendingTextWall: Data?
417
+ private var lastTextWall: Data?
418
+ private var textWallResendsRemaining = 0
419
+ private let TEXT_WALL_RESEND_COUNT = 1
420
+ private var textWallDrainTask: Task<Void, Never>?
421
+
399
422
  // MARK: - Initialization
400
423
 
401
424
  override private init() {
@@ -508,12 +531,17 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
508
531
  /// reassemble messages larger than one MTU. Single-fragment messages set totalChunks = 1
509
532
  /// and are decoded directly by the firmware's fast path.
510
533
  private func queueDataWithOptimalChunking(
511
- _ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0
534
+ _ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0, emitTelemetry: Bool = true
512
535
  ) {
513
536
  // Telemetry: report the full logical command (type byte + protobuf) before fragmenting.
514
- var packetData = Data([packetType])
515
- packetData.append(data)
516
- emitBleCommandSent(packetData)
537
+ // Callers on high-rate paths (caption text walls, up to 10/sec) pass
538
+ // emitTelemetry: false — emitBleCommandSent re-parses the protobuf,
539
+ // hex-dumps the packet, and crosses to the JS thread per call.
540
+ if emitTelemetry {
541
+ var packetData = Data([packetType])
542
+ packetData.append(data)
543
+ emitBleCommandSent(packetData)
544
+ }
517
545
 
518
546
  let headerSize = 4 // [packetType][seq][totalChunks][chunkIndex]
519
547
  let effectiveChunkSize = max(1, maxChunkSize - headerSize)
@@ -544,9 +572,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
544
572
  index += 1
545
573
  }
546
574
 
547
- Bridge.log(
548
- "NEX: 📦 Fragmented protobuf into \(chunks.count) chunk(s) (seq=\(seq), max payload \(effectiveChunkSize) bytes)"
549
- )
575
+ // No per-send log: fires for every outbound message (up to 10/sec text
576
+ // walls during captions).
577
+ // Bridge.log(
578
+ // "NEX: 📦 Fragmented protobuf into \(chunks.count) chunk(s) (seq=\(seq), max payload \(effectiveChunkSize) bytes)"
579
+ // )
550
580
  queueChunks(chunks, waitTimeMs: waitTimeMs)
551
581
  }
552
582
 
@@ -560,34 +590,34 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
560
590
 
561
591
  // Send each chunk sequentially
562
592
  for (index, chunk) in command.chunks.enumerated() {
563
- let timeSinceConnection = Date().timeIntervalSince1970 * 1000 - lastConnectionTimestamp
564
- if timeSinceConnection < Double(INITIAL_CONNECTION_DELAY_MS) {
565
- let remainingMs = UInt64(Double(INITIAL_CONNECTION_DELAY_MS) - timeSinceConnection)
566
- try? await Task.sleep(nanoseconds: remainingMs * 1_000_000)
567
- }
593
+ // let timeSinceConnection = Date().timeIntervalSince1970 * 1000 - lastConnectionTimestamp
594
+ // if timeSinceConnection < Double(INITIAL_CONNECTION_DELAY_MS) {
595
+ // let remainingMs = UInt64(Double(INITIAL_CONNECTION_DELAY_MS) - timeSinceConnection)
596
+ // try? await Task.sleep(nanoseconds: remainingMs * 1_000_000)
597
+ // }
568
598
  let data = Data(chunk)
569
- Bridge.log(
570
- "NEX: 📦 Sending chunk \(index) of \(command.chunks.count) to \(peripheral.name ?? "Unknown")"
571
- )
572
- Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
573
- // Write WITHOUT response: a write-with-response is one outstanding request at a
574
- // time gated on a remote round trip, which puts the (screen-off-throttled) app
575
- // thread in the path of every fragment and makes captions crawl in the background.
576
- // .withoutResponse lets CoreBluetooth batch fragments into connection events.
577
- // CoreBluetooth does NOT call didWriteValueFor for .withoutResponse, so we use its
578
- // flow-control flag instead of an ack — gating prevents dropped fragments (which
579
- // would make the firmware discard the whole reassembled caption). See
580
- // docs/ble-disconnects-during-captions.md in the firmware repo.
581
- if !peripheral.canSendWriteWithoutResponse {
582
- await waitUntilReadyToWrite()
583
- }
599
+ // Bridge.log(
600
+ // "NEX: 📦 Sending chunk \(index) of \(command.chunks.count) to \(peripheral.name ?? "Unknown")"
601
+ // )
602
+ // Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
603
+ // // Write WITHOUT response: a write-with-response is one outstanding request at a
604
+ // // time gated on a remote round trip, which puts the (screen-off-throttled) app
605
+ // // thread in the path of every fragment and makes captions crawl in the background.
606
+ // // .withoutResponse lets CoreBluetooth batch fragments into connection events.
607
+ // // CoreBluetooth does NOT call didWriteValueFor for .withoutResponse, so we use its
608
+ // // flow-control flag instead of an ack — gating prevents dropped fragments (which
609
+ // // would make the firmware discard the whole reassembled caption). See
610
+ // // docs/ble-disconnects-during-captions.md in the firmware repo.
611
+ // if !peripheral.canSendWriteWithoutResponse {
612
+ // await waitUntilReadyToWrite()
613
+ // }
584
614
  peripheral.writeValue(data, for: writeCharacteristic, type: .withoutResponse)
585
615
 
586
- // Delay between chunks except maybe after the last chunk if waitTime will handle it
587
- if index < command.chunks.count - 1 {
588
- try? await Task.sleep(nanoseconds: UInt64(command.chunkDelayMs) * 1_000_000)
589
- }
590
- try? await Task.sleep(nanoseconds: DELAY_BETWEEN_CHUNKS_SEND_MS * 1_000_000)
616
+ // // Delay between chunks except maybe after the last chunk if waitTime will handle it
617
+ // if index < command.chunks.count - 1 {
618
+ // try? await Task.sleep(nanoseconds: UInt64(command.chunkDelayMs) * 1_000_000)
619
+ // }
620
+ // try? await Task.sleep(nanoseconds: DELAY_BETWEEN_CHUNKS_SEND_MS * 1_000_000)
591
621
  }
592
622
 
593
623
  // Optional wait after the command
@@ -666,6 +696,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
666
696
  "Mentra_([0-9A-Fa-f]+)",
667
697
  "NEX_([0-9A-Fa-f]+)",
668
698
  "MENTRA_NEX_([0-9A-Fa-f]+)",
699
+ "MENTRA_DISPLAY_([0-9A-Fa-f]+)",
669
700
  ]
670
701
 
671
702
  for pattern in patterns {
@@ -693,6 +724,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
693
724
  stopScan()
694
725
  }
695
726
  peripheralToConnectName = name
727
+ isDiscoveryScan = false
696
728
  startScan()
697
729
  }
698
730
 
@@ -712,19 +744,36 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
712
744
  )
713
745
  let peripherals = centralManager.retrievePeripherals(withIdentifiers: [uuid])
714
746
 
715
- if let peripheralToConnect = peripherals.first {
747
+ guard let peripheralToConnect = peripherals.first else {
716
748
  Bridge.log(
717
- "NEX-CONN: 🔵 Found peripheral by UUID: \(peripheralToConnect.name ?? "Unknown"). Initiating connection."
749
+ "NEX-CONN: 🔵 Could not find peripheral for stored UUID. Will proceed to scan."
718
750
  )
719
- peripheral = peripheralToConnect
720
- centralManager.connect(peripheralToConnect, options: nil)
721
- return true
722
- } else {
751
+ return false
752
+ }
753
+
754
+ // The stored UUID is a single "last connected" identifier. When the caller is
755
+ // targeting a specific device by name, only take this fast-path if the cached
756
+ // peripheral IS that device — otherwise we'd silently reconnect the *previously*
757
+ // paired glasses, and since iOS's connect() never times out and startScan()
758
+ // returns early on success, pairing the new device would hang forever.
759
+ // (Mirrors Android, which reconnects to the target device's own address, never a
760
+ // global "last" one. peripheralToConnectName == nil means an auto-reconnect with no
761
+ // specific target, so the cached device is exactly what we want — keep using it.)
762
+ if let targetName = peripheralToConnectName,
763
+ !(peripheralToConnect.name?.contains(targetName) ?? false)
764
+ {
723
765
  Bridge.log(
724
- "NEX-CONN: 🔵 Could not find peripheral for stored UUID. Will proceed to scan."
766
+ "NEX-CONN: 🔵 Stored UUID is '\(peripheralToConnect.name ?? "unnamed")' but target is '\(targetName)'. Skipping UUID fast-path; will scan for the target."
725
767
  )
726
768
  return false
727
769
  }
770
+
771
+ Bridge.log(
772
+ "NEX-CONN: 🔵 Found peripheral by UUID: \(peripheralToConnect.name ?? "Unknown"). Initiating connection."
773
+ )
774
+ peripheral = peripheralToConnect
775
+ centralManager.connect(peripheralToConnect, options: nil)
776
+ return true
728
777
  }
729
778
 
730
779
  private func startReconnectionTimer() {
@@ -766,6 +815,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
766
815
 
767
816
  reconnectionAttempts += 1
768
817
  Bridge.log("NEX-CONN: 🔄 Attempting reconnection (\(reconnectionAttempts))...")
818
+ isDiscoveryScan = false
769
819
  startScan()
770
820
  }
771
821
 
@@ -788,37 +838,42 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
788
838
  return
789
839
  }
790
840
 
791
- // First, try to reconnect using stored UUID (faster and works in background)
792
- if connectByUUID() {
793
- Bridge.log("NEX-CONN: 🔄 Attempting connection with stored UUID. Halting scan.")
794
- return
795
- }
796
-
797
- // If that fails, check for already-connected system devices
798
- let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [
799
- MAIN_SERVICE_UUID,
800
- ])
801
- if let targetName = peripheralToConnectName,
802
- let existingPeripheral = connectedPeripherals.first(where: {
803
- $0.name?.contains(targetName) == true
804
- })
805
- {
806
- Bridge.log(
807
- "NEX-CONN: 📱 Found already connected peripheral that matches target: \(existingPeripheral.name ?? "Unknown")"
808
- )
809
- if peripheral == nil {
810
- peripheral = existingPeripheral
811
- centralManager.connect(existingPeripheral, options: nil)
841
+ // Reconnect short-circuits below are for the targeted connect/reconnect paths only.
842
+ // A user-initiated discovery scan must fall through to scanForPeripherals so the
843
+ // device list populates even after we've previously paired (which persisted a UUID).
844
+ if !isDiscoveryScan {
845
+ // First, try to reconnect using stored UUID (faster and works in background)
846
+ if connectByUUID() {
847
+ Bridge.log("NEX-CONN: 🔄 Attempting connection with stored UUID. Halting scan.")
812
848
  return
813
849
  }
814
- }
815
850
 
816
- // Check if we have a saved device name to reconnect to (like MentraLive)
817
- if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
818
- !savedDeviceName.isEmpty
819
- {
820
- Bridge.log("NEX-CONN: 🔄 Looking for saved device: \(savedDeviceName)")
821
- // This will be handled in didDiscover when the device is found
851
+ // If that fails, check for already-connected system devices
852
+ let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [
853
+ MAIN_SERVICE_UUID,
854
+ ])
855
+ if let targetName = peripheralToConnectName,
856
+ let existingPeripheral = connectedPeripherals.first(where: {
857
+ $0.name?.contains(targetName) == true
858
+ })
859
+ {
860
+ Bridge.log(
861
+ "NEX-CONN: 📱 Found already connected peripheral that matches target: \(existingPeripheral.name ?? "Unknown")"
862
+ )
863
+ if peripheral == nil {
864
+ peripheral = existingPeripheral
865
+ centralManager.connect(existingPeripheral, options: nil)
866
+ return
867
+ }
868
+ }
869
+
870
+ // Check if we have a saved device name to reconnect to (like MentraLive)
871
+ if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
872
+ !savedDeviceName.isEmpty
873
+ {
874
+ Bridge.log("NEX-CONN: 🔄 Looking for saved device: \(savedDeviceName)")
875
+ // This will be handled in didDiscover when the device is found
876
+ }
822
877
  }
823
878
 
824
879
  Bridge.log("NEX-CONN: ✅ Bluetooth is powered on, starting scan...")
@@ -852,6 +907,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
852
907
  @objc func stopScan() {
853
908
  centralManager?.stopScan()
854
909
  _isScanning = false
910
+ // The flag describes the scan that's currently running; once it stops (10s discovery
911
+ // timeout, manual stop, or the stop inside connect()), clear it so a later
912
+ // reconnect/autoconnect scan isn't wrongly treated as discovery and suppressed.
913
+ isDiscoveryScan = false
855
914
  Bridge.log("NEX-CONN: 🛑 Stopped scanning.")
856
915
  }
857
916
 
@@ -907,6 +966,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
907
966
 
908
967
  // Clear specific connect target, but keep saved pairing data like Android.
909
968
  peripheralToConnectName = nil
969
+ // Pure discovery: don't let startScan short-circuit into reconnecting the last
970
+ // paired device, and don't auto-connect a saved device found mid-scan.
971
+ isDiscoveryScan = true
910
972
 
911
973
  Task {
912
974
  if centralManager == nil {
@@ -936,7 +998,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
936
998
  return normalized.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
937
999
  }
938
1000
 
939
- func sendTextWall(_ text: String) {
1001
+ func sendTextWall(_ text: String) async {
940
1002
  guard nexReady else {
941
1003
  Bridge.log("NEX: Not ready to display text. Device not initialized.")
942
1004
  return
@@ -946,7 +1008,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
946
1008
  // can't render (CJK etc.). When enabled, pass the text through unmodified.
947
1009
  let chineseCaptionsEnabled = DeviceStore.shared.get("bluetooth", "nex_chinese_captions") as? Bool ?? false
948
1010
  let sanitizedText = chineseCaptionsEnabled ? text : sanitizeDisplayText(text)
949
- Bridge.log("NEX: Displaying text wall: '\(sanitizedText)'")
1011
+ // No per-call log: this runs for every interim transcript (several/sec
1012
+ // during continuous speech) and each Bridge.log costs the JS thread.
1013
+ // Bridge.log("NEX: Displaying text wall: '\(sanitizedText)'")
950
1014
 
951
1015
  let displayText = Mentraos_Ble_DisplayText.with {
952
1016
  $0.text = sanitizedText
@@ -961,26 +1025,69 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
961
1025
  }
962
1026
 
963
1027
  let protobufData = try! phoneToGlasses.serializedData()
964
- queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1028
+ // Latest-wins: overwrite the pending slot; the 100 ms drain ticker sends it.
1029
+ // Do NOT enqueue on commandQueue — that's what backlogs under continuous speech.
1030
+ textWallLock.lock()
1031
+ pendingTextWall = protobufData
1032
+ textWallLock.unlock()
1033
+ }
1034
+
1035
+ private func startTextWallDrain() {
1036
+ textWallDrainTask?.cancel()
1037
+ textWallDrainTask = Task { [weak self] in
1038
+ while !Task.isCancelled {
1039
+ try? await Task.sleep(nanoseconds: 100_000_000)
1040
+ guard !Task.isCancelled else { break }
1041
+ self?.drainPendingTextWall()
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ private func stopTextWallDrain() {
1047
+ textWallDrainTask?.cancel()
1048
+ textWallDrainTask = nil
1049
+ textWallLock.lock()
1050
+ pendingTextWall = nil
1051
+ lastTextWall = nil
1052
+ textWallResendsRemaining = 0
1053
+ textWallLock.unlock()
1054
+ }
1055
+
1056
+ private func drainPendingTextWall() {
1057
+ textWallLock.lock()
1058
+ let msg = pendingTextWall
1059
+ pendingTextWall = nil
1060
+ var toSend: Data?
1061
+ if let msg {
1062
+ lastTextWall = msg
1063
+ textWallResendsRemaining = TEXT_WALL_RESEND_COUNT
1064
+ toSend = msg
1065
+ } else if textWallResendsRemaining > 0, let last = lastTextWall {
1066
+ textWallResendsRemaining -= 1
1067
+ toSend = last
1068
+ }
1069
+ textWallLock.unlock()
1070
+ guard let toSend else { return }
1071
+ queueDataWithOptimalChunking(toSend, packetType: PACKET_TYPE_PROTOBUF, emitTelemetry: false)
965
1072
  }
966
1073
 
967
1074
  @objc func displayTextLine(_ text: String) {
968
- sendTextWall(text)
1075
+ Task { await sendTextWall(text) }
969
1076
  }
970
1077
 
971
1078
  @objc func displayDoubleTextWall(_ textTop: String, textBottom: String) {
972
1079
  let combinedText = "\(textTop)\n\(textBottom)"
973
- sendTextWall(combinedText)
1080
+ Task { await sendTextWall(combinedText) }
974
1081
  }
975
1082
 
976
1083
  @objc func displayReferenceCardSimple(_ title: String, body: String) {
977
1084
  let combinedText = "\(title)\n\n\(body)"
978
- sendTextWall(combinedText)
1085
+ Task { await sendTextWall(combinedText) }
979
1086
  }
980
1087
 
981
1088
  @objc func displayRowsCard(_ rowStrings: [String]) {
982
1089
  let combinedText = rowStrings.joined(separator: "\n")
983
- sendTextWall(combinedText)
1090
+ Task { await sendTextWall(combinedText) }
984
1091
  }
985
1092
 
986
1093
  @objc func displayBulletList(_ title: String, bullets: [String]) {
@@ -989,7 +1096,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
989
1096
  text += "\n"
990
1097
  }
991
1098
  text += bullets.map { "• \($0)" }.joined(separator: "\n")
992
- sendTextWall(text)
1099
+ Task { await sendTextWall(text) }
993
1100
  }
994
1101
 
995
1102
  @objc func displayScrollingText(_ text: String) {
@@ -1150,6 +1257,14 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1150
1257
 
1151
1258
  Bridge.log("NEX: Clearing display")
1152
1259
 
1260
+ // Drop any pending/resendable text wall so a stale caption can't
1261
+ // repaint the display after this clear.
1262
+ textWallLock.lock()
1263
+ pendingTextWall = nil
1264
+ lastTextWall = nil
1265
+ textWallResendsRemaining = 0
1266
+ textWallLock.unlock()
1267
+
1153
1268
  let clearDisplay = Mentraos_Ble_ClearDisplay()
1154
1269
 
1155
1270
  let phoneToGlasses = Mentraos_Ble_PhoneToGlasses.with {
@@ -1419,7 +1534,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1419
1534
 
1420
1535
  @objc func displayCustomContent(_ content: String) {
1421
1536
  // For now, treat custom content as regular text
1422
- sendTextWall(content)
1537
+ Task { await sendTextWall(content) }
1423
1538
  }
1424
1539
 
1425
1540
  @objc func setUpdatingScreen(_ updating: Bool) {
@@ -1503,7 +1618,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1503
1618
  private func processProtobufData(_ protobufData: Data) {
1504
1619
  do {
1505
1620
  let glassesToPhone = try Mentraos_Ble_GlassesToPhone(serializedData: protobufData)
1506
- Bridge.log("NEX: Processing protobuf payload case: \(glassesToPhone.payload)")
1621
+ // No per-message log: String(describing: payload) stringifies the whole
1622
+ // protobuf and every Bridge.log costs the JS thread.
1623
+ // Bridge.log("NEX: Processing protobuf payload case: \(glassesToPhone.payload)")
1507
1624
 
1508
1625
  let fullPacket = Data([PACKET_TYPE_PROTOBUF]) + protobufData
1509
1626
  emitBleCommandReceived(fullPacket, payloadDescription: String(describing: glassesToPhone.payload))
@@ -1554,10 +1671,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
1554
1671
  }
1555
1672
 
1556
1673
  private func processAudioData(_ audioData: Data, sequenceNumber: UInt8) {
1557
- Bridge.log(
1558
- "NEX: Received audio data - sequence: \(sequenceNumber), size: \(audioData.count) bytes"
1559
- )
1560
-
1674
+ // No per-packet log: fires 20x/sec while the mic streams; each Bridge.log
1675
+ // costs the JS thread. Keep only the sequence-mismatch log below (rare,
1676
+ // fires on actual packet loss).
1561
1677
  if lastReceivedLc3Sequence != -1, UInt8((lastReceivedLc3Sequence + 1) & 0xFF) != sequenceNumber {
1562
1678
  Bridge.log("NEX: LC3 packet sequence mismatch. Expected \((lastReceivedLc3Sequence + 1) & 0xFF), got \(sequenceNumber)")
1563
1679
  }
@@ -2028,6 +2144,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2028
2144
  Task { await commandQueue.clear() }
2029
2145
  resumePendingWrite()
2030
2146
  stopReconnectionTimer()
2147
+ stopTextWallDrain()
2031
2148
  }
2032
2149
 
2033
2150
  // MARK: - Lifecycle Management (ported from Java)
@@ -2186,7 +2303,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2186
2303
  switch central.state {
2187
2304
  case .poweredOn:
2188
2305
  Bridge.log("NEX: ✅ Bluetooth is On and ready for scanning")
2189
- if scanOnPowerOn {
2306
+ if scanOnPowerOn || peripheralToConnectName != nil {
2190
2307
  Bridge.log("NEX: 🚀 Triggering scan after power on.")
2191
2308
  scanOnPowerOn = false
2192
2309
  startScan()
@@ -2242,6 +2359,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2242
2359
  shouldConnect = true
2243
2360
  connectionReason = "Target device name match: \(targetName)"
2244
2361
  }
2362
+ // During a user-initiated discovery scan, only list devices — never auto-connect a
2363
+ // saved/preferred device, so the user can pick a different one.
2364
+ else if isDiscoveryScan {
2365
+ shouldConnect = false
2366
+ }
2245
2367
  // Check if this matches our saved device for reconnection
2246
2368
  else if let savedName = savedDeviceName, deviceName == savedName {
2247
2369
  shouldConnect = true
@@ -2370,6 +2492,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2370
2492
 
2371
2493
  // Stop mic beat system (like Java implementation)
2372
2494
  stopMicBeat()
2495
+ stopTextWallDrain()
2373
2496
 
2374
2497
  nexReady = false
2375
2498
  deviceReady = false
@@ -2510,6 +2633,9 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2510
2633
  // Initialize command queue worker to process queued commands
2511
2634
  setupCommandQueue()
2512
2635
 
2636
+ // Start the 100 ms latest-wins drain for caption text walls
2637
+ startTextWallDrain()
2638
+
2513
2639
  // Emit device ready event to React Native
2514
2640
  // emitDeviceReady()
2515
2641
  }
@@ -2655,7 +2781,11 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
2655
2781
  Bridge.log("NEX-CONN: ⚠️ Received notification with no data.")
2656
2782
  return
2657
2783
  }
2658
- Bridge.log("NEX-CONN: 📥 Received data (\(data.count) bytes): \(data.toHexString())")
2784
+ // No per-notification logging here: this fires for EVERY inbound packet
2785
+ // (~20/sec audio alone), and Bridge.log is a typed message the JS thread
2786
+ // must process — plus the toHexString() is O(n) per packet. This was a
2787
+ // top contributor to the background CPU kill (cpu_resource_fatal.ips).
2788
+ // Bridge.log("NEX-CONN: 📥 Received data (\(data.count) bytes): \(data.toHexString())")
2659
2789
 
2660
2790
  // Process the received data based on packet type
2661
2791
  processReceivedData(data)
@@ -47,8 +47,8 @@ protocol SGCManager {
47
47
 
48
48
  func setBrightness(_ level: Int, autoMode: Bool)
49
49
  func clearDisplay()
50
- func sendTextWall(_ text: String)
51
- func sendDoubleTextWall(_ top: String, _ bottom: String)
50
+ func sendTextWall(_ text: String) async
51
+ func sendDoubleTextWall(_ top: String, _ bottom: String) async
52
52
  /// Display a bitmap. Optional `x`/`y`/`width`/`height` position and size the target
53
53
  /// container (used by G2; other SGCs ignore positioning and render the bitmap as before).
54
54
  func displayBitmap(base64ImageData: String, x: Int32?, y: Int32?, width: Int32?, height: Int32?) async -> Bool
@@ -64,7 +64,7 @@ protocol SGCManager {
64
64
 
65
65
  // MARK: - Notification Panel
66
66
 
67
- func showNotificationsPanel()
67
+ func showNotificationsPanel() async
68
68
 
69
69
  // MARK: - Calendar Events
70
70
 
@@ -77,6 +77,9 @@ protocol SGCManager {
77
77
  // MARK: - Device Control
78
78
 
79
79
  func setHeadUpAngle(_ angle: Int)
80
+ /// Enable/disable raw accelerometer (IMU) reporting from the glasses.
81
+ /// Default no-op; only G2 streams IMU data today.
82
+ func setImuEnabled(_ enabled: Bool) async
80
83
  func getBatteryStatus()
81
84
  func setSilentMode(_ enabled: Bool)
82
85
  func exit()
@@ -165,7 +168,13 @@ extension SGCManager {
165
168
 
166
169
  // MARK: - Notification Panel (default no-op — only G2 supports this)
167
170
 
168
- func showNotificationsPanel() {}
171
+ func showNotificationsPanel() async {}
172
+
173
+ // MARK: - IMU (default no-op — only G2 streams accelerometer data)
174
+
175
+ func setImuEnabled(_: Bool) async {
176
+ Bridge.log("SGC: setImuEnabled not supported")
177
+ }
169
178
 
170
179
  // MARK: - Calendar Events (default no-op — only G2 supports this)
171
180
 
@@ -133,11 +133,11 @@ class Simulated: SGCManager {
133
133
  Bridge.log("clearDisplay")
134
134
  }
135
135
 
136
- func sendTextWall(_: String) {
136
+ func sendTextWall(_: String) async {
137
137
  Bridge.log("sendTextWall")
138
138
  }
139
139
 
140
- func sendDoubleTextWall(_: String, _: String) {
140
+ func sendDoubleTextWall(_: String, _: String) async {
141
141
  Bridge.log("sendDoubleTextWall")
142
142
  }
143
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",