@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.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +39 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +22 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -1
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +1 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +14 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +3 -0
- package/ios/Source/Bridge.swift +10 -0
- package/ios/Source/DeviceManager.swift +24 -21
- package/ios/Source/DeviceStore.swift +8 -2
- package/ios/Source/camera/CameraModels.swift +1 -0
- package/ios/Source/sgcs/G1.swift +3 -3
- package/ios/Source/sgcs/G2.swift +1022 -640
- package/ios/Source/sgcs/Mach1.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +2 -2
- package/ios/Source/sgcs/MentraNex.swift +215 -85
- package/ios/Source/sgcs/SGCManager.swift +13 -4
- package/ios/Source/sgcs/Simulated.swift +2 -2
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +13 -1
- package/src/_private/BluetoothSdkModule.ts +22 -3
- 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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
747
|
+
guard let peripheralToConnect = peripherals.first else {
|
|
716
748
|
Bridge.log(
|
|
717
|
-
"NEX-CONN: 🔵
|
|
749
|
+
"NEX-CONN: 🔵 Could not find peripheral for stored UUID. Will proceed to scan."
|
|
718
750
|
)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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: 🔵
|
|
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
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|